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'])) {