Compare commits

...
10 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 749a1e860d feat(professionals): send FCM push notifications on status changes
Same events that trigger email (approve, deny, deactivate, setPending, upsert)
now also call tryPush() to send a silent FCM push to the professional's device.
NotificationsModule added to ProfessionalsModule imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 08:57:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1f3efb50da Add SMTP test email endpoint and mail logs in admin settings
- POST /settings/test-email: send test email to diagnose SMTP config
- Admin settings: test email form + last 20 email logs with status/error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:20:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0ca6804fa Fix JSON parse error on empty responses: handle empty body in API client and return message from saveSmtpConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:12:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 27f4a2d7ec Add transactional emails: welcome, professional submission, status changes
- mail/mail.service.ts: MailService with HTML templates (welcome, submitted,
  approved, rejected, pending, deactivated). Silent if SMTP not configured.
- mail/mail.module.ts: global module so all services can inject MailService
- auth.service.ts: send welcome email on register()
- professionals.service.ts: notify admin on new submission; notify professional
  on approve/deny/deactivate/setPending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:01:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0a67f5d80a Add Verifik RETHUS integration: backend module, settings auth flow, admin query button
- verifik.service.ts: email OTP auth, token storage/refresh, RETHUS query by professional
- verifik.controller.ts: send-otp, confirm, refresh, rethus/professional/:id endpoints
- settings/page.tsx: Verifik connection section (email→OTP→token)
- professionals/[id]/page.tsx: Consultar RETHUS button with inline result display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:50:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 66bc47b6e1 Fix UpdateProfessionalDto: add identification_picture and certificate_picture
These fields were missing from the DTO so NestJS/class-validator was stripping
them from PATCH /professionals/me requests before they reached Prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:41:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 aa35f4b661 Always show documents section in professional detail, with No subido fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:31:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0e3467565 Fix resetRejected: always reset pro_state even without professional record
Previous code deleted the professional record; if pro_state=3 but no record
exists, the 404 blocked the retry. Now we unconditionally reset pro_state=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:03:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5f02c45759 Fix retry flow: reset pro_state without deleting professional record
- resetRejected: only set pro_state=0, no DELETE (avoids FK constraint errors
  from services/reputations with onDelete: NoAction)
- upsert: detect re-submission after rejection (pro_state=0) and set pro_state=1
  so the request appears in admin pending list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:00:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0899105a52 Add rejection retry flow with configurable wait days
- Backend: DELETE /professionals/me resets rejected state so user can retry
- Backend: findRejected/findPending now filter by pro_state
- Admin settings: rejection_wait_days field (default 7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 18:03:13 -05:00
16 changed files with 826 additions and 60 deletions
+78 -42
View File
@@ -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<any>(null);
const [loadingRethus, setLoadingRethus] = useState(false);
const [proForm, setProForm] = useState<ProForm>({ profession: '', identification: '', address: '', rate: '', rethus_code: '' });
const [userForm, setUserForm] = useState<UserForm>({ 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<any>(`/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() {
)}
</div>
<div className="flex flex-wrap gap-3">
<Button
size="sm"
variant="outline"
onClick={consultRethus}
disabled={loadingRethus}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{loadingRethus ? 'Consultando...' : 'Consultar en RETHUS'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}
>
<ExternalLink className="mr-2 h-4 w-4" />
Abrir portal RETHUS (Minsalud)
Portal Minsalud
</Button>
{!professional.rethus_validated && (
<Button
@@ -369,9 +393,33 @@ export default function ProfessionalDetailPage() {
</Button>
)}
</div>
{rethusResult && (
<div className="mt-3 rounded-lg border bg-muted/40 p-4 space-y-2 text-sm">
<p className="font-semibold">{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}</p>
<p><span className="text-muted-foreground">Estado: </span>
<span className={rethusResult.rethus?.status?.includes('ACTIVO') ? 'text-green-600 font-medium' : 'text-destructive font-medium'}>
{rethusResult.rethus?.status || '—'}
</span>
</p>
{rethusResult.rethus?.academic?.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium">Títulos registrados:</p>
{rethusResult.rethus.academic.map((a: any, i: number) => (
<div key={i} className="pl-3 border-l-2 border-muted-foreground/30">
<p className="font-medium">{a.profession}</p>
<p className="text-xs text-muted-foreground">{a.type} · {a.originDegree} · {a.startDate}</p>
{a.entity && <p className="text-xs text-muted-foreground">{a.entity}</p>}
</div>
))}
</div>
)}
</div>
)}
{!professional.rethus_validated && (
<p className="text-xs text-muted-foreground">
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".
</p>
)}
</>
@@ -382,49 +430,37 @@ export default function ProfessionalDetailPage() {
</Card>
{/* Documentos subidos */}
{(professional.identification_picture || professional.certificate_picture || professional.banner_picture) && (
<Card>
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{professional.identification_picture && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Foto de cédula</p>
{professional.identification_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? (
<a href={professional.identification_picture} target="_blank" rel="noreferrer">
<img src={professional.identification_picture} alt="Cédula" className="rounded-lg border object-cover w-full max-h-48" />
</a>
<Card>
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{(['identification_picture', 'certificate_picture', 'banner_picture'] as const).map((field) => {
const labels: Record<string, string> = {
identification_picture: 'Foto de cédula',
certificate_picture: 'Certificado / Diploma',
banner_picture: 'Foto de perfil / Banner',
};
const url = professional[field];
return (
<div key={field} className="space-y-2">
<p className="text-sm text-muted-foreground">{labels[field]}</p>
{url ? (
url.match(/\.(jpg|jpeg|png|webp)$/i) ? (
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={labels[field]} className="rounded-lg border object-cover w-full max-h-48" />
</a>
) : (
<a href={url} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
<ExternalLink className="h-4 w-4" /> Ver documento
</a>
)
) : (
<a href={professional.identification_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
<ExternalLink className="h-4 w-4" /> Ver documento
</a>
<p className="text-sm text-muted-foreground italic">No subido</p>
)}
</div>
)}
{professional.certificate_picture && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Certificado / Diploma</p>
{professional.certificate_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? (
<a href={professional.certificate_picture} target="_blank" rel="noreferrer">
<img src={professional.certificate_picture} alt="Certificado" className="rounded-lg border object-cover w-full max-h-48" />
</a>
) : (
<a href={professional.certificate_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
<ExternalLink className="h-4 w-4" /> Ver documento
</a>
)}
</div>
)}
{professional.banner_picture && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Foto de perfil / Banner</p>
<a href={professional.banner_picture} target="_blank" rel="noreferrer">
<img src={professional.banner_picture} alt="Banner" className="rounded-lg border object-cover w-full max-h-48" />
</a>
</div>
)}
</CardContent>
</Card>
)}
);
})}
</CardContent>
</Card>
{/* Métodos de pago */}
{professional.payment_methods && professional.payment_methods.length > 0 && (
+222 -2
View File
@@ -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,10 +57,20 @@ export default function SettingsPage() {
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
const [savingPolicy, setSavingPolicy] = useState<string | null>(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<SmtpConfig>({ host: '', port: '587', user: '', pass: '', from: '' });
const [showSmtpPass, setShowSmtpPass] = useState(false);
const [savingSmtp, setSavingSmtp] = useState(false);
const [testEmailTo, setTestEmailTo] = useState('');
const [sendingTest, setSendingTest] = useState(false);
const [mailLogs, setMailLogs] = useState<{ id: string; recipient: string; body: string; status: string; error?: string; created_at: string }[]>([]);
const load = useCallback(() => {
setLoading(true);
@@ -69,12 +79,16 @@ export default function SettingsPage() {
api.get<{ configured: boolean }>('/settings/maps-key'),
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
api.get<SmtpConfig | null>('/settings/smtp').catch(() => null),
api.get<{ connected: boolean }>('/verifik/status').catch(() => ({ connected: false })),
api.get<{ data: any[] }>('/settings/message-logs?limit=20&channel=email').catch(() => ({ data: [] })),
])
.then(([globalData, mapsData, policiesData, smtpData]) => {
.then(([globalData, mapsData, policiesData, smtpData, verifikStatus, logsData]) => {
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);
setMailLogs(logsData.data || []);
})
.catch(() => toast.error('Error al cargar configuración'))
.finally(() => setLoading(false));
@@ -134,6 +148,62 @@ export default function SettingsPage() {
}
};
const sendTestEmail = async () => {
if (!testEmailTo.trim()) return toast.error('Ingresa un correo destino');
setSendingTest(true);
try {
await api.post('/settings/test-email', { to: testEmailTo.trim() });
toast.success(`Correo de prueba enviado a ${testEmailTo}`);
load(); // refresh logs
} catch (e: any) {
toast.error(e?.message || 'Error al enviar correo de prueba');
} finally {
setSendingTest(false);
}
};
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');
@@ -263,6 +333,32 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Solicitudes de profesional */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock size={18} className="text-muted-foreground" />
Solicitudes de profesional
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Días de espera para reintentar tras rechazo</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
className="w-28"
value={global.rejection_wait_days ?? 7}
onChange={(e) => setG('rejection_wait_days', Number(e.target.value))}
/>
<span className="text-sm text-muted-foreground">días</span>
</div>
<p className="text-xs text-muted-foreground">Con 0 el usuario puede reintentar inmediatamente.</p>
</div>
</CardContent>
</Card>
<Button onClick={saveGlobal} disabled={savingGlobal}>
<Save className="mr-1 h-4 w-4" />
{savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'}
@@ -392,6 +488,130 @@ export default function SettingsPage() {
</Card>
</section>
{/* ── Test de correo + Logs ── */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail size={18} className="text-muted-foreground" />
Probar configuración SMTP
</CardTitle>
<CardDescription>Envía un correo de prueba para verificar que el servidor SMTP funciona.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="destino@ejemplo.com"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
className="flex-1"
/>
<Button onClick={sendTestEmail} disabled={sendingTest}>
{sendingTest ? 'Enviando...' : 'Enviar prueba'}
</Button>
</div>
{mailLogs.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Últimos 20 correos enviados</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium">Destinatario</th>
<th className="text-left px-3 py-2 font-medium">Asunto</th>
<th className="text-left px-3 py-2 font-medium">Estado</th>
<th className="text-left px-3 py-2 font-medium">Fecha</th>
</tr>
</thead>
<tbody>
{mailLogs.map((log) => (
<tr key={log.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-2 text-muted-foreground">{log.recipient}</td>
<td className="px-3 py-2 max-w-[200px] truncate">{log.body}</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
log.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{log.status === 'sent' ? '✓ enviado' : '✗ error'}
</span>
{log.error && <p className="text-xs text-destructive mt-0.5 truncate max-w-[180px]" title={log.error}>{log.error}</p>}
</td>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{new Date(log.created_at).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' })}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
</section>
{/* ── Verifik ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Verifik Validación de profesionales</h2>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Link size={18} className="text-muted-foreground" />
Cuenta Verifik
</CardTitle>
<CardDescription>
Permite consultar RETHUS (registro de profesionales de salud) directamente desde el perfil de cada profesional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
{verifikConnected
? <><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium text-green-600">Conectado</span></>
: <><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No conectado</span></>}
</div>
{!verifikOtpSent ? (
<div className="flex gap-2">
<Input
type="email"
placeholder="tu@email.com (cuenta Verifik)"
value={verifikEmail}
onChange={(e) => setVerifikEmail(e.target.value)}
className="flex-1"
/>
<Button onClick={sendVerifikOtp} disabled={verifikLoading || !verifikEmail.trim()}>
{verifikLoading ? 'Enviando...' : 'Enviar OTP'}
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Ingresa el código que llegó a <strong>{verifikEmail}</strong></p>
<div className="flex gap-2">
<Input
placeholder="Código OTP"
value={verifikOtp}
onChange={(e) => setVerifikOtp(e.target.value)}
className="w-40 font-mono"
/>
<Button onClick={confirmVerifikOtp} disabled={verifikLoading}>
{verifikLoading ? 'Confirmando...' : 'Confirmar'}
</Button>
<Button variant="ghost" onClick={() => setVerifikOtpSent(false)}>Cancelar</Button>
</div>
</div>
)}
{verifikConnected && (
<Button variant="outline" size="sm" onClick={refreshVerifikToken} disabled={verifikLoading}>
{verifikLoading ? 'Renovando...' : 'Renovar token (30 días)'}
</Button>
)}
</CardContent>
</Card>
</section>
{/* ── Documentos legales ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Documentos legales</h2>
+2 -1
View File
@@ -24,7 +24,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
throw new ApiError(res.status, body.message || 'Error de servidor');
}
return res.json();
const text = await res.text();
return text ? JSON.parse(text) : ({} as T);
}
export const api = {
+4
View File
@@ -14,6 +14,8 @@ 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';
import { MailModule } from './mail/mail.module';
@Module({
imports: [
@@ -32,6 +34,8 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
NotificationsModule,
SmsModule,
SuggestionsModule,
VerifikModule,
MailModule,
],
})
export class AppModule {}
+3
View File
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { MailService } from '../mail/mail.service';
@Injectable()
export class AuthService {
@@ -10,6 +11,7 @@ export class AuthService {
private prisma: PrismaService,
private jwt: JwtService,
private sms: SmsService,
private mail: MailService,
) {}
async register(email: string, password: string, name: string) {
@@ -21,6 +23,7 @@ export class AuthService {
data: { email, password_hash, name },
});
this.mail.sendWelcome(name, email).catch(() => {});
return this.generateToken(user);
}
+1
View File
@@ -46,6 +46,7 @@ export class EmailOtpService {
create: { key: 'smtp_config', value: config as any },
update: { value: config as any },
});
return { message: 'Configuración SMTP guardada' };
}
async sendOtp(email: string): Promise<void> {
+11
View File
@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';
import { PrismaModule } from '../prisma/prisma.module';
@Global()
@Module({
imports: [PrismaModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+248
View File
@@ -0,0 +1,248 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { PrismaService } from '../prisma/prisma.service';
const BRAND_COLOR = '#42A4EF';
const LOGO = 'https://prosapp.co/img/logo_prosapp.png';
const APP_URL = 'https://app.prosapp.co';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private prisma: PrismaService) {}
// ── transport ───────────────────────────────────────────────────────────────
private async createTransport(): Promise<{ transport: nodemailer.Transporter; from: string } | null> {
const dbConfig = await this.prisma.settings
.findUnique({ where: { key: 'smtp_config' } })
.then(r => r?.value as any)
.catch(() => null);
const host = dbConfig?.host || process.env.SMTP_HOST;
const port = parseInt(dbConfig?.port || process.env.SMTP_PORT || '587');
const user = dbConfig?.user || process.env.SMTP_USER;
const pass = dbConfig?.pass || process.env.SMTP_PASS;
const from = dbConfig?.from || process.env.EMAIL_FROM || user;
if (!host || !user || !pass) return null;
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return { transport, from: from || user };
}
// Silent send — never throws, logs errors
async tryMail(to: string, subject: string, html: string) {
const ctx = await this.createTransport();
if (!ctx) return; // SMTP not configured — silently skip
try {
await ctx.transport.sendMail({ from: `ProsApp <${ctx.from}>`, to, subject, html });
this.logger.log(`Mail enviado a ${to}: ${subject}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'sent' },
}).catch(() => {});
} catch (err) {
this.logger.warn(`Mail falló a ${to}: ${err}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'error', error: String(err) },
}).catch(() => {});
}
}
private async adminEmail(): Promise<string | null> {
const g = await this.prisma.settings.findUnique({ where: { key: 'global' } }).then(r => r?.value as any).catch(() => null);
if (g?.admin_email) return g.admin_email;
const smtp = await this.prisma.settings.findUnique({ where: { key: 'smtp_config' } }).then(r => r?.value as any).catch(() => null);
return smtp?.user || null;
}
// ── base template ────────────────────────────────────────────────────────────
private wrap(content: string, preheader = '') {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ProsApp</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
</head>
<body style="margin:0;padding:0;background:#F0F4F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
${preheader ? `<div style="display:none;max-height:0;overflow:hidden;color:#F0F4F8">${preheader}</div>` : ''}
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F4F8;padding:40px 16px">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,${BRAND_COLOR},#1565C0);border-radius:16px 16px 0 0;padding:32px 40px;text-align:center">
<img src="${LOGO}" height="36" alt="ProsApp" style="display:block;margin:0 auto 16px">
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:0.5px">ProsApp — Profesionales de salud</p>
</td></tr>
<!-- Body -->
<tr><td style="background:#ffffff;padding:40px;border-left:1px solid #E2E8F0;border-right:1px solid #E2E8F0">
${content}
</td></tr>
<!-- Footer -->
<tr><td style="background:#F8FAFC;border:1px solid #E2E8F0;border-top:0;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
<p style="margin:0;color:#94A3B8;font-size:12px">© ${new Date().getFullYear()} ProsApp · <a href="${APP_URL}" style="color:${BRAND_COLOR};text-decoration:none">app.prosapp.co</a></p>
<p style="margin:8px 0 0;color:#CBD5E1;font-size:11px">Si no esperabas este correo, puedes ignorarlo.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
private badge(text: string, color: string, bg: string) {
return `<span style="display:inline-block;padding:4px 12px;background:${bg};color:${color};border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.3px">${text}</span>`;
}
private btn(text: string, href: string) {
return `<a href="${href}" style="display:inline-block;padding:14px 32px;background:${BRAND_COLOR};color:#ffffff;border-radius:10px;font-size:15px;font-weight:600;text-decoration:none;margin-top:24px">${text}</a>`;
}
// ── templates ────────────────────────────────────────────────────────────────
async sendWelcome(name: string, email: string) {
const html = this.wrap(`
<h1 style="margin:0 0 8px;color:#1E293B;font-size:24px">¡Bienvenido a ProsApp, ${name}! 👋</h1>
<p style="margin:0 0 24px;color:#64748B;font-size:15px;line-height:1.6">
Nos alegra tenerte aquí. Ya puedes explorar profesionales de salud, agendar citas y mucho más.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F8FF;border:1px solid #BAE3FF;border-radius:12px;padding:20px 24px;margin-bottom:24px">
<tr>
<td style="padding:8px 0">
<p style="margin:0;color:#1E293B;font-size:14px">✅ &nbsp;<strong>Cuenta creada</strong> con correo <strong>${email}</strong></p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">📋 &nbsp;Completa tu perfil para una mejor experiencia</p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">🔍 &nbsp;Encuentra y agenda profesionales de salud</p>
</td>
</tr>
</table>
<div style="text-align:center">${this.btn('Ir a ProsApp', APP_URL)}</div>
`, `Bienvenido a ProsApp, ${name}`);
await this.tryMail(email, `¡Bienvenido a ProsApp, ${name}!`, html);
}
async sendProfessionalSubmitted(profName: string, profEmail: string, profession: string) {
const admin = await this.adminEmail();
if (!admin) return;
const html = this.wrap(`
<p style="margin:0 0 4px;color:#64748B;font-size:13px;text-transform:uppercase;letter-spacing:0.5px">Nueva solicitud pendiente</p>
<h1 style="margin:0 0 24px;color:#1E293B;font-size:22px">Solicitud de profesional recibida</h1>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #E2E8F0;border-radius:12px;overflow:hidden;margin-bottom:24px">
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;width:40%">Nombre</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500">${profName}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Correo</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;border-top:1px solid #E2E8F0">${profEmail}</td>
</tr>
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Profesión</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500;border-top:1px solid #E2E8F0">${profession || '—'}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Estado</td>
<td style="padding:16px 20px;border-top:1px solid #E2E8F0">${this.badge('En revisión', '#92400E', '#FEF3C7')}</td>
</tr>
</table>
<p style="margin:0 0 24px;color:#64748B;font-size:14px;line-height:1.6">
Revisa los documentos adjuntos y aprueba o rechaza la solicitud desde el panel de administración.
</p>
<div style="text-align:center">${this.btn('Revisar en el panel', 'https://admin.prosapp.co/professionals')}</div>
`, `${profName} ha enviado una solicitud de profesional`);
await this.tryMail(admin, `Nueva solicitud de profesional — ${profName}`, html);
}
async sendStatusChanged(
profName: string,
email: string,
status: 'approved' | 'rejected' | 'pending' | 'deactivated',
) {
const configs = {
approved: {
subject: '¡Tu solicitud fue aprobada! 🎉',
badge: this.badge('Aprobado', '#065F46', '#D1FAE5'),
title: '¡Felicidades, ya eres profesional en ProsApp!',
body: 'Tu solicitud fue revisada y <strong>aprobada</strong>. Ya puedes recibir clientes, gestionar tu agenda y ofrecer tus servicios de salud en la plataforma.',
cta: this.btn('Ver mi perfil profesional', APP_URL),
accent: '#10B981',
accentBg: '#D1FAE5',
icon: '✅',
},
rejected: {
subject: 'Actualización sobre tu solicitud de profesional',
badge: this.badge('No aprobada', '#991B1B', '#FEE2E2'),
title: 'Tu solicitud no fue aprobada',
body: 'Lamentablemente tu solicitud <strong>no fue aprobada</strong> en esta ocasión. Puedes corregir los documentos y volver a intentarlo desde la aplicación.',
cta: this.btn('Volver a intentarlo', APP_URL),
accent: '#EF4444',
accentBg: '#FEE2E2',
icon: '📋',
},
pending: {
subject: 'Tu solicitud está en revisión',
badge: this.badge('En revisión', '#92400E', '#FEF3C7'),
title: 'Tu solicitud volvió a revisión',
body: 'Un administrador ha puesto tu solicitud nuevamente <strong>en revisión</strong>. Te notificaremos cuando haya una actualización.',
cta: this.btn('Ver estado', APP_URL),
accent: '#F59E0B',
accentBg: '#FEF3C7',
icon: '🔍',
},
deactivated: {
subject: 'Tu cuenta profesional fue desactivada',
badge: this.badge('Desactivada', '#374151', '#F3F4F6'),
title: 'Tu cuenta profesional fue desactivada',
body: 'Tu perfil profesional ha sido <strong>desactivado</strong>. Si crees que es un error, comunícate con el soporte de ProsApp.',
cta: this.btn('Contactar soporte', APP_URL),
accent: '#6B7280',
accentBg: '#F3F4F6',
icon: '⚠️',
},
};
const c = configs[status];
const html = this.wrap(`
<div style="text-align:center;margin-bottom:28px">
<div style="display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;background:${c.accentBg};border-radius:50%;font-size:28px;margin-bottom:16px">${c.icon}</div>
<br>${c.badge}
</div>
<h1 style="margin:0 0 12px;color:#1E293B;font-size:22px;text-align:center">${c.title}</h1>
<p style="margin:0 0 28px;color:#64748B;font-size:15px;line-height:1.7;text-align:center">
Hola <strong>${profName}</strong>, ${c.body}
</p>
<div style="background:#F8FAFC;border-left:4px solid ${c.accent};border-radius:0 8px 8px 0;padding:16px 20px;margin-bottom:28px">
<p style="margin:0;color:#475569;font-size:13px;line-height:1.6">
Si tienes preguntas, escríbenos a través del soporte en la app o responde este correo.
</p>
</div>
<div style="text-align:center">${c.cta}</div>
`, c.title);
await this.tryMail(email, c.subject, html);
}
}
@@ -57,6 +57,14 @@ export class UpdateProfessionalDto {
@IsNumber()
rate?: number;
@IsOptional()
@IsString()
identification_picture?: string;
@IsOptional()
@IsString()
certificate_picture?: string;
@IsOptional()
@IsString()
banner_picture?: string;
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ProfessionalsService } from './professionals.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -76,6 +76,13 @@ export class ProfessionalsController {
return this.pros.requestProfessional(req.user.sub, dto);
}
@Delete('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async deleteMe(@Req() req) {
return this.pros.resetRejected(req.user.sub);
}
@Patch('me/schedules')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ProfessionalsService } from './professionals.service';
import { ProfessionalsController } from './professionals.controller';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, NotificationsModule],
providers: [ProfessionalsService],
controllers: [ProfessionalsController],
exports: [ProfessionalsService],
@@ -1,9 +1,24 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { MailService } from '../mail/mail.service';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class ProfessionalsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private mail: MailService,
private notifications: NotificationsService,
) {}
private async tryPush(userId: string, title: string, body: string) {
try {
const u = await this.prisma.users.findUnique({
where: { id: userId }, select: { fcm_token: true },
});
if (u?.fcm_token) await this.notifications.send(u.fcm_token, title, body);
} catch (_) {}
}
async findAllActive(page = 1, limit = 20, city?: string) {
const skip = (page - 1) * limit;
@@ -72,7 +87,23 @@ export class ProfessionalsService {
if (!existing) {
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
return this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } });
const prof = await this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } });
const u = await this.prisma.users.findUnique({ where: { id: userId }, select: { name: true, email: true } });
if (u?.email) this.mail.sendProfessionalSubmitted(u.name || 'Usuario', u.email, data.profession || '').catch(() => {});
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
return prof;
}
// Re-submitting after rejection reset (pro_state=0): move back to pending
const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { pro_state: true, name: true, email: true } });
if (user?.pro_state === 0) {
await this.prisma.$transaction([
this.prisma.professionals.update({ where: { user_id: userId }, data: { ...data, is_active: false } }),
this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } }),
]);
if (user.email) this.mail.sendProfessionalSubmitted(user.name || 'Usuario', user.email, data.profession || '').catch(() => {});
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
return this.prisma.professionals.findUnique({ where: { user_id: userId } });
}
return this.prisma.professionals.update({ where: { user_id: userId }, data });
@@ -145,15 +176,12 @@ export class ProfessionalsService {
if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([
this.prisma.professionals.update({
where: { id: professionalId },
data: { is_active: true },
}),
this.prisma.users.update({
where: { id: prof.user_id },
data: { pro_state: 2 },
}),
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: true } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 2 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'approved').catch(() => {});
this.tryPush(prof.user_id, '¡Solicitud aprobada! 🎉', 'Tu perfil profesional ha sido aprobado en ProsApp.');
return { message: 'Profesional aprobado' };
}
@@ -162,12 +190,21 @@ export class ProfessionalsService {
if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false } }),
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false, updated_at: new Date() } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'rejected').catch(() => {});
this.tryPush(prof.user_id, 'Actualización de tu solicitud', 'Tu solicitud profesional fue rechazada. Puedes volver a intentarlo.');
return { message: 'Solicitud rechazada' };
}
async resetRejected(userId: string) {
// Always reset pro_state regardless of whether a professional record exists
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 0 } });
return { message: 'Solicitud reiniciada' };
}
async deactivate(id: string) {
const prof = await this.prisma.professionals.findUnique({ where: { id } });
if (!prof) throw new NotFoundException('Profesional no encontrado');
@@ -175,6 +212,9 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'deactivated').catch(() => {});
this.tryPush(prof.user_id, 'Cuenta desactivada', 'Tu cuenta profesional ha sido desactivada.');
return { message: 'Profesional desactivado' };
}
@@ -185,6 +225,9 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 1 } }),
]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'pending').catch(() => {});
this.tryPush(prof.user_id, 'Solicitud en revisión', 'Tu solicitud está siendo revisada por el equipo de ProsApp.');
return { message: 'Profesional puesto en revisión' };
}
+18 -2
View File
@@ -1,14 +1,15 @@
import { Controller, Get, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
import { Controller, Get, Post, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { EmailOtpService } from '../auth/email-otp.service';
import { MailService } from '../mail/mail.service';
@ApiTags('Settings')
@Controller('settings')
export class SettingsController {
constructor(private settings: SettingsService, private emailOtp: EmailOtpService) {}
constructor(private settings: SettingsService, private emailOtp: EmailOtpService, private mail: MailService) {}
@Get()
getGlobal() { return this.settings.getGlobal(); }
@@ -44,6 +45,21 @@ export class SettingsController {
return this.emailOtp.saveSmtpConfig(body);
}
@Post('test-email')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
async testEmail(@Body() body: { to: string }) {
await this.mail.tryMail(
body.to,
'Correo de prueba — ProsApp',
`<div style="font-family:sans-serif;padding:32px;max-width:480px;margin:auto">
<h2 style="color:#1e293b">✅ Configuración SMTP correcta</h2>
<p style="color:#64748b">Si recibes este correo, el servidor SMTP está funcionando correctamente en ProsApp.</p>
<p style="color:#94a3b8;font-size:12px">Enviado desde el panel de administración.</p>
</div>`,
);
return { message: `Correo de prueba enviado a ${body.to}` };
}
// Message logs
@Get('message-logs')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
+43
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+111
View File
@@ -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<string | null> {
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;
}
}