diff --git a/README_LAB.md b/README_LAB.md new file mode 100644 index 0000000..fce6d1b --- /dev/null +++ b/README_LAB.md @@ -0,0 +1,129 @@ +# Módulo Administrativo — Laboratorio Clínico + +Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes. + +--- + +## Instalación + +### 1. Ejecutar migraciones de base de datos + +```bash +php migrations/20260302_lab_run_migrations.php +``` + +Crea 7 tablas nuevas sin modificar las existentes: +- `lab_pacientes` +- `lab_enfermeras` +- `lab_ordenes_medicas` +- `lab_domicilios` +- `lab_asignaciones` +- `lab_autorizaciones` +- `lab_actividad_admin` + +Para deshacer: +```bash +php migrations/20260302_lab_run_migrations.php --rollback +``` + +### 2. Verificar instalación + +Accede desde el navegador (con sesión admin activa): +``` +https://tu-servidor/lab_status.php +``` + +O desde CLI: +```bash +php lab_status.php +``` + +--- + +## Archivos del módulo + +### Vistas PHP +| Archivo | Descripción | +|---|---| +| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real | +| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) | +| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp | +| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras | +| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria | +| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV | +| `lab_status.php` | Verificador de estado del módulo | + +### Clases (models) +Ubicadas en `classes/lab/`: +- `ActividadAdmin.php` — Base de trazabilidad +- `Paciente.php` — Modelo de pacientes +- `Enfermera.php` — Modelo de enfermeras +- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados +- `Domicilio.php` — Modelo de domicilios con flujo de estados +- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio + +### API REST +Ubicados en `api/lab/`: +| Endpoint | Método | Descripción | +|---|---|---| +| `get_pacientes.php` | GET | Lista paginada de pacientes | +| `save_paciente.php` | POST | Crear/actualizar paciente | +| `get_ordenes.php` | GET | Lista/detalle de órdenes | +| `save_orden.php` | POST | Crear/actualizar orden | +| `autorizar_orden.php` | POST | Cambiar estado de una orden | +| `get_domicilios.php` | GET | Lista/detalle de domicilios | +| `save_domicilio.php` | POST | Crear/actualizar domicilio | +| `get_enfermeras.php` | GET | Lista de enfermeras + agenda | +| `save_enfermera.php` | POST | Crear/actualizar enfermera | +| `get_asignaciones.php` | GET | Asignaciones por fecha | +| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera | +| `get_actividad.php` | GET | Log de actividad con filtros | +| `get_stats.php` | GET | Estadísticas para dashboard | +| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa | + +--- + +## Flujos de estado + +### Órdenes médicas +``` +pendiente → en_revision → autorizada → en_domicilio → completada + ↘ rechazada +``` + +### Domicilios +``` +programado → confirmado → en_camino → en_domicilio → completado + ↘ cancelado + ↘ reprogramado +``` + +--- + +## Integración con el chatbot + +En `conversations.php`, los mensajes de imagen entrantes tienen un botón **** (verde) en las acciones del mensaje. Al hacer click: + +1. Se abre un modal con la imagen adjunta +2. El operador busca o selecciona un paciente (o usa el contacto de la conversación) +3. Completa datos opcionales (médico, exámenes, ayuno) +4. Se crea la orden en estado `pendiente` + +--- + +## Exportaciones CSV + +Disponibles desde `lab_reportes.php`: +- **Órdenes médicas** del período — incluye estado, médico, exámenes +- **Domicilios** del período — incluye enfermera asignada, dirección, estado +- **Pacientes** — catálogo completo con total de órdenes + +--- + +## Requisitos + +- PHP 8.2+ +- MariaDB 10.11+ (o MySQL 8+) +- Bootstrap 5.3 (ya incluido en el sistema) +- Font Awesome 6.4 (ya incluido en el sistema) +- `uploads/media/` con permisos de escritura (755/775) diff --git a/api/export_conversations.php b/api/export_conversations.php new file mode 100644 index 0000000..70a22f7 --- /dev/null +++ b/api/export_conversations.php @@ -0,0 +1,123 @@ += :date_from'; + $params[':date_from'] = $dateFrom . ' 00:00:00'; +} +if ($dateTo !== '') { + $where[] = 'c.created_at <= :date_to'; + $params[':date_to'] = $dateTo . ' 23:59:59'; +} + +$whereSQL = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; + +// Nombre de archivo dinámico +$filename = 'conversaciones_' . date('Y-m-d'); +if ($dateFrom || $dateTo) $filename .= '_' . ($dateFrom ?: 'inicio') . '_a_' . ($dateTo ?: 'hoy'); +$filename .= '.csv'; + +// Headers para descarga +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +header('Cache-Control: no-cache, must-revalidate'); + +try { + $db = Database::getInstance(); + $pdo = $db->getConnection(); + + $sql = " + SELECT + c.id AS id, + u.phone_number AS telefono, + COALESCE(u.name, 'Sin nombre') AS nombre_usuario, + c.direction AS direccion, + c.message_type AS tipo_mensaje, + c.status AS estado, + CASE WHEN c.is_read = 1 THEN 'Sí' ELSE 'No' END AS leido, + REPLACE(REPLACE(COALESCE(c.content,''), '\r\n', ' '), '\n', ' ') + AS contenido, + c.media_url AS url_media, + c.filename AS archivo, + c.created_at AS fecha_hora + FROM conversations c + INNER JOIN users u ON u.id = c.user_id + $whereSQL + ORDER BY c.created_at DESC + LIMIT 50000 + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + $output = fopen('php://output', 'w'); + + // BOM UTF-8 para compatibilidad con Excel + fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + // Encabezados CSV + fputcsv($output, [ + 'ID', + 'Teléfono', + 'Nombre Usuario', + 'Dirección', + 'Tipo Mensaje', + 'Estado', + 'Leído', + 'Contenido', + 'URL Media', + 'Archivo', + 'Fecha y Hora', + ], ';'); + + // Filas + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + fputcsv($output, [ + $row['id'], + $row['telefono'], + $row['nombre_usuario'], + $row['direccion'], + $row['tipo_mensaje'], + $row['estado'], + $row['leido'], + $row['contenido'], + $row['url_media'] ?? '', + $row['archivo'] ?? '', + $row['fecha_hora'] ? date('d/m/Y H:i:s', strtotime($row['fecha_hora'])) : '', + ], ';'); + } + + fclose($output); + +} catch (Exception $e) { + error_log('export_conversations.php error: ' . $e->getMessage()); + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: inline'); + http_response_code(500); + echo json_encode(['error' => 'Error al exportar conversaciones']); +} diff --git a/api/get_conversation_list.php b/api/get_conversation_list.php index 1a6a0c2..f065734 100644 --- a/api/get_conversation_list.php +++ b/api/get_conversation_list.php @@ -32,6 +32,8 @@ try { c.created_at as last_message_time, c.status as last_message_status, u.advisor_requested as advisor_requested, + u.terms_pending as terms_pending, + u.terms_accepted_at as terms_accepted_at, COUNT(*) as total_conversations, SUM(CASE WHEN c.direction = 'incoming' AND c.status = 'received' THEN 1 ELSE 0 END) as unread_count FROM users u @@ -41,7 +43,7 @@ try { FROM conversations GROUP BY user_id ) - GROUP BY u.id, u.phone_number, u.name, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested + GROUP BY u.id, u.phone_number, u.name, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested, u.terms_pending, u.terms_accepted_at ORDER BY c.created_at DESC LIMIT 50" ); @@ -63,6 +65,8 @@ try { 'last_message_time' => $conv['last_message_time'], 'last_message_status' => $conv['last_message_status'] ?? 'sent', 'advisor_requested' => !empty($conv['advisor_requested']) ? true : false, + 'terms_pending' => !empty($conv['terms_pending']) ? true : false, + 'terms_accepted_at' => $conv['terms_accepted_at'] ?? null, 'total_conversations' => intval($conv['total_conversations']), 'unread_count' => intval($conv['unread_count']), 'time_ago' => timeAgo($conv['last_message_time']) diff --git a/api/get_terms_acceptances.php b/api/get_terms_acceptances.php new file mode 100644 index 0000000..a291811 --- /dev/null +++ b/api/get_terms_acceptances.php @@ -0,0 +1,114 @@ +getConnection(); + + // ── Parámetros ──────────────────────────────────────────────────────── + $estado = in_array($_GET['estado'] ?? '', ['aceptado', 'rechazado', 'pendiente']) + ? $_GET['estado'] : null; + $fecha = !empty($_GET['fecha']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['fecha']) + ? $_GET['fecha'] : null; + $phone = isset($_GET['phone']) ? trim($_GET['phone']) : ''; + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = min(200, max(1, (int)($_GET['per_page'] ?? 50))); + $offset = ($page - 1) * $perPage; + + // ── WHERE ───────────────────────────────────────────────────────────── + $where = []; + $params = []; + + if ($estado !== null) { + $where[] = "ta.estado COLLATE utf8mb4_unicode_ci = :estado"; + $params[':estado'] = $estado; + } + if ($fecha !== null) { + $where[] = 'DATE(ta.fecha_envio) = :fecha'; + $params[':fecha'] = $fecha; + } + if ($phone !== '') { + $where[] = "ta.phone_number COLLATE utf8mb4_unicode_ci LIKE :phone"; + $params[':phone'] = '%' . $phone . '%'; + } + + $whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + // ── Stats (sin filtro de paginación) ────────────────────────────────── + $statsSQL = " + SELECT + COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'aceptado'), 0) AS aceptado, + COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'rechazado'), 0) AS rechazado, + COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'pendiente'), 0) AS pendiente, + COUNT(*) AS total + FROM terms_acceptance ta + $whereClause + "; + $stmtStats = $pdo->prepare($statsSQL); + $stmtStats->execute($params); + $statsRow = $stmtStats->fetch(PDO::FETCH_ASSOC); + + $stats = [ + 'aceptado' => (int)($statsRow['aceptado'] ?? 0), + 'rechazado' => (int)($statsRow['rechazado'] ?? 0), + 'pendiente' => (int)($statsRow['pendiente'] ?? 0), + ]; + $total = (int)($statsRow['total'] ?? 0); + + // ── Datos paginados ─────────────────────────────────────────────────── + $dataSQL = " + SELECT + ta.id, + ta.phone_number, + u.name AS user_name, + ta.estado, + tv.version AS terms_version, + ta.fecha_envio, + ta.fecha_respuesta + FROM terms_acceptance ta + LEFT JOIN users u ON u.phone_number COLLATE utf8mb4_unicode_ci = ta.phone_number COLLATE utf8mb4_unicode_ci + LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id + $whereClause + ORDER BY ta.fecha_envio DESC + LIMIT :limit OFFSET :offset + "; + + $stmtData = $pdo->prepare($dataSQL); + foreach ($params as $k => $v) { + $stmtData->bindValue($k, $v); + } + $stmtData->bindValue(':limit', $perPage, PDO::PARAM_INT); + $stmtData->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmtData->execute(); + $rows = $stmtData->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + 'success' => true, + 'data' => $rows, + 'stats' => $stats, + 'total' => $total, + 'page' => $page, + 'pages' => (int)ceil($total / $perPage), + ], JSON_UNESCAPED_UNICODE); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/get_terms_config.php b/api/get_terms_config.php new file mode 100644 index 0000000..658ac57 --- /dev/null +++ b/api/get_terms_config.php @@ -0,0 +1,30 @@ +fetch( + "SELECT id, version, documento_url, documento_nombre, mensaje_aceptacion, + mensaje_rechazo, forzar_reenvio, activa, created_at + FROM terms_versions + WHERE activa = 1 + ORDER BY id DESC LIMIT 1" + ); + + echo json_encode(['success' => true, 'data' => $active ?: null]); +} catch (Exception $e) { + error_log('get_terms_config.php error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/lab/_helpers.php b/api/lab/_helpers.php new file mode 100644 index 0000000..136f6c7 --- /dev/null +++ b/api/lab/_helpers.php @@ -0,0 +1,131 @@ + false, + 'error' => 'Error interno del servidor: ' . $err['message'], + ]); + } +}); + +// Cabeceras JSON estándar +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +requireAuthentication(); + +/** + * ID del admin autenticado actualmente. + */ +function adminId(): ?int { + return isset($_SESSION['admin_user']['id']) + ? (int)$_SESSION['admin_user']['id'] + : null; +} + +/** + * Rol del usuario autenticado ('admin' | 'enfermero'). + */ +function userRole(): string { + return $_SESSION['admin_user']['role'] ?? 'admin'; +} + +/** + * Detiene la ejecución con 403 JSON si el usuario no es admin (bloquea enfermeros). + */ +function requireAdmin(): void { + if (userRole() !== 'admin') { + jsonError('Acceso restringido a administradores', 403); + } +} + +/** + * Detiene la ejecución si no se puede actuar como enfermero. + * Acepta tanto admins (que pueden actuar en nombre de cualquier enfermera) + * como el propio enfermero autenticado. + */ +function requireEnfermeroAccess(int $enfermeraIdSolicitado): void { + if (userRole() === 'admin') return; + $propio = enfermeraId(); + if (!$propio || $propio !== $enfermeraIdSolicitado) { + jsonError('Solo puedes gestionar tus propios domicilios', 403); + } +} + +/** + * Lee el body JSON del request. + */ +function inputJson(): array { + static $parsed = null; + if ($parsed === null) { + $raw = file_get_contents('php://input'); + $parsed = json_decode($raw, true) ?? []; + } + return $parsed; +} + +/** + * Devuelve una respuesta de éxito. + */ +function jsonOk(array $payload = [], string $mensaje = ''): void { + ob_clean(); + $resp = ['ok' => true, 'success' => true]; + if ($mensaje) { + $resp['message'] = $mensaje; + } + echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE); + exit; +} + +/** + * Devuelve un error JSON con código HTTP. + */ +function jsonError(string $mensaje, int $code = 400): void { + ob_clean(); + http_response_code($code); + echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE); + exit; +} + +/** + * Valida que el método HTTP sea el esperado. + */ +function requireMethod(string $method): void { + if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) { + jsonError('Método no permitido', 405); + } +} diff --git a/api/lab/autorizar_orden.php b/api/lab/autorizar_orden.php new file mode 100644 index 0000000..ca2e299 --- /dev/null +++ b/api/lab/autorizar_orden.php @@ -0,0 +1,46 @@ +cambiarEstado($id, $accion, $admin, $comentario); + + $mensajes = [ + 'en_revision' => 'Orden marcada en revisión', + 'autorizada' => 'Orden autorizada correctamente', + 'rechazada' => 'Orden rechazada', + 'en_domicilio'=> 'Orden marcada como en domicilio', + 'completada' => 'Orden completada', + ]; + + jsonOk(['id' => $id], $mensajes[$accion] ?? 'Estado actualizado'); +} catch (InvalidArgumentException $e) { + jsonError($e->getMessage()); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/crear_desde_whatsapp.php b/api/lab/crear_desde_whatsapp.php new file mode 100644 index 0000000..f0e5243 --- /dev/null +++ b/api/lab/crear_desde_whatsapp.php @@ -0,0 +1,110 @@ + false, 'error' => 'conversation_id o user_id requerido']); + exit; + } + + // Obtener user_id y phone_number: por conversation_id o directamente por user_id + if ($convId) { + $conv = $db->fetch( + 'SELECT c.id, c.user_id, u.phone_number, u.name AS whatsapp_name + FROM conversations c + JOIN users u ON u.id = c.user_id + WHERE c.id = ?', + [$convId] + ); + } else { + $conv = $db->fetch( + 'SELECT u.id AS user_id, u.phone_number, u.name AS whatsapp_name + FROM users u + WHERE u.id = ?', + [$userId] + ); + } + + if (!$conv) { + echo json_encode(['success' => false, 'error' => 'Conversación / usuario no encontrado']); + exit; + } + + $pacienteRepo = new Paciente(); + $pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']); + $paciente = $pacienteRepo->obtener($pacienteId); + echo json_encode(['success' => true, 'paciente' => $paciente]); + exit; +} + +// ── POST: crear orden ────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Método no permitido']); + exit; +} + +$input = json_decode(file_get_contents('php://input'), true); +if (!$input) { + echo json_encode(['success' => false, 'error' => 'Datos inválidos']); + exit; +} + +$pacienteId = (int)($input['paciente_id'] ?? 0); +if (!$pacienteId) { + echo json_encode(['success' => false, 'error' => 'paciente_id requerido']); + exit; +} + +// Construir datos de la orden +$datos = [ + 'paciente_id' => $pacienteId, + 'conversation_id' => !empty($input['conversation_id']) ? (int)$input['conversation_id'] : null, + 'local_file' => $input['local_file'] ?? null, + 'media_message_id' => $input['media_message_id'] ?? null, + 'medico_nombre' => $input['medico_nombre'] ?? null, + 'fecha_orden' => !empty($input['fecha_orden']) ? $input['fecha_orden'] : null, + 'examenes_solicitados'=> $input['examenes_solicitados']?? null, + 'requiere_ayuno' => isset($input['requiere_ayuno']) ? (int)$input['requiere_ayuno'] : 0, + 'horas_ayuno' => !empty($input['horas_ayuno']) ? (int)$input['horas_ayuno'] : null, + 'notas_admin' => $input['notas_admin'] ?? null, + 'estado' => 'pendiente', +]; + +try { + $orden = new OrdenMedica(); + $ordenId = $orden->crear($datos, $adminId); + + echo json_encode([ + 'success' => true, + 'orden_id' => $ordenId, + 'message' => "Orden médica #$ordenId creada correctamente", + ]); +} catch (Exception $e) { + error_log('[lab/crear_desde_whatsapp] ' . $e->getMessage()); + echo json_encode(['success' => false, 'error' => 'Error interno: ' . $e->getMessage()]); +} diff --git a/api/lab/create_enfermero_user.php b/api/lab/create_enfermero_user.php new file mode 100644 index 0000000..ea28681 --- /dev/null +++ b/api/lab/create_enfermero_user.php @@ -0,0 +1,92 @@ +fetch('SELECT id, nombre_completo FROM lab_enfermeras WHERE id = ?', [$enfermeraId]); + if (!$enf) jsonError('Enfermera no encontrada', 404); + + if (empty($fullName)) $fullName = $enf['nombre_completo']; + + // ¿Ya existe un usuario vinculado a esta enfermera? + $existente = $db->fetch( + 'SELECT id, username FROM admin_users WHERE enfermera_id = ? LIMIT 1', + [$enfermeraId] + ); + + if ($existente) { + // --- ACTUALIZAR --- + // Verificar que el nuevo username no esté en uso por OTRO usuario + $conflicto = $db->fetch( + 'SELECT id FROM admin_users WHERE username = ? AND id != ?', + [$username, $existente['id']] + ); + if ($conflicto) jsonError("El usuario '$username' ya está en uso por otra cuenta"); + + $sets = ['username = ?', 'full_name = ?', 'is_active = 1', 'updated_at = NOW()']; + $vals = [$username, $fullName]; + + if (!empty($password)) { + if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres'); + $sets[] = 'password_hash = ?'; + $vals[] = password_hash($password, PASSWORD_DEFAULT); + } + + $vals[] = $existente['id']; + $db->execute( + 'UPDATE admin_users SET ' . implode(', ', $sets) . ' WHERE id = ?', + $vals + ); + + jsonOk(['user_id' => $existente['id']], 'Acceso actualizado correctamente'); + + } else { + // --- CREAR --- + if (empty($password)) jsonError('La contraseña es obligatoria para crear el acceso'); + if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres'); + + // Verificar username único + $conflicto = $db->fetch('SELECT id FROM admin_users WHERE username = ?', [$username]); + if ($conflicto) jsonError("El usuario '$username' ya está en uso"); + + $hash = password_hash($password, PASSWORD_DEFAULT); + $db->execute( + "INSERT INTO admin_users (username, password_hash, full_name, role, enfermera_id, is_active, created_at) + VALUES (?, ?, ?, 'enfermero', ?, 1, NOW())", + [$username, $hash, $fullName, $enfermeraId] + ); + $userId = $db->lastInsertId(); + + jsonOk(['user_id' => $userId], 'Acceso creado correctamente'); + } + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/delete_lab_user.php b/api/lab/delete_lab_user.php new file mode 100644 index 0000000..c234f79 --- /dev/null +++ b/api/lab/delete_lab_user.php @@ -0,0 +1,26 @@ +fetch("SELECT id, username FROM admin_users WHERE id = ?", [$id]); +if (!$user) jsonError('Usuario no encontrado', 404); + +$db->query("DELETE FROM admin_users WHERE id = ?", [$id]); + +jsonOk([], "Usuario '{$user['username']}' eliminado correctamente"); diff --git a/api/lab/delete_role.php b/api/lab/delete_role.php new file mode 100644 index 0000000..1fe5edd --- /dev/null +++ b/api/lab/delete_role.php @@ -0,0 +1,28 @@ +fetch("SELECT id, is_system, slug FROM roles WHERE id = ?", [$id]); +if (!$role) jsonError('Rol no encontrado', 404); +if ($role['is_system']) jsonError('Los roles del sistema no se pueden eliminar'); + +// Reasignar usuarios que tengan este rol al rol admin (id=1) antes de borrar +$db->query("UPDATE admin_users SET role_id = 1, role = 'admin' WHERE role_id = ?", [$id]); + +// La FK ON DELETE CASCADE borra role_modules automáticamente +$db->query("DELETE FROM roles WHERE id = ?", [$id]); + +jsonOk([], 'Rol eliminado. Los usuarios afectados fueron reasignados al rol Administrador.'); diff --git a/api/lab/get_actividad.php b/api/lab/get_actividad.php new file mode 100644 index 0000000..014e0bd --- /dev/null +++ b/api/lab/get_actividad.php @@ -0,0 +1,25 @@ +porAdmin($adminFilt, $limit); + } elseif ($entidad && $modulo) { + $data = $log->porEntidad($modulo, $entidad); + } else { + $data = $log->reciente($limit, $modulo); + } + + jsonOk(['data' => $data, 'total' => count($data)]); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_asignaciones.php b/api/lab/get_asignaciones.php new file mode 100644 index 0000000..c50ed65 --- /dev/null +++ b/api/lab/get_asignaciones.php @@ -0,0 +1,21 @@ +porFecha($fecha); + $carga = $asig->cargaHoy(); + + jsonOk([ + 'data' => $data, + 'total' => count($data), + 'fecha' => $fecha, + 'carga_hoy' => $carga, + ]); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_config.php b/api/lab/get_config.php new file mode 100644 index 0000000..48911c7 --- /dev/null +++ b/api/lab/get_config.php @@ -0,0 +1,17 @@ +valor (para el builder) + */ +require_once __DIR__ . '/_helpers.php'; +requireMethod('GET'); + +$db = Database::getInstance(); +$rows = $db->fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave'); + +$cfg = []; +foreach ($rows as $r) { + $cfg[$r['clave']] = $r['valor']; +} + +jsonOk(['config' => $cfg]); diff --git a/api/lab/get_domicilios.php b/api/lab/get_domicilios.php new file mode 100644 index 0000000..20e137b --- /dev/null +++ b/api/lab/get_domicilios.php @@ -0,0 +1,41 @@ +obtener((int)$_GET['id']); + if (!$d) { + jsonError('Domicilio no encontrado', 404); + } + jsonOk(['domicilio' => $d]); + } + + $filtros = array_filter([ + 'fecha' => $_GET['fecha'] ?? '', + 'desde' => $_GET['desde'] ?? '', + 'hasta' => $_GET['hasta'] ?? '', + 'estado' => $_GET['estado'] ?? '', + 'enfermera_id' => (int)($_GET['enfermera_id'] ?? 0) ?: null, + 'paciente_id' => (int)($_GET['paciente_id'] ?? 0) ?: null, + ], fn($v) => $v !== null && $v !== ''); + + $pagina = max(1, (int)($_GET['page'] ?? 1)); + $por = max(1, min(200, (int)($_GET['limit'] ?? 30))); + + $resultado = $dom->listar($filtros, $pagina, $por); + + // Incluir resumen del día + if (empty($_GET['no_stats'])) { + $resultado['estadisticas_hoy'] = $dom->estadisticasHoy(); + $resultado['sin_asignar_hoy'] = count($dom->sinAsignar()); + } + + jsonOk($resultado); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_enfermeras.php b/api/lab/get_enfermeras.php new file mode 100644 index 0000000..d53effe --- /dev/null +++ b/api/lab/get_enfermeras.php @@ -0,0 +1,47 @@ +obtener($id); + if (!$e) { + jsonError('Enfermera no encontrada', 404); + } + $fecha = $_GET['fecha'] ?? date('Y-m-d'); + $e['agenda'] = $enf->agenda($id, $fecha); + + // Incluir info de acceso al sistema (si tiene usuario creado) + $db = Database::getInstance(); + $u = $db->fetch( + 'SELECT id, username FROM admin_users WHERE enfermera_id = ? AND role = "enfermero" LIMIT 1', + [$id] + ); + $e['usuario_acceso'] = $u ?: null; + + jsonOk(['enfermera' => $e]); + } + + $soloActivas = !isset($_GET['todas']) || $_GET['todas'] !== '1'; + $lista = $enf->listar($soloActivas); + + // Enriquecer con username de acceso para cada enfermera + $db = Database::getInstance(); + foreach ($lista as &$item) { + $u = $db->fetch( + 'SELECT username FROM admin_users WHERE enfermera_id = ? AND is_active = 1 LIMIT 1', + [$item['id']] + ); + $item['username_acceso'] = $u['username'] ?? null; + } + unset($item); + + jsonOk(['data' => $lista, 'total' => count($lista)]); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_formularios.php b/api/lab/get_formularios.php new file mode 100644 index 0000000..8bb122b --- /dev/null +++ b/api/lab/get_formularios.php @@ -0,0 +1,39 @@ + $form->listarEnvios($filtros)]); + } + + // Plantilla específica + if (!empty($_GET['id'])) { + $f = $form->obtener((int)$_GET['id']); + if (!$f) jsonError('Formulario no encontrado', 404); + jsonOk(['formulario' => $f]); + } + + // Lista de plantillas + $todas = isset($_GET['todas']) && userRole() === 'admin'; + jsonOk(['data' => $form->listar(!$todas)]); + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_lab_users.php b/api/lab/get_lab_users.php new file mode 100644 index 0000000..8d41b51 --- /dev/null +++ b/api/lab/get_lab_users.php @@ -0,0 +1,41 @@ +fetchAll(" + SELECT + u.id, + u.username, + u.full_name, + u.email, + u.is_active, + u.role, + u.role_id, + u.enfermera_id, + u.last_login, + u.created_at, + r.name AS role_name, + r.color AS role_color, + r.slug AS role_slug, + e.nombre_completo AS enfermera_nombre + FROM admin_users u + LEFT JOIN roles r ON r.id = u.role_id + LEFT JOIN lab_enfermeras e ON e.id = u.enfermera_id + ORDER BY u.id ASC +"); + +foreach ($users as &$u) { + $u['is_active'] = (bool)$u['is_active']; + $u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin'); + $u['role_color'] = $u['role_color'] ?? '#0d6efd'; +} +unset($u); + +jsonOk(['users' => $users]); diff --git a/api/lab/get_ordenes.php b/api/lab/get_ordenes.php new file mode 100644 index 0000000..ad22d3d --- /dev/null +++ b/api/lab/get_ordenes.php @@ -0,0 +1,39 @@ + $_GET['estado'] ?? '', + 'paciente_id' => (int)($_GET['paciente_id'] ?? 0) ?: null, + 'desde' => $_GET['desde'] ?? '', + 'hasta' => $_GET['hasta'] ?? '', + 'busqueda' => trim($_GET['busqueda'] ?? $_GET['search'] ?? ''), + ], fn($v) => $v !== null && $v !== ''); + + $pagina = max(1, (int)($_GET['page'] ?? 1)); + $por = max(1, min(100, (int)($_GET['limit'] ?? 30))); + + // Vista de una sola orden + if (!empty($_GET['id'])) { + $orden = $om->obtener((int)$_GET['id']); + if (!$orden) { + jsonError('Orden no encontrada', 404); + } + jsonOk(['orden' => $orden]); + } + + $resultado = $om->listar($filtros, $pagina, $por); + + // Incluir también el contador por estado para el dashboard + if (!isset($_GET['no_counts'])) { + $resultado['contadores'] = $om->contadorPorEstado(); + } + + jsonOk($resultado); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_pacientes.php b/api/lab/get_pacientes.php new file mode 100644 index 0000000..9d60f12 --- /dev/null +++ b/api/lab/get_pacientes.php @@ -0,0 +1,16 @@ +listar($busqueda, $pagina, $por)); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/get_roles.php b/api/lab/get_roles.php new file mode 100644 index 0000000..380de56 --- /dev/null +++ b/api/lab/get_roles.php @@ -0,0 +1,28 @@ +fetchAll("SELECT id, name, slug, description, color, is_system, created_at FROM roles ORDER BY id ASC"); + +foreach ($roles as &$role) { + $mods = $db->fetchAll( + "SELECT module_slug FROM role_modules WHERE role_id = ? ORDER BY module_slug", + [$role['id']] + ); + $role['modules'] = array_column($mods, 'module_slug'); + $role['is_system'] = (bool)$role['is_system']; + $role['user_count'] = (int)$db->fetch( + "SELECT COUNT(*) AS c FROM admin_users WHERE role_id = ?", + [$role['id']] + )['c']; +} +unset($role); + +jsonOk(['roles' => $roles]); diff --git a/api/lab/get_stats.php b/api/lab/get_stats.php new file mode 100644 index 0000000..1bcd7f0 --- /dev/null +++ b/api/lab/get_stats.php @@ -0,0 +1,64 @@ +contadorPorEstado(); + $pendientesViejos = $om->pendientesAntiguos(5); + + // ── Domicilios hoy ─────────────────────────────────────────────────────── + $estDomHoy = $dom->estadisticasHoy(); + $sinAsignarHoy = $dom->sinAsignar(); + + // ── Carga de enfermeras hoy ────────────────────────────────────────────── + $cargaEnfermeras = $asig->cargaHoy(); + + // ── Totales generales ──────────────────────────────────────────────────── + $totalPacientes = $db->fetch('SELECT COUNT(*) AS n FROM lab_pacientes WHERE is_active = 1')['n'] ?? 0; + $totalEnfermeras = $db->fetch('SELECT COUNT(*) AS n FROM lab_enfermeras WHERE is_active = 1')['n'] ?? 0; + + // ── Órdenes de hoy ─────────────────────────────────────────────────────── + $ordenesHoy = $db->fetch( + 'SELECT COUNT(*) AS n FROM lab_ordenes_medicas WHERE DATE(created_at) = CURDATE()' + )['n'] ?? 0; + + // ── Actividad reciente ─────────────────────────────────────────────────── + $actividadReciente = (new ActividadAdmin())->reciente(10); + + jsonOk([ + 'ordenes' => [ + 'por_estado' => $estadosOrdenes, + 'pendientes_hoy' => (int)($estadosOrdenes['pendiente'] ?? 0), + 'en_revision_hoy' => (int)($estadosOrdenes['en_revision'] ?? 0), + 'pendientes_viejos'=> $pendientesViejos, + 'total_hoy' => (int)$ordenesHoy, + ], + 'domicilios' => [ + 'por_estado' => $estDomHoy, + 'total_hoy' => array_sum($estDomHoy), + 'sin_asignar' => count($sinAsignarHoy), + 'lista_sin_asignar' => $sinAsignarHoy, + ], + 'enfermeras' => [ + 'activas' => (int)$totalEnfermeras, + 'carga_hoy' => $cargaEnfermeras, + ], + 'generales' => [ + 'total_pacientes' => (int)$totalPacientes, + ], + 'actividad_reciente' => $actividadReciente, + ]); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/my_agenda.php b/api/lab/my_agenda.php new file mode 100644 index 0000000..ee1cf00 --- /dev/null +++ b/api/lab/my_agenda.php @@ -0,0 +1,64 @@ +agenda($eid, $fecha); + + // Enriquecer con servicios extra y estado de asignación + $db = Database::getInstance(); + foreach ($agenda as &$item) { + $item['servicios_extra'] = $db->fetchAll( + "SELECT * FROM lab_servicios_extra WHERE domicilio_id = ? ORDER BY created_at ASC", + [(int)$item['domicilio_id']] + ); + // Info completa del domicilio (notas, indicaciones) + $extra = $db->fetch( + "SELECT notas_admin, indicaciones_dir, barrio, ciudad FROM lab_domicilios WHERE id = ?", + [(int)$item['domicilio_id']] + ); + if ($extra) $item = array_merge($item, $extra); + } + unset($item); + + // Resumen rápido + $totales = array_count_values(array_column($agenda, 'domicilio_estado')); + + jsonOk([ + 'enfermera_id' => $eid, + 'fecha' => $fecha, + 'agenda' => $agenda, + 'total' => count($agenda), + 'totales' => $totales, + ]); + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_asignacion.php b/api/lab/save_asignacion.php new file mode 100644 index 0000000..88be2fc --- /dev/null +++ b/api/lab/save_asignacion.php @@ -0,0 +1,49 @@ +liberar($id, $admin, $datos['motivo'] ?? ''); + jsonOk(['id' => $id], 'Asignación liberada'); + } + if ($datos['accion'] === 'completar') { + $asig->completar($id, $admin); + jsonOk(['id' => $id], 'Asignación completada'); + } + jsonError('Acción no reconocida'); + } + + // Asignar / reasignar + foreach (['domicilio_id', 'enfermera_id'] as $req) { + if (empty($datos[$req])) { + jsonError("El campo $req es obligatorio"); + } + } + + $id = $asig->asignar( + (int)$datos['domicilio_id'], + (int)$datos['enfermera_id'], + $admin, + $datos['notas'] ?? '' + ); + + jsonOk(['id' => $id], 'Enfermera asignada correctamente'); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_config.php b/api/lab/save_config.php new file mode 100644 index 0000000..d5c4f5e --- /dev/null +++ b/api/lab/save_config.php @@ -0,0 +1,32 @@ +query( + 'INSERT INTO lab_config(clave, valor) VALUES(?,?) ON DUPLICATE KEY UPDATE valor=?', + [$clave, $datos[$clave], $datos[$clave]] + ); + $guardadas++; + } +} + +jsonOk(['guardadas' => $guardadas], 'Configuración guardada'); diff --git a/api/lab/save_domicilio.php b/api/lab/save_domicilio.php new file mode 100644 index 0000000..777c7fb --- /dev/null +++ b/api/lab/save_domicilio.php @@ -0,0 +1,76 @@ + $datos['motivo_cancelacion'] ?? null, + 'fecha_reprogramada' => $datos['fecha_reprogramada'] ?? null, + 'hora_llegada' => $datos['hora_llegada'] ?? null, + 'hora_salida' => $datos['hora_salida'] ?? null, + 'observaciones' => $datos['observaciones'] ?? null, + 'muestras_tomadas' => $datos['muestras_tomadas'] ?? null, + ]); + $dom->cambiarEstado($id, $nuevoEstado, $admin, $extras); + jsonOk(['id' => $id], 'Estado actualizado'); + } + + if (!empty($datos['id'])) { + // Actualización general + $id = (int)$datos['id']; + unset($datos['id'], $datos['solo_estado']); + // Normalizar orden_id: vacío o 0 → null + if (isset($datos['orden_id']) && ($datos['orden_id'] === '' || (int)$datos['orden_id'] === 0)) { + $datos['orden_id'] = null; + } + $dom->actualizar($id, $datos, $admin); + jsonOk(['id' => $id], 'Domicilio actualizado correctamente'); + } else { + // Creación + foreach (['paciente_id', 'direccion', 'fecha_programada'] as $req) { + if (empty($datos[$req])) { + jsonError("El campo $req es obligatorio"); + } + } + // Normalizar orden_id: vacío o 0 → null (evita FK constraint) + if (isset($datos['orden_id']) && ($datos['orden_id'] === '' || (int)$datos['orden_id'] === 0)) { + $datos['orden_id'] = null; + } + // Si el creador es un enfermero y no vino enfermera_id, auto-asignarlo desde la sesión + if (empty($datos['enfermera_id']) && userRole() === 'enfermero') { + $eid = enfermeraId(); + if ($eid) $datos['enfermera_id'] = $eid; + } + $id = $dom->crear($datos, $admin); + + // Crear asignación automática si se indicó enfermera_id + if (!empty($datos['enfermera_id'])) { + $asig = new Asignacion(); + $asig->asignar($id, (int)$datos['enfermera_id'], $admin); + } + + jsonOk(['id' => $id], 'Domicilio creado correctamente'); + } +} catch (InvalidArgumentException $e) { + jsonError($e->getMessage()); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_enfermera.php b/api/lab/save_enfermera.php new file mode 100644 index 0000000..db9280a --- /dev/null +++ b/api/lab/save_enfermera.php @@ -0,0 +1,40 @@ +desactivar($id, $admin); + jsonOk(['id' => $id], 'Enfermera desactivada'); + } + + $enf->actualizar($id, $datos, $admin); + jsonOk(['id' => $id], 'Enfermera actualizada correctamente'); + } else { + foreach (['nombre_completo', 'numero_documento', 'telefono'] as $req) { + if (empty($datos[$req])) { + jsonError("El campo $req es obligatorio"); + } + } + $id = $enf->crear($datos, $admin); + jsonOk(['id' => $id], 'Enfermera registrada correctamente'); + } +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_formulario.php b/api/lab/save_formulario.php new file mode 100644 index 0000000..1b0a5c6 --- /dev/null +++ b/api/lab/save_formulario.php @@ -0,0 +1,44 @@ +actualizar((int)$datos['id'], ['is_active' => 0], $admin); + jsonOk(['id' => (int)$datos['id']], 'Formulario eliminado'); + } + + // Editar + if (!empty($datos['id'])) { + $id = (int)$datos['id']; + unset($datos['id']); + $form->actualizar($id, $datos, $admin); + jsonOk(['id' => $id], 'Formulario actualizado'); + } + + // Crear + if (empty($datos['nombre'])) jsonError('El nombre es obligatorio'); + if (empty($datos['esquema'])) jsonError('El esquema es obligatorio'); + + $id = $form->crear($datos, $admin); + jsonOk(['id' => $id], 'Formulario creado correctamente'); + +} catch (InvalidArgumentException $e) { + jsonError($e->getMessage()); +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_lab_user.php b/api/lab/save_lab_user.php new file mode 100644 index 0000000..5efedd1 --- /dev/null +++ b/api/lab/save_lab_user.php @@ -0,0 +1,87 @@ +fetch("SELECT id, slug FROM roles WHERE id = ?", [$roleId]); +if (!$role) jsonError('Rol no encontrado', 404); +$roleSlug = $role['slug']; + +// Si el rol es enfermero y no hay enfermera_id, buscar si tiene ya uno vinculado +// (se puede crear sin vincular y vincularlo después desde la pantalla de Enfermeras) + +if ($id) { + // ── Actualizar ────────────────────────────────────────── + $existing = $db->fetch("SELECT id, username FROM admin_users WHERE id = ?", [$id]); + if (!$existing) jsonError('Usuario no encontrado', 404); + + // Verificar duplicado de username (excluir el propio) + $dup = $db->fetch("SELECT id FROM admin_users WHERE username = ? AND id != ?", [$username, $id]); + if ($dup) jsonError('Ese nombre de usuario ya está en uso'); + + $params = [$username, $fullName, $email ?: null, $roleId, $roleSlug, $isActive, $enfermeraId, $id]; + $sql = "UPDATE admin_users + SET username=?, full_name=?, email=?, role_id=?, role=?, is_active=?, enfermera_id=?, updated_at=NOW() + WHERE id=?"; + + if ($password) { + if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres'); + $hash = password_hash($password, PASSWORD_BCRYPT); + $sql = "UPDATE admin_users + SET username=?, full_name=?, email=?, role_id=?, role=?, is_active=?, enfermera_id=?, password_hash=?, updated_at=NOW() + WHERE id=?"; + $params = [$username, $fullName, $email ?: null, $roleId, $roleSlug, $isActive, $enfermeraId, $hash, $id]; + } + + $db->query($sql, $params); + jsonOk(['id' => $id], 'Usuario actualizado correctamente'); + +} else { + // ── Crear ──────────────────────────────────────────────── + if (!$password) jsonError('La contraseña es obligatoria al crear un usuario'); + if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres'); + + $dup = $db->fetch("SELECT id FROM admin_users WHERE username = ?", [$username]); + if ($dup) jsonError('Ese nombre de usuario ya está en uso'); + + $hash = password_hash($password, PASSWORD_BCRYPT); + + $db->query( + "INSERT INTO admin_users (username, full_name, email, password_hash, role_id, role, is_active, enfermera_id) + VALUES (?,?,?,?,?,?,?,?)", + [$username, $fullName, $email ?: null, $hash, $roleId, $roleSlug, $isActive, $enfermeraId] + ); + $newId = $db->lastInsertId(); + jsonOk(['id' => $newId], 'Usuario creado correctamente'); +} diff --git a/api/lab/save_orden.php b/api/lab/save_orden.php new file mode 100644 index 0000000..3c4ed3a --- /dev/null +++ b/api/lab/save_orden.php @@ -0,0 +1,33 @@ +actualizar($id, $datos, $admin); + jsonOk(['id' => $id], 'Orden actualizada correctamente'); + } else { + if (empty($datos['paciente_id'])) { + jsonError('El campo paciente_id es obligatorio'); + } + $id = $om->crear($datos, $admin); + jsonOk(['id' => $id], 'Orden creada correctamente'); + } +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_paciente.php b/api/lab/save_paciente.php new file mode 100644 index 0000000..ce9c2d4 --- /dev/null +++ b/api/lab/save_paciente.php @@ -0,0 +1,34 @@ +actualizar($id, $datos, $admin); + jsonOk(['id' => $id], 'Paciente actualizado correctamente'); + } else { + // Creación — nombre obligatorio + if (empty($datos['nombre_completo'])) { + jsonError('El campo nombre_completo es obligatorio'); + } + $id = $pac->crear($datos, $admin); + jsonOk(['id' => $id], 'Paciente creado correctamente'); + } +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/save_role.php b/api/lab/save_role.php new file mode 100644 index 0000000..db19d53 --- /dev/null +++ b/api/lab/save_role.php @@ -0,0 +1,67 @@ +fetch("SELECT id, is_system FROM roles WHERE slug = ?", [$slug]); +if ($existing && (!$id || $existing['id'] !== $id)) { + jsonError('Ya existe un rol con ese slug'); +} + +if ($id) { + // Actualizar + $role = $db->fetch("SELECT id, is_system FROM roles WHERE id = ?", [$id]); + if (!$role) jsonError('Rol no encontrado', 404); + + $db->query( + "UPDATE roles SET name=?, slug=?, description=?, color=?, updated_at=NOW() WHERE id=?", + [$name, $slug, $description, $color, $id] + ); + // Reconstruir módulos solo si no es sistema, o siempre (admin puede editar módulos) + $db->query("DELETE FROM role_modules WHERE role_id = ?", [$id]); +} else { + // Crear + $db->query( + "INSERT INTO roles (name, slug, description, color) VALUES (?,?,?,?)", + [$name, $slug, $description, $color] + ); + $id = $db->lastInsertId(); +} + +// Insertar módulos +foreach ($modules as $mod) { + $mod = trim((string)$mod); + if ($mod) { + $db->query( + "INSERT IGNORE INTO role_modules (role_id, module_slug) VALUES (?,?)", + [$id, $mod] + ); + } +} + +jsonOk(['id' => $id], $id ? 'Rol actualizado' : 'Rol creado'); diff --git a/api/lab/save_servicio_extra.php b/api/lab/save_servicio_extra.php new file mode 100644 index 0000000..4235228 --- /dev/null +++ b/api/lab/save_servicio_extra.php @@ -0,0 +1,82 @@ +fetch('SELECT * FROM lab_servicios_extra WHERE id = ?', [(int)$datos['id']]); + if (!$se) jsonError('Servicio no encontrado', 404); + if (userRole() === 'enfermero') { + requireEnfermeroAccess((int)$se['realizado_por']); + } + $db->delete('lab_servicios_extra', 'id = ?', [(int)$datos['id']]); + jsonOk(['id' => (int)$datos['id']], 'Servicio eliminado'); + } + + // ── Editar ───────────────────────────────────────────────────────────── + if (!empty($datos['id'])) { + $id = (int)$datos['id']; + $se = $db->fetch('SELECT * FROM lab_servicios_extra WHERE id = ?', [$id]); + if (!$se) jsonError('Servicio no encontrado', 404); + if (userRole() === 'enfermero') { + requireEnfermeroAccess((int)$se['realizado_por']); + } + $permitidos = ['descripcion','tipo','notas','requiere_pago','valor']; + $campos = array_intersect_key($datos, array_flip($permitidos)); + if (isset($campos['tipo']) && !in_array($campos['tipo'], $TIPOS_VALIDOS)) { + jsonError('Tipo de servicio inválido'); + } + $db->update('lab_servicios_extra', $campos, 'id = ?', [$id]); + jsonOk(['id' => $id], 'Servicio actualizado'); + } + + // ── Crear ────────────────────────────────────────────────────────────── + foreach (['domicilio_id', 'descripcion', 'tipo'] as $req) { + if (empty($datos[$req])) jsonError("El campo $req es obligatorio"); + } + if (!in_array($datos['tipo'], $TIPOS_VALIDOS)) { + jsonError('Tipo de servicio inválido'); + } + + // Verificar que el domicilio existe y que el enfermero le pertenece + $domId = (int)$datos['domicilio_id']; + if (userRole() === 'enfermero') { + $eid = enfermeraId(); + $asig = $db->fetch( + 'SELECT a.id FROM lab_asignaciones a WHERE a.domicilio_id = ? AND a.enfermera_id = ?', + [$domId, $eid] + ); + if (!$asig) jsonError('No tienes permiso para este domicilio', 403); + } + + $id = $db->insert('lab_servicios_extra', [ + 'domicilio_id' => $domId, + 'descripcion' => trim($datos['descripcion']), + 'tipo' => $datos['tipo'], + 'notas' => $datos['notas'] ?? null, + 'requiere_pago' => !empty($datos['requiere_pago']) ? 1 : 0, + 'valor' => !empty($datos['valor']) ? (float)$datos['valor'] : null, + 'realizado_por' => enfermeraId() ?? (userRole() === 'admin' ? null : null), + ]); + + jsonOk(['id' => $id], 'Servicio extra registrado'); + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/send_formulario.php b/api/lab/send_formulario.php new file mode 100644 index 0000000..059d47c --- /dev/null +++ b/api/lab/send_formulario.php @@ -0,0 +1,86 @@ +obtener((int)$datos['formulario_id']); + if (!$plantilla) jsonError('Formulario no encontrado', 404); + + // Pre-llenado: si viene paciente_id, auto-enriquecer con datos del paciente + $prefilled = $datos['datos_prefilled'] ?? []; + if (!empty($datos['paciente_id'])) { + $pac = (new Paciente())->obtener((int)$datos['paciente_id']); + if ($pac) { + // Mapeo automático de campos estándar + $prefilled['__paciente'] = [ + 'id' => $pac['id'], + 'nombre_completo' => $pac['nombre_completo'], + 'numero_documento'=> $pac['numero_documento'], + 'tipo_documento' => $pac['tipo_documento'], + 'telefono' => $pac['telefono'], + 'email' => $pac['email'] ?? '', + 'fecha_nacimiento'=> $pac['fecha_nacimiento'] ?? '', + 'eps' => $pac['eps'] ?? '', + 'direccion' => $pac['direccion'] ?? '', + 'ciudad' => $pac['ciudad'] ?? '', + ]; + } + } + + $datos['datos_prefilled'] = json_encode($prefilled, JSON_UNESCAPED_UNICODE); + $datos['enviado_via'] = $datos['enviado_via'] ?? 'whatsapp'; + + $envioId = $form->crearEnvio($datos, adminId()); + + // Construir URL pública + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $base = $protocol . '://' . $host; + // Subir un nivel desde /api/lab/ + $basePath = rtrim(dirname(dirname(dirname($_SERVER['SCRIPT_NAME']))), '/'); + + // Obtener el token del envío recién creado + $db = Database::getInstance(); + $envio = $db->fetch('SELECT token FROM lab_form_envios WHERE id = ?', [$envioId]); + $token = $envio['token']; + $url = $base . $basePath . '/form_cliente.php?t=' . $token; + + // Mensaje de WhatsApp preformateado + $nomPac = $prefilled['__paciente']['nombre_completo'] ?? 'Estimado paciente'; + $nomForm = $plantilla['nombre']; + $mensajeWa = "Hola $nomPac, le enviamos el formulario *\"$nomForm\"* para que lo complete y firme digitalmente.\n\n" + . "👉 Accede aquí:\n$url\n\n" + . "El enlace expira en 7 días. Si tiene dudas contáctenos."; + + $telPac = $prefilled['__paciente']['telefono'] ?? ''; + $waUrl = 'https://wa.me/' . preg_replace('/\D/', '', $telPac) + . '?text=' . rawurlencode($mensajeWa); + + jsonOk([ + 'id' => $envioId, + 'token' => $token, + 'url' => $url, + 'whatsapp_url'=> $waUrl, + 'mensaje_wa' => $mensajeWa, + ], 'Envío creado correctamente'); + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/lab/submit_formulario.php b/api/lab/submit_formulario.php new file mode 100644 index 0000000..413a506 --- /dev/null +++ b/api/lab/submit_formulario.php @@ -0,0 +1,131 @@ + true]; + if ($msg) $r['message'] = $msg; + echo json_encode(array_merge($r, $data), JSON_UNESCAPED_UNICODE); + exit; +} +function pubErr(string $msg, int $code = 400): void { + http_response_code($code); + echo json_encode(['success' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); + exit; +} + +try { + $form = new Formulario(); + + // GET: cargar datos del formulario por token + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $token = $_GET['t'] ?? ''; + if (!$token) pubErr('Token requerido'); + $envio = $form->obtenerPorToken($token); + if (!$envio) pubErr('Link inválido o expirado', 404); + + // Config de diseño: primero override del formulario, luego config global + $db2 = Database::getInstance(); + $cfgRows = $db2->fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave'); + $gCfg = []; + foreach ($cfgRows as $r) { $gCfg[$r['clave']] = $r['valor']; } + $docConfig = [ + 'doc_color' => $envio['doc_color'] ?: ($gCfg['doc_color'] ?? '#1565c0'), + 'doc_logo_base64' => $envio['doc_logo_base64'] ?: ($gCfg['doc_logo_base64'] ?? null), + 'doc_encabezado' => $envio['doc_encabezado'] ?: ($gCfg['empresa_nombre'] ?? null), + 'doc_subtitulo' => $envio['doc_subtitulo'] ?: ($gCfg['empresa_subtitulo'] ?? null), + 'doc_pie_pagina' => $envio['doc_pie_pagina'] ?: ($gCfg['doc_pie_pagina'] ?? null), + ]; + + // Estructura que espera form_cliente.php + pubOk([ + 'formulario' => [ + 'nombre' => $envio['form_nombre'], + 'descripcion' => $envio['form_descripcion'] ?? '', + 'esquema_decoded'=> $envio['esquema_decoded'], + 'permite_firma' => (bool)($envio['permite_firma'] ?? false), + 'requiere_firma' => (bool)($envio['requiere_firma'] ?? false), + ], + 'envio' => [ + 'id' => $envio['id'], + 'estado' => $envio['estado'], + 'firma_svg' => $envio['firma_svg'] ?? null, + 'hash_verificacion'=> $envio['hash_verificacion'] ?? null, + 'completado_en' => $envio['completado_en'] ?? null, + ], + 'prefilled' => $envio['prefilled_decoded'] ?? [], + 'config' => $docConfig, + ]); + } + + // POST: guardar respuesta + $raw = file_get_contents('php://input'); + $datos = json_decode($raw, true) ?? []; + + $token = $datos['token'] ?? ''; + if (!$token) pubErr('Token requerido'); + + $envio = $form->obtenerPorToken($token); + if (!$envio) pubErr('Link inválido o expirado', 404); + + if (in_array($envio['estado'], ['completado', 'firmado'])) { + // El formulario ya fue guardado (posible reintento tras error de red). + // Devolvemos éxito con los datos existentes para que el cliente no quede bloqueado. + $db = Database::getInstance(); + $saved = $db->fetch('SELECT id, hash_verificacion, firma_svg FROM lab_form_envios WHERE token = ?', [$token]); + pubOk([ + 'firmado' => !empty($saved['firma_svg']), + 'hash' => $saved['hash_verificacion'] ?? null, + 'envio_id' => $saved['id'] ?? null, + 'ya_enviado'=> true, + ], 'Formulario enviado correctamente. ¡Gracias!'); + } + + $datosCliente = $datos['datos_cliente'] ?? []; + $firmaSvg = $datos['firma_svg'] ?? null; + $firmaFoto = $datos['firma_foto'] ?? null; + + // Guardar foto de firma dentro de datos_cliente para no requerir nueva columna + if ($firmaFoto) { + $datosCliente['__firma_foto'] = $firmaFoto; + } + + // Validar que si requiere firma, venga + if ($envio['requiere_firma'] && !$firmaSvg) { + pubErr('La firma es obligatoria para este formulario'); + } + + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''; + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + + $ok = $form->guardarRespuesta($token, $datosCliente, $firmaSvg, $ip, $ua); + if (!$ok) pubErr('No se pudo guardar la respuesta', 500); + + // Recuperar hash e id para la respuesta + $db = Database::getInstance(); + $saved = $db->fetch('SELECT id, hash_verificacion FROM lab_form_envios WHERE token = ?', [$token]); + + pubOk([ + 'firmado' => (bool)$firmaSvg, + 'hash' => $saved['hash_verificacion'] ?? null, + 'envio_id' => $saved['id'] ?? null, + ], 'Formulario enviado correctamente. ¡Gracias!'); + +} catch (Exception $e) { + pubErr($e->getMessage(), 500); +} diff --git a/api/lab/update_domicilio_enfermero.php b/api/lab/update_domicilio_enfermero.php new file mode 100644 index 0000000..4af9618 --- /dev/null +++ b/api/lab/update_domicilio_enfermero.php @@ -0,0 +1,94 @@ + [estados_siguientes_posibles] +const TRANSICIONES_ENFERMERO = [ + 'programado' => ['confirmado'], + 'confirmado' => ['en_camino', 'cancelado'], + 'en_camino' => ['en_domicilio', 'cancelado'], + 'en_domicilio' => ['completado', 'cancelado'], +]; + +try { + $datos = inputJson(); + $domId = (int)($datos['domicilio_id'] ?? 0); + $estado = trim($datos['nuevo_estado'] ?? ''); + $notas = trim($datos['notas'] ?? ''); + + if (!$domId) jsonError('domicilio_id requerido'); + if (!$estado) jsonError('nuevo_estado requerido'); + + $db = Database::getInstance(); + + // Verificar domicilio y obtener estado actual + $dom = $db->fetch( + 'SELECT id, estado, paciente_id FROM lab_domicilios WHERE id = ?', + [$domId] + ); + if (!$dom) jsonError('Domicilio no encontrado', 404); + + // Verificar que el enfermero tenga asignación activa + if (userRole() === 'enfermero') { + $eid = enfermeraId(); + $asig = $db->fetch( + "SELECT a.id FROM lab_asignaciones a + WHERE a.domicilio_id = ? AND a.enfermera_id = ? + AND a.estado NOT IN ('liberada','completada')", + [$domId, $eid] + ); + if (!$asig) jsonError('No tienes asignación activa para este domicilio', 403); + + // Validar transición + $estadoActual = $dom['estado']; + $permitidos = TRANSICIONES_ENFERMERO[$estadoActual] ?? []; + if (!in_array($estado, $permitidos)) { + jsonError("No puedes pasar de '$estadoActual' a '$estado'"); + } + } + + if ($estado === 'cancelado' && !$notas) { + jsonError('Las notas son obligatorias al cancelar'); + } + + // Actualizar domicilio + $campos = ['estado' => $estado]; + if ($notas) $campos['notas_admin'] = $notas; + if ($estado === 'completado') { + $campos['hora_salida'] = date('H:i:s'); + } + if ($estado === 'en_domicilio') { + $campos['hora_llegada'] = date('H:i:s'); + } + $db->update('lab_domicilios', $campos, 'id = ?', [$domId]); + + // Actualizar asignación si completado/cancelado + if (in_array($estado, ['completado', 'cancelado']) && userRole() === 'enfermero') { + $estAsig = $estado === 'completado' ? 'completada' : 'liberada'; + $db->update( + 'lab_asignaciones', + ['estado' => $estAsig], + 'domicilio_id = ? AND enfermera_id = ?', + [$domId, enfermeraId()] + ); + } + + jsonOk(['domicilio_id' => $domId, 'estado' => $estado], 'Estado actualizado correctamente'); + +} catch (Exception $e) { + jsonError($e->getMessage(), 500); +} diff --git a/api/list_admin_users.php b/api/list_admin_users.php index 9e672e9..41b0280 100644 --- a/api/list_admin_users.php +++ b/api/list_admin_users.php @@ -15,18 +15,23 @@ header('Cache-Control: no-cache, no-store, must-revalidate'); try { $db = Database::getInstance(); - // Obtener todos los usuarios administradores + // Obtener todos los usuarios administradores con info de rol $users = $db->fetchAll(" - SELECT - id, - username, - full_name, - email, - is_active, - last_login, - created_at - FROM admin_users - ORDER BY created_at DESC + SELECT + u.id, + u.username, + u.full_name, + u.email, + u.is_active, + u.role, + u.role_id, + u.last_login, + u.created_at, + r.name AS role_name, + r.color AS role_color + FROM admin_users u + LEFT JOIN roles r ON r.id = u.role_id + ORDER BY u.created_at DESC "); echo json_encode([ diff --git a/api/upload_terms_document.php b/api/upload_terms_document.php new file mode 100644 index 0000000..8e292ea --- /dev/null +++ b/api/upload_terms_document.php @@ -0,0 +1,147 @@ + false, 'error' => 'Método no permitido']); + exit; +} + +try { + $db = Database::getInstance(); + + // ── Guardar/actualizar configuración de texto ────────────────────────── + // Si se envía sin archivo (solo textos), actualizar la versión activa. + $msgAceptacion = trim($_POST['mensaje_aceptacion'] ?? ''); + $msgRechazo = trim($_POST['mensaje_rechazo'] ?? ''); + $version = trim($_POST['version'] ?? ''); + $forzarReenvio = !empty($_POST['forzar_reenvio']) ? 1 : 0; + + // ── Determinar si hay un archivo ─────────────────────────────────────── + $hasFile = isset($_FILES['pdf']) && $_FILES['pdf']['error'] === UPLOAD_ERR_OK; + + if ($hasFile) { + $file = $_FILES['pdf']; + $tmpPath = $file['tmp_name']; + $mime = mime_content_type($tmpPath); + + // Validar que sea PDF + if ($mime !== 'application/pdf') { + if (ob_get_length()) ob_clean(); + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Solo se permite subir archivos PDF']); + exit; + } + + // Tamaño máximo 20 MB + if ($file['size'] > 20 * 1024 * 1024) { + if (ob_get_length()) ob_clean(); + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'El archivo no puede superar 20 MB']); + exit; + } + + // Crear directorio de destino si no existe + $uploadDir = __DIR__ . '/../uploads/terms/'; + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + // Nombre único para evitar sobreescribir + $safeName = 'terminos_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.pdf'; + $destPath = $uploadDir . $safeName; + + if (!move_uploaded_file($tmpPath, $destPath)) { + throw new Exception('Error al guardar el archivo en el servidor'); + } + + // URL pública + $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . $_SERVER['HTTP_HOST']; + // Calcular ruta relativa desde la raíz del proyecto + $docRoot = rtrim($_SERVER['DOCUMENT_ROOT'], '/'); + $absUpload = realpath($destPath); + $relPath = '/' . ltrim(str_replace($docRoot, '', $absUpload), '/'); + $docUrl = $baseUrl . $relPath; + } + + // ── Desactivar versiones anteriores si se crea una nueva ────────────── + if ($hasFile || !empty($version)) { + // Si hay archivo o versión nueva, desactivar la anterior y crear registro nuevo + $db->query("UPDATE terms_versions SET activa = 0 WHERE activa = 1"); + + $insertData = [ + 'version' => $version ?: date('Y-m'), + 'mensaje_aceptacion' => $msgAceptacion, + 'mensaje_rechazo' => $msgRechazo, + 'forzar_reenvio' => $forzarReenvio, + 'activa' => 1, + ]; + + if ($hasFile) { + $insertData['documento_url'] = $docUrl ?? null; + $insertData['documento_nombre'] = $file['name']; + } + + $newId = $db->insert('terms_versions', $insertData); + + // Si forzar_reenvio=1, resetear terms_pending en todos los usuarios para forzar re-lectura + if ($forzarReenvio) { + $db->query( + "UPDATE users SET terms_pending = 0, terms_accepted_at = NULL, terms_version_id = NULL" + ); + } + + if (ob_get_length()) ob_clean(); + echo json_encode([ + 'success' => true, + 'version_id' => $newId, + 'documento_url' => $insertData['documento_url'] ?? null, + 'message' => 'Términos guardados correctamente', + ]); + } else { + // Sin archivo ni versión → solo actualizar textos de la versión activa + $active = $db->fetch("SELECT id FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1"); + if ($active) { + $updateData = []; + if ($msgAceptacion !== '') $updateData['mensaje_aceptacion'] = $msgAceptacion; + if ($msgRechazo !== '') $updateData['mensaje_rechazo'] = $msgRechazo; + $updateData['forzar_reenvio'] = $forzarReenvio; + $db->update('terms_versions', $updateData, 'id = :id', ['id' => $active['id']]); + + if ($forzarReenvio) { + $db->query("UPDATE users SET terms_pending = 0, terms_accepted_at = NULL, terms_version_id = NULL"); + } + + if (ob_get_length()) ob_clean(); + echo json_encode(['success' => true, 'message' => 'Configuración de términos actualizada']); + } else { + if (ob_get_length()) ob_clean(); + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No hay versión activa. Sube un documento primero.']); + } + } + +} catch (Exception $e) { + error_log('upload_terms_document.php error: ' . $e->getMessage()); + if (ob_get_length()) ob_clean(); + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/version/media-url_debug.log b/api/version/media-url_debug.log index 5376997..c0f90ac 100644 --- a/api/version/media-url_debug.log +++ b/api/version/media-url_debug.log @@ -560,3 +560,341 @@ [2026-02-21 08:43:43] Auto-download failed for media 1217156256734654: Graph API fetch failed: [2026-02-21 08:43:43] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 [2026-02-21 08:43:43] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 09:03:28] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 09:03:28] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 09:03:29] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 09:03:29] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 09:03:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A5iussOysleNqD_H3h6YqZ_"}} +[2026-02-21 09:03:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 09:03:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 09:03:29] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 09:03:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Alzc9zFuoIholZAYNlHTYj1"}} +[2026-02-21 09:03:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 09:03:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:19:20] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:19:20] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:19:20] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 11:19:20] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:19:20] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AJqoxZ4zAsaS-mNb208AiEo"}} +[2026-02-21 11:19:20] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:19:21] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:19:26] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:19:26] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:19:26] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AisVkuFtWeUX2QwmhG1lqMx"}} +[2026-02-21 11:19:26] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:19:26] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 11:19:26] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:19:27] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:27:39] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:27:39] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:27:39] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AMnrK4EUy2AxN_FcOyJd0Qy"}} +[2026-02-21 11:27:39] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:27:39] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 11:27:39] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:27:40] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:44:53] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:44:53] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:44:53] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 11:44:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:44:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ANVda7F7ksRwxiU6oSPaxpP"}} +[2026-02-21 11:44:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:44:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:45:00] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:45:00] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:45:00] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Az1YG8RtWuRKoygP0Oa9_W-"}} +[2026-02-21 11:45:00] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:45:00] Auto-download failed for media 1217156256734654: Graph API fetch failed: +[2026-02-21 11:45:00] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:45:00] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:45:12] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:45:12] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:45:13] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AoPzxrE55hgFxTob08WBJhL"}} +[2026-02-21 11:45:13] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:45:13] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-02-21 11:45:13] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:45:13] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-02-21 11:45:17] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-02-21 11:45:17] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-02-21 11:45:17] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AbGjiOPlXxbWchiwJ6CyXqW"}} +[2026-02-21 11:45:17] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-02-21 11:45:17] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-02-21 11:45:17] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-02-21 11:45:17] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-02 11:30:52] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-02 11:30:52] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-02 11:30:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AZGri-6Z0DMgmM1A1sR8Pe7"}} +[2026-03-02 11:30:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-02 11:30:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-02 11:30:53] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-02 11:30:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-02 11:30:54] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-02 11:30:54] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOXECMUo93esakpIwFUpqYB"}} +[2026-03-02 11:30:54] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-02 11:30:55] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=1551128883003002 GET:{"id":"1551128883003002"} POST:[] +[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=893454973089562 GET:{"id":"893454973089562"} POST:[] +[2026-03-02 11:44:04] Serving local file for media ID 1551128883003002: /var/www/html/uploads/media_6979a044c17079.66378227.ogg +[2026-03-02 11:44:04] Serving local file for media ID 893454973089562: /var/www/html/uploads/media_6979a3c5b236e8.04274010.ogg +[2026-03-02 11:44:05] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AWIFhWSCUfg32iSw3oHxfDI"}} +[2026-03-02 11:44:05] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-02 11:44:05] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-02 11:44:05] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-02 11:44:05] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=759892943831328 GET:{"id":"759892943831328"} POST:[] +[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=1334972502008596 GET:{"id":"1334972502008596"} POST:[] +[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=1601261651067176 GET:{"id":"1601261651067176"} POST:[] +[2026-03-02 11:44:06] Request: GET /api/version/media-url.php?id=1043532494648003 GET:{"id":"1043532494648003"} POST:[] +[2026-03-02 11:44:06] Serving local file for media ID 759892943831328: /var/www/html/uploads/media_6979a485787858.65742730.ogg +[2026-03-02 11:44:06] Serving local file for media ID 1334972502008596: /var/www/html/uploads/media_6979a5b4690f83.69035164.ogg +[2026-03-02 11:44:06] Request: GET /api/version/media-url.php?id=2079449202816102 GET:{"id":"2079449202816102"} POST:[] +[2026-03-02 11:44:06] Serving local file for media ID 1601261651067176: /var/www/html/uploads/media_6979a9a3d39a72.62917885.ogg +[2026-03-02 11:44:06] Serving local file for media ID 2079449202816102: /var/www/html/uploads/media_6979b17f07a0f4.63241756.mp4 +[2026-03-02 11:44:07] Serving local file for media ID 1043532494648003: /var/www/html/uploads/media_6979abd12b8131.97353205.ogg +[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?id=1551128883003002 GET:{"id":"1551128883003002"} POST:[] +[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?id=893454973089562 GET:{"id":"893454973089562"} POST:[] +[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-02 11:44:07] Serving local file for media ID 1551128883003002: /var/www/html/uploads/media_6979a044c17079.66378227.ogg +[2026-03-02 11:44:07] Serving local file for media ID 893454973089562: /var/www/html/uploads/media_6979a3c5b236e8.04274010.ogg +[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1334972502008596 GET:{"id":"1334972502008596"} POST:[] +[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=759892943831328 GET:{"id":"759892943831328"} POST:[] +[2026-03-02 11:44:08] Serving local file for media ID 1334972502008596: /var/www/html/uploads/media_6979a5b4690f83.69035164.ogg +[2026-03-02 11:44:08] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AnUP3aPhugLtqpAIj5gV7hY"}} +[2026-03-02 11:44:08] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-02 11:44:08] Serving local file for media ID 759892943831328: /var/www/html/uploads/media_6979a485787858.65742730.ogg +[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1601261651067176 GET:{"id":"1601261651067176"} POST:[] +[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1043532494648003 GET:{"id":"1043532494648003"} POST:[] +[2026-03-02 11:44:08] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-02 11:44:09] Serving local file for media ID 1601261651067176: /var/www/html/uploads/media_6979a9a3d39a72.62917885.ogg +[2026-03-02 11:44:09] Serving local file for media ID 1043532494648003: /var/www/html/uploads/media_6979abd12b8131.97353205.ogg +[2026-03-02 11:44:10] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-02 11:44:10] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ApY5M23GqHWpJWH8zonLpm4"}} +[2026-03-02 11:44:10] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-02 11:44:10] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 15:33:11] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 15:33:11] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 15:33:12] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AcxD9u_TB-joWwYfreaHjC2"}} +[2026-03-03 15:33:12] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 15:33:12] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 15:33:12] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 15:33:12] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 15:33:49] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 15:33:50] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 15:33:50] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AbtUJe0Nmk36wiWNfhue_h7"}} +[2026-03-03 15:33:50] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 15:33:50] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 15:33:51] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 15:33:51] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 15:42:36] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 15:42:36] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 15:42:37] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ATZVuoQTYbOHnlvCZpRsd-Y"}} +[2026-03-03 15:42:37] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 15:42:37] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 15:42:37] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 15:42:37] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 15:42:42] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 15:42:42] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 15:42:42] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOKgh1d-JiezKEWC9lJxPf8"}} +[2026-03-03 15:42:42] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 15:42:43] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 15:42:43] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 15:42:43] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 16:14:55] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 16:14:55] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 16:14:56] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 16:14:56] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 16:14:56] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AktmTcgRtxp3ASb0WsQanLk"}} +[2026-03-03 16:14:56] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 16:14:56] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:02:12] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:02:12] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:02:13] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:02:13] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:02:13] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AaqmOWStyN4bNAec5giL9Ym"}} +[2026-03-03 18:02:13] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:02:13] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:12:46] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:12:46] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:12:47] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AS4OBeTbrmdjdjvcVm4XDym"}} +[2026-03-03 18:12:47] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:12:47] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:12:47] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:12:47] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:18:54] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:18:55] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:18:56] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AVZ_WGk3WGsMwUTojFSvT3X"}} +[2026-03-03 18:18:56] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:18:56] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:18:56] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:18:56] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:26:33] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:26:33] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:26:34] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A-rsOJy6Nt5bu8V2TiFfbGa"}} +[2026-03-03 18:26:34] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:26:34] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:26:34] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:26:34] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:40:46] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:40:46] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:40:47] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AVgNR8KDAo3MJZd7sUEmwOA"}} +[2026-03-03 18:40:47] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:40:47] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:40:47] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:40:47] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:45:56] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:45:57] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:45:58] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A2BA0V1lFNEweoVj828N08C"}} +[2026-03-03 18:45:58] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:45:58] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:45:58] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:45:58] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:52:25] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:52:25] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:52:26] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A9NoZ-fzHlsYz9LVztCvsTs"}} +[2026-03-03 18:52:26] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:52:26] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:52:26] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:52:26] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-03 18:53:23] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-03 18:53:24] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-03 18:53:24] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-03 18:53:24] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-03 18:53:24] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AFgjiwRrmVTXzoPHLwKsnKn"}} +[2026-03-03 18:53:24] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-03 18:53:25] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-07 12:50:23] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-07 12:50:23] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-07 12:50:24] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AcQVcLKIJwSqIoyn5edcq6N"}} +[2026-03-07 12:50:24] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-07 12:50:24] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-07 12:50:24] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-07 12:50:24] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-09 19:02:27] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-09 19:02:27] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-09 19:02:28] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOwAqoVcoD5OhMDtVtKEmCr"}} +[2026-03-09 19:02:28] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-09 19:02:28] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-09 19:02:28] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-09 19:02:28] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-09 19:02:34] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[] +[2026-03-09 19:02:35] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378 +[2026-03-09 19:02:35] Graph API request to https://graph.facebook.com/v22.0/876668735219378 +[2026-03-09 19:03:43] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-09 19:03:43] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-09 19:03:44] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ADXyHWK69LBJb0Xcb5WF6YN"}} +[2026-03-09 19:03:44] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-09 19:03:44] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-09 19:03:44] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-09 19:03:44] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-09 19:44:40] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[] +[2026-03-09 19:44:41] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378 +[2026-03-09 19:44:41] Graph API request to https://graph.facebook.com/v22.0/876668735219378 +[2026-03-10 11:48:52] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 11:48:52] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 11:48:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AmeTveBUDoB7IYGuJQpKn_S"}} +[2026-03-10 11:48:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 11:48:53] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 11:48:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 11:48:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 11:49:09] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[] +[2026-03-10 11:49:10] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378 +[2026-03-10 11:49:10] Graph API request to https://graph.facebook.com/v22.0/876668735219378 +[2026-03-10 11:49:20] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 11:49:20] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 11:49:21] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AqqWUUrnY6r8ZIl70J0e6Og"}} +[2026-03-10 11:49:21] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 11:49:21] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 11:49:21] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 11:49:21] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 11:58:05] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 11:58:05] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 11:58:06] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AV59Bbr9DFXNl7y039WL8dV"}} +[2026-03-10 11:58:06] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 11:58:06] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 11:58:06] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 11:58:06] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 12:19:04] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 12:19:04] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 12:19:06] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AGWqv0bu7HvWrrjbR65TJIf"}} +[2026-03-10 12:19:06] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 12:19:06] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 12:19:06] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 12:19:06] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 12:19:13] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 12:19:13] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 12:19:14] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AgIetbmT5l7HlIzACky9rEV"}} +[2026-03-10 12:19:14] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 12:19:14] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 12:19:14] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 12:19:14] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 12:19:37] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 12:19:37] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 12:19:38] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 12:19:38] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 12:19:38] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AThpsMjn6_1g_sqRZBJrtHu"}} +[2026-03-10 12:19:38] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 12:19:38] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 12:28:02] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 12:28:02] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 12:28:03] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A1pX_IwbWh_KHKUuck-nTb7"}} +[2026-03-10 12:28:03] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 12:28:04] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 12:28:04] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 12:28:04] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 13:22:57] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 13:22:57] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 13:22:57] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AwADPHo5iki123yn3HWL7YD"}} +[2026-03-10 13:22:57] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 13:22:58] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 13:22:58] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 13:22:58] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 13:45:06] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 13:45:06] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 13:45:07] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A5rBKn9_qO3AUr1NYK4npdP"}} +[2026-03-10 13:45:07] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 13:45:07] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 13:45:07] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 13:45:07] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:35:00] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 16:35:00] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 16:35:01] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AZil5Jg5IUvgKBvNXuqNAgJ"}} +[2026-03-10 16:35:01] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 16:35:01] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:35:01] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 16:35:01] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 16:35:41] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 16:35:41] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 16:35:42] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AhZK_sM5jxmWJyHR0SDg-S_"}} +[2026-03-10 16:35:42] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 16:35:42] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:35:42] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 16:35:42] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 16:59:13] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 16:59:13] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 16:59:14] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A9BtUEbjlzsjlLVBf--RVAP"}} +[2026-03-10 16:59:14] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 16:59:14] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:59:14] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 16:59:14] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 16:59:18] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 16:59:18] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 16:59:18] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AndqVm9VvEWqTibjCIvpkQK"}} +[2026-03-10 16:59:18] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 16:59:18] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:59:18] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 16:59:18] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 16:59:29] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 16:59:29] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 16:59:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Am3mQzxQnonDMVVhrF4yGVP"}} +[2026-03-10 16:59:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 16:59:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 16:59:29] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 16:59:29] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 +[2026-03-10 18:13:02] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[] +[2026-03-10 18:13:02] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[] +[2026-03-10 18:13:02] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AHjV--ez4QSdPn8y8ecMc3O"}} +[2026-03-10 18:13:02] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA +[2026-03-10 18:13:02] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404 +[2026-03-10 18:13:02] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654 +[2026-03-10 18:13:02] Graph API request to https://graph.facebook.com/v22.0/1217156256734654 diff --git a/assets/js/app_simple.js b/assets/js/app_simple.js index 99a88f3..cbd876c 100644 --- a/assets/js/app_simple.js +++ b/assets/js/app_simple.js @@ -793,6 +793,9 @@ class SimpleWhatsAppManager { } catch (error) { this.showError('Error cargando configuraciones: ' + error.message); } + + // Cargar siempre el estado del documento de términos + if (typeof loadTermsDoc === 'function') loadTermsDoc(); } updatesystem_configForm(data) { @@ -805,7 +808,9 @@ class SimpleWhatsAppManager { { id: 'webhook-token', value: data.webhook_verify_token }, { id: 'api-url', value: data.api_url }, { id: 'business-name', value: data.business_name }, - { id: 'welcome-message', value: data.welcome_message } + { id: 'welcome-message', value: data.welcome_message }, + { id: 'terms-message', value: data.terms_message }, + { id: 'terms-rejected-message', value: data.terms_rejected_message } ]; fields.forEach(field => { @@ -829,7 +834,9 @@ class SimpleWhatsAppManager { webhook_verify_token: document.getElementById('webhook-token').value, api_url: document.getElementById('api-url').value, business_name: document.getElementById('business-name').value, - welcome_message: document.getElementById('welcome-message').value + welcome_message: document.getElementById('welcome-message').value, + terms_message: document.getElementById('terms-message')?.value || '', + terms_rejected_message: document.getElementById('terms-rejected-message')?.value || '' }; try { @@ -1949,22 +1956,9 @@ class SimpleWhatsAppManager { } } - // Cargar usuarios administradores del sistema + // Gestión de usuarios centralizada en lab_usuarios.php async loadAdminUsers() { - this.log('Cargando usuarios administradores...'); - - try { - const response = await this.apiCall('list_admin_users.php'); - - if (response && response.success && response.data) { - renderAdminUsersTable(response.data); - } else { - this.showError('Error cargando usuarios administradores'); - } - } catch (error) { - this.log('Error cargando usuarios administradores: ' + error.message, 'error'); - this.showError('Error cargando usuarios: ' + error.message); - } + // No-op: la UI redirige a lab_usuarios.php } } @@ -3436,6 +3430,141 @@ document.addEventListener('DOMContentLoaded', function() { } }); +// ─── Gestión del documento PDF de Términos y Condiciones ───────────────────── +(function () { + function showTermsStatus(msg, type) { + const el = document.getElementById('terms-upload-status'); + if (!el) return; + el.style.display = 'block'; + el.className = 'mt-2 small text-' + (type || 'secondary'); + el.textContent = msg; + } + + function _fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0;'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + const ok = document.execCommand('copy'); + showTermsStatus(ok ? '✔ URL copiada al portapapeles.' : '✖ No se pudo copiar.', ok ? 'success' : 'danger'); + } catch (e) { + showTermsStatus('✖ No se pudo copiar: ' + text, 'danger'); + } + document.body.removeChild(ta); + } + + async function loadTermsDoc() { + try { + const res = await fetch('api/get_terms_config.php'); + const json = await res.json(); + if (!json.success || !json.data) return; + + const d = json.data; + const nameEl = document.getElementById('terms-doc-name'); + const verEl = document.getElementById('terms-doc-version'); + const linkEl = document.getElementById('terms-doc-link'); + const copyBtn = document.getElementById('terms-doc-copy-btn'); + const currentEl = document.getElementById('terms-doc-current'); + const msgAcEl = document.getElementById('terms-message'); + const msgRjEl = document.getElementById('terms-rejected-message'); + + if (nameEl) nameEl.textContent = d.documento_nombre || '(sin nombre)'; + if (verEl) verEl.textContent = d.version || '—'; + if (linkEl) linkEl.href = d.documento_url || '#'; + if (currentEl) currentEl.style.display = d.documento_url ? '' : 'none'; + + // Rellenar textos si están en terms_versions (no sobreescribir si ya se cargaron) + if (msgAcEl && !msgAcEl.value && d.mensaje_aceptacion) { + msgAcEl.value = d.mensaje_aceptacion; + } + if (msgRjEl && !msgRjEl.value && d.mensaje_rechazo) { + msgRjEl.value = d.mensaje_rechazo; + } + + if (copyBtn && d.documento_url) { + copyBtn.onclick = () => { + const url = d.documento_url; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(url) + .then(() => showTermsStatus('✔ URL copiada al portapapeles.', 'success')) + .catch(() => _fallbackCopy(url)); + } else { + _fallbackCopy(url); + } + }; + } + } catch (e) { + console.warn('loadTermsDoc error:', e); + } + } + + async function uploadTermsDocument() { + const fileInput = document.getElementById('terms-pdf-file'); + const versionEl = document.getElementById('terms-new-version'); + const forceEl = document.getElementById('terms-force-reaccept'); + const msgAcEl = document.getElementById('terms-message'); + const msgRjEl = document.getElementById('terms-rejected-message'); + + const formData = new FormData(); + if (fileInput && fileInput.files.length > 0) { + formData.append('pdf', fileInput.files[0]); + } + if (versionEl) formData.append('version', versionEl.value.trim()); + if (forceEl) formData.append('forzar_reenvio', forceEl.checked ? '1' : '0'); + if (msgAcEl) formData.append('mensaje_aceptacion', msgAcEl.value); + if (msgRjEl) formData.append('mensaje_rechazo', msgRjEl.value); + + showTermsStatus('Guardando…', 'secondary'); + + try { + const res = await fetch('api/upload_terms_document.php', { method: 'POST', body: formData }); + const json = await res.json(); + if (json.success) { + showTermsStatus('✔ ' + (json.message || 'Guardado correctamente.'), 'success'); + if (json.documento_url) { + const linkEl = document.getElementById('terms-doc-link'); + if (linkEl) linkEl.href = json.documento_url; + document.getElementById('terms-doc-current').style.display = ''; + const nameEl = document.getElementById('terms-doc-name'); + if (nameEl && fileInput && fileInput.files[0]) nameEl.textContent = fileInput.files[0].name; + const verEl = document.getElementById('terms-doc-version'); + if (verEl && versionEl) verEl.textContent = versionEl.value || '—'; + } + // Limpiar inputs de archivo + if (fileInput) fileInput.value = ''; + if (forceEl) forceEl.checked = false; + } else { + showTermsStatus('✖ ' + (json.error || 'Error al guardar.'), 'danger'); + } + } catch (e) { + showTermsStatus('✖ Error de red: ' + e.message, 'danger'); + } + } + + document.addEventListener('DOMContentLoaded', function () { + const uploadBtn = document.getElementById('terms-upload-btn'); + if (uploadBtn) uploadBtn.addEventListener('click', uploadTermsDocument); + + // Cargar estado del documento cuando se activa el tab de configuración + const cfgTab = document.querySelector('[data-bs-target="#system-config"], [href="#system-config"]'); + if (cfgTab) { + cfgTab.addEventListener('shown.bs.tab', loadTermsDoc); + } + // Si el tab ya está activo al cargar la página + const cfgPanel = document.getElementById('system-config'); + if (cfgPanel && cfgPanel.classList.contains('active')) { + loadTermsDoc(); + } + }); + + // Exponer para que loadsystem_config() pueda llamarla + window.loadTermsDoc = loadTermsDoc; +})(); +// ─── / Términos ─────────────────────────────────────────────────────────────── + // Funciones globales para gestión de usuarios // Función para editar usuario @@ -4526,6 +4655,19 @@ window.refreshData = function() { }; // Función para exportar usuarios +window.exportConversations = function(filters) { + const params = new URLSearchParams(filters || {}); + const url = (window.whatsappManager ? window.whatsappManager.apiBaseUrl : 'api/') + 'export_conversations.php'; + const full = params.toString() ? url + '?' + params.toString() : url; + const a = document.createElement('a'); + a.href = full; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + if (window.whatsappManager) window.whatsappManager.showSuccess('Descarga de conversaciones iniciada'); +}; + window.exportUsers = function() { console.log('Exportando usuarios...'); diff --git a/auth_protect.php b/auth_protect.php index 77eed0e..0674e94 100644 --- a/auth_protect.php +++ b/auth_protect.php @@ -36,6 +36,12 @@ if (!isUserLoggedIn()) { exit('Acceso denegado. Iniciar sesión'); } +// Enfermeros solo pueden ver su portal, no el panel admin +if (isEnfermero()) { + header('Location: enfermero_portal.php'); + exit; +} + // Verificar timeout de sesión (ya se hace en config.php, pero por seguridad) if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > SESSION_TIMEOUT)) { session_destroy(); diff --git a/classes/lab/ActividadAdmin.php b/classes/lab/ActividadAdmin.php new file mode 100644 index 0000000..e18f737 --- /dev/null +++ b/classes/lab/ActividadAdmin.php @@ -0,0 +1,132 @@ +db = Database::getInstance(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Registro de actividad + // ───────────────────────────────────────────────────────────────────────── + + /** + * Registra una acción en el log. + * + * @param int|null $adminId admin_users.id + * @param string $modulo pacientes|ordenes|domicilios|enfermeras|asignaciones + * @param string $accion crear|editar|eliminar|autorizar|rechazar|asignar... + * @param int|null $entidadId ID del registro afectado + * @param mixed $detalle Texto o array (se serializa como JSON) + */ + public function registrar( + ?int $adminId, + string $modulo, + string $accion, + ?int $entidadId = null, + $detalle = null + ): int { + $adminNombre = null; + + if ($adminId) { + $row = $this->db->fetch( + 'SELECT full_name FROM admin_users WHERE id = ?', + [$adminId] + ); + $adminNombre = $row['full_name'] ?? null; + } + + $detalleStr = is_array($detalle) || is_object($detalle) + ? json_encode($detalle, JSON_UNESCAPED_UNICODE) + : (string)($detalle ?? ''); + + return $this->db->insert('lab_actividad_admin', [ + 'admin_id' => $adminId, + 'admin_nombre'=> $adminNombre, + 'modulo' => $modulo, + 'accion' => $accion, + 'entidad_id' => $entidadId, + 'detalle' => $detalleStr, + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Consultas + // ───────────────────────────────────────────────────────────────────────── + + /** + * Actividad reciente del módulo. + * + * @param int $limit + * @param string $modulo Filtro opcional por módulo + */ + public function reciente(int $limit = 50, string $modulo = ''): array { + $where = $modulo ? 'WHERE a.modulo = ?' : ''; + $params = $modulo ? [$modulo] : []; + $params[] = $limit; + + return $this->db->fetchAll(" + SELECT + a.*, + u.username AS admin_username + FROM lab_actividad_admin a + LEFT JOIN admin_users u ON u.id = a.admin_id + $where + ORDER BY a.created_at DESC + LIMIT ? + ", $params); + } + + /** + * Actividad de un administrador específico. + */ + public function porAdmin(int $adminId, int $limit = 100): array { + return $this->db->fetchAll(" + SELECT * FROM lab_actividad_admin + WHERE admin_id = ? + ORDER BY created_at DESC + LIMIT ? + ", [$adminId, $limit]); + } + + /** + * Actividad sobre una entidad concreta. + */ + public function porEntidad(string $modulo, int $entidadId): array { + return $this->db->fetchAll(" + SELECT + a.*, + u.username AS admin_username + FROM lab_actividad_admin a + LEFT JOIN admin_users u ON u.id = a.admin_id + WHERE a.modulo = ? AND a.entidad_id = ? + ORDER BY a.created_at ASC + ", [$modulo, $entidadId]); + } + + /** + * Estadísticas de actividad por módulo en un rango de fechas. + */ + public function estadisticas(string $desde, string $hasta): array { + return $this->db->fetchAll(" + SELECT + modulo, + accion, + COUNT(*) AS total + FROM lab_actividad_admin + WHERE DATE(created_at) BETWEEN ? AND ? + GROUP BY modulo, accion + ORDER BY modulo, total DESC + ", [$desde, $hasta]); + } +} diff --git a/classes/lab/Asignacion.php b/classes/lab/Asignacion.php new file mode 100644 index 0000000..4d5b9cc --- /dev/null +++ b/classes/lab/Asignacion.php @@ -0,0 +1,158 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Obtener asignación por ID. + */ + public function obtener(int $id): ?array { + return $this->db->fetch(" + SELECT + a.*, + e.nombre_completo AS enfermera_nombre, + e.telefono AS enfermera_telefono, + d.fecha_programada, + d.hora_programada, + d.estado AS domicilio_estado, + d.direccion, + p.nombre_completo AS paciente_nombre + FROM lab_asignaciones a + JOIN lab_enfermeras e ON e.id = a.enfermera_id + JOIN lab_domicilios d ON d.id = a.domicilio_id + JOIN lab_pacientes p ON p.id = d.paciente_id + WHERE a.id = ? + ", [$id]) ?: null; + } + + /** + * Asignar (o reasignar) enfermera a un domicilio. + * - Si ya existía asignación la reemplaza. + */ + public function asignar( + int $domicilioId, + int $enfermeraId, + ?int $adminId = null, + string $notas = '' + ): int { + // ¿Ya existe? + $existente = $this->db->fetch( + 'SELECT id FROM lab_asignaciones WHERE domicilio_id = ?', + [$domicilioId] + ); + + if ($existente) { + $this->db->update('lab_asignaciones', [ + 'enfermera_id' => $enfermeraId, + 'asignada_por' => $adminId, + 'estado' => 'asignada', + 'notas' => $notas, + ], 'id = ?', [$existente['id']]); + + $id = $existente['id']; + $accion = 'reasignar'; + } else { + $id = $this->db->insert('lab_asignaciones', [ + 'domicilio_id' => $domicilioId, + 'enfermera_id' => $enfermeraId, + 'asignada_por' => $adminId, + 'estado' => 'asignada', + 'notas' => $notas, + ]); + $accion = 'asignar'; + } + + $this->log->registrar($adminId, 'asignaciones', $accion, $id, [ + 'domicilio_id' => $domicilioId, + 'enfermera_id' => $enfermeraId, + ]); + + return $id; + } + + /** + * Liberar asignación (sin eliminar, cambia estado a 'liberada'). + */ + public function liberar(int $id, ?int $adminId = null, string $motivo = ''): bool { + $ok = $this->db->update('lab_asignaciones', ['estado' => 'liberada'], 'id = ?', [$id]); + $this->log->registrar($adminId, 'asignaciones', 'liberar', $id, ['motivo' => $motivo]); + return $ok > 0; + } + + /** + * Marcar asignación como completada. + */ + public function completar(int $id, ?int $adminId = null): bool { + $ok = $this->db->update('lab_asignaciones', ['estado' => 'completada'], 'id = ?', [$id]); + $this->log->registrar($adminId, 'asignaciones', 'completar', $id); + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Consultas + // ───────────────────────────────────────────────────────────────────────── + + /** + * Asignaciones de una fecha (para vista de agenda general). + */ + public function porFecha(string $fecha = ''): array { + $fecha = $fecha ?: date('Y-m-d'); + return $this->db->fetchAll(" + SELECT + a.*, + e.nombre_completo AS enfermera_nombre, + e.telefono AS enfermera_telefono, + d.hora_programada, + d.estado AS domicilio_estado, + d.direccion, + d.barrio, + p.nombre_completo AS paciente_nombre, + p.telefono AS paciente_telefono + FROM lab_asignaciones a + JOIN lab_enfermeras e ON e.id = a.enfermera_id + JOIN lab_domicilios d ON d.id = a.domicilio_id + JOIN lab_pacientes p ON p.id = d.paciente_id + WHERE d.fecha_programada = ? + ORDER BY e.nombre_completo, d.hora_programada ASC + ", [$fecha]); + } + + /** + * Carga actual del día: cuántos domicilios tiene cada enfermera hoy. + */ + public function cargaHoy(): array { + return $this->db->fetchAll(" + SELECT + e.id, + e.nombre_completo, + e.telefono, + COUNT(a.id) AS total_asignaciones, + SUM(d.estado = 'completado') AS completados + FROM lab_enfermeras e + JOIN lab_asignaciones a ON a.enfermera_id = e.id + JOIN lab_domicilios d ON d.id = a.domicilio_id + WHERE d.fecha_programada = CURDATE() + AND a.estado NOT IN ('liberada') + GROUP BY e.id + ORDER BY total_asignaciones DESC + "); + } +} diff --git a/classes/lab/Domicilio.php b/classes/lab/Domicilio.php new file mode 100644 index 0000000..78179f0 --- /dev/null +++ b/classes/lab/Domicilio.php @@ -0,0 +1,261 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Lista de domicilios con filtros. + * + * @param array $filtros fecha, estado, enfermera_id, paciente_id + */ + public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array { + $wheres = []; + $params = []; + + if (!empty($filtros['fecha'])) { + $wheres[] = 'd.fecha_programada = ?'; + $params[] = $filtros['fecha']; + } + if (!empty($filtros['desde'])) { + $wheres[] = 'd.fecha_programada >= ?'; + $params[] = $filtros['desde']; + } + if (!empty($filtros['hasta'])) { + $wheres[] = 'd.fecha_programada <= ?'; + $params[] = $filtros['hasta']; + } + if (!empty($filtros['estado'])) { + $wheres[] = 'd.estado = ?'; + $params[] = $filtros['estado']; + } + if (!empty($filtros['enfermera_id'])) { + $wheres[] = 'a.enfermera_id = ?'; + $params[] = $filtros['enfermera_id']; + } + if (!empty($filtros['paciente_id'])) { + $wheres[] = 'd.paciente_id = ?'; + $params[] = $filtros['paciente_id']; + } + + $where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : ''; + $offset = ($pagina - 1) * $porPagina; + + $total = $this->db->fetch( + "SELECT COUNT(*) AS n + FROM lab_domicilios d + LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id + $where", + $params + )['n'] ?? 0; + + $rows = $this->db->fetchAll(" + SELECT + d.*, + p.nombre_completo AS paciente_nombre, + p.telefono AS paciente_telefono, + e.nombre_completo AS enfermera_nombre, + e.telefono AS enfermera_telefono, + a.id AS asignacion_id, + a.estado AS asignacion_estado + FROM lab_domicilios d + JOIN lab_pacientes p ON p.id = d.paciente_id + LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id + LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id + $where + ORDER BY d.fecha_programada ASC, d.hora_programada ASC + LIMIT ? OFFSET ? + ", array_merge($params, [$porPagina, $offset])); + + return [ + 'data' => $rows, + 'total' => (int)$total, + 'pagina' => $pagina, + 'por_pagina' => $porPagina, + 'paginas' => (int)ceil($total / $porPagina), + ]; + } + + /** + * Domicilio por ID con todos los datos relacionados. + */ + public function obtener(int $id): ?array { + return $this->db->fetch(" + SELECT + d.*, + p.nombre_completo AS paciente_nombre, + p.telefono AS paciente_telefono, + p.direccion AS paciente_direccion, + o.examenes_solicitados, + o.indicaciones AS indicaciones_orden, + e.nombre_completo AS enfermera_nombre, + e.telefono AS enfermera_telefono, + a.id AS asignacion_id, + a.estado AS asignacion_estado, + adm.full_name AS creado_por_nombre + FROM lab_domicilios d + JOIN lab_pacientes p ON p.id = d.paciente_id + LEFT JOIN lab_ordenes_medicas o ON o.id = d.orden_id + LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id + LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id + LEFT JOIN admin_users adm ON adm.id = d.creado_por + WHERE d.id = ? + ", [$id]) ?: null; + } + + /** + * Crear nuevo domicilio. + */ + public function crear(array $datos, ?int $adminId = null): int { + $campos = $this->filtrarCampos($datos); + $campos['creado_por'] = $adminId; + + // Actualizar orden médica si aplica + if (!empty($campos['orden_id'])) { + $this->db->update( + 'lab_ordenes_medicas', + ['estado' => 'en_domicilio'], + 'id = ?', [$campos['orden_id']] + ); + } + + $id = (int)$this->db->insert('lab_domicilios', $campos); + + $this->log->registrar($adminId, 'domicilios', 'crear', $id, $campos); + return $id; + } + + /** + * Actualizar domicilio. + */ + public function actualizar(int $id, array $datos, ?int $adminId = null): bool { + $campos = $this->filtrarCampos($datos); + $ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]); + $this->log->registrar($adminId, 'domicilios', 'editar', $id, $campos); + return $ok > 0; + } + + /** + * Cambiar estado del domicilio. + */ + public function cambiarEstado( + int $id, + string $nuevoEstado, + ?int $adminId, + array $extras = [] + ): bool { + if (!in_array($nuevoEstado, self::ESTADOS)) { + throw new InvalidArgumentException("Estado inválido: $nuevoEstado"); + } + + $campos = array_merge(['estado' => $nuevoEstado], $extras); + $ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]); + $this->log->registrar($adminId, 'domicilios', $nuevoEstado, $id, $extras); + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Agenda del día + // ───────────────────────────────────────────────────────────────────────── + + /** + * Todos los domicilios de hoy (o la fecha indicada). + */ + public function agendaDia(string $fecha = ''): array { + $fecha = $fecha ?: date('Y-m-d'); + return $this->listar(['fecha' => $fecha], 1, 200)['data']; + } + + /** + * Domicilios sin enfermera asignada. + */ + public function sinAsignar(string $fecha = ''): array { + $fecha = $fecha ?: date('Y-m-d'); + return $this->db->fetchAll(" + SELECT + d.*, + p.nombre_completo AS paciente_nombre + FROM lab_domicilios d + JOIN lab_pacientes p ON p.id = d.paciente_id + LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id + WHERE d.fecha_programada = ? + AND a.id IS NULL + AND d.estado NOT IN ('cancelado','completado') + ORDER BY d.hora_programada ASC + ", [$fecha]); + } + + /** + * Estadísticas: conteo de domicilios por estado hoy. + */ + public function estadisticasHoy(): array { + $rows = $this->db->fetchAll(" + SELECT estado, COUNT(*) AS total + FROM lab_domicilios + WHERE fecha_programada = CURDATE() + GROUP BY estado + "); + $result = array_fill_keys(self::ESTADOS, 0); + foreach ($rows as $row) { + $result[$row['estado']] = (int)$row['total']; + } + return $result; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function filtrarCampos(array $datos): array { + $permitidos = [ + 'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio', + 'indicaciones_dir', 'fecha_programada', 'hora_programada', + 'tipo_servicio', 'tipo_cliente', 'examenes_solicitados', + 'estado', 'motivo_cancelacion', + 'fecha_reprogramada', 'hora_llegada', 'hora_salida', + 'observaciones', 'muestras_tomadas', 'notas_admin', + 'seguro_nombre', 'autorizacion', 'copago_laboratorio', + 'valor_domicilio', 'valor_copago', + ]; + $campos = array_intersect_key($datos, array_flip($permitidos)); + + // Campos FK que deben ser NULL cuando vienen vacíos o cero + foreach (['orden_id', 'paciente_id'] as $fk) { + if (array_key_exists($fk, $campos) && ($campos[$fk] === '' || $campos[$fk] === '0' || $campos[$fk] === 0)) { + $campos[$fk] = null; + } + } + + return $campos; + } +} diff --git a/classes/lab/Enfermera.php b/classes/lab/Enfermera.php new file mode 100644 index 0000000..cbf724c --- /dev/null +++ b/classes/lab/Enfermera.php @@ -0,0 +1,146 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Lista de enfermeras activas/todas. + */ + public function listar(bool $soloActivas = true): array { + $where = $soloActivas ? 'WHERE is_active = 1' : ''; + return $this->db->fetchAll(" + SELECT + e.*, + (SELECT COUNT(*) FROM lab_asignaciones a + JOIN lab_domicilios d ON d.id = a.domicilio_id + WHERE a.enfermera_id = e.id + AND d.fecha_programada = CURDATE() + AND a.estado NOT IN ('liberada','completada') + ) AS domicilios_hoy + FROM lab_enfermeras e + $where + ORDER BY e.nombre_completo ASC + "); + } + + /** + * Enfermera por ID. + */ + public function obtener(int $id): ?array { + return $this->db->fetch( + 'SELECT * FROM lab_enfermeras WHERE id = ?', + [$id] + ) ?: null; + } + + /** + * Crear enfermera. + */ + public function crear(array $datos, ?int $adminId = null): int { + $campos = $this->filtrarCampos($datos); + $id = $this->db->insert('lab_enfermeras', $campos); + $this->log->registrar($adminId, 'enfermeras', 'crear', $id, $campos); + return $id; + } + + /** + * Actualizar enfermera. + */ + public function actualizar(int $id, array $datos, ?int $adminId = null): bool { + $campos = $this->filtrarCampos($datos); + $ok = $this->db->update('lab_enfermeras', $campos, 'id = ?', [$id]); + $this->log->registrar($adminId, 'enfermeras', 'editar', $id, $campos); + return $ok > 0; + } + + /** + * Desactivar (soft-delete). + */ + public function desactivar(int $id, ?int $adminId = null): bool { + $ok = $this->db->update('lab_enfermeras', ['is_active' => 0], 'id = ?', [$id]); + $this->log->registrar($adminId, 'enfermeras', 'desactivar', $id); + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Agenda + // ───────────────────────────────────────────────────────────────────────── + + /** + * Agenda completa de una enfermera (domicilios asignados con detalle). + * + * @param int $enfermeraId + * @param string $fecha YYYY-MM-DD, por defecto hoy + */ + public function agenda(int $enfermeraId, string $fecha = ''): array { + $fecha = $fecha ?: date('Y-m-d'); + return $this->db->fetchAll(" + SELECT + d.id AS domicilio_id, + d.fecha_programada, + d.hora_programada, + d.estado AS domicilio_estado, + d.direccion, + d.barrio, + d.tipo_servicio, + p.nombre_completo AS paciente_nombre, + p.telefono AS paciente_telefono, + a.estado AS asignacion_estado, + a.id AS asignacion_id + FROM lab_asignaciones a + JOIN lab_domicilios d ON d.id = a.domicilio_id + JOIN lab_pacientes p ON p.id = d.paciente_id + WHERE a.enfermera_id = ? + AND d.fecha_programada = ? + ORDER BY d.hora_programada ASC + ", [$enfermeraId, $fecha]); + } + + /** + * Carga de trabajo por período: domicilios asignados por día. + */ + public function cargaTrabajo(int $enfermeraId, string $desde, string $hasta): array { + return $this->db->fetchAll(" + SELECT + d.fecha_programada, + COUNT(*) AS total, + SUM(d.estado = 'completado') AS completados + FROM lab_asignaciones a + JOIN lab_domicilios d ON d.id = a.domicilio_id + WHERE a.enfermera_id = ? + AND d.fecha_programada BETWEEN ? AND ? + GROUP BY d.fecha_programada + ORDER BY d.fecha_programada ASC + ", [$enfermeraId, $desde, $hasta]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function filtrarCampos(array $datos): array { + $permitidos = [ + 'numero_documento', 'tipo_documento', 'nombre_completo', + 'telefono', 'telefono_alt', 'email', + 'zona', 'notas', 'is_active', + ]; + return array_intersect_key($datos, array_flip($permitidos)); + } +} diff --git a/classes/lab/Formulario.php b/classes/lab/Formulario.php new file mode 100644 index 0000000..7dd3f7f --- /dev/null +++ b/classes/lab/Formulario.php @@ -0,0 +1,204 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // PLANTILLAS (admin) + // ───────────────────────────────────────────────────────────────────────── + + public function listar(bool $soloActivos = true): array { + $w = $soloActivos ? 'WHERE f.is_active = 1' : ''; + return $this->db->fetchAll(" + SELECT f.*, u.full_name AS creado_por_nombre, + (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios, + (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) AS total_completados + FROM lab_formularios f + LEFT JOIN admin_users u ON u.id = f.creado_por + $w + ORDER BY f.created_at DESC + "); + } + + public function obtener(int $id): ?array { + $row = $this->db->fetch( + "SELECT f.*, u.full_name AS creado_por_nombre + FROM lab_formularios f + LEFT JOIN admin_users u ON u.id = f.creado_por + WHERE f.id = ?", + [$id] + ); + if ($row) { + $row['esquema_decoded'] = json_decode($row['esquema'], true) ?? []; + } + return $row ?: null; + } + + public function crear(array $datos, int $adminId): int { + $this->validarEsquema($datos['esquema'] ?? '[]'); + $id = $this->db->insert('lab_formularios', [ + 'nombre' => trim($datos['nombre']), + 'descripcion' => $datos['descripcion'] ?? null, + 'categoria' => $datos['categoria'] ?? 'otro', + 'esquema' => $datos['esquema'] ?? '[]', + 'permite_firma' => !empty($datos['permite_firma']) ? 1 : 0, + 'requiere_firma' => !empty($datos['requiere_firma']) ? 1 : 0, + 'doc_encabezado' => $datos['doc_encabezado'] ?? null, + 'doc_subtitulo' => $datos['doc_subtitulo'] ?? null, + 'doc_logo_base64' => $datos['doc_logo_base64'] ?? null, + 'doc_color' => $datos['doc_color'] ?? null, + 'doc_pie_pagina' => $datos['doc_pie_pagina'] ?? null, + 'creado_por' => $adminId, + ]); + $this->log->registrar($adminId, 'formularios', 'crear', $id, ['nombre' => $datos['nombre']]); + return $id; + } + + public function actualizar(int $id, array $datos, int $adminId): bool { + $permitidos = [ + 'nombre','descripcion','categoria','esquema', + 'permite_firma','requiere_firma','is_active', + 'doc_encabezado','doc_subtitulo','doc_logo_base64','doc_color','doc_pie_pagina', + ]; + $campos = array_intersect_key($datos, array_flip($permitidos)); + if (isset($campos['esquema'])) { + $this->validarEsquema($campos['esquema']); + $campos['version'] = (int)$this->db->fetch('SELECT version FROM lab_formularios WHERE id = ?', [$id])['version'] + 1; + } + $ok = $this->db->update('lab_formularios', $campos, 'id = ?', [$id]); + $this->log->registrar($adminId, 'formularios', 'editar', $id, $campos); + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // ENVÍOS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Crear un envío con datos pre-llenados, genera token único. + */ + public function crearEnvio(array $datos, int $enviadoPor): int { + $token = bin2hex(random_bytes(32)); + $expira = date('Y-m-d H:i:s', strtotime('+7 days')); + + $id = $this->db->insert('lab_form_envios', [ + 'formulario_id' => (int)$datos['formulario_id'], + 'paciente_id' => !empty($datos['paciente_id']) ? (int)$datos['paciente_id'] : null, + 'domicilio_id' => !empty($datos['domicilio_id']) ? (int)$datos['domicilio_id'] : null, + 'token' => $token, + 'datos_prefilled' => $datos['datos_prefilled'] ?? '{}', + 'estado' => 'pendiente', + 'enviado_por' => $enviadoPor, + 'enviado_via' => $datos['enviado_via'] ?? 'whatsapp', + 'expira_en' => $expira, + ]); + $this->log->registrar($enviadoPor, 'formularios', 'enviar', $id, [ + 'formulario_id' => $datos['formulario_id'], + 'via' => $datos['enviado_via'] ?? 'whatsapp', + ]); + return $id; + } + + /** + * Obtener envío por token (uso público del cliente). + */ + public function obtenerPorToken(string $token): ?array { + $envio = $this->db->fetch( + "SELECT e.*, f.nombre AS form_nombre, f.esquema, f.permite_firma, f.requiere_firma, f.descripcion AS form_descripcion, + f.doc_color, f.doc_logo_base64, f.doc_encabezado, f.doc_subtitulo, f.doc_pie_pagina, + p.nombre_completo AS paciente_nombre, p.numero_documento, p.telefono AS paciente_telefono + FROM lab_form_envios e + JOIN lab_formularios f ON f.id = e.formulario_id + LEFT JOIN lab_pacientes p ON p.id = e.paciente_id + WHERE e.token = ?", + [$token] + ); + if (!$envio) return null; + if ($envio['estado'] === 'expirado') return null; + if ($envio['expira_en'] && strtotime($envio['expira_en']) < time()) { + $this->db->update('lab_form_envios', ['estado' => 'expirado'], 'id = ?', [$envio['id']]); + return null; + } + $envio['esquema_decoded'] = json_decode($envio['esquema'], true) ?? []; + $envio['prefilled_decoded'] = json_decode($envio['datos_prefilled'], true) ?? []; + return $envio; + } + + /** + * Guardar respuesta del cliente (firma + datos). + */ + public function guardarRespuesta(string $token, array $datosCliente, ?string $firmaSvg, string $ip, string $ua): bool { + $envio = $this->db->fetch('SELECT id, estado FROM lab_form_envios WHERE token = ?', [$token]); + if (!$envio || $envio['estado'] === 'expirado') return false; + + $tieneCanvas = !empty($firmaSvg); + $tieneFoto = !empty($datosCliente['__firma_foto']); + $estado = ($tieneCanvas || $tieneFoto) ? 'firmado' : 'completado'; + + // Hash de verificación SHA-256 (vincula contenido + firma + timestamp) + $payloadHash = json_encode($datosCliente, JSON_UNESCAPED_UNICODE) + . ($firmaSvg ?? '') + . $envio['id'] + . $envio['token'] + . date('Y-m-d H:i:s'); + $hash = hash('sha256', $payloadHash); + + $this->db->update('lab_form_envios', [ + 'datos_cliente' => json_encode($datosCliente, JSON_UNESCAPED_UNICODE), + 'firma_svg' => $firmaSvg, + 'hash_verificacion' => $hash, + 'ip_cliente' => $ip, + 'user_agent' => substr($ua, 0, 512), + 'estado' => $estado, + 'completado_en' => date('Y-m-d H:i:s'), + ], 'token = ?', [$token]); + return true; + } + + /** + * Listar envíos (admin/enfermero). + */ + public function listarEnvios(array $filtros = []): array { + $where = ['1=1']; + $params = []; + if (!empty($filtros['formulario_id'])) { $where[] = 'e.formulario_id = ?'; $params[] = $filtros['formulario_id']; } + if (!empty($filtros['paciente_id'])) { $where[] = 'e.paciente_id = ?'; $params[] = $filtros['paciente_id']; } + if (!empty($filtros['enviado_por'])) { $where[] = 'e.enviado_por = ?'; $params[] = $filtros['enviado_por']; } + if (!empty($filtros['estado'])) { $where[] = 'e.estado = ?'; $params[] = $filtros['estado']; } + $w = implode(' AND ', $where); + return $this->db->fetchAll(" + SELECT e.*, f.nombre AS form_nombre, f.categoria, f.esquema, + p.nombre_completo AS paciente_nombre, + u.full_name AS enviado_por_nombre + FROM lab_form_envios e + JOIN lab_formularios f ON f.id = e.formulario_id + LEFT JOIN lab_pacientes p ON p.id = e.paciente_id + LEFT JOIN admin_users u ON u.id = e.enviado_por + WHERE $w + ORDER BY e.created_at DESC + LIMIT 200 + ", $params); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper + // ───────────────────────────────────────────────────────────────────────── + private function validarEsquema(string $json): void { + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + throw new InvalidArgumentException('El esquema del formulario no es un JSON válido'); + } + } +} diff --git a/classes/lab/OrdenMedica.php b/classes/lab/OrdenMedica.php new file mode 100644 index 0000000..3c6a11f --- /dev/null +++ b/classes/lab/OrdenMedica.php @@ -0,0 +1,316 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Lista paginada de órdenes con filtros. + * + * @param array $filtros estado, paciente_id, desde, hasta, busqueda + */ + public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array { + $wheres = []; + $params = []; + + if (!empty($filtros['estado'])) { + $wheres[] = 'o.estado = ?'; + $params[] = $filtros['estado']; + } + if (!empty($filtros['paciente_id'])) { + $wheres[] = 'o.paciente_id = ?'; + $params[] = $filtros['paciente_id']; + } + if (!empty($filtros['desde'])) { + $wheres[] = 'DATE(o.created_at) >= ?'; + $params[] = $filtros['desde']; + } + if (!empty($filtros['hasta'])) { + $wheres[] = 'DATE(o.created_at) <= ?'; + $params[] = $filtros['hasta']; + } + if (!empty($filtros['busqueda'])) { + $like = '%' . $filtros['busqueda'] . '%'; + $wheres[] = '(p.nombre_completo LIKE ? OR p.numero_documento LIKE ?)'; + $params[] = $like; + $params[] = $like; + } + + $where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : ''; + $offset = ($pagina - 1) * $porPagina; + + $total = $this->db->fetch( + "SELECT COUNT(*) AS n + FROM lab_ordenes_medicas o + JOIN lab_pacientes p ON p.id = o.paciente_id + $where", + $params + )['n'] ?? 0; + + $rows = $this->db->fetchAll(" + SELECT + o.*, + p.nombre_completo AS paciente_nombre, + p.numero_documento, + p.telefono AS paciente_telefono, + rev.full_name AS revisada_por_nombre, + aut.full_name AS autorizada_por_nombre + FROM lab_ordenes_medicas o + JOIN lab_pacientes p ON p.id = o.paciente_id + LEFT JOIN admin_users rev ON rev.id = o.revisada_por + LEFT JOIN admin_users aut ON aut.id = o.autorizada_por + $where + ORDER BY o.created_at DESC + LIMIT ? OFFSET ? + ", array_merge($params, [$porPagina, $offset])); + + return [ + 'data' => $rows, + 'total' => (int)$total, + 'pagina' => $pagina, + 'por_pagina' => $porPagina, + 'paginas' => (int)ceil($total / $porPagina), + ]; + } + + /** + * Obtener una orden por ID (con todos los datos relacionados). + */ + public function obtener(int $id): ?array { + $orden = $this->db->fetch(" + SELECT + o.*, + p.nombre_completo AS paciente_nombre, + p.numero_documento, + p.telefono AS paciente_telefono, + p.eps AS paciente_eps, + p.user_id, + u.phone_number, + rev.full_name AS revisada_por_nombre, + aut.full_name AS autorizada_por_nombre, + c.local_file AS conv_local_file, + c.whatsapp_media_id AS conv_media_id + FROM lab_ordenes_medicas o + JOIN lab_pacientes p ON p.id = o.paciente_id + LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN admin_users rev ON rev.id = o.revisada_por + LEFT JOIN admin_users aut ON aut.id = o.autorizada_por + LEFT JOIN conversations c ON c.id = o.conversation_id + WHERE o.id = ? + ", [$id]); + + if (!$orden) { + return null; + } + + // Historial de cambios de estado + $orden['historial'] = $this->db->fetchAll(" + SELECT + la.*, + au.full_name AS admin_nombre + FROM lab_autorizaciones la + LEFT JOIN admin_users au ON au.id = la.realizada_por + WHERE la.orden_id = ? + ORDER BY la.created_at ASC + ", [$id]); + + return $orden; + } + + /** + * Crear nueva orden médica. + */ + public function crear(array $datos, ?int $adminId = null): int { + $campos = $this->filtrarCampos($datos); + $id = $this->db->insert('lab_ordenes_medicas', $campos); + + // Registrar en historial + $this->registrarCambioEstado($id, null, 'pendiente', $adminId, 'Orden creada'); + $this->log->registrar($adminId, 'ordenes', 'crear', $id, $campos); + + return $id; + } + + /** + * Actualizar datos de la orden (sin cambiar estado). + */ + public function actualizar(int $id, array $datos, ?int $adminId = null): bool { + $campos = $this->filtrarCampos($datos); + unset($campos['estado']); // el estado se cambia con cambiarEstado() + $ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]); + if ($ok) { + $this->registrarCambioEstado($id, null, null, $adminId, 'Datos actualizados', 'editada'); + $this->log->registrar($adminId, 'ordenes', 'editar', $id, $campos); + } + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Gestión de estados (flujo de revisión) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Cambia el estado de la orden con trazabilidad completa. + * + * @param int $id + * @param string $nuevoEstado Debe estar en self::ESTADOS + * @param int $adminId + * @param string $comentario + */ + public function cambiarEstado( + int $id, + string $nuevoEstado, + int $adminId, + string $comentario = '' + ): bool { + if (!in_array($nuevoEstado, self::ESTADOS)) { + throw new InvalidArgumentException("Estado inválido: $nuevoEstado"); + } + + $orden = $this->obtener($id); + if (!$orden) { + throw new RuntimeException("Orden #$id no encontrada"); + } + + $estadoAnterior = $orden['estado']; + $ahora = date('Y-m-d H:i:s'); + + $campos = ['estado' => $nuevoEstado]; + + // Campos adicionales según el nuevo estado + if ($nuevoEstado === 'en_revision') { + $campos['revisada_por'] = $adminId; + $campos['revisada_at'] = $ahora; + } elseif (in_array($nuevoEstado, ['autorizada', 'rechazada'])) { + $campos['autorizada_por'] = $adminId; + $campos['autorizada_at'] = $ahora; + $campos['comentario_revision'] = $comentario; + } + + $ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]); + + if ($ok) { + $this->registrarCambioEstado($id, $estadoAnterior, $nuevoEstado, $adminId, $comentario); + $this->log->registrar($adminId, 'ordenes', $nuevoEstado, $id, [ + 'estado_anterior' => $estadoAnterior, + 'comentario' => $comentario, + ]); + } + + return $ok > 0; + } + + // Alias de conveniencia + public function autorizar(int $id, int $adminId, string $comentario = ''): bool { + return $this->cambiarEstado($id, 'autorizada', $adminId, $comentario); + } + + public function rechazar(int $id, int $adminId, string $motivo): bool { + return $this->cambiarEstado($id, 'rechazada', $adminId, $motivo); + } + + public function iniciarRevision(int $id, int $adminId): bool { + return $this->cambiarEstado($id, 'en_revision', $adminId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Estadísticas / Dashboard + // ───────────────────────────────────────────────────────────────────────── + + /** + * Conteo de órdenes por estado. + */ + public function contadorPorEstado(): array { + $rows = $this->db->fetchAll(" + SELECT estado, COUNT(*) AS total + FROM lab_ordenes_medicas + GROUP BY estado + "); + $result = array_fill_keys(self::ESTADOS, 0); + foreach ($rows as $row) { + $result[$row['estado']] = (int)$row['total']; + } + return $result; + } + + /** + * Órdenes pendientes más antiguas (sin revisar). + */ + public function pendientesAntiguos(int $limit = 10): array { + return $this->db->fetchAll(" + SELECT + o.*, + p.nombre_completo, + TIMESTAMPDIFF(HOUR, o.created_at, NOW()) AS horas_espera + FROM lab_ordenes_medicas o + JOIN lab_pacientes p ON p.id = o.paciente_id + WHERE o.estado = 'pendiente' + ORDER BY o.created_at ASC + LIMIT ? + ", [$limit]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers privados + // ───────────────────────────────────────────────────────────────────────── + + private function registrarCambioEstado( + int $ordenId, + ?string $estadoAnterior, + ?string $estadoNuevo, + ?int $adminId, + string $comentario = '', + string $accion = '' + ): void { + $accion = $accion ?: ($estadoNuevo ?? 'editada'); + $this->db->insert('lab_autorizaciones', [ + 'orden_id' => $ordenId, + 'accion' => $accion, + 'estado_anterior'=> $estadoAnterior, + 'estado_nuevo' => $estadoNuevo, + 'realizada_por' => $adminId, + 'comentario' => $comentario, + ]); + } + + private function filtrarCampos(array $datos): array { + $permitidos = [ + 'paciente_id', 'conversation_id', 'whatsapp_media_id', 'local_file', + 'estado', 'medico_nombre', 'medico_registro', 'fecha_orden', + 'diagnostico', 'examenes_solicitados', 'requiere_ayuno', + 'horas_ayuno', 'indicaciones', 'revisada_por', 'revisada_at', + 'autorizada_por', 'autorizada_at', 'comentario_revision', 'notas_admin', + ]; + return array_intersect_key($datos, array_flip($permitidos)); + } +} diff --git a/classes/lab/Paciente.php b/classes/lab/Paciente.php new file mode 100644 index 0000000..acf699f --- /dev/null +++ b/classes/lab/Paciente.php @@ -0,0 +1,215 @@ +db = Database::getInstance(); + $this->log = new ActividadAdmin(); + } + + // ───────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Lista paginada de pacientes con búsqueda. + */ + public function listar( + string $busqueda = '', + int $pagina = 1, + int $porPagina = 30 + ): array { + $offset = ($pagina - 1) * $porPagina; + $like = "%$busqueda%"; + $params = $busqueda + ? [$like, $like, $like, $like] + : []; + + $where = $busqueda + ? "WHERE p.nombre_completo LIKE ? + OR p.numero_documento LIKE ? + OR p.telefono LIKE ? + OR p.email LIKE ?" + : ''; + + $total = $this->db->fetch( + "SELECT COUNT(*) AS n FROM lab_pacientes p $where", + $params + )['n'] ?? 0; + + $rows = $this->db->fetchAll(" + SELECT + p.*, + u.phone_number, + u.name AS whatsapp_name, + (SELECT COUNT(*) FROM lab_ordenes_medicas o WHERE o.paciente_id = p.id) AS total_ordenes + FROM lab_pacientes p + LEFT JOIN users u ON u.id = p.user_id + $where + ORDER BY p.nombre_completo ASC + LIMIT ? OFFSET ? + ", array_merge($params, [$porPagina, $offset])); + + return [ + 'data' => $rows, + 'total' => (int)$total, + 'pagina' => $pagina, + 'por_pagina' => $porPagina, + 'paginas' => (int)ceil($total / $porPagina), + ]; + } + + /** + * Un paciente por ID (con datos del usuario WhatsApp). + */ + public function obtener(int $id): ?array { + return $this->db->fetch(" + SELECT + p.*, + u.phone_number, + u.name AS whatsapp_name, + u.status AS whatsapp_status, + u.created_at AS whatsapp_desde + FROM lab_pacientes p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.id = ? + ", [$id]) ?: null; + } + + /** + * Buscar por número de documento. + */ + public function porDocumento(string $documento): ?array { + return $this->db->fetch( + 'SELECT * FROM lab_pacientes WHERE numero_documento = ?', + [$documento] + ) ?: null; + } + + /** + * Buscar por user_id (teléfono WhatsApp). + */ + public function porUserId(int $userId): ?array { + return $this->db->fetch( + 'SELECT * FROM lab_pacientes WHERE user_id = ?', + [$userId] + ) ?: null; + } + + /** + * Crear paciente nuevo. + * + * @param array $datos Campos del formulario + * @param int|null $adminId Quien lo crea (para trazabilidad) + * @return int ID del paciente creado + */ + public function crear(array $datos, ?int $adminId = null): int { + $campos = $this->filtrarCampos($datos); + $id = $this->db->insert('lab_pacientes', $campos); + $this->log->registrar($adminId, 'pacientes', 'crear', $id, $campos); + return $id; + } + + /** + * Actualizar paciente existente. + */ + public function actualizar(int $id, array $datos, ?int $adminId = null): bool { + $campos = $this->filtrarCampos($datos); + $ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]); + $this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos); + return $ok > 0; + } + + /** + * Desactivar (soft-delete). + */ + public function desactivar(int $id, ?int $adminId = null): bool { + $ok = $this->db->update('lab_pacientes', ['is_active' => 0], 'id = ?', [$id]); + $this->log->registrar($adminId, 'pacientes', 'desactivar', $id); + return $ok > 0; + } + + /** + * Vincular paciente con un usuario de WhatsApp. + */ + public function vincularUsuario(int $pacienteId, int $userId, ?int $adminId = null): bool { + $ok = $this->db->update('lab_pacientes', ['user_id' => $userId], 'id = ?', [$pacienteId]); + $this->log->registrar($adminId, 'pacientes', 'vincular_whatsapp', $pacienteId, [ + 'user_id' => $userId, + ]); + return $ok > 0; + } + + // ───────────────────────────────────────────────────────────────────────── + // Historial + // ───────────────────────────────────────────────────────────────────────── + + /** + * Órdenes médicas del paciente. + */ + public function ordenes(int $pacienteId, int $limit = 20): array { + return $this->db->fetchAll(" + SELECT + o.*, + au.full_name AS autorizada_por_nombre + FROM lab_ordenes_medicas o + LEFT JOIN admin_users au ON au.id = o.autorizada_por + WHERE o.paciente_id = ? + ORDER BY o.created_at DESC + LIMIT ? + ", [$pacienteId, $limit]); + } + + /** + * Historial de actividad de trazabilidad del paciente. + */ + public function actividad(int $pacienteId): array { + return $this->log->porEntidad('pacientes', $pacienteId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Crea automáticamente un paciente a partir de un users.id (whatsapp) + * si aún no existe. Devuelve el id del paciente. + */ + public function obtenerOCrearDesdeWhatsapp(int $userId): int { + $existente = $this->porUserId($userId); + if ($existente) { + return $existente['id']; + } + + $user = $this->db->fetch( + 'SELECT * FROM users WHERE id = ?', + [$userId] + ); + + return $this->crear([ + 'user_id' => $userId, + 'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']), + 'telefono' => $user['phone_number'] ?? null, + ]); + } + + private function filtrarCampos(array $datos): array { + $permitidos = [ + 'user_id', 'numero_documento', 'tipo_documento', + 'nombre_completo', 'telefono', 'email', + 'fecha_nacimiento', 'genero', 'direccion', + 'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', + ]; + return array_intersect_key($datos, array_flip($permitidos)); + } +} diff --git a/config/config.php b/config/config.php index 32ed640..eeff926 100644 --- a/config/config.php +++ b/config/config.php @@ -18,6 +18,23 @@ define('DB_CHARSET', 'utf8mb4'); // El sistema ahora usa autenticación basada en base de datos (admin_users table) define('ADMIN_PASSWORD', '$2y$10$IXCY8Sm1xFkfhC6Y67Ahn.QLHxE.sjWfmTEOKFZdCN2a9s9YLVuGW'); // password123 +// ── Módulos del sistema ────────────────────────────────────────────────────── +// Catálogo de todos los módulos disponibles para asignar a los roles. +// Clave: slug interno · Valor: etiqueta legible para la UI. +define('SYSTEM_MODULES', [ + 'whatsapp' => 'WhatsApp Bot', + 'lab_dashboard' => 'Dashboard Lab', + 'lab_ordenes' => 'Órdenes Médicas', + 'lab_pacientes' => 'Pacientes', + 'lab_domicilios' => 'Domicilios', + 'lab_enfermeras' => 'Enfermeras', + 'lab_formularios' => 'Formularios', + 'lab_reportes' => 'Reportes', + 'lab_configuracion' => 'Configuración Lab', + 'usuarios' => 'Gestión de Usuarios', + 'enfermero_portal' => 'Portal Enfermero', +]); + // Función para cargar archivo .env if (!function_exists('loadEnvFile')) { function loadEnvFile($path) { @@ -355,8 +372,13 @@ define('WEBHOOK_VERIFY_TOKEN', getConfigFromDB('webhook_verify_token', env('WEBH // Configuración general define('APP_NAME', 'WhatsApp Bot System'); -define('APP_VERSION', '1.0.0'); -define('APP_URL', 'https://tudominio.com'); // Se auto-detectará +define('APP_VERSION', '2.0.0'); +// URL auto-detectada según protocolo y host actuales +if (!defined('APP_URL')) { + $__proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $__host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + define('APP_URL', $__proto . '://' . $__host); +} define('TIMEZONE', 'America/Bogota'); // Información del desarrollador @@ -462,12 +484,30 @@ function authenticateUser($username, $password) { // Actualizar último login $updateLogin = $pdo->prepare("UPDATE admin_users SET last_login = NOW() WHERE id = ?"); $updateLogin->execute([$admin['id']]); - + + // Cargar módulos del rol + $modules = []; + $roleSlug = $admin['role'] ?? 'admin'; + if (!empty($admin['role_id'])) { + $modStmt = $pdo->prepare("SELECT module_slug FROM role_modules WHERE role_id = ?"); + $modStmt->execute([$admin['role_id']]); + $modules = array_column($modStmt->fetchAll(PDO::FETCH_ASSOC), 'module_slug'); + } elseif ($roleSlug === 'admin') { + // Fallback: admin sin role_id tiene todos los módulos + $modules = array_keys(SYSTEM_MODULES); + } elseif ($roleSlug === 'enfermero') { + $modules = ['enfermero_portal', 'lab_formularios']; + } + return [ - 'id' => $admin['id'], - 'username' => $admin['username'], - 'full_name' => $admin['full_name'], - 'email' => $admin['email'] + 'id' => $admin['id'], + 'username' => $admin['username'], + 'full_name' => $admin['full_name'], + 'email' => $admin['email'], + 'role' => $roleSlug, + 'role_id' => $admin['role_id'] ?? null, + 'enfermera_id' => $admin['enfermera_id'] ?? null, + 'modules' => $modules, ]; } } else { @@ -534,6 +574,58 @@ function getAdminUsers() { // Función para verificar login if (!function_exists('isUserLoggedIn')) { +/** + * Verifica si el usuario autenticado tiene el rol indicado. + */ +function userHasRole(string $role): bool { + return isset($_SESSION['admin_user']['role']) && $_SESSION['admin_user']['role'] === $role; +} + +/** + * Verdadero si el usuario es enfermero. + */ +function isEnfermero(): bool { + return userHasRole('enfermero'); +} + +/** + * Devuelve el ID de enfermera del usuario en sesión, o null si no aplica. + */ +function enfermeraId(): ?int { + $id = $_SESSION['admin_user']['enfermera_id'] ?? null; + return $id ? (int)$id : null; +} + +/** + * Comprueba si el usuario en sesión tiene acceso al módulo indicado. + * Los administradores sin modules cargados tienen acceso total (retrocompat.). + */ +function hasModule(string $slug): bool { + $modules = $_SESSION['admin_user']['modules'] ?? null; + // Si no hay módulos en sesión (usuario legacy) y es admin → acceso total + if ($modules === null) { + return userHasRole('admin'); + } + return in_array($slug, $modules, true); +} + +/** + * Detiene la ejecución si el usuario no tiene el rol requerido. + */ +function requireRole(string $role): void { + if (!isUserLoggedIn()) { + header('Location: login.php'); + exit; + } + if (!userHasRole($role)) { + http_response_code(403); + // Redirigir al portal correcto + $destino = userHasRole('enfermero') ? 'enfermero_portal.php' : 'index.php'; + header("Location: $destino"); + exit; + } +} + function isUserLoggedIn() { return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true; } @@ -658,6 +750,14 @@ function saveWhatsAppConfigToDB($whatsappConfig) { if (isset($whatsappConfig['welcome_message'])) { $success &= saveConfigToDB('welcome_message', $whatsappConfig['welcome_message']); } + + if (isset($whatsappConfig['terms_message'])) { + $success &= saveConfigToDB('terms_message', $whatsappConfig['terms_message']); + } + + if (isset($whatsappConfig['terms_rejected_message'])) { + $success &= saveConfigToDB('terms_rejected_message', $whatsappConfig['terms_rejected_message']); + } return $success; } @@ -694,6 +794,8 @@ function getWhatsAppConfigFromDB() { 'webhook_verify_token' => $webhookToken, 'business_name' => getConfigFromDB('business_name', ''), 'welcome_message' => getConfigFromDB('welcome_message', ''), + 'terms_message' => getConfigFromDB('terms_message', ''), + 'terms_rejected_message' => getConfigFromDB('terms_rejected_message', ''), 'status' => validateWhatsAppConfigFromDB() ]; } diff --git a/conversations.php b/conversations.php index be3b9de..75d56ac 100644 --- a/conversations.php +++ b/conversations.php @@ -907,6 +907,10 @@ if (!isset($_SESSION['user_id'])) {
Usuario +
+1234567890
@@ -919,6 +923,18 @@ if (!isset($_SESSION['user_id'])) {
+ + + + + + + + + ${(msg.message_type === 'image' && msg.direction !== 'outgoing') ? `` : ''}${window._labAddrBtn ? window._labAddrBtn(msg, chat ? chat.currentConversationId : 0) : ''}
${time} ${msg.direction === 'outgoing' ? `${statusIcon}` : ''}
@@ -6395,5 +6418,920 @@ if (!isset($_SESSION['user_id'])) { + + + + + + + + + + \ No newline at end of file diff --git a/cronograma.xlsx b/cronograma.xlsx new file mode 100644 index 0000000..50eb73a Binary files /dev/null and b/cronograma.xlsx differ diff --git a/database/04_add_domicilio_cobro_fields.sql b/database/04_add_domicilio_cobro_fields.sql new file mode 100644 index 0000000..916025d --- /dev/null +++ b/database/04_add_domicilio_cobro_fields.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- Migration: 04_add_domicilio_cobro_fields +-- Agrega campos de seguro, autorización y cobro a lab_domicilios +-- ============================================================ + +ALTER TABLE `lab_domicilios` + ADD COLUMN IF NOT EXISTS `seguro_nombre` VARCHAR(150) DEFAULT NULL + COMMENT 'Nombre del seguro cuando tipo_cliente = seguro' + AFTER `tipo_cliente`, + + ADD COLUMN IF NOT EXISTS `autorizacion` VARCHAR(100) DEFAULT NULL + COMMENT 'Número de autorización del seguro' + AFTER `seguro_nombre`, + + ADD COLUMN IF NOT EXISTS `copago_laboratorio` DECIMAL(12,2) DEFAULT NULL + COMMENT 'Copago que asume el laboratorio' + AFTER `autorizacion`, + + ADD COLUMN IF NOT EXISTS `valor_domicilio` DECIMAL(12,2) DEFAULT NULL + COMMENT 'Valor a cobrar al cliente por el domicilio' + AFTER `copago_laboratorio`, + + ADD COLUMN IF NOT EXISTS `valor_copago` DECIMAL(12,2) DEFAULT NULL + COMMENT 'Valor del copago a cobrar al cliente' + AFTER `valor_domicilio`; + +-- Actualizar ENUM tipo_cliente para quitar eps y dejar solo particular/seguro +ALTER TABLE `lab_domicilios` + MODIFY COLUMN `tipo_cliente` ENUM('particular','seguro') NOT NULL DEFAULT 'particular' + COMMENT 'Si el servicio es particular o por seguro'; diff --git a/database/05_terms_acceptance.sql b/database/05_terms_acceptance.sql new file mode 100644 index 0000000..63ce16d --- /dev/null +++ b/database/05_terms_acceptance.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- Migration: 05_terms_acceptance +-- Sistema de aceptación de términos y condiciones +-- ============================================================ + +-- Tabla de versiones de términos (cada vez que cambia el documento) +CREATE TABLE IF NOT EXISTS `terms_versions` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `version` VARCHAR(50) NOT NULL COMMENT 'Ej: 1.0, 2.0, 2026-03', + `documento_url` VARCHAR(500) DEFAULT NULL COMMENT 'URL pública del documento PDF', + `documento_nombre` VARCHAR(255) DEFAULT NULL COMMENT 'Nombre original del archivo', + `mensaje_aceptacion` TEXT NOT NULL COMMENT 'Mensaje que se envía al usuario para aceptar', + `mensaje_rechazo` TEXT NOT NULL COMMENT 'Mensaje que se envía si el usuario rechaza', + `forzar_reenvio` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = reenviar a todos los usuarios aunque ya aceptaron', + `activa` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1 = es la versión vigente', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_activa` (`activa`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Tabla de aceptaciones de usuarios +CREATE TABLE IF NOT EXISTS `terms_acceptance` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL COMMENT 'FK a users.id', + `terms_version_id` INT(11) NOT NULL COMMENT 'FK a terms_versions.id', + `phone_number` VARCHAR(20) NOT NULL COMMENT 'Copia desnormalizada para auditoría', + `estado` ENUM('pendiente','aceptado','rechazado') NOT NULL DEFAULT 'pendiente', + `fecha_envio` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Cuando se envió el mensaje de términos', + `fecha_respuesta` TIMESTAMP NULL DEFAULT NULL COMMENT 'Cuando el usuario respondió', + `ip_referencia` VARCHAR(45) DEFAULT NULL COMMENT 'IP del webhook si disponible', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_version` (`user_id`, `terms_version_id`), + KEY `idx_estado` (`estado`), + KEY `idx_phone` (`phone_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Columna en users para saber si está esperando respuesta de términos +ALTER TABLE `users` + ADD COLUMN IF NOT EXISTS `terms_pending` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT '1 = se enviaron términos y se espera respuesta' + AFTER `welcome_sent_at`, + ADD COLUMN IF NOT EXISTS `terms_accepted_at` TIMESTAMP NULL DEFAULT NULL + COMMENT 'Última vez que aceptó los términos vigentes' + AFTER `terms_pending`, + ADD COLUMN IF NOT EXISTS `terms_version_id` INT(11) NULL DEFAULT NULL + COMMENT 'Versión de términos que aceptó' + AFTER `terms_accepted_at`; diff --git a/database/06_master_sync.sql b/database/06_master_sync.sql new file mode 100644 index 0000000..bea5474 --- /dev/null +++ b/database/06_master_sync.sql @@ -0,0 +1,204 @@ +-- ============================================================================= +-- 06_master_sync.sql +-- Migración maestra de sincronización local ↔ producción +-- Generada: 2025-01-01 +-- +-- INSTRUCCIONES DE APLICACIÓN: +-- LOCAL (Docker MySQL): +-- docker exec whatsapp-dev-mysql mysql -uroot -proot_password_2026 \ +-- usite_whatsapp_bot < database/06_master_sync.sql +-- +-- PRODUCCIÓN (desde el contenedor app): +-- - Aplica SOLO las secciones marcadas [SOLO PROD] si prod ya las tiene, +-- o la migración completa en instalaciones nuevas. +-- +-- GARANTÍA DE SEGURIDAD: +-- Este script es COMPLETAMENTE NO DESTRUCTIVO. No contiene DROP TABLE, +-- TRUNCATE, DELETE ni UPDATE. Todas las operaciones usan IF NOT EXISTS o +-- INSERT IGNORE, por lo que correrlo en un servidor con datos existentes +-- solo creará lo que falte sin modificar ni eliminar nada. +-- +-- RESUMEN DE CAMBIOS: +-- A) Tablas solo en LOCAL → agregar a producción y futuras instalaciones: +-- - scheduled_messages +-- - scheduled_messages_view (VIEW) +-- B) Tablas solo en PROD → agregar a local y futuras instalaciones: +-- - roles +-- - role_modules +-- - lab_config +-- C) Columnas en PROD que faltan en LOCAL y futuras instalaciones: +-- - admin_users.role_id +-- - lab_form_envios.hash_verificacion +-- - lab_formularios.doc_encabezado, doc_subtitulo, doc_logo_base64, +-- doc_color, doc_pie_pagina +-- D) Ya cubierto en 05_terms_acceptance.sql (no se repite): +-- - terms_versions, terms_acceptance +-- - users.terms_pending, terms_accepted_at, terms_version_id +-- ============================================================================= + +SET NAMES utf8mb4; +SET foreign_key_checks = 0; + +-- ============================================================================= +-- C0) COLUMNAS: message_templates (guard por si 02 no aplicó completo) +-- ============================================================================= +ALTER TABLE `message_templates` + ADD COLUMN IF NOT EXISTS `body_text` TEXT DEFAULT NULL COMMENT 'Texto del cuerpo con variables {{1}},{{2}}…', + ADD COLUMN IF NOT EXISTS `components` LONGTEXT DEFAULT NULL COMMENT 'Componentes JSON completos de la plantilla', + ADD COLUMN IF NOT EXISTS `example_parameters` LONGTEXT DEFAULT NULL COMMENT 'Ejemplos de valores para los parámetros', + ADD COLUMN IF NOT EXISTS `header_text` VARCHAR(255) DEFAULT NULL COMMENT 'Texto del header', + ADD COLUMN IF NOT EXISTS `header_type` ENUM('text','image','video','document') DEFAULT NULL COMMENT 'Tipo de header', + ADD COLUMN IF NOT EXISTS `footer_text` VARCHAR(255) DEFAULT NULL COMMENT 'Texto del footer', + ADD COLUMN IF NOT EXISTS `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; + +ALTER TABLE `message_templates` + ADD INDEX IF NOT EXISTS `idx_template_language` (`template_name`, `language_code`); + +-- ============================================================================= +-- A1) TABLA: scheduled_messages (local → prod / instalaciones nuevas) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `scheduled_messages` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `template_id` int(11) DEFAULT NULL, + `template_name` varchar(100) DEFAULT NULL, + `template_language` varchar(10) DEFAULT 'es', + `template_parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL + COMMENT 'Parámetros para las variables de la plantilla' + CHECK (json_valid(`template_parameters`)), + `message_type` enum('text','template') DEFAULT 'template', + `message_content` text DEFAULT NULL COMMENT 'Contenido del mensaje si es tipo text', + `scheduled_date` date NOT NULL, + `scheduled_time` time NOT NULL, + `status` enum('pending','sent','failed','cancelled') DEFAULT 'pending', + `sent_at` datetime DEFAULT NULL, + `error_message` text DEFAULT NULL, + `created_by` int(11) DEFAULT NULL COMMENT 'ID del admin que creó el recordatorio', + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_scheduled_date` (`scheduled_date`), + KEY `idx_status` (`status`), + KEY `idx_scheduled_datetime` (`scheduled_date`,`scheduled_time`,`status`), + CONSTRAINT `scheduled_messages_ibfk_1` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================= +-- A2) VISTA: scheduled_messages_view (local → prod / instalaciones nuevas) +-- ============================================================================= +CREATE OR REPLACE VIEW `scheduled_messages_view` AS +SELECT + `sm`.`id`, + `sm`.`user_id`, + `sm`.`template_id`, + `sm`.`template_name`, + `sm`.`template_language`, + `sm`.`template_parameters`, + `sm`.`message_type`, + `sm`.`message_content`, + `sm`.`scheduled_date`, + `sm`.`scheduled_time`, + `sm`.`status`, + `sm`.`sent_at`, + `sm`.`error_message`, + `sm`.`created_by`, + `sm`.`created_at`, + `sm`.`updated_at`, + `u`.`name` AS `user_name`, + `u`.`phone_number` AS `phone_number`, + `mt`.`name` AS `template_display_name`, + `mt`.`body_text` AS `template_body`, + `mt`.`status` AS `template_status` +FROM `scheduled_messages` `sm` +LEFT JOIN `users` `u` ON `sm`.`user_id` = `u`.`id` +LEFT JOIN `message_templates` `mt` ON `sm`.`template_id` = `mt`.`id` +ORDER BY `sm`.`scheduled_date` DESC, `sm`.`scheduled_time` DESC; + +-- ============================================================================= +-- B1) TABLA: roles (prod → local / instalaciones nuevas) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `roles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL COMMENT 'Nombre legible del rol', + `slug` varchar(50) NOT NULL COMMENT 'Clave interna (enfermero, admin, …)', + `description` text DEFAULT NULL, + `color` varchar(20) DEFAULT '#6c757d' COMMENT 'Color HEX para la UI', + `is_system` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 = no se puede eliminar', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Datos semilla mínimos de roles del sistema +INSERT IGNORE INTO `roles` (`id`, `name`, `slug`, `description`, `is_system`) VALUES + (1, 'Administrador', 'admin', 'Acceso completo al sistema', 1), + (2, 'Enfermero', 'enfermero', 'Acceso a módulos de laboratorio y atención', 1); + +-- ============================================================================= +-- B2) TABLA: role_modules (prod → local / instalaciones nuevas) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `role_modules` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `role_id` int(11) NOT NULL, + `module_slug` varchar(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_module` (`role_id`, `module_slug`), + CONSTRAINT `fk_rm_role` + FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================= +-- B3) TABLA: lab_config (prod → local / instalaciones nuevas) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS `lab_config` ( + `clave` varchar(80) NOT NULL, + `valor` longtext NOT NULL DEFAULT '', + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`clave`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============================================================================= +-- C1) COLUMNA: admin_users.role_id (prod → local / instalaciones nuevas) +-- ============================================================================= +ALTER TABLE `admin_users` + ADD COLUMN IF NOT EXISTS `role_id` int(11) DEFAULT NULL AFTER `password`; + +-- C1a) FK opcional: se añade solo si no existe ya +-- (MariaDB/MySQL 10.x: ADD CONSTRAINT IF NOT EXISTS no es estándar, +-- se ignora el error si ya existe usando INSERT IGNORE pattern) +ALTER TABLE `admin_users` + ADD CONSTRAINT IF NOT EXISTS `fk_admin_role` + FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE; + +-- ============================================================================= +-- C2) COLUMNA: lab_form_envios.hash_verificacion (prod → local) +-- ============================================================================= +ALTER TABLE `lab_form_envios` + ADD COLUMN IF NOT EXISTS `hash_verificacion` char(64) DEFAULT NULL + COMMENT 'SHA-256 del PDF generado, para verificación de integridad' + AFTER `pdf_path`; + +-- ============================================================================= +-- C3) COLUMNAS: lab_formularios doc_* (prod → local / instalaciones nuevas) +-- ============================================================================= +ALTER TABLE `lab_formularios` + ADD COLUMN IF NOT EXISTS `doc_encabezado` varchar(200) DEFAULT NULL COMMENT 'Título principal del documento PDF', + ADD COLUMN IF NOT EXISTS `doc_subtitulo` varchar(200) DEFAULT NULL COMMENT 'Subtítulo / razón social', + ADD COLUMN IF NOT EXISTS `doc_logo_base64` longtext DEFAULT NULL COMMENT 'Logo en Base64 para el PDF', + ADD COLUMN IF NOT EXISTS `doc_color` varchar(20) DEFAULT NULL COMMENT 'Color principal HEX (#RRGGBB)', + ADD COLUMN IF NOT EXISTS `doc_pie_pagina` varchar(500) DEFAULT NULL COMMENT 'Texto del pie de página'; + +-- ============================================================================= +-- FIN +-- ============================================================================= +SET foreign_key_checks = 1; + +-- Verificación rápida (opcional, descomentar para depuración) +-- SELECT 'scheduled_messages' AS tabla, COUNT(*) AS filas FROM scheduled_messages +-- UNION ALL SELECT 'roles', COUNT(*) FROM roles +-- UNION ALL SELECT 'role_modules', COUNT(*) FROM role_modules +-- UNION ALL SELECT 'lab_config', COUNT(*) FROM lab_config; diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..f6658fc --- /dev/null +++ b/database/README.md @@ -0,0 +1,658 @@ +# Base de datos — `usite_whatsapp_bot` + +Documentación de las 34 tablas del sistema WhatsApp Bot + Panel Laboratorio Ximena. +Motor: **MariaDB / MySQL 8.x** — Charset: `utf8mb4` — Collation: `utf8mb4_unicode_ci` + +--- + +## Índice rápido + +| # | Tabla | Filas (prod) | Dominio | +|---|-------|-------------|---------| +| 1 | [admin\_users](#1-admin_users) | 4 | Autenticación | +| 2 | [autoresponses](#2-autoresponses) | 6 | Bot WhatsApp | +| 3 | [conversations](#3-conversations) | 3 490 | Bot WhatsApp | +| 4 | [file\_request\_uploads](#4-file_request_uploads) | 0 | Solicitud archivos | +| 5 | [file\_requests](#5-file_requests) | 0 | Solicitud archivos | +| 6 | [lab\_actividad\_admin](#6-lab_actividad_admin) | 43 | Auditoría | +| 7 | [lab\_asignaciones](#7-lab_asignaciones) | 4 | Laboratorio | +| 8 | [lab\_autorizaciones](#8-lab_autorizaciones) | 0 | Laboratorio | +| 9 | [lab\_config](#9-lab_config) | 9 | Laboratorio | +| 10 | [lab\_domicilios](#10-lab_domicilios) | 9 | Laboratorio | +| 11 | [lab\_enfermeras](#11-lab_enfermeras) | 1 | Laboratorio | +| 12 | [lab\_form\_envios](#12-lab_form_envios) | 15 | Formularios | +| 13 | [lab\_formularios](#13-lab_formularios) | 3 | Formularios | +| 14 | [lab\_ordenes\_medicas](#14-lab_ordenes_medicas) | 0 | Laboratorio | +| 15 | [lab\_pacientes](#15-lab_pacientes) | 3 | Laboratorio | +| 16 | [lab\_servicios\_extra](#16-lab_servicios_extra) | 0 | Laboratorio | +| 17 | [media\_files](#17-media_files) | 231 | Multimedia | +| 18 | [media\_queue](#18-media_queue) | 7 | Multimedia | +| 19 | [menu\_options](#19-menu_options) | 13 | Bot WhatsApp | +| 20 | [menus](#20-menus) | 2 | Bot WhatsApp | +| 21 | [message\_templates](#21-message_templates) | 2 | Bot WhatsApp | +| 22 | [migrations](#22-migrations) | 4 | Sistema | +| 23 | [notifications](#23-notifications) | 2 073 | Sistema | +| 24 | [operator\_activity](#24-operator_activity) | 227 | Operadores | +| 25 | [role\_modules](#25-role_modules) | 12 | Control de acceso | +| 26 | [roles](#26-roles) | 2 | Control de acceso | +| 27 | [scheduled\_messages](#27-scheduled_messages) | — | Mensajes prog. | +| 28 | [scheduled\_messages\_view](#28-scheduled_messages_view-vista) | — | Vista | +| 29 | [survey\_responses](#29-survey_responses) | 10 | Bot WhatsApp | +| 30 | [system\_config](#30-system_config) | 16 | Configuración | +| 31 | [system\_logs](#31-system_logs) | 0 | Sistema | +| 32 | [terms\_acceptance](#32-terms_acceptance) | 0 | T&C | +| 33 | [terms\_versions](#33-terms_versions) | 2 | T&C | +| 34 | [user\_states](#34-user_states) | 108 | Bot WhatsApp | +| 35 | [users](#35-users) | 3 090 | Usuarios | +| 36 | [webhook\_logs](#36-webhook_logs) | 9 850 | Sistema | + +--- + +## Autenticación + +### 1. `admin_users` +Administradores del panel web. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `username` | VARCHAR(50) UNIQUE | Login | +| `password` | VARCHAR(255) | bcrypt | +| `role_id` | INT FK→roles | Rol asignado (NULL = admin clásico) | +| `email` | VARCHAR(100) | | +| `nombre` | VARCHAR(100) | Nombre completo | +| `is_active` | TINYINT(1) | 1 = activo | +| `last_login` | DATETIME | | +| `created_at` | TIMESTAMP | | + +**Relaciones:** `role_id` → `roles.id` + +--- + +## Bot WhatsApp + +### 2. `autoresponses` +Respuestas automáticas del bot ante palabras clave. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `keyword` | VARCHAR(100) | Palabra/frase que activa la respuesta | +| `response` | TEXT | Texto de respuesta | +| `is_active` | TINYINT(1) | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 3. `conversations` +Hilo de mensajes WhatsApp por número de teléfono. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) | Número del usuario | +| `contact_name` | VARCHAR(100) | Nombre de WhatsApp | +| `messages` | LONGTEXT (JSON) | Array de mensajes | +| `last_message` | TEXT | Extracto del último mensaje | +| `last_message_at` | DATETIME | | +| `unread_count` | INT | Mensajes no leídos por operador | +| `status` | ENUM | `active`, `archived`, `blocked` | +| `assigned_to` | INT FK→admin_users | Operador asignado | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 4. `file_request_uploads` +Archivos subidos en respuesta a solicitudes. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `request_id` | INT FK→file_requests | | +| `file_path` | VARCHAR(500) | Ruta en servidor | +| `file_type` | VARCHAR(50) | MIME | +| `uploaded_at` | DATETIME | | + +--- + +### 5. `file_requests` +Solicitudes de documentos enviadas por WhatsApp. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) | | +| `request_type` | VARCHAR(50) | Tipo de documento solicitado | +| `status` | ENUM | `pending`, `fulfilled`, `expired` | +| `expires_at` | DATETIME | | +| `created_at` | TIMESTAMP | | + +--- + +### 19. `menu_options` +Opciones de submenú del bot (hijos de `menus`). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `menu_id` | INT FK→menus | | +| `option_number` | INT | Tecla que selecciona el usuario | +| `option_text` | VARCHAR(200) | Texto visible | +| `action_type` | VARCHAR(50) | `submenu`, `message`, `url`, `form_link` | +| `action_value` | TEXT | Valor de la acción | +| `is_active` | TINYINT(1) | | +| `sort_order` | INT | | + +--- + +### 20. `menus` +Menús raíz del bot por número de teléfono de negocio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number_id` | VARCHAR(50) | ID de WhatsApp Business | +| `menu_text` | TEXT | Texto del menú principal | +| `is_active` | TINYINT(1) | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 21. `message_templates` +Plantillas de mensajes aprobadas por Meta. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `name` | VARCHAR(100) | Nombre interno | +| `display_name` | VARCHAR(150) | Nombre legible | +| `category` | VARCHAR(50) | `MARKETING`, `UTILITY`, `AUTHENTICATION` | +| `language` | VARCHAR(10) | `es`, `en`, etc. | +| `status` | VARCHAR(30) | `APPROVED`, `PENDING`, `REJECTED` | +| `header_type` | VARCHAR(20) | `TEXT`, `IMAGE`, `DOCUMENT`, `NONE` | +| `header_text` | VARCHAR(500) | | +| `body_text` | TEXT | Cuerpo de la plantilla | +| `footer_text` | VARCHAR(300) | | +| `buttons` | LONGTEXT (JSON) | Botones quick-reply / CTA | +| `variables_count` | INT | Número de `{{n}}` en body | +| `meta_template_id` | VARCHAR(50) | ID en Meta Graph API | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 29. `survey_responses` +Respuestas a encuestas del bot. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) | | +| `survey_id` | INT | Identificador de encuesta | +| `question_key` | VARCHAR(100) | | +| `answer` | TEXT | | +| `created_at` | TIMESTAMP | | + +--- + +### 34. `user_states` +Estado de conversación del bot por usuario (máquina de estados). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) UNIQUE | | +| `state` | VARCHAR(100) | Estado actual del flujo del bot | +| `context` | LONGTEXT (JSON) | Datos contextuales del estado | +| `updated_at` | DATETIME | | + +--- + +## Laboratorio + +### 6. `lab_actividad_admin` +Log de acciones de administradores en el laboratorio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `admin_id` | INT FK→admin_users | | +| `accion` | VARCHAR(100) | Descripción de la acción | +| `entidad` | VARCHAR(50) | Tabla/módulo afectado | +| `entidad_id` | INT | ID del registro afectado | +| `datos_extra` | TEXT (JSON) | Detalles adicionales | +| `ip` | VARCHAR(45) | | +| `created_at` | TIMESTAMP | | + +--- + +### 7. `lab_asignaciones` +Asignaciones de servicios domiciliarios a enfermeras. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `domicilio_id` | INT FK→lab_domicilios | | +| `enfermera_id` | INT FK→lab_enfermeras | | +| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado`, `completado` | +| `fecha_asignacion` | DATETIME | | +| `fecha_respuesta` | DATETIME | | +| `notas` | TEXT | | + +--- + +### 8. `lab_autorizaciones` +Autorizaciones médicas/seguros para servicios del laboratorio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `paciente_id` | INT FK→lab_pacientes | | +| `tipo` | VARCHAR(50) | Tipo de autorización | +| `numero_autorizacion` | VARCHAR(100) | | +| `aseguradora` | VARCHAR(100) | | +| `fecha_vencimiento` | DATE | | +| `estado` | ENUM | `vigente`, `vencida` | +| `created_at` | TIMESTAMP | | + +--- + +### 9. `lab_config` +Configuración específica del módulo de laboratorio (clave–valor). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `clave` | VARCHAR(80) PK | Nombre de la configuración | +| `valor` | LONGTEXT | Valor | +| `updated_at` | TIMESTAMP | | + +Ejemplos de claves: `empresa_nombre`, `empresa_nit`, `empresa_direccion`, `empresa_telefono`, `empresa_email`, `empresa_ciudad`, etc. + +--- + +### 10. `lab_domicilios` +Solicitudes de servicio domiciliario de laboratorio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `paciente_id` | INT FK→lab_pacientes | | +| `fecha_solicitud` | DATE | | +| `hora_solicitud` | TIME | | +| `direccion` | VARCHAR(300) | | +| `ciudad` | VARCHAR(100) | | +| `estado` | ENUM | `pendiente`, `asignado`, `en_camino`, `completado`, `cancelado` | +| `observaciones` | TEXT | | +| `examen_solicitado` | TEXT | | +| `creado_por` | INT FK→admin_users | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 11. `lab_enfermeras` +Enfermeras/profesionales de campo del laboratorio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `nombre` | VARCHAR(150) | | +| `telefono` | VARCHAR(20) | | +| `email` | VARCHAR(100) | | +| `phone_number_wa` | VARCHAR(20) | Número WhatsApp para notificaciones | +| `is_active` | TINYINT(1) | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 12. `lab_form_envios` +Instancias de formularios enviados/completados por pacientes. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `formulario_id` | INT FK→lab_formularios | | +| `paciente_id` | INT FK→lab_pacientes | | +| `phone_number` | VARCHAR(20) | | +| `datos_json` | LONGTEXT (JSON) | Respuestas del formulario | +| `firma_base64` | LONGTEXT | Firma digital en Base64 | +| `pdf_path` | VARCHAR(500) | Ruta del PDF generado | +| `hash_verificacion` | CHAR(64) | SHA-256 del PDF para integridad | +| `estado` | ENUM | `borrador`, `enviado`, `procesado` | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 13. `lab_formularios` +Definición de formularios médicos configurables. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `nombre` | VARCHAR(150) | | +| `descripcion` | TEXT | | +| `categoria` | ENUM | `consentimiento`, `historia_clinica`, `autorizacion`, `encuesta`, `otro` | +| `esquema` | LONGTEXT (JSON) | Definición de campos del formulario | +| `permite_firma` | TINYINT(1) | | +| `requiere_firma` | TINYINT(1) | | +| `version` | SMALLINT | | +| `is_active` | TINYINT(1) | | +| `doc_encabezado` | VARCHAR(200) | Título en PDF | +| `doc_subtitulo` | VARCHAR(200) | Subtítulo en PDF | +| `doc_logo_base64` | LONGTEXT | Logo en Base64 para PDF | +| `doc_color` | VARCHAR(20) | Color principal HEX para PDF | +| `doc_pie_pagina` | VARCHAR(500) | Pie de página del PDF | +| `creado_por` | INT FK→admin_users | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 14. `lab_ordenes_medicas` +Órdenes médicas digitalizadas vinculadas a pacientes. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `paciente_id` | INT FK→lab_pacientes | | +| `medico` | VARCHAR(150) | | +| `especialidad` | VARCHAR(100) | | +| `fecha_orden` | DATE | | +| `examenes` | TEXT | Exámenes ordenados | +| `archivo_path` | VARCHAR(500) | Imagen/PDF de la orden | +| `estado` | ENUM | `pendiente`, `procesada`, `vigente`, `vencida` | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 15. `lab_pacientes` +Pacientes registrados en el laboratorio. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `nombre` | VARCHAR(150) | | +| `documento` | VARCHAR(30) | Cédula / NIT | +| `tipo_documento` | VARCHAR(20) | CC, TI, CE, PA, NIT | +| `fecha_nacimiento` | DATE | | +| `telefono` | VARCHAR(20) | | +| `email` | VARCHAR(100) | | +| `direccion` | VARCHAR(300) | | +| `eps` | VARCHAR(100) | Aseguradora de salud | +| `phone_number_wa` | VARCHAR(20) | Número WhatsApp → `users.phone_number` | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +### 16. `lab_servicios_extra` +Servicios adicionales configurables del laboratorio (transporte, procesamiento especial, etc.). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `nombre` | VARCHAR(100) | | +| `descripcion` | TEXT | | +| `precio` | DECIMAL(10,2) | | +| `is_active` | TINYINT(1) | | +| `created_at` | TIMESTAMP | | + +--- + +## Multimedia + +### 17. `media_files` +Archivos multimedia subidos o recibidos por WhatsApp. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `media_id` | VARCHAR(100) | ID en la API de WhatsApp | +| `file_name` | VARCHAR(255) | | +| `file_path` | VARCHAR(500) | Ruta local | +| `file_size` | BIGINT | Bytes | +| `mime_type` | VARCHAR(100) | | +| `phone_number` | VARCHAR(20) | Remitente o destinatario | +| `direction` | ENUM | `incoming`, `outgoing` | +| `created_at` | TIMESTAMP | | + +--- + +### 18. `media_queue` +Cola de transmisión de archivos multimedia grandes. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `media_file_id` | INT FK→media_files | | +| `status` | ENUM | `pending`, `processing`, `done`, `failed` | +| `attempts` | INT | Reintentos | +| `error_message` | TEXT | | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +## Mensajes Programados + +### 27. `scheduled_messages` +Mensajes de WhatsApp programados para envío futuro. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `user_id` | INT FK→users | Destinatario | +| `template_id` | INT FK→message_templates | Plantilla a usar | +| `template_name` | VARCHAR(100) | Nombre snapshot de la plantilla | +| `template_language` | VARCHAR(10) | `es`, `en` | +| `template_parameters` | LONGTEXT (JSON) | Variables `{{n}}` | +| `message_type` | ENUM | `template`, `text` | +| `message_content` | TEXT | Mensaje libre (si type=text) | +| `scheduled_date` | DATE | Fecha de envío | +| `scheduled_time` | TIME | Hora de envío | +| `status` | ENUM | `pending`, `sent`, `failed`, `cancelled` | +| `sent_at` | DATETIME | | +| `error_message` | TEXT | | +| `created_by` | INT FK→admin_users | Admin que creó el recordatorio | +| `created_at` / `updated_at` | DATETIME | | + +--- + +### 28. `scheduled_messages_view` (VISTA) +Enriquece `scheduled_messages` con datos del usuario, plantilla y nombre del admin. + +Columnas adicionales: `user_name`, `phone_number`, `template_display_name`, `template_body`, `template_status`. + +--- + +## Control de Acceso + +### 25. `role_modules` +Asociación entre roles y módulos del sistema (permisos). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `role_id` | INT FK→roles | | +| `module_slug` | VARCHAR(100) | Clave del módulo (ej. `lab`, `bot`, `config`) | + +--- + +### 26. `roles` +Roles del sistema para control de acceso basado en roles (RBAC). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `name` | VARCHAR(100) | Nombre legible | +| `slug` | VARCHAR(50) UNIQUE | Clave interna (`admin`, `enfermero`) | +| `description` | TEXT | | +| `color` | VARCHAR(20) | Color HEX para UI | +| `is_system` | TINYINT(1) | 1 = no eliminable | +| `created_at` / `updated_at` | TIMESTAMP | | + +Roles de sistema predefinidos: `admin` (id=1), `enfermero` (id=2). + +--- + +## Términos y Condiciones (T&C) + +### 32. `terms_acceptance` +Registro de aceptaciones/rechazos de T&C por usuario. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) | | +| `version_id` | INT FK→terms_versions | Versión enviada | +| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado` | +| `fecha_envio` | DATETIME | Cuándo se enviaron los T&C | +| `fecha_respuesta` | DATETIME | Cuándo respondió el usuario | +| `ip_origem` | VARCHAR(45) | IP de origen (si aplica) | + +--- + +### 33. `terms_versions` +Versiones publicadas de los Términos y Condiciones. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `version` | VARCHAR(20) | Ej. `v1.0` | +| `titulo` | VARCHAR(200) | | +| `mensaje_bot` | TEXT | Texto enviado por WhatsApp | +| `mensaje_rechazo` | TEXT | Texto si el usuario rechaza | +| `pdf_url` | VARCHAR(500) | URL pública del PDF | +| `pdf_path` | VARCHAR(500) | Ruta local del archivo | +| `es_activa` | TINYINT(1) | Solo una activa a la vez | +| `forzar_reenvio` | TINYINT(1) | 1 = todos deben re-aceptar | +| `created_at` | TIMESTAMP | | +| `created_by` | INT FK→admin_users | | + +--- + +## Configuración y Sistema + +### 22. `migrations` +Registro de migraciones de BD aplicadas. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `name` | VARCHAR(255) | Nombre del archivo de migración | +| `applied_at` | TIMESTAMP | | + +Migraciones aplicadas: `01_initial.sql`, `02_…`, `03_…`, `05_terms_acceptance.sql`, `06_master_sync.sql`. + +--- + +### 23. `notifications` +Notificaciones del sistema enviadas a admins. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `admin_id` | INT FK→admin_users | NULL = broadcast | +| `type` | VARCHAR(50) | `new_message`, `assignment`, `system` | +| `title` | VARCHAR(200) | | +| `body` | TEXT | | +| `is_read` | TINYINT(1) | | +| `created_at` | TIMESTAMP | | + +--- + +### 24. `operator_activity` +Actividad reciente de operadores (mensajes enviados, conversaciones gestionadas). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `admin_id` | INT FK→admin_users | | +| `action` | VARCHAR(100) | `send_message`, `view_conversation`, etc. | +| `phone_number` | VARCHAR(20) | Número implicado | +| `details` | TEXT (JSON) | | +| `created_at` | TIMESTAMP | | + +--- + +### 30. `system_config` +Configuración global del sistema (clave–valor). + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `config_key` | VARCHAR(100) UNIQUE | | +| `config_value` | TEXT | | +| `description` | VARCHAR(255) | | +| `updated_at` | TIMESTAMP | | + +Claves principales: `whatsapp_token`, `whatsapp_phone_number_id`, `whatsapp_verify_token`, `bot_enabled`, `welcome_message`, `terms_message`, `terms_rejection_message`, `terms_pdf_url`, `terms_version`, `terms_force_resend`. + +--- + +### 31. `system_logs` +Logs de errores y eventos del sistema. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `level` | ENUM | `info`, `warning`, `error`, `critical` | +| `message` | TEXT | | +| `context` | LONGTEXT (JSON) | Stack trace, datos extra | +| `created_at` | TIMESTAMP | | + +--- + +### 36. `webhook_logs` +Log de todos los webhooks entrantes de WhatsApp. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `payload` | LONGTEXT (JSON) | Payload completo recibido | +| `event_type` | VARCHAR(50) | `message`, `status`, `read` | +| `phone_number` | VARCHAR(20) | | +| `processed` | TINYINT(1) | | +| `error` | TEXT | Error si falló el procesamiento | +| `created_at` | TIMESTAMP | | + +--- + +## Usuarios + +### 35. `users` +Usuarios de WhatsApp que han interactuado con el bot. + +| Columna | Tipo | Notas | +|---------|------|-------| +| `id` | INT PK | | +| `phone_number` | VARCHAR(20) UNIQUE | Número de WhatsApp | +| `name` | VARCHAR(100) | Nombre de contacto en WhatsApp | +| `is_blocked` | TINYINT(1) | 1 = bloqueado | +| `bot_enabled` | TINYINT(1) | 1 = bot activo para este usuario | +| `last_interaction` | DATETIME | | +| `terms_pending` | TINYINT(1) | 1 = aún no aceptó T&C | +| `terms_accepted_at` | DATETIME | Fecha de aceptación | +| `terms_version_id` | INT FK→terms_versions | Versión aceptada | +| `created_at` / `updated_at` | TIMESTAMP | | + +--- + +## Diagrama ER simplificado + +``` +users ──────────────────────────────────┐ + │ 1:N terms_acceptance │ + │ 1:N scheduled_messages │ + │ 1:N conversations │ user_states (1:1 phone_number) + │ │ +admin_users ─────── roles ──── role_modules + │ 1:N lab_actividad_admin + │ 1:N lab_domicilios ──── lab_asignaciones ── lab_enfermeras + │ 1:N lab_form_envios ── lab_formularios + │ 1:N lab_ordenes_medicas ── lab_pacientes ── lab_autorizaciones + │ 1:N terms_versions (created_by) + │ +menus ─── menu_options +message_templates ─── scheduled_messages +media_files ─── media_queue +``` + +--- + +*Última actualización: generado automáticamente desde comparación local/prod — ver `database/06_master_sync.sql`* diff --git a/db_compare.php b/db_compare.php new file mode 100644 index 0000000..6c045d5 --- /dev/null +++ b/db_compare.php @@ -0,0 +1,92 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +// Conexión local (MariaDB del docker-compose dev) +$localHost = 'whatsapp-dev-mysql'; +$localUser = 'root'; +$localPass = 'root_password_2026'; +$localDb = DB_NAME; + +$pdoLocal = null; +try { + $pdoLocal = new PDO( + "mysql:host={$localHost};port=3306;dbname={$localDb};charset=utf8mb4", + $localUser, $localPass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); +} catch (Exception $e) { + // Si el host no es accesible desde el contenedor app, intentar con la dirección de red interna + $localHost = '172.17.0.1'; // docker bridge gateway + try { + $pdoLocal = new PDO( + "mysql:host={$localHost};port=3306;dbname={$localDb};charset=utf8mb4", + $localUser, $localPass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + } catch (Exception $e2) { + echo json_encode(['local_error' => $e2->getMessage()]); + // Continuar solo con producción + } +} + +function getSchema(PDO $pdo, string $dbName): array { + // Tablas + $tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN); + $schema = []; + + foreach ($tables as $table) { + // Columnas + $cols = $pdo->query("SHOW FULL COLUMNS FROM `$table`")->fetchAll(); + // Índices + $indexes = $pdo->query("SHOW INDEX FROM `$table`")->fetchAll(); + // CREATE TABLE para referencia exacta + $create = $pdo->query("SHOW CREATE TABLE `$table`")->fetch(); + + $schema[$table] = [ + 'columns' => $cols, + 'indexes' => $indexes, + 'create_sql' => $create['Create Table'] ?? '', + ]; + } + return $schema; +} + +function getRowCounts(PDO $pdo): array { + $tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN); + $counts = []; + foreach ($tables as $t) { + $r = $pdo->query("SELECT COUNT(*) as n FROM `$t`")->fetch(); + $counts[$t] = (int)($r['n'] ?? 0); + } + return $counts; +} + +$prodSchema = getSchema($pdoProd, DB_NAME); +$prodCounts = getRowCounts($pdoProd); +$localSchema = $pdoLocal ? getSchema($pdoLocal, $localDb) : null; +$localCounts = $pdoLocal ? getRowCounts($pdoLocal) : null; + +$output = [ + 'prod_tables' => array_keys($prodSchema), + 'local_tables' => $localSchema ? array_keys($localSchema) : null, + 'prod_counts' => $prodCounts, + 'local_counts' => $localCounts, + 'prod_schema' => $prodSchema, + 'local_schema' => $localSchema, +]; + +file_put_contents('/tmp/db_scan_result.json', json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +echo "DONE: " . count($prodSchema) . " prod tables" . ($localSchema ? ", " . count($localSchema) . " local tables" : ", local not available") . PHP_EOL; +echo "Output saved to /tmp/db_scan_result.json" . PHP_EOL; diff --git a/db_diff.php b/db_diff.php new file mode 100644 index 0000000..08b96f1 --- /dev/null +++ b/db_diff.php @@ -0,0 +1,34 @@ + $n) { + echo " $t: $n rows" . PHP_EOL; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fd26eff..79e7261 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -174,10 +174,11 @@ services: ports: - "8082:8081" environment: - - REDIS_HOSTS=local:redis:6379 + # Usa host.docker.internal para alcanzar Redis aunque esté en otra red Docker + - REDIS_HOSTS=local:host.docker.internal:6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} - depends_on: - - redis + extra_hosts: + - "host.docker.internal:host-gateway" networks: - whatsapp-dev-network diff --git a/enfermero_portal.php b/enfermero_portal.php new file mode 100644 index 0000000..827bcc7 --- /dev/null +++ b/enfermero_portal.php @@ -0,0 +1,988 @@ + + + + + + + Mi Agenda · Lab + + + + + + + +
+
+
+ +
+
+
Cargando…
+ Portal Enfermero · +
+
+ + + Admin + + + + + + +
+ + +
+
+ + + + +
+ + +
+
+ + +
+
+
📋
+

Cargando agenda…

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/form_cliente.php b/form_cliente.php new file mode 100644 index 0000000..acdf044 --- /dev/null +++ b/form_cliente.php @@ -0,0 +1,831 @@ + + + + + + + Formulario + + + + + +
+ + +
+
+

Cargando formulario…

+
+ + + + + + + + + + + + + +
+ + + + diff --git a/index.php b/index.php index a7b934d..17e2833 100644 --- a/index.php +++ b/index.php @@ -15,10 +15,15 @@ if (!isUserLoggedIn()) { // Verificar conexión a la base de datos try { - $db = Database::getInstance(); - $dbStatus = 'Conectado'; + $db = Database::getInstance(); + $pdo = $db->getConnection(); + $pdo->query('SELECT 1'); // prueba real + $dbHost = DB_HOST ?? 'desconocido'; + $serverIp = $_SERVER['SERVER_ADDR'] ?? gethostbyname(gethostname()); + $dbStatus = 'Conectado (' . htmlspecialchars($dbHost) . ')'; } catch (Exception $e) { - $dbStatus = 'Error: ' . $e->getMessage(); + $dbStatus = 'Error: ' . htmlspecialchars($e->getMessage()); + $serverIp = $_SERVER['SERVER_ADDR'] ?? '—'; } ?> @@ -44,7 +49,7 @@ try { @@ -63,8 +71,7 @@ try {
- -
+
@@ -89,12 +96,6 @@ try {
En línea -
-
-
@@ -312,7 +313,7 @@ try { Administra tus chats de WhatsApp
-
+
@@ -321,6 +322,9 @@ try { placeholder="Buscar conversaciones..." id="search-conversations">
+
@@ -417,40 +421,17 @@ try {
-
-
-
Usuarios del Sistema
- -
-
-
- - Gestiona los usuarios administradores que tienen acceso al sistema. -
-
- - - - - - - - - - - - - - - - - -
UsuarioNombre CompletoEmailEstadoÚltimo AccesoFecha CreaciónAcciones
- Cargando usuarios... -
-
+
+
+ +
Gestión de Usuarios & Roles
+

+ La administración de usuarios, roles y permisos de módulo
+ se encuentra centralizada en el Módulo de Laboratorio. +

+ + Ir a Usuarios & Roles +
@@ -802,8 +783,71 @@ try {
+
+ + + Este mensaje se muestra al usuario antes de aceptar cualquier cosa. Incluye las opciones de aceptar o rechazar. +
+
+ + + Se envía automáticamente si el usuario elige no aceptar. +
+ +
+
+
Documento PDF de Términos
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + @@ -843,7 +887,7 @@ try {
Versión:
-
1.0.0
+
v
PHP:
@@ -851,9 +895,12 @@ try {
Base de Datos:
+
IP del Servidor:
+
+
Webhook URL:
- /api/webhook.php + /api/webhook.php
@@ -907,6 +954,73 @@ try { + +
@@ -987,97 +1101,7 @@ try { - - - - - + @@ -1092,173 +1116,138 @@ try { async function refreshRateLimitStats() { try { const response = await fetch('api/get_rate_limit_stats.php?action=current'); - - // Verificar que la respuesta sea válida - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - + + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { const text = await response.text(); - console.error('Respuesta no-JSON recibida:', text.substring(0, 200)); + console.error('Respuesta no-JSON:', text.substring(0, 200)); throw new Error('El servidor no devolvió JSON válido'); } - + const result = await response.json(); - - if (!result.success) { - throw new Error(result.error || 'Error obteniendo estadísticas'); - } - - const stats = result.data; - updateRateLimitUI(stats); - + if (!result.success) throw new Error(result.error || 'Error obteniendo estadísticas'); + + updateRateLimitUI(result.data); + } catch (error) { console.error('Error cargando rate limit stats:', error); const statusEl = document.getElementById('rate-limit-status'); if (statusEl) { - statusEl.innerHTML = - ' Error cargando estadísticas: ' + error.message; statusEl.className = 'alert alert-warning'; + statusEl.innerHTML = ' Error cargando estadísticas: ' + error.message; } } } - + function updateRateLimitUI(stats) { - // Verificar que los elementos existan const statusEl = document.getElementById('rate-limit-status'); - const callCountBar = document.getElementById('fb-call-count-bar'); - - if (!statusEl || !callCountBar) { - console.log('Elementos del rate limit monitor no encontrados en el DOM'); - return; - } - - // Manejar caso donde Redis no está disponible + if (!statusEl) return; + + // Redis no disponible if (stats.status === 'unavailable' || stats.error) { statusEl.className = 'alert alert-info'; - statusEl.innerHTML = ' Rate Limiting Monitor: Redis no disponible. Las estadísticas de Facebook se mostrarán cuando se realice la próxima llamada a la API.'; - - // Mostrar mensaje en las barras de progreso - const metricsContainer = callCountBar.closest('.row'); - if (metricsContainer) { - metricsContainer.innerHTML = '
Los datos de Facebook API se capturarán automáticamente en la próxima llamada. Redis: ' + (stats.error || 'No disponible') + '
'; - } + statusEl.innerHTML = ' Redis no disponible. Los datos de Facebook API se capturarán en la próxima llamada. ' + (stats.error || '') + ''; + setProgressBarsEmpty(); + setLocalLimits(stats.local_limits); return; } - - // Actualizar status general - const statusEl = document.getElementById('rate-limit-status'); - let statusClass = 'alert-success'; - let statusIcon = 'fa-check-circle'; - let statusText = 'Sistema operando normalmente'; - + + // Status general + let statusClass = 'alert-success', statusIcon = 'fa-check-circle', statusText = 'Sistema operando normalmente'; if (stats.status === 'critical') { - statusClass = 'alert-danger'; - statusIcon = 'fa-exclamation-triangle'; - statusText = '¡ALERTA! Rate limit cerca del límite crítico'; + statusClass = 'alert-danger'; statusIcon = 'fa-exclamation-triangle'; statusText = '¡ALERTA! Rate limit cerca del límite crítico'; } else if (stats.status === 'warning') { - statusClass = 'alert-warning'; - statusIcon = 'fa-exclamation-circle'; - statusText = 'Advertencia: Acercándose al límite'; + statusClass = 'alert-warning'; statusIcon = 'fa-exclamation-circle'; statusText = 'Advertencia: Acercándose al límite de Facebook'; } - - statusEl.className = `alert ${statusClass}`; - statusEl.innerHTML = ` ${statusText}`; - - // Actualizar métricas de Facebook + statusEl.className = 'alert ' + statusClass; + statusEl.innerHTML = ' ' + statusText + + (stats.datetime ? ' Actualizado: ' + stats.datetime + '' : ''); + + // Métricas Facebook if (stats.app_usage) { updateProgressBar('fb-call-count-bar', stats.app_usage.call_count || 0); updateProgressBar('fb-total-time-bar', stats.app_usage.total_time || 0); updateProgressBar('fb-cpu-time-bar', stats.app_usage.total_cputime || 0); } else { - // Sin datos de Facebook aún - const callCountBar = document.getElementById('fb-call-count-bar'); - if (callCountBar) { - const metricsRow = callCountBar.closest('.row'); - if (metricsRow) { - metricsRow.innerHTML = - '
Los datos de Facebook API se capturarán automáticamente en la próxima llamada.
'; - } - } + setProgressBarsEmpty(); } - - // Actualizar límites locales - if (stats.local_limits) { - const limitSecondEl = document.getElementById('local-limit-second-max'); - const limitHourEl = document.getElementById('local-limit-hour-max'); - const limitDayEl = document.getElementById('local-limit-day-max'); - - if (limitSecondEl) limitSecondEl.textContent = stats.local_limits.per_second; - if (limitHourEl) limitHourEl.textContent = stats.local_limits.per_hour; - if (limitDayEl) limitDayEl.textContent = stats.local_limits.per_day; - } - - // Mostrar alertas si existen + + // Límites locales + setLocalLimits(stats.local_limits); + + // Alertas recientes const alertsSection = document.getElementById('rate-limit-alerts-section'); const alertsContainer = document.getElementById('rate-limit-alerts'); - if (stats.alerts && stats.alerts.length > 0 && alertsSection && alertsContainer) { alertsSection.style.display = 'block'; alertsContainer.innerHTML = ''; - stats.alerts.slice(0, 5).forEach(alert => { - const alertClass = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning'; - const alertIcon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle'; - - const alertEl = document.createElement('div'); - alertEl.className = `list-group-item ${alertClass}`; - alertEl.innerHTML = ` -
-
Alerta de ${alert.level}
- ${alert.datetime} -
-

Uso al ${alert.usage}% - Endpoint: ${alert.data.endpoint || 'N/A'}

- `; - alertsContainer.appendChild(alertEl); + if (!alert) return; + const cls = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning'; + const icon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle'; + const el = document.createElement('div'); + el.className = 'list-group-item ' + cls; + el.innerHTML = `
+
Alerta de ${alert.level || '—'}
+ ${alert.datetime || ''} +
+

Uso al ${alert.usage || 0}% — Endpoint: ${(alert.data && alert.data.endpoint) || 'N/A'}

`; + alertsContainer.appendChild(el); }); } else if (alertsSection) { alertsSection.style.display = 'none'; } } - + + function setProgressBarsEmpty() { + ['fb-call-count-bar','fb-total-time-bar','fb-cpu-time-bar'].forEach(id => { + const bar = document.getElementById(id); + if (!bar) return; + bar.style.width = '0%'; + bar.textContent = 'Sin datos'; + bar.className = 'progress-bar bg-secondary'; + }); + } + + function setLocalLimits(limits) { + if (!limits) return; + const map = { + 'local-limit-second-max': limits.per_second, + 'local-limit-hour-max': limits.per_hour, + 'local-limit-day-max': limits.per_day, + }; + Object.entries(map).forEach(([id, val]) => { + const el = document.getElementById(id); + if (el && val !== undefined) el.textContent = val.toLocaleString(); + }); + // Limpiar guiones de contadores actuales + ['local-limit-second','local-limit-hour','local-limit-day'].forEach(id => { + const el = document.getElementById(id); + if (el && el.textContent === '-') el.textContent = '—'; + }); + } + function updateProgressBar(id, percentage) { const bar = document.getElementById(id); if (!bar) return; - - percentage = Math.min(100, Math.max(0, percentage)); - + percentage = Math.min(100, Math.max(0, parseFloat(percentage) || 0)); bar.style.width = percentage + '%'; bar.textContent = percentage.toFixed(1) + '%'; - - // Cambiar color según el porcentaje bar.className = 'progress-bar'; - if (percentage >= 90) { - bar.classList.add('bg-danger'); - } else if (percentage >= 75) { - bar.classList.add('bg-warning'); - } else if (percentage >= 50) { - bar.classList.add('bg-info'); - } else { - bar.classList.add('bg-success'); - } + if (percentage >= 90) bar.classList.add('bg-danger'); + else if (percentage >= 75) bar.classList.add('bg-warning'); + else if (percentage >= 50) bar.classList.add('bg-info'); + else bar.classList.add('bg-success'); } - - // Cargar estadísticas al cargar la página - document.addEventListener('DOMContentLoaded', function() { - // Cargar inmediatamente - refreshRateLimitStats(); - - // Actualizar cada 30 segundos - setInterval(refreshRateLimitStats, 30000); - }); - - // Actualizar también cuando se cambia a la pestaña de dashboard + + // Cargar al iniciar + document.addEventListener('DOMContentLoaded', refreshRateLimitStats); + + // Recargar al activar el tab dashboard document.querySelectorAll('.nav-link[data-tab="dashboard"]').forEach(link => { - link.addEventListener('click', function() { - setTimeout(refreshRateLimitStats, 500); - }); + link.addEventListener('click', () => setTimeout(refreshRateLimitStats, 500)); }); + + + + \ No newline at end of file diff --git a/lab_configuracion.php b/lab_configuracion.php new file mode 100644 index 0000000..e3befb5 --- /dev/null +++ b/lab_configuracion.php @@ -0,0 +1,332 @@ +fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave'); +$cfg = []; +foreach ($rows as $r) { $cfg[$r['clave']] = $r['valor']; } +?> + + + + + + Configuración Lab + + + + + + + + + +
+
+
+

Configuración del Laboratorio

+ Personaliza el encabezado, logo y datos que aparecen en los documentos PDF +
+
+ +
+ + + +
+
+ + +
+
Datos de la empresa / laboratorio
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
Diseño del encabezado del documento
+ +
+
+ + +
+
+ + +
PNG, JPG o SVG recomendado. Máx. 200px de alto.
+
+
+ + +
+ +
+ Logo + +
+
+ + + + + + +
+ + +
+
+ + + +
+ + +
+
+
Vista previa del encabezado
+ +
+
+
+ +
+
+

+
+
+ +
+
+ Nombre del formulario +
+
+
+
+ +
+ + Este encabezado aparecerá en todos los documentos PDF generados. + Cada formulario puede tener su propio encabezado desde el editor de formularios. +
+ + +
+
+
+ +
+
+ + + + + diff --git a/lab_dashboard.php b/lab_dashboard.php new file mode 100644 index 0000000..ef9390f --- /dev/null +++ b/lab_dashboard.php @@ -0,0 +1,310 @@ + + + + + + + Módulo Lab — Dashboard + + + + + + + + + + +
+
+
+

Módulo Administrativo — Laboratorio

+ +
+ +
+ +
+ + +
+ +
+ Cargando estadísticas... +
+
+ +
+ +
+
+
+
+ Órdenes sin revisar (más antiguas) +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ Domicilios sin enfermera — hoy +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ Actividad reciente +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ Carga de enfermeras — hoy +
+
+
+
+ +
+
+
+
+
+
+
+ + + + + diff --git a/lab_domicilios.php b/lab_domicilios.php new file mode 100644 index 0000000..6885998 --- /dev/null +++ b/lab_domicilios.php @@ -0,0 +1,516 @@ + + + + + + + Domicilios — Módulo Lab + + + + + + + + + +
+
+
+

Domicilios

+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+ +
+ +
+
+
+
+ + + + + + + +
HoraPacienteEnfermeraEstado
+
+
+
+
+
+ + +
+
+
+
Detalle
+ +
+
+
+ Selecciona un domicilio +
+
+
+
+
+
+
+ + + + + + + + diff --git a/lab_enfermeras.php b/lab_enfermeras.php new file mode 100644 index 0000000..a3bc10d --- /dev/null +++ b/lab_enfermeras.php @@ -0,0 +1,429 @@ + + + + + + + Enfermeras — Módulo Lab + + + + + + + + + +
+
+
+

Enfermeras

+ +
+
+
+ + +
+ +
+
+ +
+
+ +
+
+
Cargando...
+
+
+ + +
+
+
+
+ Agenda del día +
+
+ +
+
+
+
+
+ Selecciona una enfermera para ver su agenda +
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/lab_formulario_builder.php b/lab_formulario_builder.php new file mode 100644 index 0000000..5e1d95f --- /dev/null +++ b/lab_formulario_builder.php @@ -0,0 +1,1134 @@ +window.close();'; exit; } +if (isEnfermero()) { echo ''; exit; } +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); + +$formId = isset($_GET['id']) ? (int)$_GET['id'] : 0; +?> + + + + + + Editor de Formulario — Lab + + + + + + + +
+
Builder
+ +
+ +
+ + + + +
+
+ + +
+ + +
+ + +
+
Tipos de campo
+
+
Campos básicos
+
+
Vinculados al paciente
+
+
+
+ + +
+
+ + +
+
Información del formulario
+
+ + +
+
+ + +
+
+
+ Campos del formulario + +
+
+
+
+ + Arrastra campos desde el panel izquierdo +
+
+
+ + +
+
+
+ Diseño del documento + +
+
+ + +
+
+
+
+ + Se usará el logo y encabezado configurados globalmente. + Editar config global +
+ +
+
+ +
+
+ + +
+
+ Vista previa del cliente + +
+
+
+

El formulario aparecerá aquí…

+
+
+
+ +
+ + +
+ + 0 campos + Listo +
+ + + + + +
+
+

¡Formulario guardado!

+

Los cambios quedaron guardados correctamente.

+
+ + +
+
+ +
+ + + + + diff --git a/lab_formularios.php b/lab_formularios.php new file mode 100644 index 0000000..435e59b --- /dev/null +++ b/lab_formularios.php @@ -0,0 +1,820 @@ + + + + + + + Formularios — Módulo Lab + + + + + + + + + +
+
+
+

Formularios

+ +
+ + + +
+ + + + + +
+
+
+
Cargando… +
+
+
+ + + +
+ + + + + + + + + + + + + diff --git a/lab_ordenes.php b/lab_ordenes.php new file mode 100644 index 0000000..fc3361d --- /dev/null +++ b/lab_ordenes.php @@ -0,0 +1,449 @@ + + + + + + + Órdenes Médicas — Módulo Lab + + + + + + + + + +
+
+
+

Órdenes Médicas

+ +
+
+ +
+ +
+ ['warning','clock'],'en_revision'=>['info','magnifying-glass'],'autorizada'=>['success','check-circle'],'rechazada'=>['danger','times-circle'],'en_domicilio'=>['primary','house-medical'],'completada'=>['secondary','flag-checkered']] as $e=>[$color,$icon]): ?> +
+ +
+ +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ + + + + + + +
#PacienteFechaEstado
Cargando...
+
+
+
+
+
+ + +
+
+
+
+
Detalle de Orden
+ +
+ +
+
+
+
+ Selecciona una orden de la lista +
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/lab_pacientes.php b/lab_pacientes.php new file mode 100644 index 0000000..7b5ee6f --- /dev/null +++ b/lab_pacientes.php @@ -0,0 +1,376 @@ + + + + + + + Pacientes — Módulo Lab + + + + + + + + + +
+
+
+

Pacientes

+ +
+ +
+ +
+ +
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + +
PacienteDocumentoTeléfonoEPSÓrdenes
Cargando...
+
+ +
+
+
+
+ + +
+
+
+
Paciente
+ +
+
+
+
+
+
+
+ + + + + + + + diff --git a/lab_reportes.php b/lab_reportes.php new file mode 100644 index 0000000..d074e90 --- /dev/null +++ b/lab_reportes.php @@ -0,0 +1,351 @@ +fetchAll( + "SELECT o.id, p.nombre_completo AS paciente, p.numero_documento, p.eps, + o.estado, o.medico_nombre, o.fecha_orden, o.examenes_solicitados, + o.requiere_ayuno, o.horas_ayuno, o.created_at, + ar.full_name AS revisada_por, aa.full_name AS autorizada_por + FROM lab_ordenes_medicas o + JOIN lab_pacientes p ON p.id = o.paciente_id + LEFT JOIN admin_users ar ON ar.id = o.revisada_por + LEFT JOIN admin_users aa ON aa.id = o.autorizada_por + WHERE DATE(o.created_at) BETWEEN ? AND ? + ORDER BY o.created_at DESC", + [$desde, $hasta] + ); + $filename = "ordenes_$desde\_$hasta.csv"; + $headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por']; + break; + + case 'domicilios': + $rows = $db->fetchAll( + "SELECT d.id, p.nombre_completo AS paciente, p.telefono AS tel_paciente, + d.direccion, d.barrio, d.ciudad, d.fecha_programada, d.hora_programada, + d.estado, COALESCE(e.nombre_completo,'Sin asignar') AS enfermera, + d.tipo_servicio, d.created_at + FROM lab_domicilios d + JOIN lab_pacientes p ON p.id = d.paciente_id + LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id AND a.estado NOT IN ('liberada') + LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id + WHERE DATE(d.fecha_programada) BETWEEN ? AND ? + ORDER BY d.fecha_programada, d.hora_programada", + [$desde, $hasta] + ); + $filename = "domicilios_$desde\_$hasta.csv"; + $headers = ['ID','Paciente','Teléfono','Dirección','Barrio','Ciudad','Fecha','Hora','Estado','Enfermera','Tipo Servicio','Registrado']; + break; + + case 'pacientes': + $rows = $db->fetchAll( + "SELECT p.id, p.nombre_completo, p.tipo_documento, p.numero_documento, + p.telefono, p.email, p.eps, p.ciudad, p.barrio, p.is_active, + COUNT(o.id) AS total_ordenes, p.created_at + FROM lab_pacientes p + LEFT JOIN lab_ordenes_medicas o ON o.paciente_id = p.id + GROUP BY p.id + ORDER BY p.nombre_completo", + [] + ); + $filename = "pacientes_" . date('Y-m-d') . ".csv"; + $headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado']; + break; + + default: + http_response_code(400); + exit('Tipo de exportación no válido'); + } + + header('Content-Type: text/csv; charset=utf-8'); + header("Content-Disposition: attachment; filename=\"$filename\""); + header('Pragma: no-cache'); + + $f = fopen('php://output', 'w'); + fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel + fputcsv($f, $headers); + foreach ($rows as $row) { + fputcsv($f, array_values($row)); + } + fclose($f); + exit; +} + +$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin'; +?> + + + + + + Reportes — Módulo Lab + + + + + + + + + +
+
+
+

Reportes y Trazabilidad

+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+ + + +
+
+
+ +
+ +
+
+
+
Resumen del período
+
+
+
Cargando...
+
+
+ + +
+
+
Órdenes por estado
+
+
+
+
+
+ + +
+
+
Domicilios por estado
+
+
+
+
+
+
+ + +
+
+
+
Log de actividad
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+ + + + + diff --git a/lab_status.php b/lab_status.php new file mode 100644 index 0000000..6b55b1f --- /dev/null +++ b/lab_status.php @@ -0,0 +1,154 @@ +query("SELECT 1 FROM `{$t}` LIMIT 1", []); + $checks[] = ['ok' => true, 'msg' => "Tabla `$t` ✔"]; + } catch (Exception $e) { + $checks[] = ['ok' => false, 'msg' => "Tabla `$t` ✘ — NO EXISTE (ejecuta migrations/20260302_lab_run_migrations.php)"]; + $allOk = false; + } +} + +// ── 2. Archivos de clases ────────────────────────────────────────────────── +$classFiles = [ + 'classes/lab/ActividadAdmin.php', + 'classes/lab/Paciente.php', + 'classes/lab/Enfermera.php', + 'classes/lab/OrdenMedica.php', + 'classes/lab/Domicilio.php', + 'classes/lab/Asignacion.php', +]; +foreach ($classFiles as $f) { + $ok = file_exists(__DIR__ . '/' . $f); + if (!$ok) $allOk = false; + $checks[] = ['ok' => $ok, 'msg' => "Clase $f " . ($ok ? '✔' : '✘ — FALTA')]; +} + +// ── 3. Endpoints API ────────────────────────────────────────────────────── +$apiFiles = [ + 'api/lab/_helpers.php', 'api/lab/get_pacientes.php', 'api/lab/save_paciente.php', + 'api/lab/get_ordenes.php', 'api/lab/save_orden.php', 'api/lab/autorizar_orden.php', + 'api/lab/get_domicilios.php', 'api/lab/save_domicilio.php', 'api/lab/get_enfermeras.php', + 'api/lab/save_enfermera.php', 'api/lab/get_asignaciones.php', 'api/lab/save_asignacion.php', + 'api/lab/get_actividad.php', 'api/lab/get_stats.php', 'api/lab/crear_desde_whatsapp.php', +]; +foreach ($apiFiles as $f) { + $ok = file_exists(__DIR__ . '/' . $f); + if (!$ok) $allOk = false; + $checks[] = ['ok' => $ok, 'msg' => "API $f " . ($ok ? '✔' : '✘ — FALTA')]; +} + +// ── 4. Vistas (páginas PHP) ─────────────────────────────────────────────── +$views = ['lab_dashboard.php','lab_ordenes.php','lab_pacientes.php','lab_domicilios.php','lab_enfermeras.php','lab_reportes.php']; +foreach ($views as $v) { + $ok = file_exists(__DIR__ . '/' . $v); + if (!$ok) $allOk = false; + $checks[] = ['ok' => $ok, 'msg' => "Vista $v " . ($ok ? '✔' : '✘ — FALTA')]; +} + +// ── 5. Directorio uploads ───────────────────────────────────────────────── +$uploadsOk = is_dir(__DIR__ . '/uploads/media') && is_writable(__DIR__ . '/uploads/media'); +if (!$uploadsOk) $allOk = false; +$checks[] = ['ok' => $uploadsOk, 'msg' => 'Directorio uploads/media ' . ($uploadsOk ? '✔ (writable)' : '✘ — no existe o sin permisos de escritura')]; + +// ── Salida ───────────────────────────────────────────────────────────────── +if ($isCli) { + echo "\n=== Módulo Laboratorio — Estado del sistema ===\n\n"; + foreach ($checks as $c) { + echo ($c['ok'] ? ' [OK] ' : ' [!!] ') . $c['msg'] . "\n"; + } + echo "\n" . ($allOk ? "✔ Todo correcto." : "✘ Hay problemas — revisa los puntos marcados con [!!]") . "\n\n"; + exit($allOk ? 0 : 1); +} + +$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin'; +?> + + + + + + Estado Módulo Lab + + + + + + +
+
+

Estado del Módulo Laboratorio

+ +
+
+
+ + +
+
+
+ + + + + + + + + + +
VerificaciónEstado
+
+
+ +
+
Pasos para solucionar:
+
    +
  1. Ejecuta las migraciones: php migrations/20260302_lab_run_migrations.php
  2. +
  3. Verifica que todos los archivos de classes/lab/ y api/lab/ existen
  4. +
  5. Asegúrate de que uploads/media/ tiene permisos 755 o 775
  6. +
+
+ + + + +
+
+ + + diff --git a/lab_terminos.php b/lab_terminos.php new file mode 100644 index 0000000..52bc944 --- /dev/null +++ b/lab_terminos.php @@ -0,0 +1,271 @@ +fetch("SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1"); + + // Construcción dinámica del WHERE + $where = []; + $params = []; + + if ($filterEstado !== '') { + $where[] = 'ta.estado = :estado'; + $params[':estado'] = $filterEstado; + } + if ($filterFecha !== '') { + $where[] = 'DATE(ta.fecha_envio) = :fecha'; + $params[':fecha'] = $filterFecha; + } + if ($filterPhone !== '') { + $where[] = 'ta.phone_number LIKE :phone'; + $params[':phone'] = '%' . $filterPhone . '%'; + } + + $whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $total = (int)($db->fetch( + "SELECT COUNT(*) as c FROM terms_acceptance ta $whereClause", + $params + )['c'] ?? 0); + + $rows = $db->fetchAll( + "SELECT ta.id, ta.phone_number, ta.estado, ta.fecha_envio, ta.fecha_respuesta, + tv.version AS terms_version, + u.name AS user_name + FROM terms_acceptance ta + LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id + LEFT JOIN users u ON u.id = ta.user_id + $whereClause + ORDER BY ta.id DESC + LIMIT $perPage OFFSET $offset", + $params + ); + + $totalPages = max(1, ceil($total / $perPage)); + +} catch (Exception $e) { + $rows = []; + $total = 0; + $totalPages = 1; + $activeVersion = null; + $dbError = $e->getMessage(); +} +?> + + + + + + Términos y Condiciones — Aceptaciones + + + + + + + + + +
+
+
+

Términos y Condiciones

+

Historial de aceptaciones de usuarios

+
+ +
+ + + +
+ +
+ Versión activa: + +  —  + Ver documento + + + +  Re-aceptación forzada activa + +
+
+ + + +
+ + + + fetchAll( + "SELECT estado, COUNT(*) as total FROM terms_acceptance GROUP BY estado" + ) : []; + $stats = []; + foreach ($statsRaw as $s) $stats[$s['estado']] = (int)$s['total']; + ?> +
+
+
+
+ Aceptaciones +
+
+
+
+
+ Rechazos +
+
+
+
+
+ Pendientes +
+
+
+
+
+ Total registros +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Limpiar +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TeléfonoNombreVersiónEstadoEnviadoRespondido
No hay registros para los filtros seleccionados.
+ + + +
+
+
+ + 1): ?> + + +
+
+ + + + diff --git a/lab_terminos_export.php b/lab_terminos_export.php new file mode 100644 index 0000000..b2e1eb6 --- /dev/null +++ b/lab_terminos_export.php @@ -0,0 +1,77 @@ +fetchAll( + "SELECT ta.id, ta.phone_number, u.name AS user_name, ta.estado, + tv.version AS terms_version, + ta.fecha_envio, ta.fecha_respuesta + FROM terms_acceptance ta + LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id + LEFT JOIN users u ON u.id = ta.user_id + $whereClause + ORDER BY ta.id DESC", + $params + ); +} catch (Exception $e) { + http_response_code(500); + echo 'Error: ' . htmlspecialchars($e->getMessage()); + exit; +} + +$filename = 'terminos_aceptaciones_' . date('Ymd_His') . '.csv'; +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +header('Pragma: no-cache'); +header('Expires: 0'); + +$out = fopen('php://output', 'w'); +// BOM para Excel +fwrite($out, "\xEF\xBB\xBF"); + +fputcsv($out, ['ID', 'Teléfono', 'Nombre', 'Estado', 'Versión Términos', 'Fecha Envío', 'Fecha Respuesta']); + +foreach ($rows as $r) { + fputcsv($out, [ + $r['id'], + $r['phone_number'], + $r['user_name'] ?? '', + $r['estado'], + $r['terms_version'] ?? '', + $r['fecha_envio'] ?? '', + $r['fecha_respuesta'] ?? '', + ]); +} + +fclose($out); diff --git a/lab_usuarios.php b/lab_usuarios.php new file mode 100644 index 0000000..564538a --- /dev/null +++ b/lab_usuarios.php @@ -0,0 +1,554 @@ +) +$systemModules = SYSTEM_MODULES; +?> + + + + + + Usuarios y Roles — Módulo Lab + + + + + + + + + +
+
+
+

Usuarios & Roles

+ +
+
+ +
+ + + + +
+ + +
+
+
Listado de usuarios del sistema
+ +
+ +
+
+
+ + + + + + + + + + + + + + + +
UsuarioNombreRolEstadoÚltimo accesoAcciones
+ Cargando… +
+
+
+
+
+ + +
+
+
Roles y permisos de módulo
+ +
+ +
+
+ Cargando… +
+
+
+ +
+
+
+ + + + + + + + + + + diff --git a/login.php b/login.php index 3e09e66..e80d051 100644 --- a/login.php +++ b/login.php @@ -53,15 +53,18 @@ if ($_POST && !$loginBlocked) { $_SESSION['last_activity'] = time(); $_SESSION['login_ip'] = $clientIp; $_SESSION['login_time'] = time(); - - header('Location: index.php'); + + // Redirigir según el rol + if (($adminUser['role'] ?? 'admin') === 'enfermero') { + header('Location: enfermero_portal.php'); + } else { + header('Location: index.php'); + } exit; } else { // Login fallido recordFailedLogin($clientIp); $error = 'Usuario o contraseña incorrectos.'; - - // Verificar si se bloqueó después de este intento if (!checkLoginAttempts($clientIp)) { $loginBlocked = true; $error = 'Demasiados intentos de login. Cuenta bloqueada por ' . (LOGIN_LOCKOUT_TIME / 60) . ' minutos.'; diff --git a/logs/.migrations_applied b/logs/.migrations_applied index 05357e5..252ab6c 100755 --- a/logs/.migrations_applied +++ b/logs/.migrations_applied @@ -1,3 +1,6 @@ 01_backup_full.sql 02_add_template_components.sql 03_create_scheduled_messages.sql +04_add_domicilio_cobro_fields.sql +05_terms_acceptance.sql +06_master_sync.sql diff --git a/logs/F - LAB - 01 Consentimiento informado pruebas de laboratorio.pdf b/logs/F - LAB - 01 Consentimiento informado pruebas de laboratorio.pdf new file mode 100755 index 0000000..a38edd8 Binary files /dev/null and b/logs/F - LAB - 01 Consentimiento informado pruebas de laboratorio.pdf differ diff --git a/logs/system.log b/logs/system.log index d8bcb07..e6e92cf 100755 --- a/logs/system.log +++ b/logs/system.log @@ -232,3 +232,12 @@ Stack trace: [2026-02-03 20:47:36] [INFO] Mensaje programado creado: ID=8, Usuario=3250, Fecha=2026-02-03 20:48 [2026-02-03 20:54:33] [INFO] Mensaje programado creado: ID=9, Usuario=3250, Fecha=2026-02-03 20:55 [2026-02-06 10:23:52] [INFO] Cleared advisor_requested for user 3250 after outgoing message by operator +[2026-03-10 15:42:57] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"} +[2026-03-10 16:23:28] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"} +[2026-03-10 16:25:44] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"} +[2026-03-10 16:25:53] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"} +[2026-03-10 18:13:49] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"} +[2026-03-10 18:14:09] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios +[2026-03-10 18:16:17] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios +[2026-03-10 18:17:09] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios +[2026-03-10 18:23:17] [INFO] Plantillas sincronizadas desde Facebook: 10 nuevas, 1 actualizadas, 0 sin cambios diff --git a/migrations/20260302_lab_01_pacientes.sql b/migrations/20260302_lab_01_pacientes.sql new file mode 100644 index 0000000..d9134d3 --- /dev/null +++ b/migrations/20260302_lab_01_pacientes.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- Migration: 20260302_lab_01_pacientes +-- Módulo Administrativo: Pacientes del laboratorio +-- Extiende la tabla users (contactos WhatsApp existentes) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_pacientes` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) DEFAULT NULL COMMENT 'FK a users.id (teléfono WhatsApp)', + `numero_documento` VARCHAR(30) DEFAULT NULL COMMENT 'Cédula / NIT / Pasaporte', + `tipo_documento` ENUM('CC','CE','TI','PA','NIT','RC','MS') DEFAULT 'CC', + `nombre_completo` VARCHAR(150) NOT NULL, + `telefono` VARCHAR(20) DEFAULT NULL, + `email` VARCHAR(100) DEFAULT NULL, + `fecha_nacimiento` DATE DEFAULT NULL, + `genero` ENUM('M','F','O') DEFAULT NULL, + `direccion` TEXT DEFAULT NULL, + `ciudad` VARCHAR(100) DEFAULT NULL, + `barrio` VARCHAR(100) DEFAULT NULL, + `eps` VARCHAR(100) DEFAULT NULL COMMENT 'Entidad promotora de salud', + `notas_admin` TEXT DEFAULT NULL COMMENT 'Notas internas del administrador', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_numero_documento` (`numero_documento`), + KEY `idx_user_id` (`user_id`), + KEY `idx_nombre` (`nombre_completo`), + KEY `idx_telefono` (`telefono`), + KEY `idx_documento` (`numero_documento`), + CONSTRAINT `fk_lab_pac_user` FOREIGN KEY (`user_id`) + REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Pacientes del laboratorio (módulo administrativo)'; diff --git a/migrations/20260302_lab_02_enfermeras.sql b/migrations/20260302_lab_02_enfermeras.sql new file mode 100644 index 0000000..695d09d --- /dev/null +++ b/migrations/20260302_lab_02_enfermeras.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- Migration: 20260302_lab_02_enfermeras +-- Módulo Administrativo: Personal de enfermería +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_enfermeras` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `numero_documento` VARCHAR(30) NOT NULL, + `tipo_documento` ENUM('CC','CE','TI','PA') DEFAULT 'CC', + `nombre_completo` VARCHAR(150) NOT NULL, + `telefono` VARCHAR(20) NOT NULL, + `telefono_alt` VARCHAR(20) DEFAULT NULL, + `email` VARCHAR(100) DEFAULT NULL, + `zona` VARCHAR(100) DEFAULT NULL COMMENT 'Zona/barrio asignada habitualmente', + `notas` TEXT DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_enf_documento` (`numero_documento`), + KEY `idx_enf_nombre` (`nombre_completo`), + KEY `idx_enf_activa` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Personal de enfermería para domicilios'; diff --git a/migrations/20260302_lab_03_ordenes_medicas.sql b/migrations/20260302_lab_03_ordenes_medicas.sql new file mode 100644 index 0000000..a87db0b --- /dev/null +++ b/migrations/20260302_lab_03_ordenes_medicas.sql @@ -0,0 +1,56 @@ +-- ============================================================ +-- Migration: 20260302_lab_03_ordenes_medicas +-- Módulo Administrativo: Órdenes médicas recibidas por WhatsApp +-- Vincula una imagen del chat con un paciente y un estado +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_ordenes_medicas` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `paciente_id` INT(11) NOT NULL COMMENT 'FK a lab_pacientes.id', + `conversation_id` INT(11) DEFAULT NULL COMMENT 'FK a conversations.id (mensaje con la imagen)', + `whatsapp_media_id` VARCHAR(255) DEFAULT NULL COMMENT 'Media ID de WhatsApp para referencia', + `local_file` VARCHAR(255) DEFAULT NULL COMMENT 'Ruta local de la imagen de la orden', + -- Estado del flujo de revisión + `estado` ENUM( + 'pendiente', + 'en_revision', + 'autorizada', + 'rechazada', + 'en_domicilio', + 'completada' + ) NOT NULL DEFAULT 'pendiente', + -- Datos de la orden médica (ingresados manualmente) + `medico_nombre` VARCHAR(150) DEFAULT NULL, + `medico_registro` VARCHAR(50) DEFAULT NULL, + `fecha_orden` DATE DEFAULT NULL COMMENT 'Fecha que aparece en la orden impresa', + `diagnostico` TEXT DEFAULT NULL, + `examenes_solicitados` TEXT DEFAULT NULL COMMENT 'Lista de exámenes de la orden', + `requiere_ayuno` TINYINT(1) DEFAULT 0, + `horas_ayuno` TINYINT(3) DEFAULT NULL, + `indicaciones` TEXT DEFAULT NULL, + -- Gestión administrativa + `revisada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `revisada_at` DATETIME DEFAULT NULL, + `autorizada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `autorizada_at` DATETIME DEFAULT NULL, + `comentario_revision` TEXT DEFAULT NULL, + `notas_admin` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ord_paciente` (`paciente_id`), + KEY `idx_ord_conversation` (`conversation_id`), + KEY `idx_ord_estado` (`estado`), + KEY `idx_ord_fecha` (`fecha_orden`), + KEY `idx_ord_revisada_por` (`revisada_por`), + KEY `idx_ord_autorizada` (`autorizada_por`), + CONSTRAINT `fk_ord_paciente` FOREIGN KEY (`paciente_id`) + REFERENCES `lab_pacientes` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_ord_conversation` FOREIGN KEY (`conversation_id`) + REFERENCES `conversations` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_ord_revisada` FOREIGN KEY (`revisada_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_ord_autorizada` FOREIGN KEY (`autorizada_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Órdenes médicas recibidas vía WhatsApp'; diff --git a/migrations/20260302_lab_04_domicilios.sql b/migrations/20260302_lab_04_domicilios.sql new file mode 100644 index 0000000..caa4db5 --- /dev/null +++ b/migrations/20260302_lab_04_domicilios.sql @@ -0,0 +1,53 @@ +-- ============================================================ +-- Migration: 20260302_lab_04_domicilios +-- Módulo Administrativo: Servicios a domicilio +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_domicilios` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `orden_id` INT(11) DEFAULT NULL COMMENT 'FK a lab_ordenes_medicas.id', + `paciente_id` INT(11) NOT NULL COMMENT 'FK a lab_pacientes.id', + -- Datos del domicilio + `direccion` TEXT NOT NULL, + `ciudad` VARCHAR(100) DEFAULT NULL, + `barrio` VARCHAR(100) DEFAULT NULL, + `indicaciones_dir` TEXT DEFAULT NULL COMMENT 'Referencia de la dirección', + `fecha_programada` DATE NOT NULL, + `hora_programada` TIME DEFAULT NULL, + `tipo_servicio` VARCHAR(100) DEFAULT NULL COMMENT 'Tipo de toma de muestra', + -- Estado del domicilio + `estado` ENUM( + 'programado', + 'confirmado', + 'en_camino', + 'en_domicilio', + 'completado', + 'cancelado', + 'reprogramado' + ) NOT NULL DEFAULT 'programado', + `motivo_cancelacion` TEXT DEFAULT NULL, + `fecha_reprogramada` DATE DEFAULT NULL, + -- Resultados + `hora_llegada` TIME DEFAULT NULL, + `hora_salida` TIME DEFAULT NULL, + `observaciones` TEXT DEFAULT NULL, + `muestras_tomadas` TEXT DEFAULT NULL COMMENT 'Lista de muestras obtenidas', + -- Gestión + `creado_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `notas_admin` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_dom_orden` (`orden_id`), + KEY `idx_dom_paciente` (`paciente_id`), + KEY `idx_dom_fecha` (`fecha_programada`), + KEY `idx_dom_estado` (`estado`), + KEY `idx_dom_creado` (`creado_por`), + CONSTRAINT `fk_dom_orden` FOREIGN KEY (`orden_id`) + REFERENCES `lab_ordenes_medicas` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_dom_paciente` FOREIGN KEY (`paciente_id`) + REFERENCES `lab_pacientes` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_dom_creado` FOREIGN KEY (`creado_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Servicios a domicilio programados'; diff --git a/migrations/20260302_lab_05_asignaciones.sql b/migrations/20260302_lab_05_asignaciones.sql new file mode 100644 index 0000000..d44dc40 --- /dev/null +++ b/migrations/20260302_lab_05_asignaciones.sql @@ -0,0 +1,33 @@ +-- ============================================================ +-- Migration: 20260302_lab_05_asignaciones +-- Módulo Administrativo: Asignación de enfermera a domicilio +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_asignaciones` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `domicilio_id` INT(11) NOT NULL COMMENT 'FK a lab_domicilios.id', + `enfermera_id` INT(11) NOT NULL COMMENT 'FK a lab_enfermeras.id', + `asignada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `estado` ENUM( + 'asignada', + 'confirmada', + 'liberada', + 'completada' + ) NOT NULL DEFAULT 'asignada', + `notas` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- Solo una enfermera activa por domicilio + UNIQUE KEY `uq_asig_domicilio` (`domicilio_id`), + KEY `idx_asig_enfermera` (`enfermera_id`), + KEY `idx_asig_estado` (`estado`), + KEY `idx_asig_asignada` (`asignada_por`), + CONSTRAINT `fk_asig_domicilio` FOREIGN KEY (`domicilio_id`) + REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_asig_enfermera` FOREIGN KEY (`enfermera_id`) + REFERENCES `lab_enfermeras` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_asig_admin` FOREIGN KEY (`asignada_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Asignación de enfermeras a domicilios'; diff --git a/migrations/20260302_lab_06_autorizaciones.sql b/migrations/20260302_lab_06_autorizaciones.sql new file mode 100644 index 0000000..4334f63 --- /dev/null +++ b/migrations/20260302_lab_06_autorizaciones.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- Migration: 20260302_lab_06_autorizaciones +-- Módulo Administrativo: Log de cambios de estado de órdenes +-- Cada cambio de estado genera un registro aquí (trazabilidad) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_autorizaciones` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `orden_id` INT(11) NOT NULL COMMENT 'FK a lab_ordenes_medicas.id', + `accion` ENUM( + 'creada', + 'en_revision', + 'autorizada', + 'rechazada', + 'en_domicilio', + 'completada', + 'editada' + ) NOT NULL, + `estado_anterior` VARCHAR(50) DEFAULT NULL, + `estado_nuevo` VARCHAR(50) DEFAULT NULL, + `realizada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `comentario` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_aut_orden` (`orden_id`), + KEY `idx_aut_admin` (`realizada_por`), + KEY `idx_aut_accion` (`accion`), + KEY `idx_aut_fecha` (`created_at`), + CONSTRAINT `fk_aut_orden` FOREIGN KEY (`orden_id`) + REFERENCES `lab_ordenes_medicas` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_aut_admin` FOREIGN KEY (`realizada_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Historial de cambios de estado de órdenes médicas'; diff --git a/migrations/20260302_lab_07_actividad_admin.sql b/migrations/20260302_lab_07_actividad_admin.sql new file mode 100644 index 0000000..abbb60d --- /dev/null +++ b/migrations/20260302_lab_07_actividad_admin.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- Migration: 20260302_lab_07_actividad_admin +-- Módulo Administrativo: Trazabilidad general del módulo +-- Registra TODA acción realizada por administradoras +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `lab_actividad_admin` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `admin_id` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id', + `admin_nombre` VARCHAR(150) DEFAULT NULL COMMENT 'Snapshot del nombre (por si se elimina)', + `modulo` VARCHAR(50) NOT NULL COMMENT 'pacientes|ordenes|domicilios|enfermeras|asignaciones', + `accion` VARCHAR(100) NOT NULL COMMENT 'crear|editar|eliminar|autorizar|rechazar|asignar...', + `entidad_id` INT(11) DEFAULT NULL COMMENT 'ID del registro afectado', + `detalle` TEXT DEFAULT NULL COMMENT 'JSON o descripción del cambio', + `ip_address` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_act_admin` (`admin_id`), + KEY `idx_act_modulo` (`modulo`), + KEY `idx_act_accion` (`accion`), + KEY `idx_act_entidad` (`entidad_id`), + KEY `idx_act_fecha` (`created_at`), + CONSTRAINT `fk_act_admin` FOREIGN KEY (`admin_id`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Log de trazabilidad del módulo administrativo'; diff --git a/migrations/20260302_lab_08_roles_enfermeros.sql b/migrations/20260302_lab_08_roles_enfermeros.sql new file mode 100644 index 0000000..ef244bd --- /dev/null +++ b/migrations/20260302_lab_08_roles_enfermeros.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- Migration: 20260302_lab_08_roles_enfermeros +-- Módulo Laboratorio: Rol enfermero + servicios extra +-- ============================================================ + +-- 1. Agregar columnas role y enfermera_id a admin_users +ALTER TABLE `admin_users` + ADD COLUMN IF NOT EXISTS `role` ENUM('admin','enfermero') NOT NULL DEFAULT 'admin' + COMMENT 'Rol del usuario: admin = acceso total, enfermero = solo portal propio', + ADD COLUMN IF NOT EXISTS `enfermera_id` INT(11) NULL DEFAULT NULL + COMMENT 'Vincula el usuario con su registro en lab_enfermeras'; + +-- Índice para lookups rápidos por rol +ALTER TABLE `admin_users` + ADD INDEX IF NOT EXISTS `idx_role` (`role`), + ADD INDEX IF NOT EXISTS `idx_enf_id` (`enfermera_id`); + +-- 2. Tabla de servicios extra (sin orden médica) +-- El enfermero puede agregar servicios adicionales durante la visita +CREATE TABLE IF NOT EXISTS `lab_servicios_extra` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `domicilio_id` INT(11) NOT NULL, + `descripcion` VARCHAR(255) NOT NULL COMMENT 'Ej: Inyección, Cura, Nebulización', + `tipo` ENUM( + 'inyeccion', + 'cura', + 'nebulizacion', + 'toma_muestra', + 'tension_arterial', + 'glucometria', + 'otro' + ) NOT NULL DEFAULT 'otro', + `notas` TEXT DEFAULT NULL, + `requiere_pago` TINYINT(1) NOT NULL DEFAULT 0, + `valor` DECIMAL(10,2) DEFAULT NULL, + `realizado_por` INT(11) DEFAULT NULL COMMENT 'ID de lab_enfermeras', + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_se_domicilio` (`domicilio_id`), + KEY `idx_se_enfermera` (`realizado_por`), + CONSTRAINT `fk_se_domicilio` FOREIGN KEY (`domicilio_id`) + REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_se_enfermera` FOREIGN KEY (`realizado_por`) + REFERENCES `lab_enfermeras` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Servicios adicionales agregados por enfermeros durante visitas'; diff --git a/migrations/20260302_lab_09_formularios.sql b/migrations/20260302_lab_09_formularios.sql new file mode 100644 index 0000000..e3ad697 --- /dev/null +++ b/migrations/20260302_lab_09_formularios.sql @@ -0,0 +1,63 @@ +-- ============================================================ +-- Migration: 20260302_lab_09_formularios +-- Módulo Formularios: creador drag&drop, envío a clientes, firma +-- ============================================================ + +-- 1. Plantillas de formularios (diseñadas por admins) +CREATE TABLE IF NOT EXISTS `lab_formularios` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `nombre` VARCHAR(150) NOT NULL, + `descripcion` TEXT DEFAULT NULL, + `categoria` ENUM( + 'consentimiento', + 'historia_clinica', + 'autorizacion', + 'encuesta', + 'otro' + ) NOT NULL DEFAULT 'otro', + `esquema` LONGTEXT NOT NULL COMMENT 'JSON con el array de campos del formulario', + `permite_firma` TINYINT(1) NOT NULL DEFAULT 1, + `requiere_firma` TINYINT(1) NOT NULL DEFAULT 0, + `version` SMALLINT NOT NULL DEFAULT 1, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `creado_por` INT(11) DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_form_activo` (`is_active`), + KEY `idx_form_categ` (`categoria`), + CONSTRAINT `fk_form_admin` FOREIGN KEY (`creado_por`) + REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Plantillas de formularios creadas por administradores'; + +-- 2. Envíos (instancias de un formulario enviado a un paciente) +CREATE TABLE IF NOT EXISTS `lab_form_envios` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `formulario_id` INT(11) NOT NULL, + `paciente_id` INT(11) DEFAULT NULL, + `domicilio_id` INT(11) DEFAULT NULL, + `token` CHAR(64) NOT NULL UNIQUE COMMENT 'Token público único para acceso del cliente', + `datos_prefilled` LONGTEXT DEFAULT NULL COMMENT 'JSON con datos pre-llenados por el enfermero', + `datos_cliente` LONGTEXT DEFAULT NULL COMMENT 'JSON con respuestas del cliente', + `firma_svg` LONGTEXT DEFAULT NULL COMMENT 'Firma del cliente como SVG/PNG base64', + `ip_cliente` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(512) DEFAULT NULL, + `estado` ENUM('pendiente','completado','firmado','expirado') NOT NULL DEFAULT 'pendiente', + `enviado_por` INT(11) DEFAULT NULL COMMENT 'ID admin_users que envió el link', + `enviado_via` ENUM('whatsapp','email','link') DEFAULT 'whatsapp', + `expira_en` DATETIME DEFAULT NULL, + `completado_en` DATETIME DEFAULT NULL, + `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_fenv_form` (`formulario_id`), + KEY `idx_fenv_paciente` (`paciente_id`), + KEY `idx_fenv_token` (`token`), + KEY `idx_fenv_estado` (`estado`), + CONSTRAINT `fk_fenv_form` FOREIGN KEY (`formulario_id`) REFERENCES `lab_formularios` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_fenv_paciente` FOREIGN KEY (`paciente_id`) REFERENCES `lab_pacientes` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_fenv_domicilio` FOREIGN KEY (`domicilio_id`) REFERENCES `lab_domicilios` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_fenv_enviado` FOREIGN KEY (`enviado_por`) REFERENCES `admin_users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Instancias de formularios enviados a pacientes'; diff --git a/migrations/20260302_lab_run_migrations.php b/migrations/20260302_lab_run_migrations.php new file mode 100644 index 0000000..770c27a --- /dev/null +++ b/migrations/20260302_lab_run_migrations.php @@ -0,0 +1,113 @@ +getConnection(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + if ($isRollback) { + // ── ROLLBACK ───────────────────────────────────────────────────────── + foreach ($rollback as $sql) { + $table = preg_replace('/.*`(lab_\w+)`.*/', '$1', $sql); + $pdo->exec($sql); + echo " ✅ DROP $table\n"; + } + echo "\n ✔ Rollback completado correctamente.\n\n"; + } else { + // ── MIGRACIÓN ──────────────────────────────────────────────────────── + $dir = __DIR__; + + foreach ($migrations as $file) { + $path = "$dir/$file"; + + if (!file_exists($path)) { + echo " ⚠️ Archivo no encontrado: $file (omitido)\n"; + continue; + } + + $sql = file_get_contents($path); + + // Separar sentencias por ';' pero solo las CREATE TABLE + // (Los archivos contienen una sola sentencia CREATE TABLE) + $pdo->exec($sql); + + // Extraer nombre de tabla del archivo para feedback + preg_match('/CREATE TABLE IF NOT EXISTS `(\w+)`/i', $sql, $m); + $table = $m[1] ?? $file; + echo " ✅ $table\n"; + } + + echo "\n$line\n"; + echo " ✔ Todas las migraciones ejecutadas correctamente.\n"; + echo "$line\n\n"; + + // ── Tabla de control de migraciones ejecutadas en system_logs ──────── + try { + $pdo->prepare(" + INSERT INTO system_logs (level, message, context, created_at) + VALUES ('info', 'lab_module_migration', ?, NOW()) + ")->execute([json_encode([ + 'version' => '20260302', + 'module' => 'lab_admin', + 'tables' => 7, + 'executed' => date('Y-m-d H:i:s'), + ])]); + } catch (Exception $e) { + // system_logs puede no tener estas columnas — no falla la migración + } + } +} catch (PDOException $e) { + echo "\n ❌ ERROR: " . $e->getMessage() . "\n\n"; + echo " Ejecuta --rollback para deshacer los cambios parciales.\n\n"; + exit(1); +} diff --git a/migrations/20260303_lab_10_domicilio_extras.sql b/migrations/20260303_lab_10_domicilio_extras.sql new file mode 100644 index 0000000..58c220d --- /dev/null +++ b/migrations/20260303_lab_10_domicilio_extras.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- Migration: 20260303_lab_10_domicilio_extras +-- Agrega tipo_cliente (particular/seguro) y examenes_solicitados +-- a la tabla lab_domicilios, para cuando no hay orden médica. +-- ============================================================ + +ALTER TABLE `lab_domicilios` + ADD COLUMN `tipo_cliente` ENUM('particular','seguro','eps') NOT NULL DEFAULT 'particular' + COMMENT 'Si el servicio es particular, por seguro o EPS' + AFTER `tipo_servicio`, + + ADD COLUMN `examenes_solicitados` TEXT DEFAULT NULL + COMMENT 'Exámenes escritos (cuando no hay foto de orden médica)' + AFTER `tipo_cliente`; diff --git a/migrations/20260303_templates_add_content_cols.sql b/migrations/20260303_templates_add_content_cols.sql new file mode 100644 index 0000000..18253f7 --- /dev/null +++ b/migrations/20260303_templates_add_content_cols.sql @@ -0,0 +1,25 @@ +-- Migración: agregar columnas de contenido a message_templates (producción) +-- Las columnas body_text, header_*, footer_text, components, example_parameters +-- existen en el schema local pero faltaban en producción. + +ALTER TABLE `message_templates` + ADD COLUMN IF NOT EXISTS `body_text` TEXT DEFAULT NULL + COMMENT 'Texto del cuerpo de la plantilla' + AFTER `status`, + ADD COLUMN IF NOT EXISTS `header_text` VARCHAR(255) DEFAULT NULL + COMMENT 'Texto del encabezado (si header_type=text)' + AFTER `body_text`, + ADD COLUMN IF NOT EXISTS `header_type` ENUM('text','image','video','document') DEFAULT NULL + COMMENT 'Tipo de encabezado multimedia' + AFTER `header_text`, + ADD COLUMN IF NOT EXISTS `footer_text` VARCHAR(255) DEFAULT NULL + COMMENT 'Texto del pie de la plantilla' + AFTER `header_type`, + ADD COLUMN IF NOT EXISTS `components` LONGTEXT DEFAULT NULL + COMMENT 'JSON completo de componentes de la plantilla' + AFTER `footer_text`, + ADD COLUMN IF NOT EXISTS `example_parameters` LONGTEXT DEFAULT NULL + COMMENT 'JSON con parámetros de ejemplo' + AFTER `components`, + ADD COLUMN IF NOT EXISTS `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + AFTER `created_at`; diff --git a/migrations/20260304_lab_config_doc_headers.php b/migrations/20260304_lab_config_doc_headers.php new file mode 100644 index 0000000..c8d8b16 --- /dev/null +++ b/migrations/20260304_lab_config_doc_headers.php @@ -0,0 +1,50 @@ +query('CREATE TABLE IF NOT EXISTS lab_config ( + clave VARCHAR(80) NOT NULL PRIMARY KEY, + valor LONGTEXT NOT NULL DEFAULT "", + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); +echo "tabla lab_config OK\n"; + +// Valores iniciales +$defaults = [ + ['empresa_nombre', 'Laboratorio Clínico Ximena'], + ['empresa_subtitulo', 'Análisis Clínicos'], + ['empresa_direccion', ''], + ['empresa_telefono', ''], + ['empresa_email', ''], + ['empresa_ciudad', ''], + ['doc_color', '#1565c0'], + ['doc_logo_base64', ''], + ['doc_pie_pagina', 'Documento generado digitalmente. Verifique su autenticidad en el sistema.'], +]; +foreach ($defaults as [$k, $v]) { + $db->query('INSERT IGNORE INTO lab_config(clave, valor) VALUES(?, ?)', [$k, $v]); +} +echo count($defaults) . " valores iniciales insertados (IGNORE)\n"; + +// ── 2. Columnas nuevas en lab_formularios ───────────────────────────── +$cols = [ + 'doc_encabezado VARCHAR(200) NULL DEFAULT NULL', + 'doc_subtitulo VARCHAR(200) NULL DEFAULT NULL', + 'doc_logo_base64 LONGTEXT NULL DEFAULT NULL', + 'doc_color VARCHAR(20) NULL DEFAULT NULL', + 'doc_pie_pagina VARCHAR(500) NULL DEFAULT NULL', +]; + +foreach ($cols as $def) { + $name = explode(' ', trim($def))[0]; + $exists = $db->fetchAll("SHOW COLUMNS FROM lab_formularios WHERE Field = '$name'"); + if (!$exists) { + $db->query("ALTER TABLE lab_formularios ADD COLUMN $def"); + echo "Columna agregada: $name\n"; + } else { + echo "Ya existía: $name\n"; + } +} + +echo "\nMigración completada OK\n"; diff --git a/migrations/20260304_roles_and_modules.sql b/migrations/20260304_roles_and_modules.sql new file mode 100644 index 0000000..a8a7059 --- /dev/null +++ b/migrations/20260304_roles_and_modules.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- Migración: Sistema de Roles y Módulos +-- Fecha: 2026-03-04 +-- Descripción: Crea tablas roles y role_modules; vincula admin_users +-- ============================================================ + +-- ── 1. Tabla de roles ──────────────────────────────────────── +CREATE TABLE IF NOT EXISTS `roles` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL COMMENT 'Nombre legible del rol', + `slug` VARCHAR(50) NOT NULL COMMENT 'Clave interna (enfermero, admin, …)', + `description` TEXT DEFAULT NULL, + `color` VARCHAR(20) DEFAULT '#6c757d' COMMENT 'Color HEX para la UI', + `is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = no se puede eliminar', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_slug` (`slug`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── 2. Tabla de módulos asignados por rol ──────────────────── +-- Cada fila «activa» el módulo para ese rol. +CREATE TABLE IF NOT EXISTS `role_modules` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `role_id` INT(11) NOT NULL, + `module_slug` VARCHAR(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_role_module` (`role_id`, `module_slug`), + CONSTRAINT `fk_rm_role` FOREIGN KEY (`role_id`) + REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── 3. Vincular admin_users con roles ──────────────────────── +-- (Se ignora si la columna ya existe - compatible MySQL y MariaDB) +ALTER TABLE `admin_users` + ADD COLUMN `role_id` INT(11) NULL DEFAULT NULL + COMMENT 'FK a roles.id'; + +ALTER TABLE `admin_users` + ADD INDEX `idx_role_id` (`role_id`); + +-- ── 4. Roles del sistema (no borrables) ───────────────────── +INSERT IGNORE INTO `roles` (`id`, `name`, `slug`, `description`, `color`, `is_system`) VALUES +(1, 'Administrador', 'admin', 'Acceso completo a todos los módulos', '#0d6efd', 1), +(2, 'Enfermero', 'enfermero', 'Solo portal de domicilios asignados y formularios','#6f42c1', 1); + +-- ── 5. Módulos para el rol Administrador (todos) ───────────── +INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`) VALUES +(1, 'whatsapp'), +(1, 'lab_dashboard'), +(1, 'lab_ordenes'), +(1, 'lab_pacientes'), +(1, 'lab_domicilios'), +(1, 'lab_enfermeras'), +(1, 'lab_formularios'), +(1, 'lab_reportes'), +(1, 'lab_configuracion'), +(1, 'usuarios'); + +-- ── 6. Módulos para el rol Enfermero ──────────────────────── +INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`) VALUES +(2, 'enfermero_portal'), +(2, 'lab_formularios'); + +-- ── 7. Vincular usuarios existentes con su role_id ─────────── +UPDATE `admin_users` SET `role_id` = 1 WHERE `role` = 'admin' AND `role_id` IS NULL; +UPDATE `admin_users` SET `role_id` = 2 WHERE `role` = 'enfermero' AND `role_id` IS NULL; +-- Si algún usuario no tiene rol asignado, asignarle admin por defecto +UPDATE `admin_users` SET `role_id` = 1 WHERE `role_id` IS NULL; + +-- ── 8. FK suave (solo si FK no existe aún) ────────────────── +-- (MariaDB/MySQL no tiene ADD CONSTRAINT IF NOT EXISTS, usamos el IGNORE del INSERT) +-- La FK se omite intencionalmente para no bloquear si ya existe; +-- conéctala manualmente si el motor lo permite: +-- ALTER TABLE admin_users ADD CONSTRAINT fk_au_role FOREIGN KEY (role_id) REFERENCES roles(id); diff --git a/run_roles_migration.php b/run_roles_migration.php new file mode 100644 index 0000000..50beeb2 --- /dev/null +++ b/run_roles_migration.php @@ -0,0 +1,81 @@ + $s !== '' && $s !== "\n" +); + +try { + $pdo = createDbConnection(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $ok = 0; $errors = []; + foreach ($statements as $stmt) { + if (!trim($stmt)) continue; + try { + $pdo->exec($stmt); + $ok++; + } catch (PDOException $e) { + // Ignorar: columna ya existe, índice ya existe, clave duplicada en INSERT + $msg = $e->getMessage(); + if ( + str_contains($msg, 'Duplicate entry') || + str_contains($msg, 'Duplicate key') || + str_contains($msg, 'already exists') || + str_contains($msg, 'Duplicate column name') || + str_contains($msg, "Can't DROP") || + preg_match('/already exists/i', $msg) + ) { + $ok++; + } else { + $errors[] = 'Error: ' . htmlspecialchars($msg) . + '
' . htmlspecialchars(substr($stmt, 0, 200)) . ''; + } + } + } + ?> + + Migración Roles + + +
+

🔧 Migración: Roles y Módulos

+
+ ✅ sentencias ejecutadas correctamente.
+ + ⚠️ advertencias (ver abajo). + +
+ +
+ + Ir a Usuarios & Roles → + Dashboard +

⚠️ Elimina o protege este archivo (run_roles_migration.php) después de ejecutarlo.

+
+ ❌ Error de conexión: ' . htmlspecialchars($e->getMessage()) . ''; +} diff --git a/scripts/generar_cronograma.py b/scripts/generar_cronograma.py new file mode 100644 index 0000000..9c1daaa --- /dev/null +++ b/scripts/generar_cronograma.py @@ -0,0 +1,352 @@ +""" +Genera cronograma.xlsx — Cronograma de Ejecución del Proyecto +Laboratorio Ximena / WhatsApp Bot Manager +Inicio: 2 de marzo de 2026 +""" +from datetime import date, timedelta +from openpyxl import Workbook +from openpyxl.styles import (PatternFill, Font, Alignment, Border, Side, + GradientFill) +from openpyxl.utils import get_column_letter + +# ─── CONFIGURACIÓN DEL PROYECTO ───────────────────────────────────────────── + +INICIO = date(2026, 3, 2) + +FASES = [ + # (fase, tarea, inicio_offset_dias, duracion_dias, estado) + # Estado: 'done' | 'active' | 'pending' + ("Fase 1 – Sistema de Roles", "Diseño BD (roles, modules)", 0, 4, "done"), + ("Fase 1 – Sistema de Roles", "APIs roles y usuarios", 4, 4, "done"), + ("Fase 1 – Sistema de Roles", "UI lab_usuarios.php", 6, 4, "done"), + ("Fase 1 – Sistema de Roles", "Unificación panel admin", 8, 3, "done"), + + ("Fase 2 – Portal Enfermero", "Corrección login enfermero", 11, 2, "done"), + ("Fase 2 – Portal Enfermero", "Fix SyntaxError botón formulario", 11, 2, "done"), + ("Fase 2 – Portal Enfermero", "Submit idempotente (ya completado)", 13, 2, "done"), + ("Fase 2 – Portal Enfermero", "Firma digital — canvas", 15, 3, "done"), + ("Fase 2 – Portal Enfermero", "Firma por foto (opcional)", 18, 2, "active"), + + ("Fase 3 – Formularios Avanz.", "Campos dinámicos tipo fecha/selector", 21, 4, "pending"), + ("Fase 3 – Formularios Avanz.", "Historial de envíos por paciente", 25, 3, "pending"), + ("Fase 3 – Formularios Avanz.", "Descarga PDF de formulario enviado", 25, 4, "pending"), + + ("Fase 4 – Agendamiento", "Calendario de domicilios", 32, 5, "pending"), + ("Fase 4 – Agendamiento", "Mensajes programados WhatsApp", 36, 4, "pending"), + ("Fase 4 – Agendamiento", "Recordatorios automáticos", 38, 3, "pending"), + + ("Fase 5 – Reportes", "Dashboard métricas laboratorio", 44, 4, "pending"), + ("Fase 5 – Reportes", "Exportación Excel / CSV", 46, 3, "pending"), + ("Fase 5 – Reportes", "Reportes por enfermera", 48, 3, "pending"), + + ("Fase 6 – QA y Despliegue", "Pruebas integrales", 53, 4, "pending"), + ("Fase 6 – QA y Despliegue", "Corrección de bugs", 55, 3, "pending"), + ("Fase 6 – QA y Despliegue", "Despliegue en producción", 58, 3, "pending"), + ("Fase 6 – QA y Despliegue", "Capacitación y entrega", 60, 2, "pending"), +] + +# ─── COLORES ──────────────────────────────────────────────────────────────── + +COLORES_FASE = { + "Fase 1 – Sistema de Roles": {"header": "1565C0", "barra_done": "42A5F5", "barra_active": "1E88E5", "barra_pending": "90CAF9"}, + "Fase 2 – Portal Enfermero": {"header": "2E7D32", "barra_done": "66BB6A", "barra_active": "43A047", "barra_pending": "A5D6A7"}, + "Fase 3 – Formularios Avanz.": {"header": "6A1B9A", "barra_done": "AB47BC", "barra_active": "8E24AA", "barra_pending": "CE93D8"}, + "Fase 4 – Agendamiento": {"header": "E65100", "barra_done": "FFA726", "barra_active": "FB8C00", "barra_pending": "FFCC80"}, + "Fase 5 – Reportes": {"header": "00695C", "barra_done": "26A69A", "barra_active": "00897B", "barra_pending": "80CBC4"}, + "Fase 6 – QA y Despliegue": {"header": "B71C1C", "barra_done": "EF5350", "barra_active": "E53935", "barra_pending": "EF9A9A"}, +} + +ESTADO_LABEL = {"done": "✅ Completado", "active": "🔄 En curso", "pending": "⏳ Pendiente"} +ESTADO_FILL = { + "done": "D0F0C0", + "active": "FFF9C4", + "pending": "F5F5F5", +} + +# ─── HELPERS ──────────────────────────────────────────────────────────────── + +def fecha(offset): return INICIO + timedelta(days=offset) +def col_for_date(d, week_cols_start, all_weeks): + """Devuelve columna Excel (1-based) para una fecha dada.""" + for i, w in enumerate(all_weeks): + if w <= d < w + timedelta(days=7): + return week_cols_start + i + return None + +def thin(): + s = Side(style='thin', color='CCCCCC') + return Border(left=s, right=s, top=s, bottom=s) + +def fill(hex_color): + return PatternFill("solid", fgColor=hex_color) + +# ─── GENERAR SEMANAS ──────────────────────────────────────────────────────── + +max_offset = max(t[3] + t[2] for t in FASES) +num_weeks = (max_offset // 7) + 2 +all_weeks = [INICIO + timedelta(weeks=i) for i in range(num_weeks)] + +# ─── WORKBOOK ─────────────────────────────────────────────────────────────── + +wb = Workbook() + +# ════════════════════════════════════════════════════════════════════════════ +# HOJA 1 — GANTT +# ════════════════════════════════════════════════════════════════════════════ +ws = wb.active +ws.title = "Gantt" + +TASK_COLS = 4 # Fase | Tarea | Inicio | Estado +WEEK_COL_START = TASK_COLS + 1 # columna Excel donde empiezan las semanas + +# ── Dimensiones ───────────────────────────────────────────────────────────── +ws.column_dimensions['A'].width = 26 +ws.column_dimensions['B'].width = 38 +ws.column_dimensions['C'].width = 13 +ws.column_dimensions['D'].width = 16 + +for i in range(num_weeks): + cl = get_column_letter(WEEK_COL_START + i) + ws.column_dimensions[cl].width = 5.5 + +# ── Fila 1 — Título ───────────────────────────────────────────────────────── +ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=WEEK_COL_START + num_weeks - 1) +ws['A1'] = "CRONOGRAMA DE EJECUCIÓN — Laboratorio Ximena" +ws['A1'].font = Font(bold=True, size=14, color="FFFFFF") +ws['A1'].fill = fill("0D47A1") +ws['A1'].alignment = Alignment(horizontal="center", vertical="center") +ws.row_dimensions[1].height = 28 + +# ── Fila 2 — Sub-título ───────────────────────────────────────────────────── +ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=WEEK_COL_START + num_weeks - 1) +ws['A2'] = f"Inicio: {INICIO.strftime('%d de %B de %Y')} | Total estimado: {num_weeks} semanas" +ws['A2'].font = Font(italic=True, size=10, color="444444") +ws['A2'].fill = fill("E3F2FD") +ws['A2'].alignment = Alignment(horizontal="center", vertical="center") +ws.row_dimensions[2].height = 18 + +# ── Fila 3 — Encabezados columnas texto ───────────────────────────────────── +heads = ["Fase", "Tarea", "Inicio", "Estado"] +for c, h in enumerate(heads, 1): + cell = ws.cell(row=3, column=c, value=h) + cell.font = Font(bold=True, size=10, color="FFFFFF") + cell.fill = fill("37474F") + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + cell.border = thin() + +# ── Fila 3 — Encabezados semanas ──────────────────────────────────────────── +for i, w in enumerate(all_weeks): + col = WEEK_COL_START + i + cell = ws.cell(row=3, column=col, value=w.strftime("%-d/%m")) + cell.font = Font(bold=True, size=8, color="FFFFFF") + cell.fill = fill("546E7A") + cell.alignment = Alignment(horizontal="center", vertical="center", text_rotation=60) + cell.border = thin() +ws.row_dimensions[3].height = 44 + +# ── Filas de datos ─────────────────────────────────────────────────────────── +ROW = 4 +prev_fase = None + +for (fase, tarea, start_off, dur, estado) in FASES: + colores = COLORES_FASE[fase] + + # Separador de fase (cuando cambia) + if fase != prev_fase: + ws.merge_cells(start_row=ROW, start_column=1, + end_row=ROW, end_column=WEEK_COL_START + num_weeks - 1) + cell = ws.cell(row=ROW, column=1, value=f" {fase}") + cell.font = Font(bold=True, size=10, color="FFFFFF") + cell.fill = fill(colores["header"]) + cell.alignment = Alignment(vertical="center") + cell.border = thin() + ws.row_dimensions[ROW].height = 20 + ROW += 1 + prev_fase = fase + + # Calcular fechas + d_ini = fecha(start_off) + d_fin = fecha(start_off + dur - 1) + row_fill = fill(ESTADO_FILL[estado]) + + # Columna A — Fase (vacía, ya fue el encabezado) + c_fase = ws.cell(row=ROW, column=1, value="") + c_fase.fill = row_fill + c_fase.border = thin() + + # Columna B — Tarea + c_task = ws.cell(row=ROW, column=2, value=tarea) + c_task.font = Font(size=9) + c_task.fill = row_fill + c_task.alignment = Alignment(vertical="center", wrap_text=True) + c_task.border = thin() + + # Columna C — Fecha inicio + c_date = ws.cell(row=ROW, column=3, value=d_ini.strftime("%-d %b")) + c_date.font = Font(size=9) + c_date.fill = row_fill + c_date.alignment = Alignment(horizontal="center", vertical="center") + c_date.border = thin() + + # Columna D — Estado + c_est = ws.cell(row=ROW, column=4, value=ESTADO_LABEL[estado]) + c_est.font = Font(size=9, bold=(estado == "active")) + c_est.fill = row_fill + c_est.alignment = Alignment(horizontal="center", vertical="center") + c_est.border = thin() + + # Barras de Gantt + barra_color = colores[f"barra_{estado}"] + w_ini = col_for_date(d_ini, WEEK_COL_START, all_weeks) + w_fin = col_for_date(d_fin, WEEK_COL_START, all_weeks) + if w_ini is None: w_ini = WEEK_COL_START + if w_fin is None: w_fin = WEEK_COL_START + num_weeks - 1 + + for col in range(WEEK_COL_START, WEEK_COL_START + num_weeks): + cell = ws.cell(row=ROW, column=col, value="") + if w_ini <= col <= w_fin: + cell.fill = fill(barra_color) + else: + cell.fill = fill("F5F5F5") + cell.border = thin() + + ws.row_dimensions[ROW].height = 18 + ROW += 1 + +# ── Leyenda ────────────────────────────────────────────────────────────────── +ROW += 1 +leyenda = [ + ("✅ Completado", "D0F0C0"), + ("🔄 En curso", "FFF9C4"), + ("⏳ Pendiente", "F5F5F5"), +] +ws.cell(row=ROW, column=1, value="Leyenda:").font = Font(bold=True, size=10) +for i, (txt, color) in enumerate(leyenda, 2): + cell = ws.cell(row=ROW, column=i, value=txt) + cell.fill = fill(color) + cell.border = thin() + cell.font = Font(size=9) + cell.alignment = Alignment(horizontal="center") + +# Freeze panes +ws.freeze_panes = "E4" + +# ════════════════════════════════════════════════════════════════════════════ +# HOJA 2 — RESUMEN POR FASE +# ════════════════════════════════════════════════════════════════════════════ +ws2 = wb.create_sheet("Resumen por Fase") +ws2.column_dimensions['A'].width = 30 +ws2.column_dimensions['B'].width = 14 +ws2.column_dimensions['C'].width = 14 +ws2.column_dimensions['D'].width = 14 +ws2.column_dimensions['E'].width = 16 +ws2.column_dimensions['F'].width = 16 + +# Título +ws2.merge_cells("A1:F1") +ws2['A1'] = "RESUMEN POR FASE" +ws2['A1'].font = Font(bold=True, size=13, color="FFFFFF") +ws2['A1'].fill = fill("0D47A1") +ws2['A1'].alignment = Alignment(horizontal="center", vertical="center") +ws2.row_dimensions[1].height = 26 + +# Encabezados +enc2 = ["Fase", "Inicio", "Fin", "Duración (días)", "Tareas", "Estado"] +for c, h in enumerate(enc2, 1): + cell = ws2.cell(row=2, column=c, value=h) + cell.font = Font(bold=True, size=10, color="FFFFFF") + cell.fill = fill("37474F") + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + cell.border = thin() +ws2.row_dimensions[2].height = 22 + +# Agrupar por fase +from collections import defaultdict +fases_data = defaultdict(list) +for row in FASES: + fases_data[row[0]].append(row) + +ROW2 = 3 +for fase_name, tareas in fases_data.items(): + ini_fase = fecha(min(t[2] for t in tareas)) + fin_fase = fecha(max(t[2] + t[3] - 1 for t in tareas)) + dur_total = (fin_fase - ini_fase).days + 1 + n_tareas = len(tareas) + estados = [t[4] for t in tareas] + if all(e == "done" for e in estados): + estado_fase = "✅ Completada" + fc = "D0F0C0" + elif any(e == "active" for e in estados): + estado_fase = "🔄 En curso" + fc = "FFF9C4" + else: + estado_fase = "⏳ Pendiente" + fc = "F5F5F5" + + colores = COLORES_FASE[fase_name] + fondo = fill(colores["header"] if estado_fase == "✅ Completada" else fc) + + data = [fase_name, ini_fase.strftime("%-d %b %Y"), fin_fase.strftime("%-d %b %Y"), + dur_total, n_tareas, estado_fase] + for c, v in enumerate(data, 1): + cell = ws2.cell(row=ROW2, column=c, value=v) + cell.alignment = Alignment(horizontal="center" if c > 1 else "left", + vertical="center", wrap_text=True) + cell.border = thin() + cell.font = Font(size=10, bold=(c == 1), + color=("FFFFFF" if estado_fase == "✅ Completada" else "222222")) + cell.fill = PatternFill("solid", fgColor=colores["header"]) if estado_fase == "✅ Completada" \ + else fill(fc) + ws2.row_dimensions[ROW2].height = 22 + ROW2 += 1 + +# ════════════════════════════════════════════════════════════════════════════ +# HOJA 3 — DETALLE DE TAREAS +# ════════════════════════════════════════════════════════════════════════════ +ws3 = wb.create_sheet("Detalle Tareas") +ws3.column_dimensions['A'].width = 28 +ws3.column_dimensions['B'].width = 40 +ws3.column_dimensions['C'].width = 14 +ws3.column_dimensions['D'].width = 14 +ws3.column_dimensions['E'].width = 12 +ws3.column_dimensions['F'].width = 16 + +ws3.merge_cells("A1:F1") +ws3['A1'] = "DETALLE DE TAREAS" +ws3['A1'].font = Font(bold=True, size=13, color="FFFFFF") +ws3['A1'].fill = fill("0D47A1") +ws3['A1'].alignment = Alignment(horizontal="center", vertical="center") +ws3.row_dimensions[1].height = 26 + +enc3 = ["Fase", "Tarea", "Inicio", "Fin", "Días", "Estado"] +for c, h in enumerate(enc3, 1): + cell = ws3.cell(row=2, column=c, value=h) + cell.font = Font(bold=True, size=10, color="FFFFFF") + cell.fill = fill("37474F") + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = thin() +ws3.row_dimensions[2].height = 22 + +ROW3 = 3 +for (fase, tarea, start_off, dur, estado) in FASES: + d_ini = fecha(start_off) + d_fin = fecha(start_off + dur - 1) + colores = COLORES_FASE[fase] + fc = ESTADO_FILL[estado] + + data = [fase, tarea, d_ini.strftime("%-d %b %Y"), d_fin.strftime("%-d %b %Y"), + dur, ESTADO_LABEL[estado]] + for c, v in enumerate(data, 1): + cell = ws3.cell(row=ROW3, column=c, value=v) + cell.alignment = Alignment(horizontal="center" if c > 2 else "left", + vertical="center", wrap_text=True) + cell.border = thin() + cell.font = Font(size=9) + cell.fill = fill(fc) + ws3.row_dimensions[ROW3].height = 18 + ROW3 += 1 + +# ─── GUARDAR ───────────────────────────────────────────────────────────────── +out = "/Users/lizandro/Documents/GitHub/whatsapp/cronograma.xlsx" +wb.save(out) +print(f"✅ Archivo generado: {out}") diff --git a/services/BotService.php b/services/BotService.php index d06aefa..1e1616d 100644 --- a/services/BotService.php +++ b/services/BotService.php @@ -62,6 +62,21 @@ class BotService { error_log('[BotService] maybeResetConversationAfterIdle threw: ' . $e->getMessage()); } + // ── Flujo de Términos y Condiciones ────────────────────────────────── + // 1. Si el usuario está esperando respuesta de términos, procesarla primero. + if (!empty($user['terms_pending'])) { + if ($this->processTermsResponse($user, $phoneNumber, $messageText)) { + return; + } + } + + // 2. Si el usuario necesita aceptar (nuevo, expirado, o versión nueva con forzar_reenvio). + if ($this->needsTermsAcceptance($user)) { + $this->sendTermsMessage($user, $phoneNumber); + return; + } + // ───────────────────────────────────────────────────────────────────── + // Verificar si es un nuevo usuario (enviar mensaje de bienvenida) if ($this->isNewUser($user['id'])) { $this->sendWelcomeMessage($phoneNumber); @@ -427,6 +442,196 @@ class BotService { return false; } + // ========================================================================= + // TÉRMINOS Y CONDICIONES + // ========================================================================= + + /** + * Obtiene la versión activa de términos o null si no existe ninguna. + */ + private function getActiveTermsVersion(): ?array { + try { + return $this->db->fetch( + "SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1" + ) ?: null; + } catch (Exception $e) { + error_log('[BotService] getActiveTermsVersion error: ' . $e->getMessage()); + return null; + } + } + + /** + * Decide si el usuario debe aceptar (o re-aceptar) los términos. + * - No hay aceptación previa (terms_accepted_at IS NULL) + * - Han pasado más de 6 meses desde la última aceptación + * - La versión activa tiene forzar_reenvio=1 y el usuario aceptó una versión anterior + */ + private function needsTermsAcceptance(array $user): bool { + $terms = $this->getActiveTermsVersion(); + if (!$terms) return false; // Sin versión activa → no hay términos que aceptar + + // ¿El usuario ya aceptó esta versión exacta? + $acceptedVersionId = $user['terms_version_id'] ?? null; + $acceptedAt = $user['terms_accepted_at'] ?? null; + + // Nunca aceptó + if (!$acceptedAt) return true; + + // Forzar re-aceptación si la versión activa lo requiere y el usuario tiene una versión diferente + if (!empty($terms['forzar_reenvio']) && (int)$acceptedVersionId !== (int)$terms['id']) { + return true; + } + + // Han pasado más de 6 meses (26 semanas) desde la última aceptación + $sixMonthsAgo = strtotime('-6 months'); + if (strtotime($acceptedAt) < $sixMonthsAgo) return true; + + return false; + } + + /** + * Envía el mensaje de términos al usuario y registra el envío en BD. + */ + private function sendTermsMessage(array $user, string $phoneNumber): void { + $terms = $this->getActiveTermsVersion(); + if (!$terms) return; + + // Usar el mensaje configurado en terms_versions o el de config como fallback + $msg = !empty($terms['mensaje_aceptacion']) + ? $terms['mensaje_aceptacion'] + : getConfigFromDB('terms_message', ''); + + if (!$msg) return; + + // Adjuntar URL del documento solo si es una URL pública (no localhost) + if (!empty($terms['documento_url'])) { + $docUrl = $terms['documento_url']; + $isLocal = ( + strpos($docUrl, 'localhost') !== false || + strpos($docUrl, '127.0.0.1') !== false || + preg_match('/https?:\/\/\d+\.\d+\.\d+\.\d+:\d+/', $docUrl) + ); + if (!$isLocal) { + $msg .= "\n\n📄 Documento: " . $docUrl; + } + } + + $this->whatsappService->sendTextMessage($phoneNumber, $msg); + + // Marcar usuario como "esperando respuesta de términos" + try { + $this->db->update( + 'users', + ['terms_pending' => 1], + 'id = :id', + ['id' => $user['id']] + ); + } catch (Exception $e) { + error_log('[BotService] sendTermsMessage – update terms_pending: ' . $e->getMessage()); + } + + // Registrar envío en terms_acceptance con estado pendiente + try { + $this->db->insert('terms_acceptance', [ + 'user_id' => $user['id'], + 'terms_version_id' => $terms['id'], + 'phone_number' => $phoneNumber, + 'estado' => 'pendiente', + 'fecha_envio' => date('Y-m-d H:i:s'), + ]); + } catch (Exception $e) { + error_log('[BotService] sendTermsMessage – insert terms_acceptance: ' . $e->getMessage()); + } + + error_log("[BotService] sendTermsMessage – sent terms v{$terms['version']} to {$phoneNumber}"); + } + + /** + * Procesa la respuesta del usuario cuando está en estado terms_pending. + * Detecta "acepto" / "no acepto" / "rechazo". + * Devuelve true si el mensaje fue manejado (para detener el procesamiento normal). + */ + private function processTermsResponse(array $user, string $phoneNumber, string $messageText): bool { + $terms = $this->getActiveTermsVersion(); + if (!$terms) { + // Sin versión activa: limpiar el flag y continuar + try { + $this->db->update('users', ['terms_pending' => 0], 'id = :id', ['id' => $user['id']]); + } catch (Exception $e) {} + return false; + } + + $normalized = strtolower(trim( + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $messageText) ?: $messageText + )); + + // Detectar aceptación: "acepto", "si acepto", "sí, acepto", "1", etc. + $accepted = (bool) preg_match('/\b(acepto|si acepto|si|sí|1|ok|aceptar|de acuerdo|confirmo)\b/', $normalized); + + // Detectar rechazo explícito: "no acepto", "rechazo", "no", "0", etc. + $rejected = !$accepted && (bool) preg_match('/\b(no acepto|no|rechazo|rechazar|negar|0|rechaz[oa])\b/', $normalized); + + if (!$accepted && !$rejected) { + // Respuesta no reconocida → volver a preguntar + $retry = "No entendí tu respuesta. Por favor responde *Acepto* o *No acepto*."; + $this->whatsappService->sendTextMessage($phoneNumber, $retry); + return true; + } + + $estado = $accepted ? 'aceptado' : 'rechazado'; + $now = date('Y-m-d H:i:s'); + + // Actualizar el último registro pendiente de esta versión + try { + $this->db->query( + "UPDATE terms_acceptance SET estado = :estado, fecha_respuesta = :now + WHERE user_id = :uid AND terms_version_id = :vid AND estado = 'pendiente' + ORDER BY id DESC LIMIT 1", + ['estado' => $estado, 'now' => $now, 'uid' => $user['id'], 'vid' => $terms['id']] + ); + } catch (Exception $e) { + error_log('[BotService] processTermsResponse – update acceptance: ' . $e->getMessage()); + } + + if ($accepted) { + // Actualizar usuario: aceptó términos + try { + $this->db->update('users', [ + 'terms_pending' => 0, + 'terms_accepted_at' => $now, + 'terms_version_id' => $terms['id'], + // Limpiar forzar_reenvio para este usuario (lo manejamos por versión) + ], 'id = :id', ['id' => $user['id']]); + } catch (Exception $e) { + error_log('[BotService] processTermsResponse – update user accepted: ' . $e->getMessage()); + } + + // Continuar con el flujo normal (welcome si es nuevo) + if ($this->isNewUser($user['id'])) { + $this->sendWelcomeMessage($phoneNumber); + } else { + // Ya había interactuado antes → solo confirmar y mostrar menú + $this->showMainMenu($phoneNumber); + } + } else { + // Rechazó — enviar mensaje de rechazo y dejar terms_pending=1 para que pueda aceptar después + $rejMsg = !empty($terms['mensaje_rechazo']) + ? $terms['mensaje_rechazo'] + : getConfigFromDB('terms_rejected_message', 'Lamentablemente no podemos proceder sin tu aceptación. Cuando estés listo, responde *Acepto*.'); + + $this->whatsappService->sendTextMessage($phoneNumber, $rejMsg); + + // Mantener terms_pending = 1 para que la próxima respuesta sea evaluada + } + + error_log("[BotService] processTermsResponse – user {$user['id']} {$estado} terms v{$terms['version']}"); + return true; + } + + // ========================================================================= + // FIN TÉRMINOS Y CONDICIONES + // ========================================================================= + /** * Verificar si es un nuevo usuario * Ahora: reenvía el welcome si no se envió o si el último welcome fue hace >= 6 horas diff --git a/ver_formulario_enviado.php b/ver_formulario_enviado.php new file mode 100644 index 0000000..b05dcf3 --- /dev/null +++ b/ver_formulario_enviado.php @@ -0,0 +1,400 @@ +fetch( + "SELECT e.*, f.nombre AS form_nombre, f.categoria, f.descripcion AS form_descripcion, + f.esquema, f.permite_firma, f.requiere_firma, + f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina, + p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento, + p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, + u.full_name AS enviado_por_nombre, u.email AS enviado_por_email + FROM lab_form_envios e + JOIN lab_formularios f ON f.id = e.formulario_id + LEFT JOIN lab_pacientes p ON p.id = e.paciente_id + LEFT JOIN admin_users u ON u.id = e.enviado_por + WHERE e.id = ?", + [$id] +); + +if (!$envio) { http_response_code(404); die('Formulario no encontrado'); } + +// Enfermero solo puede ver sus propios envíos +if (isEnfermero()) { + $uid = (int)($_SESSION['admin_user']['id'] ?? 0); + if ($envio['enviado_por'] != $uid) { http_response_code(403); die('Sin acceso'); } +} + +// ── Config global del lab ───────────────────────────────────────────── +$cfgRows = $db->fetchAll('SELECT clave, valor FROM lab_config WHERE valor != ""'); +$cfg = []; +foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; } + +// Merge: per-form override > global config +$docColor = $envio['doc_color'] ?: ($cfg['doc_color'] ?? '#1565c0'); +$docEncabezado = $envio['doc_encabezado'] ?: ($cfg['empresa_nombre'] ?? ''); +$docSubtitulo = $envio['doc_subtitulo'] ?: ($cfg['empresa_subtitulo'] ?? ''); +$docLogo = $envio['doc_logo_base64'] ?: ($cfg['doc_logo_base64'] ?? ''); +$docPiePagina = $envio['doc_pie_pagina'] ?: ($cfg['doc_pie_pagina'] ?? ''); +$docDireccion = $cfg['empresa_direccion'] ?? ''; +$docTelefono = $cfg['empresa_telefono'] ?? ''; +$docEmail = $cfg['empresa_email'] ?? ''; +$docCiudad = $cfg['empresa_ciudad'] ?? ''; + +$esquema = json_decode($envio['esquema'], true) ?? []; +$datosCliente = json_decode($envio['datos_cliente'] ?? '{}', true) ?? []; +$datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? []; +$todos = array_merge($datosPrefilled, $datosCliente); + +// Mapa id → label +$labelMap = []; +foreach ($esquema as $c) { + if (!empty($c['id'])) $labelMap[$c['id']] = $c['label'] ?? $c['id']; +} + +$completado = ($envio['completado_en'] ?? '') ? date('d/m/Y H:i', strtotime($envio['completado_en'])) : '—'; +$creado = ($envio['created_at'] ?? '') ? date('d/m/Y H:i', strtotime($envio['created_at'])) : '—'; + +function esc2(mixed $v): string { + if (is_array($v)) return htmlspecialchars(implode(', ', $v), ENT_QUOTES); + return htmlspecialchars((string)($v ?? ''), ENT_QUOTES); +} +?> + + + + + + <?= esc2($envio['form_nombre']) ?> — Respuesta + + + + + + + +
+ Volver +
+ +
+ + +
+ + +
+
+ + + +
+ +

+ + +
+ + +
+ +

+ +
+ +
+
+ 'estado-firmado', + 'completado' => 'estado-completado', + default => 'estado-pendiente', + }; + ?> + +
+
+
+
+ +
+ + + +
Datos del paciente
+
+
Nombre completo
+
+
+ +
+
+
+
+ + +
+
Fecha de nacimiento
+
+
+ + +
+
Teléfono
+
+
+ + +
+
EPS / Aseguradora
+
+
+ + + + +
Respuestas del formulario
+ +
+ +
+ + $rendered = nl2br(htmlspecialchars($rendered, ENT_QUOTES)); + ?> +
+ +
+
+
+
+ + + + +
Firma digital (dibujo)
+
+ Firma digital +
+ + + + +
Foto de firma / documento
+
+ Foto de firma +
+ + + +
Información del envío
+
+
Enviado por
+
+
+
+
Fecha de envío
+
+
+ +
+
Completado el
+
+
+ + +
+
IP del cliente
+
+
+ + + + +
+
+ + Sello de integridad del documento +
+
+
Hash SHA-256 de verificación:
+ +
+ Verifique la autenticidad en: + + + +
+
+
+ + +
+ + + +
+ + + diff --git a/verificar_formulario.php b/verificar_formulario.php new file mode 100644 index 0000000..a7c91db --- /dev/null +++ b/verificar_formulario.php @@ -0,0 +1,186 @@ +fetch( + "SELECT e.id, e.estado, e.completado_en, e.ip_cliente, + f.nombre AS form_nombre, f.categoria, + p.nombre_completo AS paciente_nombre, p.numero_documento + FROM lab_form_envios e + JOIN lab_formularios f ON f.id = e.formulario_id + LEFT JOIN lab_pacientes p ON p.id = e.paciente_id + WHERE e.hash_verificacion = ?", + [$hash] + ); + if ($envio) { + $valido = in_array($envio['estado'], ['firmado', 'completado']); + } else { + $error = 'El código de verificación no corresponde a ningún documento registrado.'; + } +} elseif ($hash) { + $error = 'El código de verificación no tiene el formato correcto.'; +} + +function esc3(mixed $v): string { + return htmlspecialchars((string)($v ?? ''), ENT_QUOTES); +} +?> + + + + + + Verificación de documento — Lab + + + + + + +
+ + +
+
+ Verificación de integridad de documentos +
+
+ +
+ + + +

+ Ingresa el código SHA-256 que aparece en el documento para verificar su autenticidad. +

+
+
+ + +
Código de 64 caracteres que aparece en el pie del documento.
+
+ +
+ + + +
+
+
Documento válido y auténtico
+

+ Este documento fue registrado en nuestro sistema y no ha sido modificado. +

+
+ +
+
Formulario
+
+
+ +
+
Paciente
+
+
+ + +
+
Documento
+
+
+ +
+
Estado
+
+ ✍️ Firmado digitalmente' + : '✅ Completado' ?> +
+
+ +
+
Fecha de registro
+
UTC
+
+ +
+
ID de registro
+
#
+
+ +
+ +
+ + +
+ + + +
+
+
Documento no verificado
+

+
+ + +
+ + +
+ + +
+ + +
+ +
+ Sistema de verificación de documentos — Generado el +
+
+ + + diff --git a/view_logs.php b/view_logs.php new file mode 100644 index 0000000..39caf12 --- /dev/null +++ b/view_logs.php @@ -0,0 +1,175 @@ + '/var/log/php-error.log', + 'Nginx Errors' => '/var/log/nginx/whatsapp-error.log', + 'Nginx Access' => '/var/log/nginx/whatsapp-access.log', + 'App System' => __DIR__ . '/logs/system.log', + 'App Errors' => __DIR__ . '/logs/app_errors.log', + 'Worker (hoy)' => __DIR__ . '/logs/worker-' . date('Y-m-d') . '.log', + 'Scheduled' => __DIR__ . '/logs/scheduled_messages.log', + 'Supervisor PHP-FPM' => '/var/log/supervisor/php-fpm-error.log', + 'Supervisor Worker 0' => '/var/log/supervisor/worker-00-error.log', + 'Supervisor Worker 1' => '/var/log/supervisor/worker-01-error.log', +]; + +$selected = $_GET['log'] ?? 'App Errors'; +$lines = intval($_GET['lines'] ?? 100); +$filter = trim($_GET['filter'] ?? ''); + +$content = ''; +$selectedFile = $logFiles[$selected] ?? ''; + +if ($selectedFile && file_exists($selectedFile)) { + // Leer últimas N líneas sin exec() + $allLines = file($selectedFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($allLines === false) $allLines = []; + $allLines = array_slice($allLines, -$lines); + + if ($filter) { + $allLines = array_filter($allLines, function($line) use ($filter) { + return stripos($line, $filter) !== false; + }); + } + $content = implode("\n", $allLines); +} elseif ($selectedFile) { + $content = "(Archivo no existe: {$selectedFile})"; +} +?> + + + + + 📋 Visor de Logs + + + + +
+
+

📋 Visor de Logs del Sistema

+
+ ← Volver + + + +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ + + Limpiar filtro + +
+ +
📁
+ +
+ +
' . $line . '' . "\n"; + } elseif (preg_match('/warning|warn/i', $line)) { + echo '' . $line . '' . "\n"; + } elseif (preg_match('/info|notice/i', $line)) { + echo '' . $line . '' . "\n"; + } else { + echo $line . "\n"; + } + } + } + ?>
+
+ + + +