From ce769dfe8d4eb97026d847ee78972cf11493c6d6 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:08:59 -0500 Subject: [PATCH] Fix professional 500 error, add SMTP config UI and message history page - Fix route ordering in professionals.controller: PATCH me was captured by PATCH :id causing 500 - Admin settings: add SMTP email config section (host, port, user, pass, sender) - Admin sidebar: add Mensajes link - Admin: new /mensajes page with SMS/email history, channel filter and pagination Co-Authored-By: Claude Sonnet 4.6 --- admin/src/app/mensajes/page.tsx | 184 ++++++++++++++++++ admin/src/app/settings/page.tsx | 107 +++++++++- admin/src/components/sidebar.tsx | 2 + .../professionals/professionals.controller.ts | 44 ++--- 4 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 admin/src/app/mensajes/page.tsx diff --git a/admin/src/app/mensajes/page.tsx b/admin/src/app/mensajes/page.tsx new file mode 100644 index 0000000..c8f72da --- /dev/null +++ b/admin/src/app/mensajes/page.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { Mail, MessageSquare, ChevronLeft, ChevronRight, AlertCircle, CheckCircle2 } from 'lucide-react'; + +function fmtDate(iso: string) { + const d = new Date(iso); + return d.toLocaleString('es-CO', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +interface MessageLog { + id: string; + channel: string; + recipient: string; + body: string; + status: string; + error?: string; + created_at: string; +} + +interface Meta { + page: number; + limit: number; + total: number; + pages: number; +} + +type ChannelFilter = 'all' | 'sms' | 'email'; + +export default function MensajesPage() { + const [logs, setLogs] = useState([]); + const [meta, setMeta] = useState({ page: 1, limit: 50, total: 0, pages: 1 }); + const [loading, setLoading] = useState(true); + const [channel, setChannel] = useState('all'); + const [page, setPage] = useState(1); + + const load = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), limit: '50' }); + if (channel !== 'all') params.set('channel', channel); + const data = await api.get<{ data: MessageLog[]; meta: Meta }>(`/settings/message-logs?${params}`); + setLogs(data.data); + setMeta(data.meta); + } catch { + toast.error('Error al cargar historial'); + } finally { + setLoading(false); + } + }, [page, channel]); + + useEffect(() => { load(); }, [load]); + + const handleChannelChange = (c: ChannelFilter) => { + setChannel(c); + setPage(1); + }; + + return ( +
+
+

Historial de mensajes

+

+ Registro de todos los SMS y correos enviados desde la plataforma. +

+
+ + {/* Filtros */} +
+ {(['all', 'sms', 'email'] as ChannelFilter[]).map((c) => ( + + ))} + + {meta.total} mensaje{meta.total !== 1 ? 's' : ''} + +
+ + {/* Tabla */} + + + Registros + + + {loading ? ( +
Cargando...
+ ) : logs.length === 0 ? ( +
+ +

No hay mensajes registrados

+
+ ) : ( +
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
CanalDestinatarioMensajeEstadoFecha
+
+ {log.channel === 'email' + ? + : } + {log.channel} +
+
{log.recipient} +

{log.body}

+ {log.error && ( +

+ {log.error} +

+ )} +
+ {log.status === 'sent' ? ( + + Enviado + + ) : ( + + Error + + )} + + {fmtDate(log.created_at)} +
+
+ )} +
+
+ + {/* Paginación */} + {meta.pages > 1 && ( +
+ + + Página {page} de {meta.pages} + + +
+ )} +
+ ); +} diff --git a/admin/src/app/settings/page.tsx b/admin/src/app/settings/page.tsx index d794a4c..a5503ee 100644 --- a/admin/src/app/settings/page.tsx +++ b/admin/src/app/settings/page.tsx @@ -9,11 +9,19 @@ 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, + Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, } from 'lucide-react'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; +interface SmtpConfig { + host: string; + port: string; + user: string; + pass: string; + from?: string; +} + interface GlobalSettings { app_version_android?: string; app_version_ios?: string; @@ -49,17 +57,24 @@ export default function SettingsPage() { const [policies, setPolicies] = useState>({ privacy: '', terms: '' }); const [savingPolicy, setSavingPolicy] = useState(null); + // SMTP + const [smtp, setSmtp] = useState({ host: '', port: '587', user: '', pass: '', from: '' }); + const [showSmtpPass, setShowSmtpPass] = useState(false); + const [savingSmtp, setSavingSmtp] = useState(false); + const load = useCallback(() => { setLoading(true); Promise.all([ api.get('/settings'), api.get<{ configured: boolean }>('/settings/maps-key'), api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'), + api.get('/settings/smtp').catch(() => null), ]) - .then(([globalData, mapsData, policiesData]) => { + .then(([globalData, mapsData, policiesData, smtpData]) => { 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 || '' }); }) .catch(() => toast.error('Error al cargar configuración')) .finally(() => setLoading(false)); @@ -106,6 +121,19 @@ export default function SettingsPage() { } }; + const saveSmtp = async () => { + if (!smtp.host || !smtp.user || !smtp.pass) return toast.error('Host, usuario y contraseña son obligatorios'); + setSavingSmtp(true); + try { + await api.patch('/settings/smtp', smtp); + toast.success('Configuración SMTP guardada'); + } catch (e: any) { + toast.error(e?.message || 'Error al guardar SMTP'); + } finally { + setSavingSmtp(false); + } + }; + const copyLink = (key: string) => { navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`); toast.success('Enlace copiado'); @@ -289,6 +317,81 @@ export default function SettingsPage() { + {/* ── SMTP Email ── */} +
+

Correo electrónico (SMTP)

+ + + + + Servidor SMTP + +

+ Configuración del servidor de correo para envío de OTP y notificaciones. Tiene prioridad sobre las variables de entorno. +

+
+ +
+
+ + setSmtp((s) => ({ ...s, host: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, port: e.target.value }))} + /> +
+
+
+ + setSmtp((s) => ({ ...s, user: e.target.value }))} + /> +
+
+ +
+ setSmtp((s) => ({ ...s, pass: e.target.value }))} + className="pr-10" + /> + +
+
+
+ + setSmtp((s) => ({ ...s, from: e.target.value }))} + /> +
+ +
+
+
+ {/* ── Documentos legales ── */}

Documentos legales

diff --git a/admin/src/components/sidebar.tsx b/admin/src/components/sidebar.tsx index eabf43f..c499f68 100644 --- a/admin/src/components/sidebar.tsx +++ b/admin/src/components/sidebar.tsx @@ -14,6 +14,7 @@ import { MessageSquare, Star, Lightbulb, + Inbox, LogOut, ChevronLeft, Menu, @@ -33,6 +34,7 @@ const menu = [ { href: '/comments', label: 'Comentarios', icon: Star }, { href: '/admin-sugerencias', label: 'Sugerencias', icon: Lightbulb }, { href: '/sms', label: 'SMS', icon: MessageSquare }, + { href: '/mensajes', label: 'Mensajes', icon: Inbox }, { href: '/settings', label: 'Configuración', icon: Settings }, ]; diff --git a/backend/src/professionals/professionals.controller.ts b/backend/src/professionals/professionals.controller.ts index d662349..80e22d0 100644 --- a/backend/src/professionals/professionals.controller.ts +++ b/backend/src/professionals/professionals.controller.ts @@ -60,6 +60,28 @@ export class ProfessionalsController { return this.pros.findByUserId(req.user.sub); } + @Post('request') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + request(@Req() req, @Body() dto: CreateProfessionalDto) { + return this.pros.requestProfessional(req.user.sub, dto); + } + + @Patch('me/schedules') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) { + const prof = await this.pros.findByUserId(req.user.sub); + return this.pros.updateSchedules(prof.id, dto.schedules); + } + + @Patch('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + async update(@Req() req, @Body() dto: UpdateProfessionalDto) { + return this.pros.upsert(req.user.sub, dto); + } + @Patch(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @@ -71,26 +93,4 @@ export class ProfessionalsController { findById(@Param('id') id: string) { return this.pros.findById(id); } - - @Post('request') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - request(@Req() req, @Body() dto: CreateProfessionalDto) { - return this.pros.requestProfessional(req.user.sub, dto); - } - - @Patch('me') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - async update(@Req() req, @Body() dto: UpdateProfessionalDto) { - return this.pros.upsert(req.user.sub, dto); - } - - @Patch('me/schedules') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) { - const prof = await this.pros.findByUserId(req.user.sub); - return this.pros.updateSchedules(prof.id, dto.schedules); - } }