This commit is contained in:
Lizandro Guarnizo
2026-01-22 00:19:20 -05:00
parent ee016d62f5
commit 5e345b951f
7 changed files with 211 additions and 7 deletions
+1 -1
View File
@@ -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
+32
View File
@@ -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;
}
+18 -4
View File
@@ -504,6 +504,8 @@ class SimpleWhatsAppManager {
// Unread badge
const unreadBadge = (conv.unread_count && conv.unread_count > 0) ?
`<div class="conversation-unread">${conv.unread_count}</div>` : '';
// 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 += `
<div class="conversation-item" onclick="openChatWindow(${conv.user_id})">
<div class="conversation-item${unreadClass}" onclick="openChatWindow(${conv.user_id})">
<div class="conversation-avatar ${isOnline ? 'online' : ''}">
${userInitial}
</div>
@@ -622,8 +624,10 @@ class SimpleWhatsAppManager {
? '<i class="fas fa-image text-info" title="Imagen"></i> '
: '';
const unreadClass = (conv.unread_count && conv.unread_count > 0) ? ' unread' : '';
html += `
<div class="conversation-item" onclick="openChatWindow(${conv.user_id})">
<div class="conversation-item${unreadClass}" onclick="openChatWindow(${conv.user_id})">
<div class="conversation-avatar ${isOnline ? 'online' : ''}">
${userInitial}
</div>
@@ -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';
+3
View File
@@ -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();
}
+29 -2
View File
@@ -72,7 +72,14 @@ try {
<div>
<h1><i class="fas fa-robot"></i> Sistema de Gestión WhatsApp Bot</h1>
<small class="text-muted">
<i class="fas fa-user"></i> Conectado como <strong><?= htmlspecialchars(ADMIN_USERNAME) ?></strong>
<i class="fas fa-user"></i> Conectado como <strong><?= htmlspecialchars(
// Preferir nombre completo del admin si existe
(
$_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? ADMIN_USERNAME
)
) ?></strong>
| <i class="fas fa-clock"></i> Sesión desde: <?= date('H:i:s', $_SESSION['login_time'] ?? time()) ?>
</small>
</div>
@@ -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();
+105
View File
@@ -0,0 +1,105 @@
<?php
require_once __DIR__ . '/../config/config_enhanced.php';
// Load DB connection
require_once __DIR__ . '/../classes/Database.php';
error_reporting(E_ALL);
ini_set('display_errors', 1);
/* This script was used to create the admin user 'yurley'.
For security reasons, the original version that contained a plaintext password
has been sanitized. To create or update admin users, use a secure script that
accepts the password interactively or via a protected environment variable. */
$username = 'yurley';
$full_name = 'yurley';
$email = 'coordinacionsig@laboratorioximenacaicedo.com';
$sessionTimeoutSeconds = 31536000; // 1 year
try {
$db = Database::getInstance();
// Ensure admin_users table exists (same schema as migrar_passwords_bd)
$db->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);
}
+23
View File
@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/../config/config_enhanced.php';
$db = Database::getInstance();
// Get an existing user_id (user of conversations)
$user = $db->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";
}