From 2c0ac049930bf212f2e9247ca24e27c52604a60d Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:04:05 -0500 Subject: [PATCH] fix(flows): CSS display conflict, addFlowCard, endpoint CRUD, menu re-send, URL vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix display:none vs display:flex conflict in PHP flow card rendering (sections were always visible) - Add addFlowCard() JS function that properly inserts new flow cards into the PHP form - Fix flowTabChange() to use block vs flex display correctly per section type - Rewrite endpoints tab as full CRUD table (add/edit/delete) with name, direction, active toggle - Add companyEndpointDelete() handler and route - Update companyEndpointSave() to handle ep_id, name, params, is_active fields - Add company_endpoints columns: name, params - Add saveEpRow/testEpRow/deleteEpRow/addNewEp JS functions - NormalBot: set __greeted sentinel after showing greeting menu to prevent re-send on every message - NormalBot: add substituteUrlVars() to replace {param} tokens in endpoint URLs with collected metadata - DB: submenu_ciclos_sanidad (4 items) → list; prod_kilos_finca endpoint linked; all >3-button menus → list - DB: fincas_list endpoint activated Co-Authored-By: Claude Sonnet 4.6 --- admin/DashboardController.php | 390 +++++++++++++++++++++++++++------- public/index.php | 5 +- services/NormalBot.php | 35 ++- 3 files changed, 346 insertions(+), 84 deletions(-) diff --git a/admin/DashboardController.php b/admin/DashboardController.php index ffccc2d..0b8fa97 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -1094,42 +1094,117 @@ HTML; HTML; - // Endpoints - $epRows = db()->prepare("SELECT endpoint_key, url, method, last_response, last_called_at FROM company_endpoints WHERE company_id=?"); - $epRows->execute([$id]); - $epMap = []; - foreach ($epRows->fetchAll(\PDO::FETCH_ASSOC) as $r) $epMap[$r['endpoint_key']] = $r; + // Endpoints — CRUD dinámico + $epStmt = db()->prepare("SELECT id,endpoint_key,name,params,direction,url,method,is_active,last_called_at FROM company_endpoints WHERE company_id=? ORDER BY direction,endpoint_key"); + $epStmt->execute([$id]); + $allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC); - $catalog = self::endpointCatalog(); - $renderEpSection = function(string $dir, string $dirLabel, string $badge) use ($catalog, $epMap, $id): string { - $html = "

{$badge} {$dirLabel}

"; - foreach ($catalog[$dir] as $key => $label) { - $saved = $epMap[$key] ?? []; - $url = self::h($saved['url'] ?? ''); - $meth = $saved['method'] ?? 'GET'; - $lastAt = $saved['last_called_at'] ? '' . self::h($saved['last_called_at']) . '' : ''; - $mSel = fn($v) => $meth === $v ? 'selected' : ''; - $html .= << -
{$label} {$lastAt}
-
- + + + + + + + + + - - - + + + +
Usa {'{var}'} para variables. {$lastAt}
+ + + + + + + + + +ROW; + } + if (!$epRows) $epRows = 'Sin endpoints. Agrega uno abajo.'; + + $epHtml = <<🔌 Endpoints API
+

+ Define los endpoints del ERP. Usa {'{variable}'} en la URL para variables que el bot pedirá al usuario.
+ Ejemplo: ?peticion=produccion&desde={'{desde}'}&hasta={'{hasta}'} +

+
+ + + + + + + {$epRows} +
KeyNombreDirecciónMétodoURLActivo
+
+
+ +
+
+ Agregar endpoint
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
-
EP; - } - return $html; - }; - - $epHtml = $renderEpSection('upload', 'Subir información (WhatsApp → ERP)', '↑ Upload') - . '
' - . $renderEpSection('download', 'Bajar información (ERP → WhatsApp)', '↓ Download'); } $tabsHtml = $isEdit ? <<✓ Guardado' : ''+j.error+''; - setTimeout(()=>box.style.display='none', 2000); + msg.innerHTML=j.ok + ? '
✓ Guardado
' + : '
'+j.error+'
'; + setTimeout(()=>msg.innerHTML='', 2000); } -async function testEp(cid, key) { - const url=document.getElementById('u_'+key).value.trim(); - const meth=document.getElementById('m_'+key).value; - const box=document.getElementById('ep_resp_'+key); - box.style.display=''; box.innerHTML='Probando...'; +async function testEpRow(cid, epId) { + const url=document.getElementById('epu_'+epId).value.trim(); + const meth=document.getElementById('epm_'+epId).value; + const respRow=document.getElementById('ep-resp-'+epId); + const respCell=respRow.querySelector('td'); + respRow.style.display=''; respCell.textContent='Probando...'; const fd=new FormData(); - fd.append('company_id',cid); fd.append('endpoint_key',key); + fd.append('company_id',cid); fd.append('ep_id',epId); + fd.append('endpoint_key', document.getElementById('epk_'+epId).value.trim()); fd.append('url',url); fd.append('method',meth); const r=await fetch('/admin/company/endpoint/test',{method:'POST',body:fd}); const j=await r.json(); if (j.ok) { - const preview=JSON.stringify(j.response,null,2).substring(0,800); - box.innerHTML='
'+preview+'
'; + respCell.innerHTML='
'+JSON.stringify(j.response,null,2).substring(0,1000)+'
'; } else { - box.innerHTML='
'+j.error+'
'; + respCell.innerHTML=''+j.error+''; + } +} + +async function deleteEpRow(cid, epId) { + if (!confirm('¿Eliminar este endpoint?')) return; + const fd=new FormData(); fd.append('company_id',cid); fd.append('ep_id',epId); + const r=await fetch('/admin/company/endpoint/delete',{method:'POST',body:fd}); + const j=await r.json(); + if (j.ok) { + document.getElementById('ep-row-'+epId)?.remove(); + document.getElementById('ep-resp-'+epId)?.remove(); + } else alert(j.error); +} + +async function addNewEp(cid) { + const key = document.getElementById('newEpKey').value.trim(); + const name = document.getElementById('newEpName').value.trim(); + const dir = document.getElementById('newEpDir').value; + const meth = document.getElementById('newEpMethod').value; + const url = document.getElementById('newEpUrl').value.trim(); + const msg = document.getElementById('newEpMsg'); + if (!key || !url) { msg.innerHTML='
Key y URL son requeridos
'; return; } + const fd=new FormData(); + fd.append('company_id',cid); fd.append('endpoint_key',key); fd.append('name',name); + fd.append('direction',dir); fd.append('method',meth); fd.append('url',url); fd.append('is_active','1'); + const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd}); + const j=await r.json(); + if (j.ok) { + msg.innerHTML='
✓ Agregado
'; + setTimeout(()=>location.reload(), 800); + } else { + msg.innerHTML='
'+j.error+'
'; } } @@ -1512,20 +1625,45 @@ HTML; SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); - $companyId = (int)($_POST['company_id'] ?? 0); - $key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? ''); - $direction = in_array($_POST['direction'] ?? '', ['upload','download']) ? $_POST['direction'] : null; - $url = trim($_POST['url'] ?? ''); - $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; + $companyId = (int)($_POST['company_id'] ?? 0); + $epId = (int)($_POST['ep_id'] ?? 0); + $key = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['endpoint_key'] ?? '')); + $name = trim($_POST['name'] ?? ''); + $direction = in_array($_POST['direction'] ?? '', ['upload','download','list']) ? $_POST['direction'] : 'download'; + $url = trim($_POST['url'] ?? ''); + $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; + $isActive = ($_POST['is_active'] ?? '0') === '1' ? 1 : 0; + $params = trim($_POST['params'] ?? ''); - if ($companyId <= 0 || $key === '' || $direction === null) { - echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); - exit; + if ($companyId <= 0 || $key === '') { + echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } try { - db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)") - ->execute([$companyId, $key, $direction, $url, $method]); + if ($epId > 0) { + db()->prepare("UPDATE company_endpoints SET endpoint_key=?,name=?,direction=?,url=?,method=?,is_active=?,params=? WHERE id=? AND company_id=?") + ->execute([$key, $name, $direction, $url, $method, $isActive, $params ?: null, $epId, $companyId]); + } else { + db()->prepare("INSERT INTO company_endpoints (company_id,endpoint_key,name,direction,url,method,is_active,params) VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),direction=VALUES(direction),url=VALUES(url),method=VALUES(method),is_active=VALUES(is_active),params=VALUES(params)") + ->execute([$companyId, $key, $name, $direction, $url, $method, $isActive, $params ?: null]); + } + echo json_encode(['ok' => true]); + } catch (\PDOException $e) { + echo json_encode(['ok' => false, 'error' => $e->getMessage()]); + } + exit; + } + + // ─── POST /admin/company/endpoint/delete ──────────────────────────────── + public static function companyEndpointDelete(): void + { + SessionAuth::require(); + header('Content-Type: application/json; charset=utf-8'); + $companyId = (int)($_POST['company_id'] ?? 0); + $epId = (int)($_POST['ep_id'] ?? 0); + if ($companyId <= 0 || $epId <= 0) { echo json_encode(['ok'=>false,'error'=>'Datos inválidos']); exit; } + try { + db()->prepare("DELETE FROM company_endpoints WHERE id=? AND company_id=?")->execute([$epId, $companyId]); echo json_encode(['ok' => true]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => $e->getMessage()]); @@ -2608,10 +2746,18 @@ HTML; $showText = $ft === 'text' ? '' : 'display:none'; $showMenu = $ft === 'menu' ? '' : 'display:none'; $showFn = $ft === 'function' ? '' : 'display:none'; - $showEp = ($ft === 'function' && $fFn === 'api_report') ? '' : 'display:none'; - $showCollect = $ft === 'collect_input' ? '' : 'display:none'; - $showDynList = $ft === 'dynamic_list' ? '' : 'display:none'; - $showSubmit = $ft === 'submit_form' ? '' : 'display:none'; + $showEp = ($ft === 'function' && $fFn === 'api_report') + ? 'display:flex;flex-direction:column;gap:8px;margin-top:8px' + : 'display:none'; + $showCollect = $ft === 'collect_input' + ? 'display:flex;flex-direction:column;gap:8px' + : 'display:none'; + $showDynList = $ft === 'dynamic_list' + ? 'display:flex;flex-direction:column;gap:8px' + : 'display:none'; + $showSubmit = $ft === 'submit_form' + ? 'display:flex;flex-direction:column;gap:8px' + : 'display:none'; $fnOpts = ''; foreach (['goodbye' => '↩️ Salir (goodbye)', 'api_report' => '📄 Descargar informe', 'forward_to_ai' => '🤖 Escalar a IA', 'forward_to_agent' => '👤 Escalar a agente'] as $fv => $fl) { @@ -2665,7 +2811,7 @@ HTML;
-
+
@@ -2676,7 +2822,7 @@ HTML;
-
+
@@ -2686,7 +2832,7 @@ HTML;
-
+
@@ -2705,7 +2851,7 @@ HTML;
-
+
@@ -2721,7 +2867,7 @@ HTML; } echo << - +
@@ -2935,13 +3081,18 @@ function addRow(btn, mi, si) { function flowTabChange(sel) { const card = sel.closest('.item-card'); const v = sel.value; - const map = { - 'ftg-text': v==='text', 'ftg-menu': v==='menu', 'ftg-fn': v==='function', - 'ftg-collect': v==='collect_input', 'ftg-dynlist': v==='dynamic_list', 'ftg-submit': v==='submit_form' + // flex sections need display:flex; block sections use '' (default block) + const sections = { + 'ftg-text': {show: v==='text', flex: false}, + 'ftg-menu': {show: v==='menu', flex: false}, + 'ftg-fn': {show: v==='function', flex: false}, + 'ftg-collect': {show: v==='collect_input', flex: true}, + 'ftg-dynlist': {show: v==='dynamic_list', flex: true}, + 'ftg-submit': {show: v==='submit_form', flex: true}, }; - for (const [cls, show] of Object.entries(map)) { + for (const [cls, cfg] of Object.entries(sections)) { const el = card.querySelector('.' + cls); - if (el) el.style.display = show ? 'flex' : 'none'; + if (el) el.style.display = cfg.show ? (cfg.flex ? 'flex' : '') : 'none'; } if (v !== 'function') { const ep = card.querySelector('.ftg-ep'); @@ -2955,6 +3106,101 @@ function flowFnTabChange(sel) { if (ep) ep.style.display = sel.value === 'api_report' ? 'flex' : 'none'; } +function addFlowCard() { + const container = document.getElementById('flowContainer'); + if (!container) return; + + const menus = CONFIG?.menus || {}; + let menuOpts = ''; + for (const k of Object.keys(menus)) menuOpts += ``; + + const eps = ENDPOINTS || {}; + let epOpts = ''; + for (const [k, ep] of Object.entries(eps)) epOpts += ``; + + const flows = CONFIG?.flows || {}; + let nnOpts = ''; + for (const k of Object.keys(flows)) nnOpts += ``; + + container.insertAdjacentHTML('beforeend', ` +
+
+ ID: + +
+
+
+ + +
+
+
+ + + + + +
+
+
`); + container.lastElementChild.scrollIntoView({behavior:'smooth'}); +} + function updatePreview() { const g = document.getElementById('prevGreeting')?.value || ''; const f = document.getElementById('prevFallback')?.value || ''; diff --git a/public/index.php b/public/index.php index 8cfa247..f497b97 100644 --- a/public/index.php +++ b/public/index.php @@ -274,8 +274,9 @@ $routes = [ ['POST', '/admin/company/phones/sync', fn() => DashboardController::companyPhonesSync()], // ─── Endpoints API por empresa ───────────────────────────────────────── - ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()], - ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()], + ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()], + ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()], + ['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()], // ─── Admin: listar pendientes de aprobación ───────────────────────────── ['GET', '/admin/pending-list', fn() => DashboardController::pendingList()], diff --git a/services/NormalBot.php b/services/NormalBot.php index 2af5602..ad9237c 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -41,14 +41,15 @@ class NormalBot } // 3. Active node — resume conversation - if ($currentNode !== null && $currentNode !== 'collecting' && isset($flows[$currentNode])) { + $sentinels = ['collecting', '__greeted']; + if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus); } - // 4. Per-type greeting menu (new user / no active context) + // 4. Per-type greeting menu — only for brand-new sessions (currentNode === null) $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) { - ConversationContext::updateNode($ctxId, null); + ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); } @@ -72,9 +73,9 @@ class NormalBot return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus); } - // 7. Greeting menu as fallback + // 7. Greeting menu as fallback (texto no reconocido después de estar en __greeted) if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) { - ConversationContext::updateNode($ctxId, null); + ConversationContext::updateNode($ctxId, '__greeted'); return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); } @@ -133,7 +134,8 @@ class NormalBot private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array { - $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company); + $meta = ConversationContext::getMetadata($ctxId); + $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta); if ($items === null || count($items) === 0) { ConversationContext::reset($ctxId); @@ -143,7 +145,6 @@ class NormalBot ); } - $meta = ConversationContext::getMetadata($ctxId); $meta['collecting'] = [ 'meta_group' => $flow['meta_group'] ?? '', 'meta_key' => $flow['meta_key'] ?? '', @@ -155,7 +156,19 @@ class NormalBot return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } - private static function fetchDynamicList(string $endpointKey, array $company): ?array + // Reemplaza {variable} en la URL con valores del metadata acumulado + private static function substituteUrlVars(string $url, array $meta): string + { + foreach ($meta as $group => $fields) { + if (!is_array($fields)) continue; + foreach ($fields as $key => $value) { + $url = str_replace('{' . $key . '}', urlencode((string)$value), $url); + } + } + return $url; + } + + private static function fetchDynamicList(string $endpointKey, array $company, array $meta = []): ?array { if ($endpointKey === '') return null; @@ -167,7 +180,8 @@ class NormalBot if (!$ep || empty($ep['url'])) return null; $apiKey = $company['api_key'] ?? ''; - $ch = curl_init($ep['url']); + $url = self::substituteUrlVars($ep['url'], $meta); + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, @@ -307,7 +321,8 @@ class NormalBot return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company); } - $url = $ep['url']; + $meta = ConversationContext::getMetadata($ctxId); + $url = self::substituteUrlVars($ep['url'], $meta); // reemplaza {var} con valores del formulario $method = strtoupper($ep['method'] ?? 'GET'); $apiKey = $company['api_key'] ?? '';