466 lines
23 KiB
PHP
466 lines
23 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../config/env.php';
|
|
require_once __DIR__ . '/../config/db.php';
|
|
require_once __DIR__ . '/../services/Settings.php';
|
|
|
|
// Merge DB settings over .env defaults (so env() always returns the latest)
|
|
(function () {
|
|
try {
|
|
$rows = db()->query("SELECT `key`, `value` FROM settings")->fetchAll();
|
|
foreach ($rows as $r) {
|
|
if ($r['value'] !== null && $r['value'] !== '') {
|
|
$_ENV[$r['key']] = $r['value'];
|
|
putenv("{$r['key']}={$r['value']}");
|
|
// Also set uppercase version for env() lookups
|
|
$upper = strtoupper($r['key']);
|
|
if ($upper !== $r['key']) {
|
|
$_ENV[$upper] = $r['value'];
|
|
putenv("{$upper}={$r['value']}");
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// DB not ready yet — fallback to .env only
|
|
}
|
|
})();
|
|
require_once __DIR__ . '/../admin/v1/WpWebhook.php';
|
|
require_once __DIR__ . '/../middleware/SessionAuth.php';
|
|
require_once __DIR__ . '/../admin/LoginController.php';
|
|
require_once __DIR__ . '/../admin/DashboardController.php';
|
|
require_once __DIR__ . '/../services/CompanyRepository.php';
|
|
require_once __DIR__ . '/../services/CompanyApiClient.php';
|
|
require_once __DIR__ . '/../services/WhatsAppSender.php';
|
|
require_once __DIR__ . '/../services/OutboundWorker.php';
|
|
require_once __DIR__ . '/../services/ErpSync.php';
|
|
require_once __DIR__ . '/../services/ConversationContext.php';
|
|
require_once __DIR__ . '/../services/NormalBot.php';
|
|
require_once __DIR__ . '/../services/AiBot.php';
|
|
require_once __DIR__ . '/../services/BotRouter.php';
|
|
require_once __DIR__ . '/../services/PendingApproval.php';
|
|
require_once __DIR__ . '/../services/ErpMonitor.php';
|
|
|
|
// ─── Helper de respuesta JSON ────────────────────────────────────────────────
|
|
function jsonResponse(int $status, array $body): void
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function serveHtml(string $filename): void
|
|
{
|
|
$file = __DIR__ . '/' . $filename;
|
|
if (!file_exists($file)) {
|
|
jsonResponse(404, ['error' => 'Página no encontrada']);
|
|
}
|
|
http_response_code(200);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
readfile($file);
|
|
exit;
|
|
}
|
|
|
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
|
// Normalizar: quitar trailing slash
|
|
$path = rtrim($path, '/') ?: '/';
|
|
|
|
// ─── Tabla de rutas ──────────────────────────────────────────────────────────
|
|
$routes = [
|
|
['GET', '/', fn() => (header('Location: /login') ?: exit())],
|
|
['GET', '/health', fn() => jsonResponse(200, ['status' => 'ok', 'service' => 'bot-palmas360'])],
|
|
|
|
// ─── WhatsApp webhook ────────────────────────────────────────────────────
|
|
// Seguridad: HMAC-SHA256 (X-Hub-Signature-256) verificado dentro del controlador
|
|
['GET', '/admin/v1/wp-webhook', fn() => WpWebhook::verify()],
|
|
['POST', '/admin/v1/wp-webhook', fn() => WpWebhook::receive()],
|
|
|
|
// ─── Login / Logout ──────────────────────────────────────────────────────
|
|
['GET', '/login', fn() => LoginController::showForm()],
|
|
['POST', '/login', fn() => LoginController::authenticate()],
|
|
['GET', '/logout', fn() => LoginController::logout()],
|
|
|
|
// ─── Dashboard protegido ─────────────────────────────────────────────────
|
|
['GET', '/admin/dashboard', fn() => DashboardController::index()],
|
|
['GET', '/admin/live', fn() => DashboardController::live()],
|
|
['GET', '/admin/webhook/stream', fn() => DashboardController::stream()],
|
|
['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()],
|
|
['GET', '/admin/chat', fn() => DashboardController::chat()],
|
|
['GET', '/admin/chat/conversations', fn() => DashboardController::chatConversations()],
|
|
['GET', '/admin/chat/messages', fn() => DashboardController::chatMessages()],
|
|
['POST', '/admin/chat/send', fn() => DashboardController::chatSend()],
|
|
|
|
// ─── Páginas legales (requeridas por Meta/WhatsApp Business) ────────────
|
|
['GET', '/politicas', fn() => serveHtml('politicas.html')],
|
|
['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')],
|
|
['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')],
|
|
|
|
// ─── API de envío (outbound) — autenticada por API Key de la empresa ────
|
|
['POST', '/api/send', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
$to = trim($input['to'] ?? '');
|
|
$type = $input['type'] ?? 'text';
|
|
if ($to === '') {
|
|
jsonResponse(400, ['error' => 'Campo "to" requerido']);
|
|
}
|
|
|
|
$payload = match ($type) {
|
|
'text' => ['text' => $input['text'] ?? ''],
|
|
'image' => ['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? ''],
|
|
'template' => ['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []],
|
|
'interactive' => ['interactive' => $input['interactive'] ?? []],
|
|
default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
|
|
};
|
|
|
|
$db = db();
|
|
$stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
|
|
$stmt->execute([$company['id'], $to, $type, json_encode($payload)]);
|
|
$queueId = (int)$db->lastInsertId();
|
|
|
|
jsonResponse(200, ['status' => 'queued', 'id' => $queueId]);
|
|
})()],
|
|
|
|
// ─── ERP: listar mensajes pendientes de aprobación ──────────────────────
|
|
['GET', '/api/pending', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
$items = PendingApproval::findByCompany((int)$company['id'], 'pending');
|
|
jsonResponse(200, ['pending' => $items, 'total' => count($items)]);
|
|
})()],
|
|
|
|
// ─── ERP: aprobar o rechazar un mensaje pendiente ───────────────────────
|
|
['POST', '/api/approval', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
|
|
$pendingId = (int)($input['pending_id'] ?? 0);
|
|
$action = $input['action'] ?? ''; // approve | reject
|
|
$note = $input['note'] ?? null;
|
|
|
|
if ($pendingId === 0) {
|
|
jsonResponse(400, ['error' => 'pending_id requerido']);
|
|
}
|
|
|
|
if ($action === 'approve') {
|
|
$item = PendingApproval::approve($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
|
|
if ($item === null) {
|
|
jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
|
|
}
|
|
jsonResponse(200, ['status' => 'approved', 'pending_id' => $pendingId, 'message' => 'Respuesta aprobada y encolada para envío']);
|
|
} elseif ($action === 'reject') {
|
|
$item = PendingApproval::reject($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
|
|
if ($item === null) {
|
|
jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
|
|
}
|
|
jsonResponse(200, ['status' => 'rejected', 'pending_id' => $pendingId, 'message' => 'Mensaje rechazado']);
|
|
} else {
|
|
jsonResponse(400, ['error' => "Acción no soportada: {$action}. Usa 'approve' o 'reject'"]);
|
|
}
|
|
})()],
|
|
|
|
// ─── ERP: enviar mensaje directo (outbound legacy) ──────────────────────
|
|
['POST', '/api/send-direct', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
$to = trim($input['to'] ?? '');
|
|
$type = $input['type'] ?? 'text';
|
|
if ($to === '') {
|
|
jsonResponse(400, ['error' => 'Campo "to" requerido']);
|
|
}
|
|
|
|
$payload = match ($type) {
|
|
'text' => json_encode(['text' => $input['text'] ?? '']),
|
|
'image' => json_encode(['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? '']),
|
|
'template' => json_encode(['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []]),
|
|
'interactive' => json_encode(['interactive' => $input['interactive'] ?? []]),
|
|
default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
|
|
};
|
|
|
|
$db = db();
|
|
$stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
|
|
$stmt->execute([$company['id'], $to, $type, $payload]);
|
|
jsonResponse(200, ['status' => 'queued', 'id' => (int)$db->lastInsertId()]);
|
|
})()],
|
|
|
|
// ─── Sincronizar empresas desde ERP (protegido) ─────────────────────────
|
|
['GET', '/admin/sync-companies', fn() => DashboardController::syncCompanies()],
|
|
|
|
// ─── Listar empresas (protegido) ────────────────────────────────────────
|
|
['GET', '/admin/companies', fn() => DashboardController::companies()],
|
|
|
|
// ─── Editar empresa (formulario) ───────────────────────────────────────
|
|
['GET', '/admin/company/edit', fn() => DashboardController::companyEdit()],
|
|
|
|
// ─── Guardar empresa (crear/actualizar) ─────────────────────────────────
|
|
['POST', '/admin/company/save', fn() => (function () {
|
|
SessionAuth::require();
|
|
$data = $_POST;
|
|
$id = CompanyRepository::save($data);
|
|
if ($id > 0) {
|
|
header('Location: /admin/companies?msg=' . ($data['id'] ?? 0 > 0 ? 'updated' : 'created'));
|
|
} else {
|
|
header('Location: /admin/companies?msg=error');
|
|
}
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Eliminar empresa ──────────────────────────────────────────────────
|
|
['GET', '/admin/company/delete', fn() => (function () {
|
|
SessionAuth::require();
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
if ($id > 0 && CompanyRepository::delete($id)) {
|
|
header('Location: /admin/companies?msg=deleted');
|
|
} else {
|
|
header('Location: /admin/companies?msg=error');
|
|
}
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Procesar cola outbound (protegido o cron) ─────────────────────────
|
|
['GET', '/admin/process-queue', fn() => DashboardController::processQueue()],
|
|
|
|
// ─── Admin: listar pendientes de aprobación ─────────────────────────────
|
|
['GET', '/admin/pending', fn() => DashboardController::pending()],
|
|
|
|
// ─── Admin: aprobar pendiente ───────────────────────────────────────────
|
|
['POST', '/admin/pending-approve', fn() => (function () {
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = (int)($input['id'] ?? 0);
|
|
if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']);
|
|
$item = PendingApproval::approve($id, $user['name'] ?? 'Admin', $input['note'] ?? null);
|
|
if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']);
|
|
jsonResponse(200, ['status' => 'approved', 'message' => 'Respuesta aprobada y encolada']);
|
|
})()],
|
|
|
|
// ─── Admin: rechazar pendiente ──────────────────────────────────────────
|
|
['POST', '/admin/pending-reject', fn() => (function () {
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$id = (int)($input['id'] ?? 0);
|
|
if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']);
|
|
$item = PendingApproval::reject($id, $user['name'] ?? 'Admin', $input['note'] ?? null);
|
|
if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']);
|
|
jsonResponse(200, ['status' => 'rejected', 'message' => 'Mensaje rechazado']);
|
|
})()],
|
|
|
|
// ─── Admin: configurador visual del bot ────────────────────────────────
|
|
['GET', '/admin/bot-config', fn() => DashboardController::botConfig()],
|
|
|
|
['POST', '/admin/bot-config/save', fn() => (function () {
|
|
SessionAuth::require();
|
|
$companyId = (int)($_POST['company_id'] ?? 0);
|
|
if ($companyId === 0) { header('Location: /admin/bot-config?msg=error'); exit; }
|
|
|
|
$config = [];
|
|
// Commands
|
|
$keywords = $_POST['cmd_keyword'] ?? [];
|
|
$actions = $_POST['cmd_action'] ?? [];
|
|
$commands = [];
|
|
foreach ($keywords as $i => $kw) {
|
|
$kw = trim($kw);
|
|
$act = trim($actions[$i] ?? '');
|
|
if ($kw !== '' && $act !== '') $commands[$kw] = $act;
|
|
}
|
|
if (!empty($commands)) $config['commands'] = $commands;
|
|
|
|
// Menus
|
|
$menuKeys = $_POST['menu_key'] ?? [];
|
|
$menuTypes = $_POST['menu_type'] ?? [];
|
|
$menuHeaders = $_POST['menu_header'] ?? [];
|
|
$menuBodies = $_POST['menu_body'] ?? [];
|
|
$menuFooters = $_POST['menu_footer'] ?? [];
|
|
$menuButtons = $_POST['menu_button'] ?? [];
|
|
$menuSections = $_POST['menu_sections'] ?? [];
|
|
$menus = [];
|
|
foreach ($menuKeys as $i => $mk) {
|
|
$mk = trim($mk);
|
|
if ($mk === '') continue;
|
|
$sections = [];
|
|
if (!empty($menuSections[$i])) {
|
|
foreach ($menuSections[$i] as $si => $section) {
|
|
$title = trim($section['title'] ?? '');
|
|
if ($title === '') continue;
|
|
$rows = [];
|
|
foreach ($section['rows'] ?? [] as $row) {
|
|
$rid = trim($row['id'] ?? '');
|
|
$rtitle = trim($row['title'] ?? '');
|
|
if ($rid === '' || $rtitle === '') continue;
|
|
$rows[] = ['id' => $rid, 'title' => $rtitle, 'description' => trim($row['description'] ?? '')];
|
|
}
|
|
$sections[] = ['title' => $title, 'rows' => $rows];
|
|
}
|
|
}
|
|
$menus[$mk] = [
|
|
'type' => $menuTypes[$i] ?? 'list',
|
|
'header' => trim($menuHeaders[$i] ?? ''),
|
|
'body' => trim($menuBodies[$i] ?? ''),
|
|
'footer' => trim($menuFooters[$i] ?? ''),
|
|
'button' => trim($menuButtons[$i] ?? ''),
|
|
'sections' => $sections,
|
|
];
|
|
}
|
|
if (!empty($menus)) $config['menus'] = $menus;
|
|
|
|
// Flows
|
|
$flowKeys = $_POST['flow_key'] ?? [];
|
|
$flowTypes = $_POST['flow_type'] ?? [];
|
|
$flowMessages = $_POST['flow_message'] ?? [];
|
|
$flowFunctions = $_POST['flow_function'] ?? [];
|
|
$flowMenus = $_POST['flow_menu'] ?? [];
|
|
$flows = [];
|
|
foreach ($flowKeys as $i => $fk) {
|
|
$fk = trim($fk);
|
|
if ($fk === '') continue;
|
|
$ftype = $flowTypes[$i] ?? 'text';
|
|
$f = ['type' => $ftype];
|
|
if ($ftype === 'text') $f['message'] = trim($flowMessages[$i] ?? '');
|
|
elseif ($ftype === 'function') $f['function'] = trim($flowFunctions[$i] ?? 'forward_to_ai');
|
|
elseif ($ftype === 'menu') $f['menu'] = trim($flowMenus[$i] ?? '');
|
|
$flows[$fk] = $f;
|
|
}
|
|
if (!empty($flows)) $config['flows'] = $flows;
|
|
|
|
// AI
|
|
$aiPrompt = trim($_POST['ai_prompt'] ?? '');
|
|
if ($aiPrompt !== '') $config['ai_prompt'] = $aiPrompt;
|
|
$aiModel = trim($_POST['ai_model'] ?? '');
|
|
if ($aiModel !== '') $config['ai_model'] = $aiModel;
|
|
$aiTemp = trim($_POST['ai_temperature'] ?? '');
|
|
if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp;
|
|
$aiProvider = trim($_POST['ai_provider'] ?? '');
|
|
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider;
|
|
|
|
// General
|
|
$greeting = trim($_POST['greeting'] ?? '');
|
|
if ($greeting !== '') $config['greeting'] = $greeting;
|
|
$fallback = trim($_POST['fallback'] ?? '');
|
|
if ($fallback !== '') $config['fallback'] = $fallback;
|
|
$ignorePrefixes = trim($_POST['ignore_prefixes'] ?? '');
|
|
if ($ignorePrefixes !== '') {
|
|
$config['ignore_prefixes'] = array_map('trim', explode("\n", $ignorePrefixes));
|
|
}
|
|
$approvalWebhook = trim($_POST['approval_webhook'] ?? '');
|
|
if ($approvalWebhook !== '') $config['approval_webhook'] = $approvalWebhook;
|
|
|
|
CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]);
|
|
header('Location: /admin/bot-config?id=' . $companyId . '&msg=saved');
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Admin: configuración general ──────────────────────────────────────
|
|
['GET', '/admin/settings', fn() => DashboardController::settings()],
|
|
|
|
['POST', '/admin/settings/save', fn() => (function () {
|
|
SessionAuth::require();
|
|
$allowed = [
|
|
'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token',
|
|
'whatsapp_business_account_id', 'whatsapp_default_phone_number_id',
|
|
'ai_provider', 'openai_api_key', 'openai_model', 'ai_max_tokens', 'ai_default_prompt',
|
|
'verify_hmac_signature',
|
|
];
|
|
// Checkbox: si no está presente, desactivar
|
|
if (!isset($_POST['verify_hmac_signature'])) {
|
|
$_POST['verify_hmac_signature'] = '0';
|
|
}
|
|
$pairs = [];
|
|
foreach ($allowed as $k) {
|
|
if (isset($_POST[$k])) {
|
|
$pairs[$k] = trim($_POST[$k]);
|
|
}
|
|
}
|
|
Settings::setMany($pairs);
|
|
// Force re-read into $_ENV
|
|
foreach ($pairs as $k => $v) {
|
|
$_ENV[$k] = $v;
|
|
putenv("{$k}={$v}");
|
|
$upper = strtoupper($k);
|
|
if ($upper !== $k) {
|
|
$_ENV[$upper] = $v;
|
|
putenv("{$upper}={$v}");
|
|
}
|
|
}
|
|
header('Location: /admin/settings?msg=saved');
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Debug: captura cruda de webhooks (loguea todo sin validar) ───────
|
|
['GET', '/admin/v1/wp-webhook-debug', fn() => (function () {
|
|
$log = '[' . date('Y-m-d H:i:s') . "] GET " . ($_SERVER['QUERY_STRING'] ?? '') . "\n";
|
|
file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND);
|
|
jsonResponse(200, ['logged' => true, 'query' => $_GET]);
|
|
})()],
|
|
['POST', '/admin/v1/wp-webhook-debug', fn() => (function () {
|
|
$raw = file_get_contents('php://input');
|
|
$headers = getallheaders();
|
|
$log = '[' . date('Y-m-d H:i:s') . "] POST\n";
|
|
$log .= "Headers: " . json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
|
$log .= "Body: {$raw}\n";
|
|
$log .= str_repeat('-', 60) . "\n";
|
|
file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND);
|
|
$decoded = json_decode($raw, true);
|
|
jsonResponse(200, ['logged' => true, 'headers' => $headers, 'body' => $decoded ?? $raw]);
|
|
})()],
|
|
|
|
// ─── Admin: estado de salud de los ERP ──────────────────────────────────
|
|
['GET', '/admin/erp-health', fn() => (function () {
|
|
SessionAuth::require();
|
|
$result = ErpMonitor::summary();
|
|
jsonResponse(200, $result);
|
|
})()],
|
|
];
|
|
|
|
// ─── Despacho ────────────────────────────────────────────────────────────────
|
|
foreach ($routes as [$routeMethod, $routePath, $handler]) {
|
|
if ($method === $routeMethod && $path === $routePath) {
|
|
$handler();
|
|
exit;
|
|
}
|
|
}
|
|
|
|
jsonResponse(404, ['error' => 'Ruta no encontrada', 'path' => $path]);
|