diff --git a/.env b/.env index 5196508..3542124 100644 --- a/.env +++ b/.env @@ -28,7 +28,7 @@ TIMEZONE=America/Bogota # Seguridad ADMIN_USERNAME=admin ADMIN_PASSWORD=contraseña_admin_segura -SESSION_TIMEOUT=1800 +SESSION_TIMEOUT=31536000 MAX_LOGIN_ATTEMPTS=3 LOGIN_LOCKOUT_TIME=900 diff --git a/assets/css/styles.css b/assets/css/styles.css index c666001..c93a9f0 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -1086,6 +1086,38 @@ body { justify-content: center; } +/* Unread conversation visual state: darker green and subtle background */ +.conversation-item.unread { + background: linear-gradient(90deg, #effff5, #f6fff9); +} +.conversation-item.unread .conversation-avatar { + background: linear-gradient(135deg, #0f9a6a, #0b7a57); + box-shadow: 0 6px 18px rgba(11, 122, 87, 0.25); +} + +/* Notification bell indicator when there are unseen notifications */ +#notification-bell.has-notifications { + position: relative; +} +#notification-bell.has-notifications::after { + content: ''; + position: absolute; + top: 6px; + right: 6px; + width: 12px; + height: 12px; + background: #15803d; /* darker green */ + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(21, 128, 61, 0.12); + animation: pulseGreen 2s infinite; +} + +@keyframes pulseGreen { + 0% { transform: scale(1); opacity: 0.9; } + 50% { transform: scale(1.15); opacity: 0.6; } + 100% { transform: scale(1); opacity: 0.9; } +} + .conversation-status-icon { font-size: 0.8rem; } diff --git a/assets/js/app_simple.js b/assets/js/app_simple.js index 3f1222b..b794a1f 100644 --- a/assets/js/app_simple.js +++ b/assets/js/app_simple.js @@ -504,6 +504,8 @@ class SimpleWhatsAppManager { // Unread badge const unreadBadge = (conv.unread_count && conv.unread_count > 0) ? `
${conv.unread_count}
` : ''; + // Add class to highlight unread conversation (darker green) + const unreadClass = (conv.unread_count && conv.unread_count > 0) ? ' unread' : ''; // Status icon const statusIcon = conv.last_direction === 'outgoing' @@ -518,7 +520,7 @@ class SimpleWhatsAppManager { : ''; html += ` -
+
${userInitial}
@@ -622,8 +624,10 @@ class SimpleWhatsAppManager { ? ' ' : ''; + const unreadClass = (conv.unread_count && conv.unread_count > 0) ? ' unread' : ''; + html += ` -
+
${userInitial}
@@ -1664,9 +1668,19 @@ window.debugAPI = async function (endpoint) { }; // Función para abrir ventana de chat -window.openChatWindow = function (userId) { +window.openChatWindow = async function (userId) { console.log('Abriendo chat del usuario:', userId); - + + // Marcar conversación como leída en el backend y refrescar lista localmente + try { + await fetch('api/mark_conversation_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({user_id: userId})}); + if (window.whatsappManager && typeof window.whatsappManager.loadConversations === 'function') { + window.whatsappManager.loadConversations(); + } + } catch (e) { + console.warn('mark_conversation_read failed', e); + } + // Abrir nueva ventana de chat const chatUrl = `chat_window.php?user_id=${userId}&debug=true`; const windowFeatures = 'width=1000,height=700,resizable=yes,scrollbars=yes,status=yes,toolbar=no,menubar=no,location=no'; diff --git a/config/config_enhanced.php b/config/config_enhanced.php index c7f8e7f..8771445 100644 --- a/config/config_enhanced.php +++ b/config/config_enhanced.php @@ -326,6 +326,9 @@ if (session_status() == PHP_SESSION_NONE) { ini_set('session.cookie_httponly', 1); ini_set('session.cookie_secure', isset($_SERVER['HTTPS'])); ini_set('session.use_strict_mode', 1); + // Asegurar que la cookie de sesión y GC respeten el timeout configurado (SESSION_TIMEOUT) + ini_set('session.cookie_lifetime', (int)SESSION_TIMEOUT); + ini_set('session.gc_maxlifetime', (int)SESSION_TIMEOUT); session_start(); } diff --git a/index.php b/index.php index 948a6e2..9291249 100644 --- a/index.php +++ b/index.php @@ -72,7 +72,14 @@ try {

Sistema de Gestión WhatsApp Bot

- Conectado como + Conectado como | Sesión desde:
@@ -936,6 +943,8 @@ try { // Global Notification Manager: polls server for unread notifications and shows toasts + native notifications const NotificationManager = { interval: 7000, + // IDs de notificaciones ya mostradas para evitar re-sonar/repetir + _seenIds: new Set(), init() { this.bell = document.getElementById('notification-bell'); this.countEl = document.getElementById('notification-count'); @@ -950,6 +959,8 @@ try { if ("Notification" in window && Notification.permission === 'default') { Notification.requestPermission().then(p => console.log('Notification permission:', p)); } + // Al abrir la campana se considera que se atendieron visualmente las notificaciones: limpiar indicador visual + this.bell.classList.remove('has-notifications'); }); } @@ -971,10 +982,19 @@ try { if (unreadCount > 0) { this.countEl.textContent = unreadCount; this.countEl.style.display = 'inline-block'; + this.bell.classList.add('has-notifications'); } else { this.countEl.style.display = 'none'; + this.bell.classList.remove('has-notifications'); } - json.data.forEach(n => this.showToast(n)); + + // Mostrar sólo las nuevas notificaciones (no repetir sonido/visual) + json.data.forEach(n => { + if (!this._seenIds.has(n.id)) { + this._seenIds.add(n.id); + this.showToast(n); + } + }); } else if (json && json.success === false) { console.warn('Notification API error:', json.error || json); } @@ -1033,6 +1053,9 @@ try { try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {} const userId = data.user_id || notification.user_id; if (userId) { + // marcar conversaciones como leídas también + try { await fetch('api/mark_conversation_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({user_id: userId})}); } catch(e) { console.warn('mark_conversation_read failed', e); } + const convLink = document.querySelector('.nav-link[data-tab="conversations"]'); if (convLink) convLink.click(); const tryOpen = () => { @@ -1070,6 +1093,9 @@ try { window.focus(); const userId = data.user_id || notification.user_id; if (userId) { + // marcar conversación leída cuando se abre desde la notification nativa + try { fetch('api/mark_conversation_read.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({user_id: userId})}); } catch(e) { console.warn('mark_conversation_read failed', e); } + const convLink = document.querySelector('.nav-link[data-tab="conversations"]'); if (convLink) convLink.click(); const tryOpen = () => { @@ -1088,6 +1114,7 @@ try { // play sound try { + // usar Web Audio API para reproducir un sonido breve const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); diff --git a/scripts/create_admin_yurley.php b/scripts/create_admin_yurley.php new file mode 100644 index 0000000..1c5c1d6 --- /dev/null +++ b/scripts/create_admin_yurley.php @@ -0,0 +1,105 @@ +execute("CREATE TABLE IF NOT EXISTS admin_users ( + id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(100), + full_name VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + last_login DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_username (username), + INDEX idx_active (is_active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"); + + // Hash password + $passwordHash = password_hash($password, PASSWORD_BCRYPT); + + // Check if user exists + $existing = $db->fetch("SELECT id FROM admin_users WHERE username = ?", [$username]); + if ($existing) { + $db->update('admin_users', [ + 'password_hash' => $passwordHash, + 'email' => $email, + 'full_name' => $full_name, + 'is_active' => 1 + ], 'id = ?', ['id' => $existing['id']]); + echo "Usuario '$username' actualizado (id={$existing['id']}).\n"; + } else { + $id = $db->insert('admin_users', [ + 'username' => $username, + 'password_hash' => $passwordHash, + 'email' => $email, + 'full_name' => $full_name, + 'is_active' => 1 + ]); + echo "Usuario '$username' creado con id=$id.\n"; + } + + // Update system_config 'session_timeout' to 1 year (in seconds) if table exists + try { + $db->execute("CREATE TABLE IF NOT EXISTS system_config ( + id INT PRIMARY KEY AUTO_INCREMENT, + config_key VARCHAR(100) UNIQUE NOT NULL, + config_value TEXT, + config_type VARCHAR(50) DEFAULT 'string', + description TEXT, + is_encrypted TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_config_key (config_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"); + + // Upsert session_timeout + $stmt = $db->getConnection()->prepare("INSERT INTO system_config (config_key, config_value, config_type, description) VALUES (?, ?, 'integer', ?) ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)"); + $stmt->execute(['session_timeout', (string)$sessionTimeoutSeconds, 'Timeout de sesión en segundos (1 año)']); + echo "Configuración 'session_timeout' actualizada a $sessionTimeoutSeconds segundos en DB.\n"; + } catch (Exception $ex) { + echo "No se pudo actualizar system_config: " . $ex->getMessage() . "\n"; + } + + // Also update .env SESSION_TIMEOUT if writable + $envFile = __DIR__ . '/../.env'; + if (is_writable($envFile)) { + $contents = file_get_contents($envFile); + if (strpos($contents, 'SESSION_TIMEOUT=') !== false) { + $newContents = preg_replace('/SESSION_TIMEOUT\s*=\s*\d+/', 'SESSION_TIMEOUT=' . $sessionTimeoutSeconds, $contents); + } else { + $newContents = rtrim($contents, "\n") . "\nSESSION_TIMEOUT={$sessionTimeoutSeconds}\n"; + } + if (@file_put_contents($envFile, $newContents) !== false) { + echo ".env actualizado: SESSION_TIMEOUT={$sessionTimeoutSeconds}\n"; + } else { + echo "No se pudo escribir en .env (permiso denegado).\n"; + } + } else { + echo ".env no es escribible o no existe, se omitió actualización del archivo.\n"; + } + + echo "Hecho. Recuerda que las sesiones actuales seguirán su tiempo de expiración hasta que los usuarios vuelvan a iniciar sesión (para aplicar el nuevo timeout).\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/scripts/test_add_notification.php b/scripts/test_add_notification.php new file mode 100644 index 0000000..b064be2 --- /dev/null +++ b/scripts/test_add_notification.php @@ -0,0 +1,23 @@ +fetch('SELECT id, phone_number FROM users LIMIT 1'); +$userId = $user['id'] ?? null; + +$data = [ + 'user_id' => $userId, + 'type' => 'message', + 'message' => 'Prueba: nuevo mensaje recibido', + 'data' => json_encode(['phone' => $user['phone_number'] ?? '']), + 'is_read' => 0, + 'created_at' => date('Y-m-d H:i:s') +]; + +$id = $db->insert('notifications', $data); +if ($id) { + echo "Inserted notification id=$id for user_id={$userId}\n"; +} else { + echo "Failed to insert notification\n"; +}