Compare commits
10
Commits
3fd7a33fc1
...
749a1e860d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749a1e860d | ||
|
|
1f3efb50da | ||
|
|
b0ca6804fa | ||
|
|
27f4a2d7ec | ||
|
|
0a67f5d80a | ||
|
|
66bc47b6e1 | ||
|
|
aa35f4b661 | ||
|
|
b0e3467565 | ||
|
|
5f02c45759 | ||
|
|
0899105a52 |
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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">✅ <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">📋 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">🔍 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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user