feat: auto-detect collect_and_post fields from endpoint body_fields

Instead of defining fields inline per flow, the admin now defines them
once on the endpoint (direction=upload). The bot reads body_fields from
company_endpoints at runtime, so the same endpoint can be reused by
multiple collect_and_post flows without duplicating field config.

DB: ALTER TABLE company_endpoints ADD COLUMN body_fields TEXT DEFAULT NULL

Changes:
- NormalBot: falls back to endpoint.body_fields when flow.fields is empty
- DashboardController: endpoint rows show a body_fields section for
  upload endpoints (key / label / prompt per field, add/remove rows)
- companyEndpointSave: reads + validates body_fields JSON and persists it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-29 11:10:37 -05:00
co-authored by Claude Sonnet 4.6
parent 3c7ea9fdf9
commit 21d30a0895
2 changed files with 99 additions and 21 deletions
+84 -20
View File
@@ -1109,26 +1109,39 @@ HTML;
HTML;
// 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 = db()->prepare("SELECT id,endpoint_key,name,params,direction,url,method,is_active,last_called_at,body_fields FROM company_endpoints WHERE company_id=? ORDER BY direction,endpoint_key");
$epStmt->execute([$id]);
$allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC);
$epRows = '';
foreach ($allEps as $ep) {
$epKey = self::h($ep['endpoint_key']);
$epName = self::h($ep['name'] ?? '');
$epUrl = self::h($ep['url'] ?? '');
$epMeth = $ep['method'] ?? 'GET';
$epDir = $ep['direction'] ?? 'download';
$epParams= self::h($ep['params'] ?? '');
$epAct = $ep['is_active'] ? 'checked' : '';
$lastAt = $ep['last_called_at'] ? '<span style="font-size:11px;color:#9ca3af">' . self::h($ep['last_called_at']) . '</span>' : '';
$dirBadge = $epDir === 'upload'
? '<span class="badge badge-blue" style="font-size:10px">↑ upload</span>'
: '<span class="badge badge-green" style="font-size:10px">↓ download</span>';
$epKey = self::h($ep['endpoint_key']);
$epName = self::h($ep['name'] ?? '');
$epUrl = self::h($ep['url'] ?? '');
$epMeth = $ep['method'] ?? 'GET';
$epDir = $ep['direction'] ?? 'download';
$epParams = self::h($ep['params'] ?? '');
$epAct = $ep['is_active'] ? 'checked' : '';
$lastAt = $ep['last_called_at'] ? '<span style="font-size:11px;color:#9ca3af">' . self::h($ep['last_called_at']) . '</span>' : '';
$mSel = fn($v) => $epMeth === $v ? 'selected' : '';
$dSel = fn($v) => $epDir === $v ? 'selected' : '';
$epId = (int)$ep['id'];
$epId = (int)$ep['id'];
// body_fields for collect_and_post
$bfData = json_decode($ep['body_fields'] ?? '', true) ?: [];
$bfRowsHtml = '';
foreach ($bfData as $bf) {
$bk = self::h($bf['key'] ?? '');
$bl = self::h($bf['label'] ?? '');
$bp = self::h($bf['prompt'] ?? '');
$bfRowsHtml .= "<div class=\"bf-row\" style=\"display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:4px;margin-bottom:3px\">
<input type=\"text\" class=\"bf-key\" value=\"{$bk}\" placeholder=\"clave\" style=\"font-family:monospace;font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<input type=\"text\" class=\"bf-lbl\" value=\"{$bl}\" placeholder=\"etiqueta\" style=\"font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<input type=\"text\" class=\"bf-prm\" value=\"{$bp}\" placeholder=\"Pregunta al usuario\" style=\"font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<button type=\"button\" onclick=\"this.closest('.bf-row').remove()\" style=\"background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px\">✕</button>
</div>";
}
$bfSection = $epDir === 'upload' ? '' : 'display:none';
$epRows .= <<<ROW
<tr id="ep-row-{$epId}">
<td style="min-width:120px">
@@ -1138,7 +1151,7 @@ HTML;
<input type="text" value="{$epName}" id="epn_{$epId}" placeholder="Nombre legible" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
</td>
<td>
<select id="epd_{$epId}" style="font-size:12px;padding:4px;border:1px solid #e5e7eb;border-radius:4px">
<select id="epd_{$epId}" onchange="toggleBfSection({$epId},this.value)" style="font-size:12px;padding:4px;border:1px solid #e5e7eb;border-radius:4px">
<option value="download" {$dSel('download')}> Download</option>
<option value="upload" {$dSel('upload')}> Upload</option>
<option value="list" {$dSel('list')}>📋 List</option>
@@ -1160,6 +1173,13 @@ HTML;
<button class="btn-danger-sm" onclick="deleteEpRow({$id},{$epId})"></button>
</td>
</tr>
<tr id="ep-bf-{$epId}" style="{$bfSection}">
<td colspan="7" style="padding:8px 12px;background:#f0f7ff;border-bottom:1px solid #e5e7eb">
<div style="font-size:11px;font-weight:600;color:#3b5bdb;margin-bottom:6px">📋 Campos del body (para "Pedir campos y enviar")</div>
<div id="bf-rows-{$epId}">{$bfRowsHtml}</div>
<button type="button" onclick="addBfRow({$epId})" style="font-size:11px;background:#e7efff;border:1px solid #c7d7ff;border-radius:3px;padding:3px 8px;cursor:pointer;margin-top:4px">+ Campo</button>
</td>
</tr>
<tr id="ep-resp-{$epId}" style="display:none"><td colspan="7" style="padding:8px;font-size:11px;color:#374151;background:#f9fafb;border-radius:6px"></td></tr>
ROW;
}
@@ -1450,6 +1470,40 @@ async function deletePhone(pid, btn) {
else { alert(j.error); btn.disabled=false; }
}
function toggleBfSection(epId, dir) {
const row = document.getElementById('ep-bf-'+epId);
if (row) row.style.display = (dir === 'upload') ? '' : 'none';
}
function addBfRow(epId) {
const c = document.getElementById('bf-rows-'+epId);
if (!c) return;
const d = document.createElement('div');
d.className = 'bf-row';
d.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:4px;margin-bottom:3px';
d.innerHTML =
'<input type="text" class="bf-key" placeholder="clave" style="font-family:monospace;font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<input type="text" class="bf-lbl" placeholder="etiqueta" style="font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<input type="text" class="bf-prm" placeholder="Pregunta al usuario" style="font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<button type="button" onclick="this.closest(\'.bf-row\').remove()" style="background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px">✕</button>';
c.appendChild(d);
}
function collectBfFields(epId) {
const rows = document.querySelectorAll('#bf-rows-'+epId+' .bf-row');
const fields = [];
rows.forEach(r => {
const key = r.querySelector('.bf-key')?.value.trim();
if (!key) return;
fields.push({
key,
label: r.querySelector('.bf-lbl')?.value.trim() || key,
prompt: r.querySelector('.bf-prm')?.value.trim() || '',
});
});
return JSON.stringify(fields);
}
async function saveEpRow(cid, epId) {
const fd=new FormData();
fd.append('company_id', cid);
@@ -1460,6 +1514,7 @@ async function saveEpRow(cid, epId) {
fd.append('method', document.getElementById('epm_'+epId).value);
fd.append('url', document.getElementById('epu_'+epId).value.trim());
fd.append('is_active', document.getElementById('epa_'+epId).checked ? '1' : '0');
fd.append('body_fields', collectBfFields(epId));
const msg=document.getElementById('epMsg');
const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd});
const j=await r.json();
@@ -1717,8 +1772,17 @@ HTML;
$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'] ?? '');
$isActive = ($_POST['is_active'] ?? '0') === '1' ? 1 : 0;
$params = trim($_POST['params'] ?? '');
$bodyFieldsRaw = trim($_POST['body_fields'] ?? '');
// Validate JSON or null
$bodyFields = null;
if ($bodyFieldsRaw !== '' && $bodyFieldsRaw !== '[]') {
$decoded = json_decode($bodyFieldsRaw, true);
if (is_array($decoded) && !empty($decoded)) {
$bodyFields = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
}
if ($companyId <= 0 || $key === '') {
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit;
@@ -1726,11 +1790,11 @@ HTML;
try {
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]);
db()->prepare("UPDATE company_endpoints SET endpoint_key=?,name=?,direction=?,url=?,method=?,is_active=?,params=?,body_fields=? WHERE id=? AND company_id=?")
->execute([$key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields, $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]);
db()->prepare("INSERT INTO company_endpoints (company_id,endpoint_key,name,direction,url,method,is_active,params,body_fields) 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),body_fields=VALUES(body_fields)")
->execute([$companyId, $key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields]);
}
echo json_encode(['ok' => true]);
} catch (\PDOException $e) {