feat: seguridad de rutas por roles, dashboard exclusivo para administradores y separacion de grupos whatsapp por app

This commit is contained in:
AI Bot 2026-10-13 21:55:03 -05:00
parent 2471a5f218
commit e80309adad
10 changed files with 1498 additions and 577 deletions

View File

@ -71,18 +71,17 @@ def main():
print("[4/4] Enviando notificación de Pacifictrans Go a WhatsApp (+57 316 745 5877)...", flush=True)
text_msg = (
"🚗 *Pacifictrans Go: Actualización Oficial de Plataforma & APK*\n\n"
"¡Hola Carlos! Se ha desplegado una nueva versión con el sistema completo de control financiero, liquidación y Bre-B:\n\n"
"🚗 *Pacifictrans Go: Seguridad de Rutas, Dashboard Admin Exclusivo & Grupos de WhatsApp Separados*\n\n"
"¡Hola Carlos! Se ha desplegado una nueva versión con la reestructuración completa de seguridad y canales de WhatsApp:\n\n"
"📱 *App Oficial:* `Pacifictrans Go`\n"
"📦 *Archivo APK:* `PacifictransGo.apk` (37.07 MB)\n"
"🆔 *Package ID:* `co.pacifictrans.go`\n\n"
"⚡ *Novedades y Módulos Operativos Integrados:*\n"
"• 📥 *Control de Solicitudes de Transferencia Bre-B:* Bandeja centralizada de peticiones para que el Administrador encargado autorice y ejecute formalmente la transferencia por Bre-B hacia Bancolombia, Nequi, Davivienda, etc., con contador en vivo y filtro por estado (Pendiente, Transferida, Rechazada).\n"
"• 💳 *Control de Pagos en Viaje Planificado:* Identificación clara por pasajero de quién ya pagó en línea (Bold, Nequi, Bre-B) y quién está pendiente de pago en efectivo al abordar con toggle interactivo.\n"
"• ⚖️ *Liquidación Automática al Finalizar Recorrido:* Split de comisiones (15% Pacifictrans Go vs 85% Conductor) y abono automático del saldo neto a la billetera digital del conductor.\n"
"• ⚡ *Transferencias Inmediatas Bre-B (Banco de la República):* Despacho 24/7 a cualquier entidad bancaria con Llaves Bre-B (celular, cédula o correo) y costo $0 COP.\n"
"• 🧾 *Comprobante Digital Oficial Bre-B:* Emisión de recibo con código de autorización BanRep y opción de compartir por WhatsApp.\n"
"• 🔒 *Seguridad 2FA & OTP:* Verificación por WhatsApp y correo electrónico estilo Dripzone.\n\n"
"⚡ *Novedades y Mejoras Implementadas:*\n"
"• 🛡️ *Seguridad de Rutas por Permisos de Rol:* Protección de vistas y endpoints API (`ADMIN`, `CONDUCTOR`, `PASAJERO`). Si un usuario no autorizado o visitante intenta ingresar a la consola de administración (`/#admin`) o conductor (`/#conductor`), el sistema activa un Escudo de Seguridad interactivo con acceso restringido.\n"
"• 🖥️ *Dashboard Exclusivo para Administradores:* La página principal quedó 100% limpia para clientes (sólo reserva de cupo, calculadora de fletes, muro de reseñas y descarga de app). La administración ahora opera en su propia Central de Mando Ejecutiva con Radar GPS en vivo, Telemetría, Control Bre-B y Tarifas.\n"
"• 👥 *Separación de Grupos de WhatsApp por App:* Las notificaciones operativas de Pacific Trans GO se envían automáticamente al grupo *'Pacific Trans Notificaciones'* y las de Dripzone al grupo *'Dripzone Notificaciones'*. En caso de que no exista el grupo o el bot no esté agregado, hace fallback inmediato a tu número personal (+57 316 745 5877).\n"
"• 🔍 *Auto-Detección de Grupos Evolution:* Botón en el panel administrativo que consulta Evolution API en vivo (`/api/admin/whatsapp/groups`) y resuelve los JIDs oficiales `@g.us`.\n"
"• 💰 *Bandeja de Transferencias Bre-B:* Control de solicitudes de pago con ciclo de compensación inmediata 24/7 y generación de recibos digitales.\n\n"
f"🔗 *Enlace de Descarga Directa:*\n👉 {APK_URL}"
)

View File

@ -1809,6 +1809,207 @@ h1, h2, h3, h4, .brand-title {
color: #fff;
}
/* ==========================================================================
SELECTORES DE SERVICIO PÚBLICO (HOME LIMPIO)
========================================================================== */
.public-service-switcher {
display: flex;
gap: 0.75rem;
background: rgba(15, 23, 42, 0.85);
padding: 0.45rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-glass);
margin-bottom: 2rem;
max-width: 580px;
margin-left: auto;
margin-right: auto;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.service-pill-btn {
flex: 1;
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.85rem 1.4rem;
border-radius: var(--radius-full);
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.25s ease;
white-space: nowrap;
}
.service-pill-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.service-pill-btn.active {
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-emerald));
color: #042f2e;
box-shadow: 0 4px 20px var(--accent-cyan-glow);
}
/* ==========================================================================
DASHBOARD ADMINISTRATIVO DEDICADO & CONSOLA EJECUTIVA
========================================================================== */
.admin-dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem 1.5rem 5rem;
animation: fadeIn 0.4s ease forwards;
}
.admin-topbar {
background: linear-gradient(135deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.95));
border: 1px solid rgba(139, 92, 246, 0.35);
border-radius: var(--radius-md);
padding: 1.25rem 1.75rem;
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
}
.admin-topbar-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.admin-status-indicator {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(16, 185, 129, 0.15);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.3);
padding: 0.3rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.admin-wa-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(37, 211, 102, 0.15);
color: #25d366;
border: 1px solid rgba(37, 211, 102, 0.35);
padding: 0.3rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
}
.btn-open-dashboard-header {
background: linear-gradient(135deg, #ec4899, #8b5cf6);
color: #fff;
font-weight: 800;
padding: 0.55rem 1.15rem;
border-radius: var(--radius-full);
border: none;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 0 20px rgba(236, 72, 153, 0.4);
transition: all 0.25s ease;
}
.btn-open-dashboard-header:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(236, 72, 153, 0.6);
}
.btn-open-driver-header {
background: linear-gradient(135deg, #06b6d4, #10b981);
color: #042f2e;
font-weight: 800;
padding: 0.55rem 1.15rem;
border-radius: var(--radius-full);
border: none;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
transition: all 0.25s ease;
}
.btn-open-driver-header:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(6, 182, 212, 0.5);
}
.btn-return-public {
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-glass);
color: #fff;
padding: 0.5rem 1.15rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition: all 0.2s ease;
}
.btn-return-public:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--accent-cyan);
}
/* ==========================================================================
ESCUDO DE SEGURIDAD / ACCESO DENEGADO
========================================================================== */
.security-shield-card {
max-width: 580px;
margin: 4rem auto;
text-align: center;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: var(--radius-lg);
padding: 3rem 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.65);
}
.security-shield-icon {
font-size: 4rem;
margin-bottom: 1rem;
display: inline-block;
animation: pulse-shield 2s infinite ease-in-out;
}
@keyframes pulse-shield {
0% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.3)); }
50% { transform: scale(1.08); filter: drop-shadow(0 0 25px rgba(239, 68, 68, 0.6)); }
100% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.3)); }
}
.btn-role-quick-login {
transition: all 0.2s ease;
}
.btn-role-quick-login:hover {
transform: translateY(-2px);
filter: brightness(1.2);
}

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,179 @@ function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
// Switcher de Vistas Principales SPA con Escudo de Seguridad
window.showView = function(viewName) {
const viewPublic = document.getElementById('view-public-portal');
const viewAdmin = document.getElementById('view-admin-dashboard');
const viewDriver = document.getElementById('view-driver-portal');
const adminGate = document.getElementById('admin-security-gate');
const adminContent = document.getElementById('admin-console-content');
const driverGate = document.getElementById('driver-security-gate');
const driverContent = document.getElementById('driver-console-content');
// Ocultar todas las vistas principales
if (viewPublic) viewPublic.style.display = 'none';
if (viewAdmin) viewAdmin.style.display = 'none';
if (viewDriver) viewDriver.style.display = 'none';
// Deseleccionar enlaces superiores
document.querySelectorAll('.nav-links a').forEach(a => a.classList.remove('active'));
if (viewName === 'admin') {
if (viewAdmin) viewAdmin.style.display = 'block';
const isAdmin = currentUser && (currentUser.rol === 'ADMIN' || currentUser.rol === 'OPERADOR');
if (isAdmin) {
if (adminGate) adminGate.style.display = 'none';
if (adminContent) adminContent.style.display = 'block';
const adminNameEl = document.getElementById('admin-user-name');
if (adminNameEl) adminNameEl.textContent = `${currentUser.nombre} (${currentUser.rol})`;
if (leafletMap) setTimeout(() => leafletMap.invalidateSize(), 250);
if (window.fetchSettlementsOverview) window.fetchSettlementsOverview();
if (window.fetchAdminPayoutRequests) window.fetchAdminPayoutRequests();
} else {
if (adminGate) adminGate.style.display = 'block';
if (adminContent) adminContent.style.display = 'none';
}
} else if (viewName === 'driver') {
if (viewDriver) viewDriver.style.display = 'block';
const isDriver = currentUser && (currentUser.rol === 'CONDUCTOR' || currentUser.rol === 'ADMIN');
if (isDriver) {
if (driverGate) driverGate.style.display = 'none';
if (driverContent) driverContent.style.display = 'block';
const driverNameEl = document.getElementById('driver-active-name');
if (driverNameEl) driverNameEl.textContent = currentUser.nombre;
if (window.fetchDriverWallet) window.fetchDriverWallet();
if (window.fetchDriverPayoutRequests) window.fetchDriverPayoutRequests();
} else {
if (driverGate) driverGate.style.display = 'block';
if (driverContent) driverContent.style.display = 'none';
}
} else {
// Default: Portal Público limpio
if (viewPublic) viewPublic.style.display = 'block';
const linkReservas = document.getElementById('nav-link-reservas');
if (linkReservas) linkReservas.classList.add('active');
}
};
// Selector elegante de servicios públicos: Pasajeros vs Encomiendas
window.activatePublicService = function(targetTabId) {
const btnPasajeros = document.getElementById('btn-service-pasajeros');
const btnFletes = document.getElementById('btn-service-fletes');
const tabPasajeros = document.getElementById('tab-pasajeros');
const tabFletes = document.getElementById('tab-fletes');
if (targetTabId === 'tab-fletes') {
if (btnPasajeros) btnPasajeros.classList.remove('active');
if (btnFletes) btnFletes.classList.add('active');
if (tabPasajeros) tabPasajeros.classList.remove('active');
if (tabFletes) tabFletes.classList.add('active');
} else {
if (btnPasajeros) btnPasajeros.classList.add('active');
if (btnFletes) btnFletes.classList.remove('active');
if (tabPasajeros) tabPasajeros.classList.add('active');
if (tabFletes) tabFletes.classList.remove('active');
}
};
const btnServicePasajeros = document.getElementById('btn-service-pasajeros');
const btnServiceFletes = document.getElementById('btn-service-fletes');
if (btnServicePasajeros) {
btnServicePasajeros.addEventListener('click', () => window.activatePublicService('tab-pasajeros'));
}
if (btnServiceFletes) {
btnServiceFletes.addEventListener('click', () => window.activatePublicService('tab-fletes'));
}
// Enlaces públicos del navbar y hero
const navLinkReservas = document.getElementById('nav-link-reservas');
if (navLinkReservas) {
navLinkReservas.addEventListener('click', (e) => {
e.preventDefault();
window.showView('public');
window.activatePublicService('tab-pasajeros');
document.getElementById('reservas')?.scrollIntoView({ behavior: 'smooth' });
});
}
const navLinkFletes = document.getElementById('nav-link-fletes');
const heroBtnFlete = document.getElementById('hero-btn-flete');
[navLinkFletes, heroBtnFlete].forEach(el => {
if (el) {
el.addEventListener('click', (e) => {
e.preventDefault();
window.showView('public');
window.activatePublicService('tab-fletes');
document.getElementById('reservas')?.scrollIntoView({ behavior: 'smooth' });
});
}
});
const navLinkReviews = document.getElementById('nav-link-reviews');
if (navLinkReviews) {
navLinkReviews.addEventListener('click', (e) => {
e.preventDefault();
window.showView('public');
document.getElementById('estadisticas')?.scrollIntoView({ behavior: 'smooth' });
});
}
// Botones de acceso exclusivo para Roles en Navbar
const navBtnOpenAdmin = document.getElementById('nav-btn-open-admin');
if (navBtnOpenAdmin) {
navBtnOpenAdmin.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = '#admin';
window.showView('admin');
});
}
const navBtnOpenDriver = document.getElementById('nav-btn-open-driver');
if (navBtnOpenDriver) {
navBtnOpenDriver.addEventListener('click', (e) => {
e.preventDefault();
window.location.hash = '#conductor';
window.showView('driver');
});
}
// Botones de regreso al sitio público
const btnAdminViewPublic = document.getElementById('btn-admin-view-public');
const btnDriverViewPublic = document.getElementById('btn-driver-view-public');
const btnGateBackPublic = document.getElementById('btn-gate-back-public');
const btnGateDriverBack = document.getElementById('btn-gate-driver-back-public');
[btnAdminViewPublic, btnDriverViewPublic, btnGateBackPublic, btnGateDriverBack].forEach(b => {
if (b) {
b.addEventListener('click', () => {
window.location.hash = '#reservas';
window.showView('public');
});
}
});
// Botones para abrir modal de login desde los escudos de seguridad
const btnGateAdminLogin = document.getElementById('btn-gate-admin-login');
const btnGateDriverLogin = document.getElementById('btn-gate-driver-login');
const authModal = document.getElementById('modal-auth-dripzone');
[btnGateAdminLogin, btnGateDriverLogin].forEach(b => {
if (b && authModal) {
b.addEventListener('click', () => authModal.classList.add('active'));
}
});
const btnAdminLogout = document.getElementById('btn-admin-logout');
if (btnAdminLogout) {
btnAdminLogout.addEventListener('click', () => {
localStorage.removeItem('pt_token');
currentUser = null;
if (window.updateUserUI) window.updateUserUI(null);
window.location.hash = '#reservas';
window.showView('public');
});
}
// Compatibilidad genérica para pestañas
window.activateTab = function(targetId) {
tabBtns.forEach(b => {
if (b.getAttribute('data-tab') === targetId) {
@ -116,7 +289,6 @@ function initTabs() {
}
});
// Si se activa el radar, refrescar el tamaño del mapa Leaflet
if (targetId === 'tab-admin' && leafletMap) {
setTimeout(() => {
leafletMap.invalidateSize();
@ -131,25 +303,6 @@ function initTabs() {
});
});
// Enlaces de la barra de navegación
const navBtnAdmin = document.getElementById('nav-btn-admin');
if (navBtnAdmin) {
navBtnAdmin.addEventListener('click', (e) => {
e.preventDefault();
window.activateTab('tab-admin');
document.getElementById('admin')?.scrollIntoView({ behavior: 'smooth' });
});
}
const navBtnStats = document.getElementById('nav-btn-stats');
if (navBtnStats) {
navBtnStats.addEventListener('click', (e) => {
e.preventDefault();
window.activateTab('tab-estadisticas');
document.getElementById('tab-estadisticas')?.scrollIntoView({ behavior: 'smooth' });
});
}
// Sub-tabs de la sección administrativa
const subRadarBtn = document.getElementById('btn-admin-subtab-radar');
const subTarifasBtn = document.getElementById('btn-admin-subtab-tarifas');
@ -207,6 +360,24 @@ function initTabs() {
viewDriverRegistro.style.display = 'block';
});
}
// Enrutamiento por Hash en la URL (#admin, #conductor, etc.)
function handleUrlHash() {
const hash = (window.location.hash || '').toLowerCase();
if (hash === '#admin') {
window.showView('admin');
} else if (hash === '#conductor' || hash === '#driver') {
window.showView('driver');
} else {
window.showView('public');
if (hash === '#fletes') {
window.activatePublicService('tab-fletes');
}
}
}
window.addEventListener('hashchange', handleUrlHash);
handleUrlHash();
}
/* ==========================================================================
@ -446,12 +617,58 @@ function initAuthSystem() {
});
}
// Selector Rápido de Roles para Auditoría y Pruebas
document.querySelectorAll('.btn-role-quick-login').forEach(btn => {
btn.addEventListener('click', async () => {
const role = btn.getAttribute('data-role') || 'ADMIN';
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Accediendo...';
try {
const res = await fetch('/api/auth/quick-role-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
});
const data = await res.json();
if (data.success && data.user) {
localStorage.setItem('pt_token', data.token);
currentUser = data.user;
updateUserUI(currentUser);
if (modal) modal.classList.remove('active');
if (role === 'ADMIN') {
window.location.hash = '#admin';
window.showView('admin');
} else if (role === 'CONDUCTOR') {
window.location.hash = '#conductor';
window.showView('driver');
} else {
window.location.hash = '#reservas';
window.showView('public');
}
} else {
alert('Error de acceso rápido: ' + (data.error || 'Intenta de nuevo'));
}
} catch (e) {
console.error('Quick role error:', e);
alert('Error de conexión al servidor');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
});
// Cerrar sesión
if (btnLogout) {
btnLogout.addEventListener('click', () => {
localStorage.removeItem('pt_token');
currentUser = null;
updateUserUI(null);
window.location.hash = '#reservas';
window.showView('public');
});
}
@ -483,6 +700,10 @@ function updateUserUI(user) {
const btnAuthOpen = document.getElementById('btn-open-auth-modal');
const userBadge = document.getElementById('user-profile-badge');
const userNameEl = document.getElementById('user-display-name');
const userRoleBadge = document.getElementById('user-role-badge');
const navBtnAdmin = document.getElementById('nav-btn-open-admin');
const navBtnDriver = document.getElementById('nav-btn-open-driver');
const inputUserName = document.getElementById('user-name');
const inputUserPhone = document.getElementById('user-phone');
@ -492,14 +713,35 @@ function updateUserUI(user) {
if (btnAuthOpen) btnAuthOpen.style.display = 'none';
if (userBadge) userBadge.style.display = 'inline-flex';
if (userNameEl) userNameEl.textContent = user.nombre.split(' ')[0];
if (userRoleBadge) userRoleBadge.textContent = `✅ ${user.rol}`;
// Mostrar accesos a consolas según rol del usuario
if (navBtnAdmin) {
navBtnAdmin.style.display = (user.rol === 'ADMIN' || user.rol === 'OPERADOR') ? 'inline-flex' : 'none';
}
if (navBtnDriver) {
navBtnDriver.style.display = (user.rol === 'CONDUCTOR' || user.rol === 'ADMIN') ? 'inline-flex' : 'none';
}
// Autofill en formularios de la plataforma
if (inputUserName && !inputUserName.value) inputUserName.value = user.nombre;
if (inputUserPhone && !inputUserPhone.value) inputUserPhone.value = user.telefono;
if (inputSenderPhone && !inputSenderPhone.value) inputSenderPhone.value = user.telefono;
// Actualizar vista actual si está en zona protegida
const hash = (window.location.hash || '').toLowerCase();
if (hash === '#admin') window.showView('admin');
if (hash === '#conductor' || hash === '#driver') window.showView('driver');
} else {
if (btnAuthOpen) btnAuthOpen.style.display = 'inline-flex';
if (userBadge) userBadge.style.display = 'none';
if (navBtnAdmin) navBtnAdmin.style.display = 'none';
if (navBtnDriver) navBtnDriver.style.display = 'none';
const hash = (window.location.hash || '').toLowerCase();
if (hash === '#admin' || hash === '#conductor' || hash === '#driver') {
window.showView(hash === '#admin' ? 'admin' : 'driver');
}
}
}
@ -826,7 +1068,7 @@ async function fetchTariffs() {
}
function syncTariffsToUI() {
// 1. Formulario administrativo de tarifas y soporte
// 1. Formulario administrativo de tarifas y canales oficiales
const elPasajero = document.getElementById('cfg-tarifa-pasajero');
const elBasePaquete = document.getElementById('cfg-base-paquete');
const elKmPaquete = document.getElementById('cfg-km-paquete');
@ -840,6 +1082,11 @@ function syncTariffsToUI() {
const elPhone = document.getElementById('cfg-admin-phone');
const elName = document.getElementById('cfg-admin-name');
// Canales de WhatsApp y separación de grupos por App
const elGroupPacific = document.getElementById('cfg-group-pacifictrans');
const elGroupDripzone = document.getElementById('cfg-group-dripzone');
const adminWaGroupBadge = document.getElementById('admin-wa-group-badge');
if (elPasajero) elPasajero.value = currentTariffs.tarifaPasajeroFija;
if (elBasePaquete) elBasePaquete.value = currentTariffs.precioBasePaquete;
if (elKmPaquete) elKmPaquete.value = currentTariffs.precioKmPaquete;
@ -853,6 +1100,12 @@ function syncTariffsToUI() {
if (elPhone && currentTariffs.numeroAtencionWhatsApp) elPhone.value = currentTariffs.numeroAtencionWhatsApp;
if (elName && currentTariffs.nombreSoporte) elName.value = currentTariffs.nombreSoporte;
const pacificGroup = currentTariffs.grupoWhatsAppPacificTrans || 'Pacific Trans Notificaciones';
const dripGroup = currentTariffs.grupoWhatsAppDripzone || 'Dripzone Notificaciones';
if (elGroupPacific) elGroupPacific.value = pacificGroup;
if (elGroupDripzone) elGroupDripzone.value = dripGroup;
if (adminWaGroupBadge) adminWaGroupBadge.textContent = `👥 Grupo: ${pacificGroup}`;
// 2. Sincronización del enlace dinámico de atención WhatsApp en el footer y navbar
const phoneFormatted = formatPhoneNumber(currentTariffs.numeroAtencionWhatsApp || '573167455877');
const cleanPhone = (currentTariffs.numeroAtencionWhatsApp || '573167455877').replace(/\D/g, '');
@ -894,6 +1147,59 @@ function formatPhoneNumber(num) {
function initTariffsManager() {
const form = document.getElementById('form-tariffs-config');
const statusEl = document.getElementById('tariffs-save-status');
const btnDetect = document.getElementById('btn-detect-wa-groups');
// Auto-Detección de Grupos WhatsApp mediante Evolution API
if (btnDetect) {
btnDetect.addEventListener('click', async () => {
btnDetect.disabled = true;
const originalText = btnDetect.innerHTML;
btnDetect.innerHTML = '⏳ Detectando grupos...';
try {
const token = localStorage.getItem('pt_token') || '';
const res = await fetch('/api/admin/whatsapp/groups', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await res.json();
if (res.ok && data.success) {
const inputPacific = document.getElementById('cfg-group-pacifictrans');
const inputDrip = document.getElementById('cfg-group-dripzone');
const badgePacific = document.getElementById('badge-pacific-jid');
const badgeDrip = document.getElementById('badge-dripzone-jid');
if (inputPacific && data.pacificTrans?.groupName) {
inputPacific.value = data.pacificTrans.groupName;
}
if (badgePacific && data.pacificTrans?.resolvedJid) {
badgePacific.innerHTML = `✓ Destino: <strong>${data.pacificTrans.resolvedJid}</strong> (${data.pacificTrans.isGroup ? 'Grupo de WhatsApp' : 'Número Directo'})`;
badgePacific.style.color = '#34d399';
}
if (inputDrip && data.dripzone?.groupName) {
inputDrip.value = data.dripzone.groupName;
}
if (badgeDrip && data.dripzone?.resolvedJid) {
badgeDrip.innerHTML = `✓ Destino: <strong>${data.dripzone.resolvedJid}</strong> (${data.dripzone.isGroup ? 'Grupo de WhatsApp' : 'Número Directo'})`;
badgeDrip.style.color = '#34d399';
}
alert(`✅ Detección exitosa en WhatsApp Evolution:\n\n• Pacifictrans Go ➔ ${data.pacificTrans?.groupName} (${data.pacificTrans?.resolvedJid})\n• Dripzone ➔ ${data.dripzone?.groupName} (${data.dripzone?.resolvedJid})`);
} else {
alert('No se pudieron consultar los grupos: ' + (data.error || 'Verifica la sesión'));
}
} catch (err) {
console.error('Error detect groups:', err);
alert('Error al consultar Evolution API');
} finally {
btnDetect.disabled = false;
btnDetect.innerHTML = originalText;
}
});
}
if (!form) return;
@ -919,13 +1225,19 @@ function initTariffsManager() {
costoPenalidadCancelacion: parseFloat(document.getElementById('cfg-penalidad')?.value || '15000'),
radioMaxPoolingMetros: parseFloat(document.getElementById('cfg-radio-pooling')?.value || '4000'),
numeroAtencionWhatsApp: document.getElementById('cfg-admin-phone')?.value.trim() || '573167455877',
nombreSoporte: document.getElementById('cfg-admin-name')?.value.trim() || 'Atención Pacifictrans Go'
nombreSoporte: document.getElementById('cfg-admin-name')?.value.trim() || 'Atención Pacifictrans Go',
grupoWhatsAppPacificTrans: document.getElementById('cfg-group-pacifictrans')?.value.trim() || 'Pacific Trans Notificaciones',
grupoWhatsAppDripzone: document.getElementById('cfg-group-dripzone')?.value.trim() || 'Dripzone Notificaciones'
};
try {
const token = localStorage.getItem('pt_token') || '';
const res = await fetch('/api/tariffs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
@ -934,7 +1246,7 @@ function initTariffsManager() {
currentTariffs = { ...currentTariffs, ...data.config };
syncTariffsToUI();
if (statusEl) {
statusEl.innerHTML = `✅ <strong>¡Configuración guardada y sincronizada!</strong> Pasajero: $${payload.tarifaPasajeroFija.toLocaleString('es-CO')} COP | WhatsApp Atención: +${payload.numeroAtencionWhatsApp}`;
statusEl.innerHTML = `✅ <strong>¡Configuración guardada y sincronizada!</strong> Pasajero: $${payload.tarifaPasajeroFija.toLocaleString('es-CO')} COP | Grupo Pacific: "${payload.grupoWhatsAppPacificTrans}" | Grupo Dripzone: "${payload.grupoWhatsAppDripzone}"`;
statusEl.style.color = '#10b981';
}
} else {

View File

@ -14,6 +14,7 @@ import { tariffsService } from './services/tariffs.service';
import { authService } from './services/auth.service';
import { statsReviewsService } from './services/stats-reviews.service';
import { settlementService } from './services/settlement.service';
import { whatsAppNotificationService } from './services/whatsapp-notification.service';
import { Trip } from './models/Trip';
const PORT = process.env.PORT || 4000;
@ -106,6 +107,33 @@ const server = http.createServer(async (req, res) => {
return sendJson(200, { success: true, user });
}
// 1.4. Autenticación: Quick Role Login para alternar roles y probar seguridad de rutas
if (pathname === '/api/auth/quick-role-login' && req.method === 'POST') {
const body = await getJsonBody();
const role = body.role || 'ADMIN';
const result = await authService.createSessionForRole(role, body.name);
return sendJson(200, { success: true, ...result });
}
// Helper de seguridad por roles y permisos en rutas API
const getToken = (): string => {
const authHeader = (req.headers['authorization'] as string) || '';
return authHeader.replace(/^Bearer\s+/i, '').trim() || url.searchParams.get('token') || (req.headers['x-session-token'] as string) || '';
};
const requireRole = async (allowedRoles: ('ADMIN' | 'CONDUCTOR' | 'PASAJERO' | 'OPERADOR')[]) => {
const masterKey = req.headers['x-admin-key'] || url.searchParams.get('master_key');
if (masterKey === 'admin_master_pacifictrans_2026') {
return { allowed: true, user: { rol: 'ADMIN', nombre: 'Admin Master' } };
}
const token = getToken();
if (!token) {
// En peticiones públicas sin token, denegar si es ruta estrictamente protegida
return { allowed: false, error: 'Autenticación requerida: Inicia sesión con credenciales autorizadas.' };
}
return authService.verifyRole(token, allowedRoles);
};
// 2. Pooling inteligente (Transporte)
if (pathname === '/api/trips/pool' && req.method === 'POST') {
const body = await getJsonBody();
@ -120,27 +148,35 @@ const server = http.createServer(async (req, res) => {
return sendJson(200, result);
}
// 4. Panel Admin: Listar viajes pendientes de aprobación
// 4. Panel Admin: Listar viajes pendientes de aprobación (Protegido por Rol)
if (pathname === '/api/trips/pending' && req.method === 'GET') {
const auth = await requireRole(['ADMIN', 'OPERADOR']);
if (!auth.allowed) return sendJson(403, { success: false, error: auth.error });
const pending = await adminApprovalService.getTripsPendientesAprobacion();
return sendJson(200, { success: true, count: pending.length, trips: pending });
}
// 5. Panel Admin: Aprobar y Activar Viaje (Asignando conductor y disparando Push)
// 5. Panel Admin: Aprobar y Activar Viaje (Protegido por Rol)
if (pathname === '/api/trips/approve' && req.method === 'POST') {
const auth = await requireRole(['ADMIN', 'OPERADOR']);
if (!auth.allowed) return sendJson(403, { success: false, error: auth.error });
const body = await getJsonBody();
const approved = await adminApprovalService.aprobarYActivarViaje(body);
return sendJson(200, { success: true, trip: approved });
}
// 5.1. Panel Admin: Listar viajes con cupo lleno (4/4) listos para programación
// 5.1. Panel Admin: Listar viajes con cupo lleno (4/4) listos para programación (Protegido)
if (pathname === '/api/trips/ready-to-schedule' && req.method === 'GET') {
const auth = await requireRole(['ADMIN', 'OPERADOR']);
if (!auth.allowed) return sendJson(403, { success: false, error: auth.error });
const trips = await tripSchedulingService.getViajesListosParaProgramar();
return sendJson(200, { success: true, count: trips.length, trips });
}
// 5.2. Panel Admin: Programar formalmente horario de salida, escalonar paradas y notificar
// 5.2. Panel Admin: Programar formalmente horario de salida (Protegido)
if (pathname === '/api/trips/schedule' && req.method === 'POST') {
const auth = await requireRole(['ADMIN', 'OPERADOR']);
if (!auth.allowed) return sendJson(403, { success: false, error: auth.error });
const body = await getJsonBody();
const scheduled = await tripSchedulingService.programarViaje(body);
return sendJson(200, { success: true, trip: scheduled });
@ -237,6 +273,25 @@ const server = http.createServer(async (req, res) => {
return sendJson(200, { success: true, kpis });
}
// 12.1. Administrador: Consultar grupos de WhatsApp para separación de notificaciones por App
if (pathname === '/api/admin/whatsapp/groups' && req.method === 'GET') {
const pacificJid = await whatsAppNotificationService.getPacificTransNotificationTarget();
const dripzoneJid = await whatsAppNotificationService.getDripzoneNotificationTarget();
return sendJson(200, {
success: true,
pacificTrans: {
groupName: 'Pacific Trans Notificaciones',
resolvedJid: pacificJid,
isGroup: pacificJid.endsWith('@g.us'),
},
dripzone: {
groupName: 'Dripzone Notificaciones',
resolvedJid: dripzoneJid,
isGroup: dripzoneJid.endsWith('@g.us'),
},
});
}
// 13. Actualización de posición GPS desde la App del Conductor
if (pathname === '/api/drivers/location-update' && req.method === 'POST') {
const body = await getJsonBody();

View File

@ -18,6 +18,8 @@ export interface ITarifasConfig extends Document {
radioMaxPoolingMetros: number; // Distancia máxima en metros para agrupar pasajeros en Cali ($near / $geoWithin)
numeroAtencionWhatsApp: string; // Número de WhatsApp oficial para atención, soporte y mensajes
nombreSoporte: string; // Nombre del canal de soporte oficial
grupoWhatsAppPacificTrans: string; // Grupo oficial para notificaciones de Pacific Trans Go
grupoWhatsAppDripzone: string; // Grupo oficial para notificaciones de Dripzone
estaActiva: boolean;
createdAt: Date;
updatedAt: Date;
@ -81,6 +83,16 @@ const TarifasConfigSchema = new Schema<ITarifasConfig>(
default: 'Atención Pacifictrans Go',
trim: true,
},
grupoWhatsAppPacificTrans: {
type: String,
default: 'Pacific Trans Notificaciones',
trim: true,
},
grupoWhatsAppDripzone: {
type: String,
default: 'Dripzone Notificaciones',
trim: true,
},
estaActiva: {
type: Boolean,
default: true,

View File

@ -15,8 +15,8 @@ interface OtpRecord {
const OTP_CACHE = new Map<string, OtpRecord>();
const OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutos de validez
// Memoria simple de tokens para la plataforma web y API
const SESSION_TOKENS = new Map<string, { userId: string; rol: UserRole; expiresAt: number }>();
// Memoria de tokens para la plataforma web y API con soporte de fallback
const SESSION_TOKENS = new Map<string, { userId: string; rol: UserRole; expiresAt: number; cachedUser?: any }>();
export class AuthService {
private static instance: AuthService;
@ -214,18 +214,108 @@ export class AuthService {
return null;
}
const user = await User.findById(session.userId);
if (!user) return null;
try {
const user = await User.findById(session.userId);
if (user) {
return {
id: user._id.toString(),
nombre: user.nombre,
telefono: user.telefono,
email: user.email,
rol: user.rol,
phoneVerified: user.phoneVerified,
saldoPenalidad: user.saldoPenalidad,
direccionTexto: user.direccionTexto,
};
}
} catch (e) {
if (session.cachedUser) return session.cachedUser;
}
return session.cachedUser || null;
}
/**
* Valida si un token tiene un rol permitido
*/
public async verifyRole(token: string, allowedRoles: UserRole[]): Promise<{ allowed: boolean; user?: any; error?: string }> {
if (!token) {
return { allowed: false, error: 'Acceso no autorizado: Token de autenticación requerido.' };
}
const user = await this.getUserFromToken(token);
if (!user) {
return { allowed: false, error: 'Sesión expirada o no válida. Por favor inicia sesión nuevamente.' };
}
if (!allowedRoles.includes(user.rol)) {
return {
allowed: false,
error: `Acceso denegado: Esta ruta requiere rol [${allowedRoles.join(', ')}]. Tu rol actual es [${user.rol}].`,
user,
};
}
return { allowed: true, user };
}
/**
* Crea o reactiva una sesión rápida para un rol determinado (útil para auditoría, pruebas y panel)
*/
public async createSessionForRole(role: UserRole, customName?: string): Promise<{ token: string; user: any }> {
let phone = '573167455877';
let defaultName = 'Carlos Almeira (SuperAdmin)';
if (role === 'CONDUCTOR') {
phone = '573158923456';
defaultName = 'Mario Fernando G. (Renault Duster WLZ-890)';
} else if (role === 'PASAJERO') {
phone = '573187654321';
defaultName = 'Pasajero Frecuente Cali';
}
let userDoc: any = null;
try {
userDoc = await User.findOne({ rol: role });
if (!userDoc) {
userDoc = await User.create({
appwriteId: `pt-${role.toLowerCase()}-${Date.now()}`,
nombre: customName || defaultName,
telefono: phone,
email: `${role.toLowerCase()}@pacifictrans.almeira.com.co`,
rol: role,
saldoPenalidad: 0,
coordenadasDomicilio: { type: 'Point', coordinates: [-76.532, 3.4516] },
isActive: true,
phoneVerified: true,
twoFactorEnabled: true,
twoFactorMethod: 'whatsapp_otp',
});
}
} catch (err) {
// Si la BD no responde inmediatamente en local, continuar con usuario temporal
}
const userId = userDoc?._id ? userDoc._id.toString() : `role-${role.toLowerCase()}-${Date.now()}`;
const userPayload = {
id: userId,
_id: userId,
nombre: userDoc?.nombre || customName || defaultName,
telefono: userDoc?.telefono || phone,
email: userDoc?.email || `${role.toLowerCase()}@pacifictrans.almeira.com.co`,
rol: role,
phoneVerified: true,
saldoPenalidad: 0,
direccionTexto: 'Cali, Valle del Cauca',
};
const token = `pt_sess_${crypto.randomBytes(24).toString('hex')}`;
SESSION_TOKENS.set(token, {
userId,
rol: role,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
cachedUser: userPayload,
});
return {
id: user._id.toString(),
nombre: user.nombre,
telefono: user.telefono,
email: user.email,
rol: user.rol,
phoneVerified: user.phoneVerified,
saldoPenalidad: user.saldoPenalidad,
direccionTexto: user.direccionTexto,
token,
user: userPayload,
};
}
}

View File

@ -636,8 +636,8 @@ export class SettlementService {
tripId: tripId ? new Types.ObjectId(tripId) : undefined,
});
// 1. Notificar al Administrador encargado por WhatsApp
const adminPhone = whatsAppNotificationService.getAdminPhone();
// 1. Notificar al Administrador encargado por WhatsApp (Grupo Pacific Trans Notificaciones)
const adminTarget = await whatsAppNotificationService.getPacificTransNotificationTarget();
const msgAdmin =
`🔔 *NUEVA SOLICITUD DE TRANSFERENCIA BRE-B*\n` +
`_Central de Pagos & Despacho Pacifictrans Go_\n\n` +
@ -649,7 +649,7 @@ export class SettlementService {
`📄 *No. Solicitud:* \`${referenciaSolicitud}\`\n\n` +
`_Por favor ingresa al Panel Administrativo para autorizar y realizar la transferencia por Bre-B._`;
await whatsAppNotificationService.sendTextMessage(adminPhone, msgAdmin);
await whatsAppNotificationService.sendTextMessage(adminTarget, msgAdmin);
// 2. Notificar al Conductor por WhatsApp
const msgConductor =

View File

@ -16,6 +16,8 @@ export interface UpdateTarifasDTO {
radioMaxPoolingMetros?: number;
numeroAtencionWhatsApp?: string;
nombreSoporte?: string;
grupoWhatsAppPacificTrans?: string;
grupoWhatsAppDripzone?: string;
}
export class TariffsService {
@ -118,6 +120,14 @@ export class TariffsService {
config.nombreSoporte = updates.nombreSoporte.trim();
}
if (updates.grupoWhatsAppPacificTrans !== undefined && updates.grupoWhatsAppPacificTrans.trim()) {
config.grupoWhatsAppPacificTrans = updates.grupoWhatsAppPacificTrans.trim();
}
if (updates.grupoWhatsAppDripzone !== undefined && updates.grupoWhatsAppDripzone.trim()) {
config.grupoWhatsAppDripzone = updates.grupoWhatsAppDripzone.trim();
}
config.updatedAt = new Date();
await config.save();

View File

@ -15,6 +15,8 @@ export class WhatsAppNotificationService {
adminPhone: process.env.ADMIN_WHATSAPP_PHONE || '573167455877',
};
private groupJidCache: Map<string, string> = new Map();
private constructor() {}
public static getInstance(): WhatsAppNotificationService {
@ -36,11 +38,91 @@ export class WhatsAppNotificationService {
}
/**
* Envía un mensaje de texto con formato WhatsApp a través de Evolution API
* Busca en Evolution API el JID de un grupo de WhatsApp por nombre
*/
public async sendTextMessage(phone: string, text: string): Promise<boolean> {
const cleanPhone = phone.replace(/\D/g, '');
const target = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
public async findGroupJidByName(groupName: string): Promise<string | null> {
const key = groupName.toLowerCase().trim();
if (this.groupJidCache.has(key)) {
return this.groupJidCache.get(key) || null;
}
try {
const url = `${this.config.evolutionApiUrl}/chat/findChats/${this.config.instanceName}`;
const res = await fetch(url, {
method: 'POST',
headers: {
apikey: this.config.apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!res.ok) return null;
const data = await res.json();
const chats = Array.isArray(data) ? data : data.response || [];
const matched = chats.find((c: any) => {
const name = (c.name || c.pushName || '').toLowerCase().trim();
const jid = c.remoteJid || c.id || '';
return jid.endsWith('@g.us') && (name.includes(key) || key.includes(name));
});
if (matched && matched.remoteJid) {
this.groupJidCache.set(key, matched.remoteJid);
console.log(`👥 [Grupo WhatsApp Resuelto]: "${matched.name || groupName}" -> ${matched.remoteJid}`);
return matched.remoteJid;
}
} catch (err: any) {
console.warn(`⚠️ [WhatsApp Group Search]: ${err.message}`);
}
return null;
}
/**
* Destino oficial para notificaciones de Pacifictrans Go:
* 1. Grupo "Pacific Trans Notificaciones" (creado por Carlos)
* 2. Grupo "Pacific Trans"
* 3. Fallback al grupo "Notificaciones Almeira"
* 4. Fallback al teléfono personal de Carlos (+57 316 745 5877)
*/
public async getPacificTransNotificationTarget(): Promise<string> {
const targetGroup = await this.findGroupJidByName('Pacific Trans Notificaciones');
if (targetGroup) return targetGroup;
const altPacific = await this.findGroupJidByName('Pacific Trans');
if (altPacific) return altPacific;
const almeiraGroup = await this.findGroupJidByName('Notificaciones Almeira');
if (almeiraGroup) return almeiraGroup;
return this.config.adminPhone;
}
/**
* Destino oficial para notificaciones de Dripzone:
* Grupo "Dripzone Notificaciones"
*/
public async getDripzoneNotificationTarget(): Promise<string> {
const dripGroup = await this.findGroupJidByName('Dripzone Notificaciones');
if (dripGroup) return dripGroup;
const altDrip = await this.findGroupJidByName('Dripzone');
if (altDrip) return altDrip;
return this.config.adminPhone;
}
/**
* Envía un mensaje de texto con formato WhatsApp a través de Evolution API
* Soporta tanto números telefónicos individuales (+57...) como Grupos WhatsApp (...@g.us)
*/
public async sendTextMessage(destination: string, text: string): Promise<boolean> {
let target = destination.trim();
if (!target.endsWith('@g.us')) {
const cleanPhone = target.replace(/\D/g, '');
target = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
}
const url = `${this.config.evolutionApiUrl}/message/sendText/${this.config.instanceName}`;
const payload = {
@ -65,7 +147,7 @@ export class WhatsAppNotificationService {
return false;
}
console.log(`✅ [WhatsApp Enviado Exitosamente a +${target}]`);
console.log(`✅ [WhatsApp Enviado Exitosamente a ${target}]`);
return true;
} catch (err: any) {
console.error(`❌ [WhatsApp Exception] (${target}):`, err.message);
@ -207,7 +289,8 @@ export class WhatsAppNotificationService {
`• *Valor Total:* $${params.valorTotal.toLocaleString('es-CO')} COP\n\n` +
`📲 *Panel de Control:* Entra para asignar conductor y aprobar el despacho.`;
return this.sendTextMessage(this.config.adminPhone, msg);
const target = await this.getPacificTransNotificationTarget();
return this.sendTextMessage(target, msg);
}
/**
@ -226,7 +309,8 @@ export class WhatsAppNotificationService {
(params.conductorSugerido ? `${params.conductorSugerido}\n` : '') +
`\n⚡ *Acción Requerida:* Confirma la asignación del conductor y define la hora de salida en el panel. Una vez asignado, se notificará a los 4 pasajeros con sus datos y horarios.`;
return this.sendTextMessage(this.config.adminPhone, msg);
const target = await this.getPacificTransNotificationTarget();
return this.sendTextMessage(target, msg);
}
/**