From db469b2134c98e78bc3c36c62013e2b48444a226 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:42:51 -0500 Subject: [PATCH] up --- README_LAB.md | 129 ++ api/export_conversations.php | 123 ++ api/get_conversation_list.php | 6 +- api/get_terms_acceptances.php | 114 ++ api/get_terms_config.php | 30 + api/lab/_helpers.php | 131 ++ api/lab/autorizar_orden.php | 46 + api/lab/crear_desde_whatsapp.php | 110 ++ api/lab/create_enfermero_user.php | 92 ++ api/lab/delete_lab_user.php | 26 + api/lab/delete_role.php | 28 + api/lab/get_actividad.php | 25 + api/lab/get_asignaciones.php | 21 + api/lab/get_config.php | 17 + api/lab/get_domicilios.php | 41 + api/lab/get_enfermeras.php | 47 + api/lab/get_formularios.php | 39 + api/lab/get_lab_users.php | 41 + api/lab/get_ordenes.php | 39 + api/lab/get_pacientes.php | 16 + api/lab/get_roles.php | 28 + api/lab/get_stats.php | 64 + api/lab/my_agenda.php | 64 + api/lab/save_asignacion.php | 49 + api/lab/save_config.php | 32 + api/lab/save_domicilio.php | 76 ++ api/lab/save_enfermera.php | 40 + api/lab/save_formulario.php | 44 + api/lab/save_lab_user.php | 87 ++ api/lab/save_orden.php | 33 + api/lab/save_paciente.php | 34 + api/lab/save_role.php | 67 + api/lab/save_servicio_extra.php | 82 ++ api/lab/send_formulario.php | 86 ++ api/lab/submit_formulario.php | 131 ++ api/lab/update_domicilio_enfermero.php | 94 ++ api/list_admin_users.php | 27 +- api/upload_terms_document.php | 147 +++ api/version/media-url_debug.log | 338 +++++ assets/js/app_simple.js | 176 ++- auth_protect.php | 6 + classes/lab/ActividadAdmin.php | 132 ++ classes/lab/Asignacion.php | 158 +++ classes/lab/Domicilio.php | 261 ++++ classes/lab/Enfermera.php | 146 +++ classes/lab/Formulario.php | 204 +++ classes/lab/OrdenMedica.php | 316 +++++ classes/lab/Paciente.php | 215 ++++ config/config.php | 116 +- conversations.php | 940 +++++++++++++- cronograma.xlsx | Bin 0 -> 11343 bytes database/04_add_domicilio_cobro_fields.sql | 30 + database/05_terms_acceptance.sql | 49 + database/06_master_sync.sql | 204 +++ database/README.md | 658 ++++++++++ db_compare.php | 92 ++ db_diff.php | 34 + docker-compose.dev.yml | 7 +- enfermero_portal.php | 988 ++++++++++++++ form_cliente.php | 831 ++++++++++++ index.php | 618 +++++---- lab_configuracion.php | 332 +++++ lab_dashboard.php | 310 +++++ lab_domicilios.php | 516 ++++++++ lab_enfermeras.php | 429 +++++++ lab_formulario_builder.php | 1134 +++++++++++++++++ lab_formularios.php | 820 ++++++++++++ lab_ordenes.php | 449 +++++++ lab_pacientes.php | 376 ++++++ lab_reportes.php | 351 +++++ lab_status.php | 154 +++ lab_terminos.php | 271 ++++ lab_terminos_export.php | 77 ++ lab_usuarios.php | 554 ++++++++ login.php | 11 +- logs/.migrations_applied | 3 + ...iento informado pruebas de laboratorio.pdf | Bin 0 -> 145170 bytes logs/system.log | 9 + migrations/20260302_lab_01_pacientes.sql | 34 + migrations/20260302_lab_02_enfermeras.sql | 24 + .../20260302_lab_03_ordenes_medicas.sql | 56 + migrations/20260302_lab_04_domicilios.sql | 53 + migrations/20260302_lab_05_asignaciones.sql | 33 + migrations/20260302_lab_06_autorizaciones.sql | 34 + .../20260302_lab_07_actividad_admin.sql | 27 + .../20260302_lab_08_roles_enfermeros.sql | 47 + migrations/20260302_lab_09_formularios.sql | 63 + migrations/20260302_lab_run_migrations.php | 113 ++ .../20260303_lab_10_domicilio_extras.sql | 14 + .../20260303_templates_add_content_cols.sql | 25 + .../20260304_lab_config_doc_headers.php | 50 + migrations/20260304_roles_and_modules.sql | 75 ++ run_roles_migration.php | 81 ++ scripts/generar_cronograma.py | 352 +++++ services/BotService.php | 205 +++ ver_formulario_enviado.php | 400 ++++++ verificar_formulario.php | 186 +++ view_logs.php | 175 +++ 98 files changed, 16365 insertions(+), 303 deletions(-) create mode 100644 README_LAB.md create mode 100644 api/export_conversations.php create mode 100644 api/get_terms_acceptances.php create mode 100644 api/get_terms_config.php create mode 100644 api/lab/_helpers.php create mode 100644 api/lab/autorizar_orden.php create mode 100644 api/lab/crear_desde_whatsapp.php create mode 100644 api/lab/create_enfermero_user.php create mode 100644 api/lab/delete_lab_user.php create mode 100644 api/lab/delete_role.php create mode 100644 api/lab/get_actividad.php create mode 100644 api/lab/get_asignaciones.php create mode 100644 api/lab/get_config.php create mode 100644 api/lab/get_domicilios.php create mode 100644 api/lab/get_enfermeras.php create mode 100644 api/lab/get_formularios.php create mode 100644 api/lab/get_lab_users.php create mode 100644 api/lab/get_ordenes.php create mode 100644 api/lab/get_pacientes.php create mode 100644 api/lab/get_roles.php create mode 100644 api/lab/get_stats.php create mode 100644 api/lab/my_agenda.php create mode 100644 api/lab/save_asignacion.php create mode 100644 api/lab/save_config.php create mode 100644 api/lab/save_domicilio.php create mode 100644 api/lab/save_enfermera.php create mode 100644 api/lab/save_formulario.php create mode 100644 api/lab/save_lab_user.php create mode 100644 api/lab/save_orden.php create mode 100644 api/lab/save_paciente.php create mode 100644 api/lab/save_role.php create mode 100644 api/lab/save_servicio_extra.php create mode 100644 api/lab/send_formulario.php create mode 100644 api/lab/submit_formulario.php create mode 100644 api/lab/update_domicilio_enfermero.php create mode 100644 api/upload_terms_document.php create mode 100644 classes/lab/ActividadAdmin.php create mode 100644 classes/lab/Asignacion.php create mode 100644 classes/lab/Domicilio.php create mode 100644 classes/lab/Enfermera.php create mode 100644 classes/lab/Formulario.php create mode 100644 classes/lab/OrdenMedica.php create mode 100644 classes/lab/Paciente.php create mode 100644 cronograma.xlsx create mode 100644 database/04_add_domicilio_cobro_fields.sql create mode 100644 database/05_terms_acceptance.sql create mode 100644 database/06_master_sync.sql create mode 100644 database/README.md create mode 100644 db_compare.php create mode 100644 db_diff.php create mode 100644 enfermero_portal.php create mode 100644 form_cliente.php create mode 100644 lab_configuracion.php create mode 100644 lab_dashboard.php create mode 100644 lab_domicilios.php create mode 100644 lab_enfermeras.php create mode 100644 lab_formulario_builder.php create mode 100644 lab_formularios.php create mode 100644 lab_ordenes.php create mode 100644 lab_pacientes.php create mode 100644 lab_reportes.php create mode 100644 lab_status.php create mode 100644 lab_terminos.php create mode 100644 lab_terminos_export.php create mode 100644 lab_usuarios.php create mode 100755 logs/F - LAB - 01 Consentimiento informado pruebas de laboratorio.pdf create mode 100644 migrations/20260302_lab_01_pacientes.sql create mode 100644 migrations/20260302_lab_02_enfermeras.sql create mode 100644 migrations/20260302_lab_03_ordenes_medicas.sql create mode 100644 migrations/20260302_lab_04_domicilios.sql create mode 100644 migrations/20260302_lab_05_asignaciones.sql create mode 100644 migrations/20260302_lab_06_autorizaciones.sql create mode 100644 migrations/20260302_lab_07_actividad_admin.sql create mode 100644 migrations/20260302_lab_08_roles_enfermeros.sql create mode 100644 migrations/20260302_lab_09_formularios.sql create mode 100644 migrations/20260302_lab_run_migrations.php create mode 100644 migrations/20260303_lab_10_domicilio_extras.sql create mode 100644 migrations/20260303_templates_add_content_cols.sql create mode 100644 migrations/20260304_lab_config_doc_headers.php create mode 100644 migrations/20260304_roles_and_modules.sql create mode 100644 run_roles_migration.php create mode 100644 scripts/generar_cronograma.py create mode 100644 ver_formulario_enviado.php create mode 100644 verificar_formulario.php create mode 100644 view_logs.php 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 0000000000000000000000000000000000000000..50eb73aa2ca0751d3fbe6065077147bbe0aaa887 GIT binary patch literal 11343 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 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 0000000000000000000000000000000000000000..a38edd815038e2be298af9a3ca117d6be953637c GIT binary patch literal 145170 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

k9h|4>EMi?TbVz_YgPUYgXSbit(HD8FKL{D|58~ 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�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 MM8w7>AumbvKf}9f%m4rY literal 0 HcmV?d00001 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"; + } + } + } + ?>
+
+ + + +