diff --git a/bamboohr-agent-core.json b/bamboohr-agent-core.json new file mode 100644 index 0000000..43eea34 --- /dev/null +++ b/bamboohr-agent-core.json @@ -0,0 +1,7842 @@ +{ + "updatedAt": "2026-06-27T13:30:36.904Z", + "createdAt": "2026-06-25T13:03:04.442Z", + "id": "5VOiadzdgbs2Qq1N", + "name": "BambooHR Agent Core", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -6000, + -4672 + ], + "id": "276ea916-3fab-46f5-a708-38ce0cce5bbe", + "name": "HTTP - Employees Directory", + "retryOnFail": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -9728, + -2512 + ], + "id": "5c339d4f-7a43-497c-813c-10a0fe9edc8a", + "name": "BambooHR Agent Core" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "{\n \"user_email\": \"rrhh.guatemala.test@gomezleemarketing.com\",\n \"user_name\": \"RRHH Guatemala Test\",\n \"channel\": \"manual_test\",\n \"environment\": \"development\",\n \"message\": \"Dame el headcount por país\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -9520, + -2512 + ], + "id": "ca88cf95-285f-4389-84c9-458198f82f94", + "name": "Set - Incoming Message" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nconst originalMessage =\n input.message ||\n input.original_message ||\n '';\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .trim();\n}\n\nfunction cleanEmployeeQuery(value) {\n return String(value || '')\n .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractEmployeeQuery(message) {\n const rawOriginal = String(message || '').trim();\n\n const raw = rawOriginal\n .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n const normalizedRaw = normalize(raw);\n\n // Si es creación/preparación de empleado nuevo, NO buscamos empleado existente.\n if (\n (\n normalizedRaw.includes('prepara') ||\n normalizedRaw.includes('preparar') ||\n normalizedRaw.includes('crea') ||\n normalizedRaw.includes('crear') ||\n normalizedRaw.includes('agrega') ||\n normalizedRaw.includes('agregar')\n ) &&\n (\n normalizedRaw.includes('empleado') ||\n normalizedRaw.includes('colaborador')\n ) &&\n (\n normalizedRaw.includes('creacion') ||\n normalizedRaw.includes('nuevo') ||\n normalizedRaw.includes('correo') ||\n normalizedRaw.includes('email') ||\n normalizedRaw.includes('fecha de ingreso')\n ) &&\n !(\n normalizedRaw.includes('desvinculacion') ||\n normalizedRaw.includes('terminacion') ||\n normalizedRaw.includes('salida')\n )\n ) {\n return null;\n }\n\n const emailMatch = rawOriginal.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n if (emailMatch) {\n return emailMatch[0].trim();\n }\n\n // Caso especial para jobInfo:\n // \"Agrega jobInfo a Batman con fecha...\"\n // Debe capturar \"Batman\", no \"Gestor de Experiencia\".\n const jobInfoEmployeePatterns = [\n /\\bjobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bjob info\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bagrega\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bagregar\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bañade\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\banade\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i\n ];\n\n for (const pattern of jobInfoEmployeePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso especial para desvinculación:\n // \"Prepara desvinculación de Batman con fecha...\"\n const terminationEmployeePatterns = [\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+de\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+del\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+de\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+al\\s+empleado\\s+(.+?)\\s+con\\s+/i\n ];\n\n for (const pattern of terminationEmployeePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso: \"empleado Batman\", \"colaborador Batman\", \"persona Batman\"\n const directPatterns = [\n /empleado\\s+(.+)$/i,\n /colaborador\\s+(.+)$/i,\n /persona\\s+(.+)$/i\n ];\n\n for (const pattern of directPatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso: \"de Batman\", \"del empleado Batman\", \"para Batman\", \"sobre Batman\"\n // No usamos \"a\" de forma general porque rompe frases como \"cargo Gestor de Experiencia\".\n const connectorMatches = [\n ...raw.matchAll(/\\b(de|del|para|sobre)\\b\\s+([^¿?¡!.,;:]+?)(?=\\s+\\b(de|del|para|sobre|con|fecha|cargo|puesto|departamento|division|división|ubicacion|ubicación|supervisor|jefe|motivo|tipo)\\b|$)/gi)\n ];\n\n if (connectorMatches.length > 0) {\n const lastMatch = connectorMatches[connectorMatches.length - 1];\n let candidate = cleanEmployeeQuery(lastMatch[2]);\n\n candidate = candidate\n .replace(/^la\\s+/i, '')\n .replace(/^el\\s+/i, '')\n .replace(/^los\\s+/i, '')\n .replace(/^las\\s+/i, '')\n .replace(/^empleado\\s+/i, '')\n .replace(/^colaborador\\s+/i, '')\n .replace(/\\s+(a|por|en|con|fecha|cargo|puesto|departamento|division|división|ubicacion|ubicación|supervisor|jefe|motivo|tipo)\\s+.+$/i, '')\n .trim();\n\n const candidateNormalized = normalize(candidate);\n\n const blockedCandidates = [\n 'cargo',\n 'cargo actual',\n 'puesto',\n 'posicion',\n 'departamento',\n 'division',\n 'ubicacion',\n 'supervisor',\n 'correo',\n 'telefono',\n 'extension',\n 'estatus',\n 'fecha de ingreso',\n 'fecha de salida',\n 'informacion general',\n 'datos generales',\n 'historial',\n 'historial de cargo',\n 'datos faltantes',\n 'campos faltantes',\n 'informacion faltante',\n 'datos incompletos',\n 'archivos',\n 'documentos',\n 'salario',\n 'sueldo',\n 'compensacion',\n 'jobinfo',\n 'job info',\n 'experiencia',\n 'desvinculacion',\n 'terminacion',\n 'salida',\n 'motivo',\n 'tipo'\n ];\n\n if (candidate && !blockedCandidates.includes(candidateNormalized)) {\n return candidate;\n }\n }\n\n // Caso: \"¿En qué departamento está Batman?\"\n const estaMatch = raw.match(/\\b(?:esta|está)\\s+(.+)$/i);\n if (estaMatch && estaMatch[1]) {\n return cleanEmployeeQuery(estaMatch[1]);\n }\n\n // Caso: \"¿Qué datos faltantes tiene Batman?\"\n const tieneMatch = raw.match(/\\b(?:tiene|tendrá|tendra)\\s+(.+)$/i);\n if (tieneMatch && tieneMatch[1]) {\n return cleanEmployeeQuery(tieneMatch[1]);\n }\n\n return null;\n}\n\nfunction extractActionValue(message, field) {\n const raw = String(message || '').trim();\n\n if (field === 'workEmail') {\n const emailMatch = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n return emailMatch ? emailMatch[0].trim() : null;\n }\n\n if (field === 'workPhone') {\n const phonePatterns = [\n /\\b(?:telefono|teléfono|phone|celular|movil|móvil|telefono laboral|teléfono laboral)\\b\\s*(?:a|por|en|=|:)?\\s*([0-9+\\-\\s().]{7,25})\\b/i,\n /\\b(?:a|por|en|=|:)\\s*([0-9+\\-\\s().]{7,25})\\s*$/i\n ];\n\n for (const pattern of phonePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/\\s+/g, '')\n .trim();\n }\n }\n\n return null;\n }\n\n if (field === 'workPhoneExtension') {\n const extensionPatterns = [\n /\\b(?:extension|extensión|ext laboral|extension laboral)\\b\\s*(?:a|por|en|=|:)?\\s*([0-9]{1,10})\\b/i,\n /\\b(?:a|por|en|=|:)\\s*([0-9]{1,10})\\s*$/i\n ];\n\n for (const pattern of extensionPatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1]).trim();\n }\n }\n\n return null;\n }\n\n return null;\n}\n\nfunction escapeRegex(value) {\n return String(value || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction extractValueAfterLabel(message, labels) {\n const raw = String(message || '').trim();\n\n for (const label of labels) {\n const escapedLabel = escapeRegex(label);\n\n const pattern = new RegExp(\n `(?:^|\\\\s|,)${escapedLabel}\\\\s*(?:es|=|:)?\\\\s*([^,;\\\\n]+)`,\n 'i'\n );\n\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractDateValue(message) {\n const raw = String(message || '').trim();\n\n const isoDateMatch = raw.match(/\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/);\n if (isoDateMatch && isoDateMatch[1]) {\n return isoDateMatch[1];\n }\n\n return null;\n}\n\nfunction extractJobInfoPayload(message) {\n const date = extractDateValue(message);\n\n const jobTitle = extractValueAfterLabel(message, [\n 'cargo',\n 'puesto',\n 'posicion',\n 'posición',\n 'job title'\n ]);\n\n const department = extractValueAfterLabel(message, [\n 'departamento',\n 'area',\n 'área'\n ]);\n\n const division = extractValueAfterLabel(message, [\n 'division',\n 'división',\n 'unidad de negocio',\n 'bu'\n ]);\n\n const location = extractValueAfterLabel(message, [\n 'ubicacion',\n 'ubicación',\n 'pais',\n 'país',\n 'location'\n ]);\n\n const reportsTo = extractValueAfterLabel(message, [\n 'supervisor',\n 'jefe',\n 'reporta a',\n 'reports to',\n 'reportsTo'\n ]);\n\n return {\n date,\n jobTitle,\n department,\n division,\n location,\n reportsTo\n };\n}\n\nfunction extractEmployeeFullNameForCreate(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:crear|crea|agregar|agrega|preparar|prepara)(?:\\s+(?:la\\s+)?(?:creacion|creación)\\s+de)?\\s+(?:nuevo\\s+)?empleado\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:crear|crea|agregar|agrega|preparar|prepara)(?:\\s+(?:la\\s+)?(?:creacion|creación)\\s+de)?\\s+(?:nuevo\\s+)?colaborador\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:creacion|creación)\\s+de\\s+(?:nuevo\\s+)?empleado\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:creacion|creación)\\s+de\\s+(?:nuevo\\s+)?colaborador\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n return null;\n}\n\nfunction splitFullName(fullName) {\n const parts = String(fullName || '')\n .replace(/\\s+/g, ' ')\n .trim()\n .split(' ')\n .filter(Boolean);\n\n if (parts.length === 0) {\n return {\n firstName: null,\n lastName: null\n };\n }\n\n if (parts.length === 1) {\n return {\n firstName: parts[0],\n lastName: null\n };\n }\n\n return {\n firstName: parts[0],\n lastName: parts.slice(1).join(' ')\n };\n}\n\nfunction extractEmailValue(message) {\n const raw = String(message || '').trim();\n const emailMatch = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n return emailMatch ? emailMatch[0].trim() : null;\n}\n\nfunction extractHireDateValue(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:fecha de ingreso|fecha ingreso|ingreso|hire date)\\b\\s*(?:es|=|:)?\\s*(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/i,\n /\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return match[1];\n }\n }\n\n return null;\n}\n\nfunction extractCreateEmployeePayload(message) {\n const fullName = extractEmployeeFullNameForCreate(message);\n const nameParts = splitFullName(fullName);\n\n const workEmail = extractEmailValue(message);\n\n const location = extractValueAfterLabel(message, [\n 'pais',\n 'país',\n 'ubicacion',\n 'ubicación',\n 'location'\n ]);\n\n const department = extractValueAfterLabel(message, [\n 'departamento',\n 'area',\n 'área'\n ]);\n\n const division = extractValueAfterLabel(message, [\n 'division',\n 'división',\n 'unidad de negocio',\n 'bu'\n ]);\n\n const jobTitle = extractValueAfterLabel(message, [\n 'cargo',\n 'puesto',\n 'posicion',\n 'posición',\n 'job title'\n ]);\n\n const hireDate = extractHireDateValue(message);\n\n return {\n fullName,\n firstName: nameParts.firstName,\n lastName: nameParts.lastName,\n workEmail,\n location,\n department,\n division,\n jobTitle,\n hireDate\n };\n}\n\nfunction extractTerminationDateValue(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:fecha de salida|fecha salida|fecha de desvinculacion|fecha de desvinculación|fecha de terminacion|fecha de terminación|salida|desvinculacion|desvinculación|terminacion|terminación)\\b\\s*(?:es|=|:)?\\s*(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/i,\n /\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return match[1];\n }\n }\n\n return null;\n}\n\nfunction extractTerminationReason(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:motivo|razon|razón|reason)\\b\\s*(?:es|=|:)?\\s*([^,;\\n]+)/i,\n /\\b(?:por motivo de|por razon de|por razón de)\\s+([^,;\\n]+)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractTerminationType(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:tipo de terminacion|tipo de terminación|tipo de salida|tipo)\\b\\s*(?:es|=|:)?\\s*([^,;\\n]+)/i,\n /\\b(?:terminacion|terminación|salida|desvinculacion|desvinculación)\\s+(voluntaria|involuntaria|renuncia|desahucio|despido|mutuo acuerdo)\\b/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractTerminationPayload(message) {\n return {\n terminationDate: extractTerminationDateValue(message),\n terminationReason: extractTerminationReason(message),\n terminationType: extractTerminationType(message)\n };\n}\n\nconst message = normalize(originalMessage);\nconst employeeQuery = extractEmployeeQuery(originalMessage);\n\nlet mode = 'unknown';\nlet intent = 'unknown';\nlet fields = [];\nlet filters = {};\nlet action = null;\nlet tableName = null;\nlet riskLevel = 'unknown';\nlet requiresConfirmation = false;\nlet confidence = 0.2;\nlet status = 'needs_clarification';\nlet responseText = 'No pude interpretar la solicitud. Por favor indica si deseas consultar, modificar, crear o generar un reporte.';\n\n// -------------------------\n// CONSULTAS DE EMPLEADO\n// -------------------------\n\nif (\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('posicion') ||\n message.includes('job title') ||\n message.includes('titulo')\n) {\n fields = ['jobTitle'];\n}\n\nif (\n message.includes('departamento') ||\n message.includes('area')\n) {\n fields = ['department'];\n}\n\nif (\n message.includes('division') ||\n message.includes('unidad de negocio') ||\n message.includes('business unit') ||\n message.includes('bu')\n) {\n fields = ['division'];\n}\n\nif (\n message.includes('ubicacion') ||\n message.includes('location') ||\n message.includes('pais') ||\n message.includes('ciudad')\n) {\n fields = ['location'];\n}\n\nif (\n message.includes('supervisor') ||\n message.includes('jefe') ||\n message.includes('reporta')\n) {\n if (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) {\n fields = ['supervisorEmail'];\n } else {\n fields = ['supervisor'];\n }\n}\n\nif (\n (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) &&\n !message.includes('supervisor')\n) {\n fields = ['workEmail'];\n}\n\nif (\n message.includes('extension') ||\n message.includes('ext laboral') ||\n message.includes('extension laboral')\n) {\n fields = ['workPhoneExtension'];\n} else if (\n message.includes('telefono') ||\n message.includes('phone') ||\n message.includes('celular')\n) {\n fields = ['workPhone'];\n}\n\nif (\n message.includes('estatus') ||\n message.includes('status') ||\n message.includes('estado laboral') ||\n message.includes('activo') ||\n message.includes('inactivo')\n) {\n fields = ['status'];\n}\n\nif (\n message.includes('fecha de ingreso') ||\n message.includes('fecha ingreso') ||\n message.includes('ingreso') ||\n message.includes('hire date') ||\n message.includes('contratacion')\n) {\n fields = ['hireDate'];\n}\n\nif (\n message.includes('informacion general') ||\n message.includes('info general') ||\n message.includes('resumen') ||\n message.includes('perfil') ||\n message.includes('datos generales')\n) {\n fields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'workEmail',\n 'status',\n 'hireDate'\n ];\n}\n\nif (fields.length > 0) {\n mode = 'consulta';\n intent = 'get_employee_field';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo la consulta, pero necesito que indiques el empleado. Ejemplo: \"¿Cuál es el cargo actual de Batman?\"';\n}\n\n// -------------------------\n// CONSULTA DE DATOS FALTANTES\n// -------------------------\n\nif (\n message.includes('datos faltantes') ||\n message.includes('campos faltantes') ||\n message.includes('informacion faltante') ||\n message.includes('datos incompletos') ||\n message.includes('campos incompletos') ||\n message.includes('que le falta')\n) {\n mode = 'consulta';\n intent = 'get_employee_missing_fields';\n\n fields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'supervisorEmail',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'status',\n 'hireDate'\n ];\n\n filters = {\n check_missing_fields: true\n };\n\n tableName = null;\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas revisar datos faltantes, pero necesito que indiques el empleado. Ejemplo: \"¿Qué datos faltantes tiene Batman?\".';\n}\n\n// -------------------------\n// CONSULTA DE ARCHIVOS DEL EMPLEADO\n// -------------------------\n\nif (\n message.includes('archivo') ||\n message.includes('archivos') ||\n message.includes('documento') ||\n message.includes('documentos') ||\n message.includes('file') ||\n message.includes('files') ||\n message.includes('adjunto') ||\n message.includes('adjuntos')\n) {\n mode = 'consulta';\n intent = 'get_employee_files';\n\n fields = [];\n\n filters = {\n list_employee_files: true\n };\n\n tableName = null;\n riskLevel = 'medium';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar archivos de un empleado, pero necesito que indiques el empleado. Ejemplo: \"¿Qué archivos tiene Batman?\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA jobInfo\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios')\n ) &&\n (\n message.includes('jobinfo') ||\n message.includes('job info') ||\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('posicion') ||\n message.includes('departamento') ||\n message.includes('division')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'jobInfo';\n\n filters = {\n table_name: 'jobInfo'\n };\n\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial jobInfo, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de cargo de Batman\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA employmentStatus\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios')\n ) &&\n (\n message.includes('employmentstatus') ||\n message.includes('employment status') ||\n message.includes('estatus laboral') ||\n message.includes('estado laboral') ||\n message.includes('historial laboral') ||\n message.includes('activo') ||\n message.includes('inactivo') ||\n message.includes('desvinculacion') ||\n message.includes('terminacion')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'employmentStatus';\n\n filters = {\n table_name: 'employmentStatus'\n };\n\n riskLevel = 'medium';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial employmentStatus, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de estatus laboral de Batman\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA compensation\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios') ||\n message.includes('consulta') ||\n message.includes('consultar') ||\n message.includes('ver') ||\n message.includes('mostrar') ||\n message.includes('muestrame')\n ) &&\n (\n message.includes('compensation') ||\n message.includes('compensacion') ||\n message.includes('salario') ||\n message.includes('sueldo') ||\n message.includes('pago') ||\n message.includes('remuneracion')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'compensation';\n\n filters = {\n table_name: 'compensation',\n sensitive: true\n };\n\n riskLevel = 'high';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial de compensación, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de salario de Batman\".';\n}\n\n// -------------------------\n// ACCIONES BÁSICAS\n// -------------------------\n\nif (\n message.includes('actualiza') ||\n message.includes('actualizar') ||\n message.includes('cambia') ||\n message.includes('cambiar') ||\n message.includes('modifica') ||\n message.includes('modificar') ||\n message.includes('agrega') ||\n message.includes('agregar') ||\n message.includes('añade') ||\n message.includes('anade') ||\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('crea') ||\n message.includes('crear') ||\n message.includes('desvincula') ||\n message.includes('desvincular') ||\n message.includes('termina') ||\n message.includes('terminar')\n) {\n mode = 'accion';\n requiresConfirmation = true;\n\n // ---------------------------------\n // Acción: ejecutar desvinculación real de empleado\n // ---------------------------------\n\n if (\n (\n message.includes('ejecuta') ||\n message.includes('ejecutar') ||\n message.includes('registra') ||\n message.includes('registrar') ||\n message.includes('aplica') ||\n message.includes('aplicar')\n ) &&\n (\n message.includes('desvinculacion') ||\n message.includes('desvinculación') ||\n message.includes('terminacion') ||\n message.includes('terminación') ||\n message.includes('salida')\n )\n ) {\n intent = 'terminate_employee';\n riskLevel = 'critical';\n\n const terminationPayload = extractTerminationPayload(originalMessage);\n\n action = {\n type: 'terminate_employee',\n label: 'ejecutar desvinculación real de empleado',\n payload: terminationPayload\n };\n\n const missingRequired = [];\n\n if (!employeeQuery) missingRequired.push('empleado');\n if (!terminationPayload.terminationDate) missingRequired.push('fecha de salida');\n if (!terminationPayload.terminationReason) missingRequired.push('motivo');\n if (!terminationPayload.terminationType) missingRequired.push('tipo de terminación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText =\n 'Entiendo que deseas ejecutar una desvinculación real, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas ejecutar la desvinculación real de ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Ejecuta desvinculación de empleado 50802 con fecha 2026-07-15, motivo prueba QA, tipo terminación voluntaria\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: preparar desvinculación de empleado\n // ---------------------------------\n\n else if (\n (\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('desvincula') ||\n message.includes('desvincular') ||\n message.includes('termina') ||\n message.includes('terminar')\n ) &&\n (\n message.includes('desvinculacion') ||\n message.includes('desvinculación') ||\n message.includes('terminacion') ||\n message.includes('terminación') ||\n message.includes('salida')\n )\n ) {\n intent = 'prepare_terminate_employee';\n riskLevel = 'high';\n\n const terminationPayload = extractTerminationPayload(originalMessage);\n\n action = {\n type: 'prepare_terminate_employee',\n label: 'preparar desvinculación de empleado',\n payload: terminationPayload\n };\n\n const missingRequired = [];\n\n if (!employeeQuery) missingRequired.push('empleado');\n if (!terminationPayload.terminationDate) missingRequired.push('fecha de salida');\n if (!terminationPayload.terminationReason) missingRequired.push('motivo');\n if (!terminationPayload.terminationType) missingRequired.push('tipo de terminación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText =\n 'Entiendo que deseas preparar una desvinculación, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas preparar la desvinculación de ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Prepara desvinculación de BambooHR Agent QA No Nomina con fecha 2026-07-15, motivo prueba QA, tipo terminación voluntaria\".`;\n } else {\n responseText = null;\n }\n }\n // ---------------------------------\n // Acción: preparar creación de empleado\n // ---------------------------------\n\n else if (\n (\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('crea') ||\n message.includes('crear') ||\n message.includes('agrega') ||\n message.includes('agregar')\n ) &&\n (\n message.includes('empleado') ||\n message.includes('colaborador')\n ) &&\n (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('cargo') ||\n message.includes('departamento') ||\n message.includes('division') ||\n message.includes('ubicacion') ||\n message.includes('pais') ||\n message.includes('ingreso')\n )\n ) {\n intent = 'prepare_create_employee';\n riskLevel = 'high';\n\n const createEmployeePayload = extractCreateEmployeePayload(originalMessage);\n\n action = {\n type: 'prepare_create_employee',\n label: 'preparar creación de empleado',\n payload: createEmployeePayload\n };\n\n const missingRequired = [];\n\n if (!createEmployeePayload.firstName) missingRequired.push('nombre');\n if (!createEmployeePayload.lastName) missingRequired.push('apellido');\n if (!createEmployeePayload.workEmail) missingRequired.push('correo laboral');\n if (!createEmployeePayload.location) missingRequired.push('país/ubicación');\n if (!createEmployeePayload.department) missingRequired.push('departamento');\n if (!createEmployeePayload.division) missingRequired.push('división');\n if (!createEmployeePayload.jobTitle) missingRequired.push('cargo');\n if (!createEmployeePayload.hireDate) missingRequired.push('fecha de ingreso');\n\n confidence =\n missingRequired.length === 0\n ? 0.9\n : 0.7;\n\n status =\n missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas preparar la creación de un empleado, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Prepara creación de empleado Clark Kent con correo clark.kent@test.com, país Colombia, departamento Field Force, división Whirlpool, cargo Gestor de Experiencia, fecha de ingreso 2026-07-01\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: agregar fila jobInfo\n // ---------------------------------\n\n else if (\n message.includes('jobinfo') ||\n message.includes('job info') ||\n (\n (\n message.includes('agrega') ||\n message.includes('agregar') ||\n message.includes('añade') ||\n message.includes('anade')\n ) &&\n (\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('departamento') ||\n message.includes('division') ||\n message.includes('ubicacion')\n )\n )\n ) {\n intent = 'add_employee_table_row';\n riskLevel = 'medium';\n\n const jobInfoPayload = extractJobInfoPayload(originalMessage);\n\n action = {\n type: 'add_table_row',\n table: 'jobInfo',\n label: 'historial jobInfo',\n payload: jobInfoPayload\n };\n\n const missingRequired = [];\n\n if (!jobInfoPayload.date) missingRequired.push('fecha');\n if (!jobInfoPayload.jobTitle) missingRequired.push('cargo');\n if (!jobInfoPayload.department) missingRequired.push('departamento');\n if (!jobInfoPayload.division) missingRequired.push('división');\n if (!jobInfoPayload.location) missingRequired.push('ubicación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText = 'Entiendo que deseas agregar una fila jobInfo, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas agregar jobInfo para ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Agrega jobInfo a Batman con fecha 2026-06-25, cargo Gestor de Experiencia, departamento Field Force, división Whirlpool, ubicación Colombia\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: actualizar campos básicos\n // ---------------------------------\n\n else {\n intent = 'update_employee_basic';\n riskLevel = 'medium';\n\n let detectedField = null;\n let detectedLabel = null;\n\n if (\n message.includes('extension') ||\n message.includes('ext laboral') ||\n message.includes('extension laboral')\n ) {\n detectedField = 'workPhoneExtension';\n detectedLabel = 'extensión laboral';\n } else if (\n message.includes('telefono laboral') ||\n message.includes('telefono') ||\n message.includes('phone') ||\n message.includes('celular') ||\n message.includes('movil')\n ) {\n detectedField = 'workPhone';\n detectedLabel = 'teléfono laboral';\n } else if (\n message.includes('correo laboral') ||\n message.includes('email laboral') ||\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) {\n detectedField = 'workEmail';\n detectedLabel = 'correo laboral';\n }\n\n if (detectedField) {\n const actionValue = extractActionValue(originalMessage, detectedField);\n\n action = {\n type: 'update_field',\n field: detectedField,\n label: detectedLabel,\n value: actionValue\n };\n }\n\n confidence =\n employeeQuery && action && action.value\n ? 0.9\n : employeeQuery && action\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && action && action.value\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText = 'Entiendo que deseas modificar datos de un empleado, pero necesito que indiques cuál empleado.';\n } else if (!action) {\n responseText = 'Entiendo que deseas modificar datos del empleado, pero no identifiqué qué campo deseas actualizar. Por ahora puedo actualizar extensión laboral, teléfono laboral, correo laboral, agregar una fila jobInfo, preparar creación de empleado o preparar desvinculación.';\n } else if (!action.value) {\n responseText = `Entiendo que deseas actualizar el campo \"${action.label}\", pero necesito que indiques el nuevo valor.`;\n } else {\n responseText = null;\n }\n }\n}\n\n// -------------------------\n// REPORTES BÁSICOS\n// -------------------------\n\nif (\n message.includes('headcount') ||\n message.includes('cantidad de empleados') ||\n message.includes('reporte') ||\n message.includes('empleados por pais') ||\n message.includes('empleados por departamento')\n) {\n mode = 'reporte';\n intent = 'generate_report';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 0.9;\n status = 'ready';\n responseText = null;\n\n if (message.includes('pais')) {\n intent = 'headcount_by_country';\n }\n\n if (message.includes('departamento')) {\n intent = 'headcount_by_department';\n }\n\n filters = {\n status: 'Active'\n };\n}\n\n// -------------------------\n// SALIDA FINAL DEL PARSER\n// -------------------------\n\nreturn {\n json: {\n ...input,\n original_message: originalMessage,\n mode,\n intent,\n employee_query: employeeQuery,\n fields,\n filters,\n action,\n table_name: tableName,\n risk_level: riskLevel,\n requires_confirmation: requiresConfirmation,\n confidence,\n status,\n response_text: responseText\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8992, + -2512 + ], + "id": "16424bed-7d3c-4d1d-8da7-8b8c1437a9f6", + "name": "Code - Intent Parser" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nif (!data.mode || data.mode === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text: 'No pude interpretar la solicitud. Por favor indica si deseas consultar, modificar, crear, desvincular o generar un reporte.'\n }\n };\n}\n\nif (!data.intent || data.intent === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text: 'No pude identificar la acción o consulta exacta. Por favor envía más detalles.'\n }\n };\n}\n\nif ((data.confidence || 0) < 0.8) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'No tengo suficiente confianza para ejecutar esta solicitud. Por favor confirma con más detalle.'\n }\n };\n}\n\n// Estas intenciones necesitan un empleado existente en BambooHR.\n// prepare_create_employee NO va aquí porque el empleado todavía no existe.\n// prepare_terminate_employee SÍ va aquí porque se prepara desvinculación de empleado existente.\n// terminate_employee SÍ va aquí porque ejecutaría desvinculación real sobre empleado existente.\nconst intentsThatNeedExistingEmployee = [\n 'get_employee_field',\n 'get_employee_table',\n 'get_employee_files',\n 'get_employee_missing_fields',\n 'update_employee_basic',\n 'add_employee_table_row',\n 'prepare_terminate_employee',\n 'terminate_employee'\n];\n\nif (\n intentsThatNeedExistingEmployee.includes(data.intent) &&\n !data.employee_query\n) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'Necesito que indiques el empleado para poder continuar.'\n }\n };\n}\n\n// Validación extra para creación/preparación de empleado.\n// Esta acción NO necesita employee_query, pero sí necesita payload.\nif (data.intent === 'prepare_create_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!payload.firstName) missingRequired.push('nombre');\n if (!payload.lastName) missingRequired.push('apellido');\n if (!payload.workEmail) missingRequired.push('correo laboral');\n if (!payload.location) missingRequired.push('país/ubicación');\n if (!payload.department) missingRequired.push('departamento');\n if (!payload.division) missingRequired.push('división');\n if (!payload.jobTitle) missingRequired.push('cargo');\n if (!payload.hireDate) missingRequired.push('fecha de ingreso');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para preparar la creación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\n// Validación extra para preparación de desvinculación.\n// Esta acción SÍ necesita employee_query porque aplica sobre un empleado existente.\n// No ejecuta cambios reales en BambooHR.\nif (data.intent === 'prepare_terminate_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!data.employee_query) missingRequired.push('empleado');\n if (!payload.terminationDate) missingRequired.push('fecha de salida');\n if (!payload.terminationReason) missingRequired.push('motivo');\n if (!payload.terminationType) missingRequired.push('tipo de terminación');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para preparar la desvinculación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\n// Validación extra para ejecución real de desvinculación.\n// Esta acción SÍ necesita employee_query porque aplica sobre un empleado existente.\n// Esta acción requiere confirmación final estricta más adelante en Action Confirmation Guard.\nif (data.intent === 'terminate_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!data.employee_query) missingRequired.push('empleado');\n if (!payload.terminationDate) missingRequired.push('fecha de salida');\n if (!payload.terminationReason) missingRequired.push('motivo');\n if (!payload.terminationType) missingRequired.push('tipo de terminación');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para ejecutar la desvinculación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\nreturn {\n json: {\n ...data,\n can_continue: true\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8704, + -2512 + ], + "id": "3f7772d0-ca2c-446f-a790-84881562ab84", + "name": "Code - Validate Intent" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.mode }}", + "rightValue": "=consulta", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "086e1819-29be-42b0-8905-c014a247cbb3" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "consulta" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "edd4011c-89a6-4a2f-90a5-4e3dbaea83d4", + "leftValue": "={{ $json.mode }}", + "rightValue": "accion", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "accion" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2dc0f609-f938-4d47-99a7-60358616ec94", + "leftValue": "={{ $json.mode }}", + "rightValue": "reporte", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "reporte" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + -7536, + -3808 + ], + "id": "1c7b9cd5-b635-4012-9b7e-e5086d2ca66c", + "name": "Switch - Mode Router" + }, + { + "parameters": { + "fieldToSplitOut": "employees", + "options": {} + }, + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + -5760, + -4672 + ], + "id": "88fcede6-4eea-4b65-b320-1a86d1da10ed", + "name": "Split Out - Employees" + }, + { + "parameters": { + "jsCode": "const intent = $node[\"Code - Intent Parser\"].json;\nconst queryRaw = intent.employee_query || '';\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .trim();\n}\n\nconst query = normalize(queryRaw);\n\nconst matches = items.filter(item => {\n const e = item.json;\n\n const searchable = normalize([\n e.id,\n e.employeeNumber,\n e.displayName,\n e.firstName,\n e.lastName,\n e.preferredName,\n e.workEmail,\n e.jobTitle,\n e.department,\n e.location,\n e.division\n ].join(' '));\n\n return searchable.includes(query);\n});\n\nif (matches.length === 0) {\n return [{\n json: {\n employee_found: false,\n employee_query: queryRaw,\n error: `No encontré ningún empleado que coincida con \"${queryRaw}\".`,\n response_text: `No encontré ningún empleado que coincida con \"${queryRaw}\".`\n }\n }];\n}\n\nif (matches.length > 1) {\n return [{\n json: {\n employee_found: false,\n employee_query: queryRaw,\n multiple_matches: true,\n matches: matches.slice(0, 5).map(item => ({\n id: item.json.id,\n displayName: item.json.displayName,\n preferredName: item.json.preferredName,\n workEmail: item.json.workEmail,\n jobTitle: item.json.jobTitle,\n location: item.json.location\n })),\n response_text: `Encontré varios empleados que coinciden con \"${queryRaw}\". Por favor especifica mejor el nombre o correo.`\n }\n }];\n}\n\nreturn [{\n json: {\n ...matches[0].json,\n employee_found: true,\n employee_query: queryRaw,\n resolved_employee_id: matches[0].json.id,\n resolved_employee_name: matches[0].json.displayName\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -5552, + -4672 + ], + "id": "8fb4ee82-f72d-46f3-b872-2b4bbb51849b", + "name": "Code - Resolve Employee" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5376, + -10384 + ], + "id": "7cbe6b31-7e91-43d8-9d72-35d4be5a9efe", + "name": "HTTP - Get Employee By ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst employee = $json;\nconst requestedFields = intent.fields || [];\n\nconst labels = {\n jobTitle: 'cargo actual',\n department: 'departamento',\n division: 'división',\n location: 'ubicación',\n supervisor: 'supervisor',\n supervisorEmail: 'correo del supervisor',\n workEmail: 'correo laboral',\n workPhone: 'teléfono laboral',\n workPhoneExtension: 'extensión laboral',\n status: 'estatus',\n hireDate: 'fecha de ingreso'\n};\n\nfunction formatPersonName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) {\n return 'No disponible';\n }\n\n if (raw.includes(',')) {\n const [lastNames, firstNames] = raw.split(',').map(part => part.trim());\n\n if (firstNames && lastNames) {\n return `${firstNames} ${lastNames}`;\n }\n }\n\n return raw;\n}\n\nfunction formatFieldValue(field, value) {\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n if (field === 'supervisor') {\n return formatPersonName(value);\n }\n\n return value;\n}\n\nconst lines = requestedFields.map(field => {\n const label = labels[field] || field;\n const value = formatFieldValue(field, employee[field]);\n return `- ${label}: ${value}`;\n});\n\nconst responseText = `Consulta completada para ${employee.displayName}:\\n\\n${lines.join('\\n')}`;\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employee.id,\n employee_name: employee.displayName,\n requested_fields: requestedFields,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employee.id,\n target_employee_name: employee.displayName,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${employee.id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5632, + -10384 + ], + "id": "aa9a1e5a-b5ef-449e-b5ee-f369185ecba0", + "name": "Code - Format Consulta Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst finishedAt = new Date().toISOString();\n\nconst auditEvent = data.audit_event || {};\n\nconst requestId =\n data.request_id ||\n auditEvent.request_id ||\n null;\n\nconst startedAt =\n data.request_started_at ||\n auditEvent.request_started_at ||\n null;\n\nconst responseTimeMs =\n startedAt\n ? Date.now() - new Date(startedAt).getTime()\n : null;\n\nconst responseTimeSeconds =\n responseTimeMs !== null\n ? Number((responseTimeMs / 1000).toFixed(2))\n : null;\n\nconst status =\n auditEvent.execution_result ||\n data.status ||\n (data.can_continue === false ? 'needs_clarification' : 'success');\n\nconst canContinue =\n data.can_continue !== undefined\n ? data.can_continue\n : status === 'success';\n\nconst responseType =\n data.response_type ||\n (\n status === 'pending_confirmation'\n ? 'confirmation_request'\n : canContinue === false\n ? 'clarification_request'\n : 'chat_message'\n );\n\nconst responseText =\n data.response_text ||\n 'Solicitud procesada correctamente.';\n\nconst mode =\n data.mode ||\n auditEvent.mode ||\n 'unknown';\n\nconst intent =\n data.intent ||\n auditEvent.intent ||\n 'unknown';\n\nconst riskLevel =\n data.risk_level ||\n auditEvent.risk_level ||\n 'unknown';\n\nconst userEmail =\n data.user_email ||\n auditEvent.user_email ||\n null;\n\nconst userName =\n data.user_name ||\n auditEvent.user_name ||\n null;\n\nconst channel =\n data.channel ||\n auditEvent.channel ||\n data.data?.channel ||\n null;\n\nconst environment =\n data.environment ||\n auditEvent.environment ||\n data.data?.environment ||\n null;\n\nconst googleChatEventType =\n data.google_chat_event_type ||\n auditEvent.google_chat_event_type ||\n null;\n\nconst googleChatSpaceName =\n data.google_chat_space_name ||\n auditEvent.google_chat_space_name ||\n null;\n\nconst googleChatSpaceDisplayName =\n data.google_chat_space_display_name ||\n auditEvent.google_chat_space_display_name ||\n null;\n\nconst googleChatMessageName =\n data.google_chat_message_name ||\n auditEvent.google_chat_message_name ||\n null;\n\nconst googleChatThreadName =\n data.google_chat_thread_name ||\n auditEvent.google_chat_thread_name ||\n null;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n auditEvent.target_employee_id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n auditEvent.target_employee_name ||\n null;\n\nconst employeeEmail =\n data.employee_email ||\n auditEvent.target_employee_email ||\n null;\n\nreturn {\n json: {\n request_id: requestId,\n status,\n can_continue: canContinue,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: userName,\n channel,\n environment,\n\n google_chat_event_type: googleChatEventType,\n google_chat_space_name: googleChatSpaceName,\n google_chat_space_display_name: googleChatSpaceDisplayName,\n google_chat_message_name: googleChatMessageName,\n google_chat_thread_name: googleChatThreadName,\n\n response_type: responseType,\n response_text: responseText,\n\n request_started_at: startedAt,\n request_finished_at: finishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n data: {\n employee_id: employeeId,\n\n employee_name: employeeName,\n\n employee_email: employeeEmail,\n\n requested_fields:\n data.requested_fields ||\n data.fields ||\n [],\n\n employee_query:\n data.employee_query ||\n null,\n\n multiple_matches:\n data.multiple_matches ||\n false,\n\n matches:\n data.matches ||\n [],\n\n table_name:\n data.table_name ||\n data.filters?.table_name ||\n null,\n\n rows_count:\n data.rows_count ??\n null,\n\n rows_preview:\n data.rows_preview ||\n [],\n\n checked_fields:\n data.checked_fields ||\n [],\n\n missing_fields:\n data.missing_fields ||\n [],\n\n present_fields:\n data.present_fields ||\n [],\n\n files_count:\n data.files_count ??\n null,\n\n categories_count:\n data.categories_count ??\n null,\n\n categories_with_files_count:\n data.categories_with_files_count ??\n null,\n\n empty_categories_count:\n data.empty_categories_count ??\n null,\n\n file_categories:\n data.file_categories ||\n [],\n\n files_preview:\n data.files_preview ||\n [],\n\n action:\n data.action ||\n auditEvent.action ||\n null,\n\n requires_confirmation:\n data.requires_confirmation ??\n auditEvent.requires_confirmation ??\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n confirmation_message:\n data.confirmation_message ||\n null,\n\n action_approved:\n data.action_approved ||\n false,\n\n report_type:\n data.report_type ||\n auditEvent.report_type ||\n null,\n\n report_total_employees_in_scope:\n data.report_total_employees_in_scope ??\n auditEvent.report_total_employees_in_scope ??\n null,\n\n report_total_active_employees_read:\n data.report_total_active_employees_read ??\n auditEvent.report_total_active_employees_read ??\n null,\n\n report_country_counts:\n data.report_country_counts ||\n auditEvent.report_country_counts ||\n [],\n\n report_department_counts:\n data.report_department_counts ||\n auditEvent.report_department_counts ||\n [],\n\n authorization_allowed:\n data.authorization_allowed ??\n null,\n\n authorization_result:\n data.authorization_result ||\n auditEvent.authorization_result ||\n null,\n\n authorization_reason:\n data.authorization_reason ||\n auditEvent.authorization_reason ||\n null,\n\n employee_scope_allowed:\n data.employee_scope_allowed ??\n null,\n\n employee_scope_result:\n data.employee_scope_result ||\n auditEvent.employee_scope_result ||\n null,\n\n employee_scope_reason:\n data.employee_scope_reason ||\n auditEvent.employee_scope_reason ||\n null\n },\n\n audit_event: {\n ...auditEvent,\n\n request_id: requestId,\n request_started_at: startedAt,\n request_finished_at: finishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: userName,\n channel,\n environment,\n\n google_chat_event_type: googleChatEventType,\n google_chat_space_name: googleChatSpaceName,\n google_chat_space_display_name: googleChatSpaceDisplayName,\n google_chat_message_name: googleChatMessageName,\n google_chat_thread_name: googleChatThreadName,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action:\n data.action ||\n auditEvent.action ||\n null,\n\n requires_confirmation:\n data.requires_confirmation ??\n auditEvent.requires_confirmation ??\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n authorization_result:\n data.authorization_result ||\n auditEvent.authorization_result ||\n null,\n\n authorization_reason:\n data.authorization_reason ||\n auditEvent.authorization_reason ||\n null,\n\n execution_result: status,\n\n bamboohr_endpoint:\n auditEvent.bamboohr_endpoint ||\n data.bamboohr_endpoint ||\n null,\n\n response_type: responseType,\n response_text: responseText\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 15824, + -2448 + ], + "id": "083583e6-d83b-4e76-8201-aa6ff7d89969", + "name": "Code - Agent Response" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "cf41f3fa-64a6-4765-a81d-78187e7f4d87", + "leftValue": "={{ $json.can_continue }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -8496, + -2512 + ], + "id": "8dfb0930-863b-4b86-905c-c457933efe42", + "name": "IF - Can Continue?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "71d4f894-3e36-418b-a407-18339757859f", + "leftValue": "={{ $json.employee_found }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -4992, + -4672 + ], + "id": "b6726c6b-7238-4cd1-92e8-a08306b2333f", + "name": "IF - Employee Found?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nconst now = new Date().toISOString();\n\nconst message =\n input.message ||\n input.original_message ||\n '';\n\nconst requestId =\n input.request_id ||\n `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nreturn {\n json: {\n ...input,\n\n request_id: requestId,\n request_started_at: input.request_started_at || now,\n\n user_email: input.user_email || null,\n user_name: input.user_name || null,\n\n channel: input.channel || 'manual_test',\n environment: input.environment || 'development',\n\n message,\n original_message: message,\n\n confirmation_status: input.confirmation_status || null,\n\n final_execution_approved: input.final_execution_approved === true,\n final_execution_token: input.final_execution_token || null,\n\n source: input.channel || 'manual_test',\n language: 'es',\n agent_name: 'BambooHR Agent Core'\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -9232, + -2512 + ], + "id": "082b337a-96ee-4cc6-960f-06351672df90", + "name": "Code - Request Context" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_field", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "2ca218f7-3ab7-413b-9fc9-1579597fd1f6" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "campos_basicos" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "db94de48-e808-468d-a14c-db0a464474f3", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_table", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "tabla_historica" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2b29eaff-c720-47b1-b1a2-4b2701bf7f47", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_missing_fields", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "datos_faltantes" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "5ddde9c9-eb33-4da6-8c75-aad88dc235bc", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_files", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "archivos_empleado" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 3488, + -9696 + ], + "id": "7dfbc88b-1c28-40e8-b3d3-fcd3d24bf606", + "name": "Switch - Consulta Type" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5360, + -10112 + ], + "id": "85414cf5-e17f-4329-a455-09d82d6ef856", + "name": "HTTP - Get Employee jobInfo Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.jobInfo)) {\n return body.jobInfo;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 8).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const jobTitle = getValue(row, ['jobTitle', 'job_title', 'title', 'position']);\n const department = getValue(row, ['department']);\n const division = getValue(row, ['division']);\n const location = getValue(row, ['location']);\n const reportsTo = getValue(row, ['reportsTo', 'supervisor']);\n\n const details = [];\n\n if (jobTitle) details.push(`cargo: ${jobTitle}`);\n if (department) details.push(`departamento: ${department}`);\n if (division) details.push(`división: ${division}`);\n if (location) details.push(`ubicación: ${location}`);\n if (reportsTo) details.push(`supervisor: ${reportsTo}`);\n\n return {\n date,\n jobTitle,\n department,\n division,\n location,\n reportsTo,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial jobInfo para ${resolved.resolved_employee_name}.`\n : `Historial jobInfo de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n\n table_name: 'jobInfo',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5568, + -10112 + ], + "id": "1e4de479-02a5-4342-85ad-76afa2c0325b", + "name": "Code - Format jobInfo Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5296, + -9872 + ], + "id": "ff9c78b4-d017-465c-a49c-6131a5feb863", + "name": "HTTP - Get Employee Missing Fields", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst employee = $json;\n\nconst fieldsToCheck = intent.fields || [];\n\nconst labels = {\n jobTitle: 'cargo actual',\n department: 'departamento',\n division: 'división',\n location: 'ubicación',\n supervisor: 'supervisor',\n supervisorEmail: 'correo del supervisor',\n workEmail: 'correo laboral',\n workPhone: 'teléfono laboral',\n workPhoneExtension: 'extensión laboral',\n status: 'estatus',\n hireDate: 'fecha de ingreso'\n};\n\nfunction isMissing(value) {\n if (value === null || value === undefined) return true;\n\n if (typeof value === 'string' && value.trim() === '') return true;\n\n if (Array.isArray(value) && value.length === 0) return true;\n\n return false;\n}\n\nfunction formatPersonName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) {\n return 'No disponible';\n }\n\n if (raw.includes(',')) {\n const [lastNames, firstNames] = raw.split(',').map(part => part.trim());\n\n if (firstNames && lastNames) {\n return `${firstNames} ${lastNames}`;\n }\n }\n\n return raw;\n}\n\nfunction formatFieldValue(field, value) {\n if (isMissing(value)) {\n return 'No disponible';\n }\n\n if (field === 'supervisor') {\n return formatPersonName(value);\n }\n\n return value;\n}\n\nconst checkedFields = fieldsToCheck.map(field => {\n const value = employee[field];\n\n return {\n field,\n label: labels[field] || field,\n value: formatFieldValue(field, value),\n is_missing: isMissing(value)\n };\n});\n\nconst missingFields = checkedFields.filter(field => field.is_missing);\nconst presentFields = checkedFields.filter(field => !field.is_missing);\n\nlet responseText;\n\nif (missingFields.length === 0) {\n responseText =\n `No encontré datos faltantes principales para ${employee.displayName}.\\n\\n` +\n `Campos revisados: ${checkedFields.map(field => field.label).join(', ')}.`;\n} else {\n responseText =\n `Datos faltantes principales para ${employee.displayName}:\\n\\n` +\n missingFields.map(field => `- ${field.label}`).join('\\n') +\n `\\n\\nCampos revisados: ${checkedFields.length}.`;\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employee.id,\n employee_name: employee.displayName,\n\n checked_fields: checkedFields,\n missing_fields: missingFields,\n present_fields: presentFields,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employee.id,\n target_employee_name: employee.displayName,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${employee.id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5504, + -9872 + ], + "id": "b1f47728-451a-429a-b5b5-4c093c7126f5", + "name": "Code - Format Missing Fields Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/files/view", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5232, + -9456 + ], + "id": "1d0187c2-1970-4e4c-821d-1cc53e4f1b1e", + "name": "HTTP - Get Employee Files", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\nconst body = $json;\n\nfunction asArray(value) {\n return Array.isArray(value) ? value : [];\n}\n\nfunction getCategories(payload) {\n if (Array.isArray(payload?.categories)) {\n return payload.categories;\n }\n\n if (Array.isArray(payload?.data?.categories)) {\n return payload.data.categories;\n }\n\n if (Array.isArray(payload)) {\n return payload;\n }\n\n return [];\n}\n\nfunction normalizeFile(file, category) {\n return {\n file_id:\n file.id ||\n file.fileId ||\n file.file_id ||\n null,\n\n file_name:\n file.name ||\n file.fileName ||\n file.filename ||\n file.displayName ||\n 'Archivo sin nombre',\n\n category_id:\n category.id ||\n category.categoryId ||\n null,\n\n category_name:\n category.name ||\n category.category ||\n 'Sin categoría',\n\n uploaded_at:\n file.createdAt ||\n file.dateCreated ||\n file.uploadedAt ||\n file.lastChanged ||\n null,\n\n shared_with_employee:\n file.sharedWithEmployee ||\n file.shareWithEmployee ||\n file.share ||\n null\n };\n}\n\nconst categories = getCategories(body);\n\nconst normalizedCategories = categories.map(category => {\n const files = asArray(category.files).map(file => normalizeFile(file, category));\n\n return {\n category_id: category.id || category.categoryId || null,\n category_name: category.name || category.category || 'Sin categoría',\n files_count: files.length,\n files\n };\n});\n\nconst files = normalizedCategories.flatMap(category => category.files);\n\nconst categoriesWithFiles = normalizedCategories.filter(category => category.files_count > 0);\nconst emptyCategories = normalizedCategories.filter(category => category.files_count === 0);\n\nlet responseText;\n\nif (files.length === 0) {\n responseText =\n `No encontré archivos cargados para ${resolved.resolved_employee_name}.\\n\\n` +\n `Categorías revisadas: ${normalizedCategories.length}.`;\n} else {\n const previewLines = categoriesWithFiles.flatMap(category => {\n const header = `\\n${category.category_name}:`;\n\n const fileLines = category.files.slice(0, 10).map(file => {\n const fileIdText = file.file_id ? `ID ${file.file_id}` : 'sin ID';\n return `- ${file.file_name} (${fileIdText})`;\n });\n\n return [header, ...fileLines];\n });\n\n responseText =\n `Archivos encontrados para ${resolved.resolved_employee_name}:\\n` +\n previewLines.join('\\n') +\n `\\n\\nTotal de archivos: ${files.length}. Categorías con archivos: ${categoriesWithFiles.length}.`;\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n\n files_count: files.length,\n categories_count: normalizedCategories.length,\n categories_with_files_count: categoriesWithFiles.length,\n\n file_categories: normalizedCategories,\n files_preview: files.slice(0, 20),\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/files/view`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5552, + -9424 + ], + "id": "09b8e86c-e027-4aea-83f1-2f38aa97762e", + "name": "Code - Format Employee Files Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "jobInfo", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "99156341-6fb2-496f-a38e-c3dc0ffd8e4d" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "jobInfo" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "a918d770-05f0-44df-b9e3-87026a62f19b", + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "employmentStatus", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "employmentStatus" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "52cb1493-f179-4f1e-a037-cb251a6257ee", + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "compensation", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "compensation" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 4048, + -9472 + ], + "id": "60a08fe1-dec8-47cd-ba67-a2c904c4e908", + "name": "Switch - Historical Table Type" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5248, + -9168 + ], + "id": "9d794497-e444-4d66-877e-5f24274e2e83", + "name": "HTTP - Get Employee employmentStatus Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.employmentStatus)) {\n return body.employmentStatus;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 10).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const employmentStatus = getValue(row, [\n 'employmentStatus',\n 'status',\n 'employment_status'\n ]);\n\n const comment = getValue(row, [\n 'comment',\n 'comments',\n 'note',\n 'notes'\n ]);\n\n const terminationReasonId = getValue(row, ['terminationReasonId']);\n const terminationTypeId = getValue(row, ['terminationTypeId']);\n const terminationRehireId = getValue(row, ['terminationRehireId']);\n const terminationRegrettableId = getValue(row, ['terminationRegrettableId']);\n\n const details = [];\n\n if (employmentStatus) details.push(`estatus: ${employmentStatus}`);\n if (comment) details.push(`comentario: ${comment}`);\n if (terminationReasonId) details.push(`terminationReasonId: ${terminationReasonId}`);\n if (terminationTypeId) details.push(`terminationTypeId: ${terminationTypeId}`);\n if (terminationRehireId) details.push(`terminationRehireId: ${terminationRehireId}`);\n if (terminationRegrettableId) details.push(`terminationRegrettableId: ${terminationRegrettableId}`);\n\n return {\n date,\n employmentStatus,\n comment,\n terminationReasonId,\n terminationTypeId,\n terminationRehireId,\n terminationRegrettableId,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial employmentStatus para ${resolved.resolved_employee_name}.`\n : `Historial employmentStatus de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n \n table_name: 'employmentStatus',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5456, + -9168 + ], + "id": "12ef617a-50b6-4f75-ad0b-3937bc90208f", + "name": "Code - Format employmentStatus Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $json;\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com'\n];\n\nconst userEmail = String(intent.user_email || '').toLowerCase();\nconst environment = String(intent.environment || 'development').toLowerCase();\n\nconst isAllowedUser = allowedEmails.includes(userEmail);\nconst isAllowedEnvironment = environment === 'development';\n\nif (!isAllowedUser || !isAllowedEnvironment) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n table_name: intent.table_name,\n employee_query: intent.employee_query,\n\n resolved_employee_id: resolved.resolved_employee_id || null,\n resolved_employee_name: resolved.resolved_employee_name || null,\n\n response_text:\n 'Acceso denegado. La consulta de compensación es sensible y requiere permisos autorizados.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id || null,\n target_employee_name: resolved.resolved_employee_name || null,\n\n authorization_result: 'denied',\n execution_result: 'blocked',\n bamboohr_endpoint: null,\n reason: 'compensation_query_not_authorized'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...resolved,\n\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n sensitive_access_approved: true,\n authorization_result: 'approved',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id || null,\n target_employee_name: resolved.resolved_employee_name || null,\n\n authorization_result: 'approved',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4768, + -8912 + ], + "id": "c194cd70-64e3-4633-a807-f35b6fa5cafb", + "name": "Code - Sensitive Consulta Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2f8d7899-2fd6-4ae1-8db3-64e2990c861d", + "leftValue": "={{ $json.sensitive_access_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4976, + -8912 + ], + "id": "5e58de3e-e6de-4675-9e87-d53de026cfc8", + "name": "IF - Sensitive Access Approved?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/compensation", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5536, + -8928 + ], + "id": "637770fc-c09d-41c6-b7da-1907ffd2919c", + "name": "HTTP - Get Employee compensation Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Sensitive Consulta Guard').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.compensation)) {\n return body.compensation;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 10).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const payRate = getValue(row, ['payRate', 'rate', 'salary']);\n const payType = getValue(row, ['payType']);\n const payPer = getValue(row, ['payPer']);\n const paySchedule = getValue(row, ['paySchedule']);\n const currency = getValue(row, ['currency']);\n const changeReason = getValue(row, ['changeReason', 'reason', 'comment']);\n\n const details = [];\n\n if (payRate) details.push(`monto: ${payRate}`);\n if (currency) details.push(`moneda: ${currency}`);\n if (payType) details.push(`tipo: ${payType}`);\n if (payPer) details.push(`periodicidad: ${payPer}`);\n if (paySchedule) details.push(`calendario: ${paySchedule}`);\n if (changeReason) details.push(`razón: ${changeReason}`);\n\n return {\n date,\n payRate,\n currency,\n payType,\n payPer,\n paySchedule,\n changeReason,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial de compensación para ${resolved.resolved_employee_name}.`\n : `Historial de compensación de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n table_name: 'compensation',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n authorization_result: 'approved',\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/compensation`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5744, + -8928 + ], + "id": "9ce7e538-f5e9-4948-a592-e57816fb7d67", + "name": "Code - Format compensation Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.mode }}", + "rightValue": "consulta", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "0796c736-66ff-4c28-99c1-3353d10a57ee" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "consulta" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "0725dec0-a413-4fd1-a8ad-eafa40fccf6e", + "leftValue": "={{ $('Code - Intent Parser').item.json.mode }}", + "rightValue": "accion", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "accion" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 928, + -5936 + ], + "id": "e79dd7ae-db69-489d-8d77-6a96c9ad7aaa", + "name": "Switch - Post Resolve Mode" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolvedEmployee = $json;\n\nconst action = intent.action || null;\n\nconst confirmationStatus =\n intent.confirmation_status ||\n intent.confirmationStatus ||\n null;\n\nfunction isMissingActionValue(action) {\n if (!action) return true;\n\n if (action.type === 'update_field') {\n return !action.value;\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n\n return (\n !payload.date ||\n !payload.jobTitle ||\n !payload.department ||\n !payload.division ||\n !payload.location\n );\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n\n return (\n !payload.terminationDate ||\n !payload.terminationReason ||\n !payload.terminationType\n );\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n\n return (\n !payload.terminationDate ||\n !payload.terminationReason ||\n !payload.terminationType\n );\n }\n\n return false;\n}\n\nfunction buildMissingValueMessage(action) {\n if (!action) {\n return 'No pude identificar la acción solicitada.';\n }\n\n if (action.type === 'update_field') {\n return `Entiendo que deseas actualizar el campo \"${action.label}\", pero necesito que indiques el nuevo valor.`;\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.date) missing.push('fecha');\n if (!payload.jobTitle) missing.push('cargo');\n if (!payload.department) missing.push('departamento');\n if (!payload.division) missing.push('división');\n if (!payload.location) missing.push('ubicación');\n\n return `Entiendo que deseas agregar jobInfo, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.terminationDate) missing.push('fecha de salida');\n if (!payload.terminationReason) missing.push('motivo');\n if (!payload.terminationType) missing.push('tipo de terminación');\n\n return `Entiendo que deseas preparar una desvinculación, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.terminationDate) missing.push('fecha de salida');\n if (!payload.terminationReason) missing.push('motivo');\n if (!payload.terminationType) missing.push('tipo de terminación');\n\n return `Entiendo que deseas ejecutar una desvinculación, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n return 'La acción solicitada no tiene los datos completos.';\n}\n\nfunction buildConfirmationMessage(action, employeeName) {\n if (action.type === 'update_field') {\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: actualizar ${action.label}\\n` +\n `Nuevo valor: ${action.value}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`\n );\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: agregar fila en jobInfo\\n` +\n `Fecha: ${payload.date}\\n` +\n `Cargo: ${payload.jobTitle}\\n` +\n `Departamento: ${payload.department}\\n` +\n `División: ${payload.division}\\n` +\n `Ubicación: ${payload.location}` +\n (payload.reportsTo ? `\\nSupervisor: ${payload.reportsTo}` : '') +\n `\\n\\nPara continuar, responde: CONFIRMAR`\n );\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n\n return (\n `CONFIRMACIÓN ESTRICTA REQUERIDA\\n\\n` +\n `Esta acción prepara una desvinculación de empleado. No se ejecutará todavía, pero debe tratarse como acción crítica.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: preparar desvinculación\\n` +\n `Fecha de salida: ${payload.terminationDate}\\n` +\n `Motivo: ${payload.terminationReason}\\n` +\n `Tipo de terminación: ${payload.terminationType}\\n\\n` +\n `Para continuar, responde exactamente: CONFIRMAR DESVINCULACIÓN`\n );\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n\n return (\n `CONFIRMACIÓN FINAL DE DESVINCULACIÓN REAL\\n\\n` +\n `Esta acción SÍ intentará registrar la desvinculación real del empleado en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: ejecutar desvinculación real\\n` +\n `Fecha de salida: ${payload.terminationDate}\\n` +\n `Motivo: ${payload.terminationReason}\\n` +\n `Tipo de terminación: ${payload.terminationType}\\n\\n` +\n `Para ejecutar la desvinculación real, responde exactamente: EJECUTAR DESVINCULACIÓN`\n );\n }\n\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: ${action.label || action.type}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`\n );\n}\n\nconst employeeFound =\n resolvedEmployee.employee_found !== false &&\n Boolean(resolvedEmployee.resolved_employee_id || resolvedEmployee.id);\n\nif (!employeeFound) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action,\n\n response_text:\n resolvedEmployee.response_text ||\n 'No pude resolver el empleado para ejecutar la acción.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: intent.employee_query,\n action,\n\n authorization_result: 'not_evaluated_employee_not_found',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst employeeId =\n resolvedEmployee.resolved_employee_id ||\n resolvedEmployee.id;\n\nconst employeeName =\n resolvedEmployee.resolved_employee_name ||\n resolvedEmployee.displayName ||\n intent.employee_query ||\n 'Empleado no disponible';\n\nif (!action) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action: null,\n\n response_text: 'No pude identificar la acción solicitada.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action: null,\n authorization_result: 'not_evaluated_missing_action',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (isMissingActionValue(action)) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action,\n\n response_text: buildMissingValueMessage(action),\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n authorization_result: 'not_evaluated_missing_action_value',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst confirmationMessage = buildConfirmationMessage(action, employeeName);\n\nconst normalizedConfirmationStatus =\n String(confirmationStatus || '')\n .trim()\n .toLowerCase();\n\nconst isStrictTerminationConfirmation =\n action.type === 'prepare_terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed_termination';\n\nconst isStrictTerminationExecutionConfirmation =\n action.type === 'terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed_termination_execute';\n\nconst isNormalConfirmation =\n action.type !== 'prepare_terminate_employee' &&\n action.type !== 'terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed';\n\nconst actionApproved =\n isStrictTerminationConfirmation ||\n isStrictTerminationExecutionConfirmation ||\n isNormalConfirmation;\n\nif (intent.requires_confirmation && !actionApproved) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'pending_confirmation',\n can_continue: false,\n response_type: 'confirmation_request',\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: 'pending',\n confirmation_message: confirmationMessage,\n\n action_approved: false,\n\n response_text: confirmationMessage,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'pending',\n\n authorization_result: 'pending_confirmation',\n execution_result: 'pending_confirmation',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst approvedConfirmationStatus =\n action.type === 'prepare_terminate_employee'\n ? 'confirmed_termination'\n : action.type === 'terminate_employee'\n ? 'confirmed_termination_execute'\n : 'confirmed';\n\nconst approvedResponseText =\n action.type === 'prepare_terminate_employee'\n ? `Preparación de desvinculación aprobada para revisión.\\n\\nEmpleado: ${employeeName}\\nFecha de salida: ${action.payload.terminationDate}`\n : action.type === 'terminate_employee'\n ? `Desvinculación real aprobada para ejecución.\\n\\nEmpleado: ${employeeName}\\nFecha de salida: ${action.payload.terminationDate}`\n : `Acción aprobada para ejecución.\\n\\nEmpleado: ${employeeName}`;\n\nreturn {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'action_approved',\n can_continue: true,\n response_type: 'chat_message',\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: approvedConfirmationStatus,\n\n action_approved: true,\n\n response_text: approvedResponseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n requires_confirmation: true,\n confirmation_status: approvedConfirmationStatus,\n\n authorization_result: 'confirmed',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1456, + -5664 + ], + "id": "f769b2b5-7055-4052-9b39-f1b3b85e8b4e", + "name": "Code - Action Confirmation Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "47d98c0f-89fc-4af8-a67f-cb155c209c18", + "leftValue": "={{ $json.action_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1760, + -5664 + ], + "id": "64ab7961-a9fb-435d-b109-c82bb40e2a3a", + "name": "IF - Action Approved?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,workPhoneExtension,workPhone,workEmail,status,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4016, + -8320 + ], + "id": "6da4eae1-939c-4aba-a674-040f44736cd1", + "name": "HTTP - Get Employee For Action", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst actionContext = $('Code - Action Confirmation Guard').item.json;\nconst currentEmployee = $json;\n\nconst action = actionContext.action || intent.action || null;\n\nconst allowedFields = [\n 'workPhoneExtension',\n 'workPhone',\n 'workEmail'\n];\n\nif (!action || !action.field) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'failed',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n response_text: 'No se pudo ejecutar la acción porque no se identificó el campo a actualizar.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n action,\n authorization_result: 'failed_validation',\n execution_result: 'failed',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (!allowedFields.includes(action.field)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El campo \"${action.field}\" todavía no está permitido para actualización automática.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_field_not_allowed',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n\nfunction isValidEmail(value) {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value || '').trim());\n}\n\nfunction isValidPhone(value) {\n const cleanValue = String(value || '').replace(/[^\\d+]/g, '');\n return cleanValue.length >= 7 && cleanValue.length <= 20;\n}\n\nif (action.field === 'workEmail' && !isValidEmail(action.value)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El correo \"${action.value}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_invalid_email',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (action.field === 'workPhone' && !isValidPhone(action.value)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El teléfono \"${action.value}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_invalid_phone',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n\nconst employeeId =\n currentEmployee.id ||\n actionContext.resolved_employee_id;\n\nconst employeeName =\n currentEmployee.displayName ||\n actionContext.resolved_employee_name;\n\nconst oldValue =\n currentEmployee[action.field] ?? null;\n\nconst newValue =\n action.value;\n\nconst bamboohrPayload = {\n [action.field]: newValue\n};\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_query: intent.employee_query || actionContext.employee_query || null,\n\n action,\n old_value: oldValue,\n new_value: newValue,\n bamboohr_payload: bamboohrPayload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n old_value: oldValue,\n new_value: newValue,\n\n authorization_result: 'confirmed',\n execution_result: 'pending_execution',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4224, + -8320 + ], + "id": "2be39c4d-e645-4ec4-aa70-f2cf39e02792", + "name": "Code - Build Update Employee Payload" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4432, + -8320 + ], + "id": "71c5c87a-8a1d-4ab4-9827-049d361bf210", + "name": "HTTP - Update Employee Basic", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build Update Employee Payload').item.json.employee_id }}?fields=displayName,firstName,lastName,preferredName,workPhoneExtension,workPhone,workEmail,status,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4640, + -8320 + ], + "id": "ae76c11f-72af-4d49-8eee-6ff3d6bfa73e", + "name": "HTTP - Get Employee After Update", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst updateContext = $('Code - Build Update Employee Payload').item.json;\nconst updatedEmployee = $json;\n\nconst action = updateContext.action;\nconst field = action.field;\n\nconst confirmedValue =\n updatedEmployee[field] ?? null;\n\nconst updateSuccess =\n String(confirmedValue ?? '') === String(updateContext.new_value ?? '');\n\nconst status =\n updateSuccess ? 'success' : 'failed';\n\nconst responseText = updateSuccess\n ? `Acción ejecutada correctamente.\\n\\nEmpleado: ${updateContext.employee_name}\\nCampo: ${action.label || field}\\nValor anterior: ${updateContext.old_value ?? 'No disponible'}\\nNuevo valor confirmado: ${confirmedValue}`\n : `La acción fue enviada, pero no pude confirmar que el valor haya quedado actualizado.\\n\\nEmpleado: ${updateContext.employee_name}\\nCampo: ${action.label || field}\\nValor esperado: ${updateContext.new_value}\\nValor leído después: ${confirmedValue ?? 'No disponible'}`;\n\nreturn {\n json: {\n request_id: updateContext.request_id,\n request_started_at: updateContext.request_started_at,\n\n status,\n can_continue: updateSuccess,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: updateSuccess ? 'chat_message' : 'error_message',\n\n employee_id: updateContext.employee_id,\n employee_name: updateContext.employee_name,\n employee_query: updateContext.employee_query,\n\n action,\n old_value: updateContext.old_value,\n new_value: updateContext.new_value,\n confirmed_value: confirmedValue,\n update_success: updateSuccess,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n request_id: updateContext.request_id,\n request_started_at: updateContext.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: updateContext.employee_id,\n target_employee_name: updateContext.employee_name,\n\n action,\n old_value: updateContext.old_value,\n new_value: updateContext.new_value,\n confirmed_value: confirmedValue,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${updateContext.employee_id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4848, + -8320 + ], + "id": "840decf9-3b17-4e54-9d0e-bf7d6cb1ac47", + "name": "Code - Format Action Update Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.action.type }}", + "rightValue": "update_field", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "0888cdba-54a5-45a1-8c8a-c70a374f70f9" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "update_field" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "e26d89c6-4ab0-442e-935d-319ae9872a90", + "leftValue": "={{ $json.action.type }}", + "rightValue": "add_table_row", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "add_job_info" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "8b65f994-feff-4b67-bac4-b7cc17e8ca2f", + "leftValue": "={{ $json.action.type }}", + "rightValue": "prepare_terminate_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "prepare_terminate" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "035e76a8-dbbc-435d-9b73-527bfb9ec03a", + "leftValue": "={{ $json.action.type }}", + "rightValue": "terminate_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "terminate_employee" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 2032, + -6288 + ], + "id": "6236bf70-4f09-49b9-b11c-6d8f4d1cb88a", + "name": "Switch - Action Type" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst actionContext = $('Code - Action Confirmation Guard').item.json;\n\nconst action = actionContext.action || intent.action || null;\nconst payload = action?.payload || {};\n\nconst requiredFields = [\n 'date',\n 'jobTitle',\n 'department',\n 'division',\n 'location'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = payload[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!action || action.type !== 'add_table_row' || action.table !== 'jobInfo') {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: 'Acción bloqueada. La acción recibida no corresponde a una fila jobInfo válida.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n\n action,\n authorization_result: 'blocked_invalid_action_type',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n missing_fields: missingFields,\n\n response_text: `Acción bloqueada. Faltan campos obligatorios para jobInfo: ${missingFields.join(', ')}.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n\n action,\n missing_fields: missingFields,\n authorization_result: 'blocked_missing_required_fields',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst bamboohrPayload = {\n date: String(payload.date).trim(),\n jobTitle: String(payload.jobTitle).trim(),\n department: String(payload.department).trim(),\n division: String(payload.division).trim(),\n location: String(payload.location).trim()\n};\n\n// reportsTo es opcional. Solo lo enviamos si viene en el mensaje.\nif (payload.reportsTo && String(payload.reportsTo).trim() !== '') {\n bamboohrPayload.reportsTo = String(payload.reportsTo).trim();\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: actionContext.resolved_employee_id,\n employee_name: actionContext.resolved_employee_name,\n employee_query: intent.employee_query || actionContext.employee_query || null,\n\n action,\n table_name: 'jobInfo',\n bamboohr_payload: bamboohrPayload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id,\n target_employee_name: actionContext.resolved_employee_name,\n\n action,\n table_name: 'jobInfo',\n payload_sent: bamboohrPayload,\n\n authorization_result: 'confirmed',\n execution_result: 'pending_execution',\n bamboohr_endpoint: `/api/v1/employees/${actionContext.resolved_employee_id}/tables/jobInfo`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3728, + -7344 + ], + "id": "05bc0198-f2b3-4fc5-9dd2-b5041ec20b7c", + "name": "Code - Build jobInfo Payload" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": " application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4896, + -7360 + ], + "id": "586fff08-ecb5-4bbd-a9cd-e7b4b8fcc53d", + "name": "HTTP - Add Employee jobInfo Row", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build jobInfo Payload').item.json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5104, + -7360 + ], + "id": "5d8696e1-7c73-4ac1-9fdf-e0887e138dd0", + "name": "HTTP - Get jobInfo After Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst buildContext = $('Code - Build jobInfo Payload').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return row.date || row.effectiveDate || row.startDate || '';\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\nconst expected = buildContext.bamboohr_payload || {};\n\nconst matchingRows = rows.filter(row => {\n const baseMatch =\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location);\n\n const reportsToMatch =\n !expected.reportsTo ||\n normalizeValue(row.reportsTo) === normalizeValue(expected.reportsTo);\n\n return baseMatch && reportsToMatch;\n});\n\nconst addSuccess = matchingRows.length > 0;\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst latestRowsPreview = sortedRows.slice(0, 5).map(row => {\n const lineParts = [\n `cargo: ${row.jobTitle || 'No disponible'}`,\n `departamento: ${row.department || 'No disponible'}`,\n `división: ${row.division || 'No disponible'}`,\n `ubicación: ${row.location || 'No disponible'}`\n ];\n\n if (row.reportsTo) {\n lineParts.push(`supervisor: ${row.reportsTo}`);\n }\n\n return {\n id: row.id || null,\n date: row.date || null,\n jobTitle: row.jobTitle || null,\n department: row.department || null,\n division: row.division || null,\n location: row.location || null,\n reportsTo: row.reportsTo || null,\n line: `- ${row.date || 'Sin fecha'}: ${lineParts.join(' · ')}`\n };\n});\n\nconst status = addSuccess ? 'success' : 'failed';\n\nconst successResponseText =\n `Fila jobInfo agregada correctamente.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Fecha: ${expected.date}\\n` +\n `Cargo: ${expected.jobTitle}\\n` +\n `Departamento: ${expected.department}\\n` +\n `División: ${expected.division}\\n` +\n `Ubicación: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor: ${expected.reportsTo}` : '');\n\nconst failedResponseText =\n `La fila jobInfo fue enviada, pero no pude confirmar que haya quedado registrada.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Fecha esperada: ${expected.date}\\n` +\n `Cargo esperado: ${expected.jobTitle}\\n` +\n `Departamento esperado: ${expected.department}\\n` +\n `División esperada: ${expected.division}\\n` +\n `Ubicación esperada: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor esperado: ${expected.reportsTo}` : '');\n\nconst responseText = addSuccess\n ? successResponseText\n : failedResponseText;\n\nreturn [{\n json: {\n request_id: buildContext.request_id,\n request_started_at: buildContext.request_started_at,\n\n status,\n can_continue: addSuccess,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: addSuccess ? 'chat_message' : 'error_message',\n\n employee_id: buildContext.employee_id,\n employee_name: buildContext.employee_name,\n employee_query: buildContext.employee_query,\n\n action: buildContext.action,\n table_name: 'jobInfo',\n\n rows_count: rows.length,\n rows_preview: latestRowsPreview,\n matching_rows_count: matchingRows.length,\n matching_rows: matchingRows.slice(0, 3),\n\n add_success: addSuccess,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n request_id: buildContext.request_id,\n request_started_at: buildContext.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: buildContext.employee_id,\n target_employee_name: buildContext.employee_name,\n\n action: buildContext.action,\n table_name: 'jobInfo',\n payload_sent: expected,\n\n matching_rows_count: matchingRows.length,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5312, + -7360 + ], + "id": "ffe1ba5e-dea1-45cf-9ea7-61c500305d12", + "name": "Code - Format jobInfo Action Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3936, + -7344 + ], + "id": "092d00f5-c521-4f29-bb6e-012f32f8c013", + "name": "HTTP - Get jobInfo Before Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build jobInfo Payload').item.json;\nconst expected = buildContext.bamboohr_payload || {};\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst duplicateRows = rows.filter(row => {\n const baseMatch =\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location);\n\n const reportsToMatch =\n !expected.reportsTo ||\n normalizeValue(row.reportsTo) === normalizeValue(expected.reportsTo);\n\n return baseMatch && reportsToMatch;\n});\n\nif (duplicateRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n jobinfo_add_allowed: false,\n duplicate_found: true,\n duplicate_rows_count: duplicateRows.length,\n duplicate_rows: duplicateRows.slice(0, 5),\n\n response_text:\n `Acción bloqueada para evitar duplicado.\\n\\n` +\n `Ya existe una fila jobInfo igual para ${buildContext.employee_name}:\\n` +\n `Fecha: ${expected.date}\\n` +\n `Cargo: ${expected.jobTitle}\\n` +\n `Departamento: ${expected.department}\\n` +\n `División: ${expected.division}\\n` +\n `Ubicación: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor: ${expected.reportsTo}` : ''),\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'blocked_duplicate_jobinfo',\n execution_result: 'blocked',\n duplicate_rows_count: duplicateRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/jobInfo`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n jobinfo_add_allowed: true,\n duplicate_found: false,\n existing_rows_count: rows.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate',\n execution_result: 'pending_execution',\n existing_rows_count: rows.length\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4144, + -7344 + ], + "id": "f5dad92b-e9f1-4102-a1a2-321da2c3883d", + "name": "Code - Guard Duplicate jobInfo" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "7afeadb4-f3c6-4d53-bdcb-7b831d839ea9", + "leftValue": "={{ $json.jobinfo_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4352, + -7344 + ], + "id": "d0f71030-cafe-407f-835e-f7970bd45723", + "name": "IF - jobInfo Add Allowed?" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.intent }}", + "rightValue": "=prepare_create_employee", + "operator": { + "type": "string", + "operation": "notEquals" + }, + "id": "95be9ad6-1771-4db3-ad0c-ac31b5876af6" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "existing_employee" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "155e24cd-d390-4596-90e8-0c40a02a384a", + "leftValue": "={{ $json.intent }}", + "rightValue": "prepare_create_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "new_employee" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + -7168, + -3760 + ], + "id": "447cf65f-c061-4e70-8181-977b66288101", + "name": "Switch - Action Needs Existing Employee?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst action = intent.action || null;\nconst payload = action?.payload || {};\n\nconst confirmationStatus =\n intent.confirmation_status ||\n intent.confirmationStatus ||\n null;\n\nfunction isValidEmail(value) {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value || '').trim());\n}\n\nconst missingRequired = [];\n\nif (!payload.firstName) missingRequired.push('nombre');\nif (!payload.lastName) missingRequired.push('apellido');\nif (!payload.workEmail) missingRequired.push('correo laboral');\nif (!payload.location) missingRequired.push('país/ubicación');\nif (!payload.department) missingRequired.push('departamento');\nif (!payload.division) missingRequired.push('división');\nif (!payload.jobTitle) missingRequired.push('cargo');\nif (!payload.hireDate) missingRequired.push('fecha de ingreso');\n\nif (payload.workEmail && !isValidEmail(payload.workEmail)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El correo \"${payload.workEmail}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'blocked_invalid_email',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingRequired.length > 0) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'needs_clarification',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'clarification_request',\n\n action,\n missing_fields: missingRequired,\n\n response_text:\n `Faltan datos para preparar la creación del empleado: ${missingRequired.join(', ')}.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'not_evaluated',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst confirmationMessage =\n `Confirma esta acción de alto riesgo antes de ejecutarla:\\n\\n` +\n `Acción: preparar creación de empleado\\n` +\n `Nombre: ${payload.firstName}\\n` +\n `Apellido: ${payload.lastName}\\n` +\n `Correo laboral: ${payload.workEmail}\\n` +\n `País/ubicación: ${payload.location}\\n` +\n `Departamento: ${payload.department}\\n` +\n `División: ${payload.division}\\n` +\n `Cargo: ${payload.jobTitle}\\n` +\n `Fecha de ingreso: ${payload.hireDate}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`;\n\nif (intent.requires_confirmation && confirmationStatus !== 'confirmed') {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'pending_confirmation',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'confirmation_request',\n\n action,\n requires_confirmation: true,\n confirmation_status: 'pending',\n confirmation_message: confirmationMessage,\n\n response_text: confirmationMessage,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'pending_confirmation',\n execution_result: 'pending_confirmation',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'pending_execution',\n can_continue: true,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'chat_message',\n\n action,\n create_employee_payload: payload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text:\n `Creación de empleado aprobada para ejecución.\\n\\n` +\n `Empleado: ${payload.firstName} ${payload.lastName}\\n` +\n `Correo laboral: ${payload.workEmail}`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'confirmed',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -6848, + -3760 + ], + "id": "0f430b61-50b6-4d78-9e43-a910091ccaea", + "name": "Code - Create Employee Confirmation Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "62f21642-ab19-4ac3-9794-65ca466072bf", + "leftValue": "={{ $json.action_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -1504, + -2800 + ], + "id": "13549ab1-68a8-4630-b6d9-e08fec43950b", + "name": "IF - Create Employee Approved?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const guard = $json;\nconst action = guard.action || null;\nconst payload = guard.create_employee_payload || action?.payload || {};\n\nfunction normalizeText(value) {\n return String(value || '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeEmail(value) {\n return String(value || '').trim().toLowerCase();\n}\n\nfunction buildEmployeeNumber(payload) {\n if (payload.employeeNumber) {\n return normalizeText(payload.employeeNumber);\n }\n\n const baseName = `${payload.firstName || ''}${payload.lastName || ''}`\n .replace(/[^a-zA-Z0-9]/g, '')\n .toUpperCase()\n .slice(0, 12);\n\n const stamp = new Date()\n .toISOString()\n .replace(/[-:TZ.]/g, '')\n .slice(0, 12);\n\n return `QA-${baseName || 'EMP'}-${stamp}`;\n}\n\nconst requiredFields = [\n 'firstName',\n 'lastName',\n 'workEmail',\n 'location',\n 'department',\n 'division',\n 'jobTitle',\n 'hireDate'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = payload[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!action || action.type !== 'prepare_create_employee') {\n return {\n json: {\n ...guard,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n response_text: 'Acción bloqueada. La acción recibida no corresponde a creación de empleado.',\n\n audit_event: {\n ...guard.audit_event,\n authorization_result: 'blocked_invalid_action_type',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n ...guard,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n missing_fields: missingFields,\n\n response_text: `Acción bloqueada. Faltan campos obligatorios para crear el empleado: ${missingFields.join(', ')}.`,\n\n audit_event: {\n ...guard.audit_event,\n authorization_result: 'blocked_missing_required_fields',\n execution_result: 'blocked',\n missing_fields: missingFields,\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst employeeNumber = buildEmployeeNumber(payload);\n\nconst bamboohrPayload = {\n firstName: normalizeText(payload.firstName),\n lastName: normalizeText(payload.lastName),\n employeeNumber,\n workEmail: normalizeEmail(payload.workEmail),\n status: 'Active',\n hireDate: normalizeText(payload.hireDate),\n jobTitle: normalizeText(payload.jobTitle),\n department: normalizeText(payload.department),\n division: normalizeText(payload.division),\n location: normalizeText(payload.location)\n};\n\nreturn {\n json: {\n ...guard,\n\n status: 'pending_execution',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_name: `${bamboohrPayload.firstName} ${bamboohrPayload.lastName}`,\n employee_email: bamboohrPayload.workEmail,\n employee_number: employeeNumber,\n\n create_employee_payload: payload,\n bamboohr_payload: bamboohrPayload,\n\n audit_event: {\n ...guard.audit_event,\n target_employee_name: `${bamboohrPayload.firstName} ${bamboohrPayload.lastName}`,\n target_employee_email: bamboohrPayload.workEmail,\n employee_number: employeeNumber,\n payload_sent: bamboohrPayload,\n authorization_result: 'confirmed',\n execution_result: 'pending_duplicate_check',\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -912, + -2816 + ], + "id": "ebcaa650-d8dd-4e78-8859-188576daa1bf", + "name": "Code - Build Create Employee Payload" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -672, + -2816 + ], + "id": "45eb91e3-e407-4896-9436-ba03ff68e397", + "name": "HTTP - Employees Directory For Create Check", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const buildContext = $('Code - Build Create Employee Payload').item.json;\nconst expected = buildContext.bamboohr_payload || {};\nconst body = $json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getEmployees(payload) {\n if (Array.isArray(payload?.employees)) return payload.employees;\n if (Array.isArray(payload?.data?.employees)) return payload.data.employees;\n if (Array.isArray(payload)) return payload;\n return [];\n}\n\nconst employees = getEmployees(body);\n\nconst expectedEmail = normalize(expected.workEmail);\nconst expectedEmployeeNumber = normalize(expected.employeeNumber);\nconst expectedName = normalize(`${expected.firstName} ${expected.lastName}`);\n\nconst duplicateMatches = employees.filter(employee => {\n const employeeEmail = normalize(employee.workEmail || employee.email);\n const employeeNumber = normalize(employee.employeeNumber);\n const employeeName = normalize(\n employee.displayName ||\n `${employee.firstName || ''} ${employee.lastName || ''}`\n );\n\n return (\n (expectedEmail && employeeEmail && employeeEmail === expectedEmail) ||\n (expectedEmployeeNumber && employeeNumber && employeeNumber === expectedEmployeeNumber) ||\n (expectedName && employeeName && employeeName === expectedName)\n );\n});\n\nif (duplicateMatches.length > 0) {\n return {\n json: {\n ...buildContext,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n create_employee_allowed: false,\n duplicate_found: true,\n duplicate_matches_count: duplicateMatches.length,\n duplicate_matches: duplicateMatches.slice(0, 5).map(employee => ({\n id: employee.id || null,\n displayName: employee.displayName || null,\n firstName: employee.firstName || null,\n lastName: employee.lastName || null,\n workEmail: employee.workEmail || employee.email || null,\n employeeNumber: employee.employeeNumber || null,\n status: employee.status || null\n })),\n\n response_text:\n `Acción bloqueada para evitar duplicado.\\n\\n` +\n `Ya existe un empleado que coincide con alguno de estos datos:\\n` +\n `Nombre: ${expected.firstName} ${expected.lastName}\\n` +\n `Correo laboral: ${expected.workEmail}\\n` +\n `Employee Number: ${expected.employeeNumber}`,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'blocked_duplicate_employee',\n execution_result: 'blocked',\n duplicate_matches_count: duplicateMatches.length,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...buildContext,\n\n create_employee_allowed: true,\n duplicate_found: false,\n existing_employees_checked: employees.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate',\n execution_result: 'pending_execution',\n existing_employees_checked: employees.length,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -368, + -2816 + ], + "id": "cea55536-999d-4c58-ae6e-f3475e830443", + "name": "Code - Guard Duplicate Create Employee" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "8ce9e043-60da-4ff7-8049-aa93788519d4", + "leftValue": "={{ $json.create_employee_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -32, + -2816 + ], + "id": "03b8b208-cfaf-490a-9174-0ab1b9c467e3", + "name": "IF - Create Employee Allowed?" + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/employees", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 304, + -2960 + ], + "id": "0353f2d9-9322-460f-b0cd-cc424552d96c", + "name": "HTTP - Create Employee", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const buildContext = $('Code - Build Create Employee Payload').item.json;\nconst createResponse = $json;\n\nfunction extractIdFromLocation(location) {\n const raw = String(location || '').trim();\n const match = raw.match(/\\/employees\\/([0-9]+)/i);\n return match ? match[1] : null;\n}\n\nconst createdEmployeeId =\n createResponse.id ||\n createResponse.employeeId ||\n createResponse.employee_id ||\n createResponse.data?.id ||\n createResponse.data?.employeeId ||\n extractIdFromLocation(createResponse.location || createResponse.Location || createResponse.headers?.location) ||\n null;\n\nif (!createdEmployeeId) {\n return {\n json: {\n ...buildContext,\n\n status: 'created_unconfirmed',\n can_continue: false,\n response_type: 'error_message',\n\n create_response: createResponse,\n created_employee_id: null,\n\n response_text:\n `La solicitud de creación fue enviada, pero no pude identificar el ID del empleado creado.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Correo laboral: ${buildContext.employee_email}\\n\\n` +\n `Revisa la respuesta del nodo HTTP - Create Employee antes de continuar.`,\n\n audit_event: {\n ...buildContext.audit_event,\n execution_result: 'created_unconfirmed',\n authorization_result: 'confirmed',\n create_response: createResponse,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...buildContext,\n\n status: 'created_pending_verification',\n can_continue: true,\n response_type: 'chat_message',\n\n create_response: createResponse,\n created_employee_id: String(createdEmployeeId),\n\n audit_event: {\n ...buildContext.audit_event,\n target_employee_id: String(createdEmployeeId),\n execution_result: 'created_pending_verification',\n authorization_result: 'confirmed',\n create_response: createResponse,\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 704, + -2960 + ], + "id": "8fa184d0-c655-406a-af9e-46a1a9fbef53", + "name": "Code - Normalize Created Employee ID" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}?fields=displayName,firstName,lastName,workEmail,status,employeeNumber,hireDate,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2448, + -2896 + ], + "id": "37befaf4-8dd5-4172-97f8-336d12633fe2", + "name": "HTTP - Get Created Employee By ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const normalizeContext = $('Code - Normalize Created Employee ID').item.json;\nconst jobInfoContext = $('Code - Build Created Employee jobInfo Payload').item.json;\nconst employee = $('HTTP - Get Created Employee By ID').item.json || {};\n\nconst expected = normalizeContext.bamboohr_payload || {};\nconst expectedJobInfo = jobInfoContext.jobinfo_payload || {};\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nconst jobInfoRows = normalizeRows(items);\n\nconst employeeId =\n employee.id ||\n normalizeContext.created_employee_id ||\n jobInfoContext.created_employee_id ||\n null;\n\nconst createdName =\n employee.displayName ||\n `${employee.firstName || expected.firstName || ''} ${employee.lastName || expected.lastName || ''}`.trim();\n\nconst emailMatches =\n normalize(employee.workEmail) === normalize(expected.workEmail);\n\nconst firstNameMatches =\n normalize(employee.firstName) === normalize(expected.firstName);\n\nconst lastNameMatches =\n normalize(employee.lastName) === normalize(expected.lastName);\n\nconst creationConfirmed =\n Boolean(employeeId) &&\n (\n emailMatches ||\n (firstNameMatches && lastNameMatches)\n );\n\nconst matchingJobInfoRows = jobInfoRows.filter(row => {\n return (\n normalize(row.date) === normalize(expectedJobInfo.date) &&\n normalize(row.jobTitle) === normalize(expectedJobInfo.jobTitle) &&\n normalize(row.department) === normalize(expectedJobInfo.department) &&\n normalize(row.division) === normalize(expectedJobInfo.division) &&\n normalize(row.location) === normalize(expectedJobInfo.location)\n );\n});\n\nconst jobInfoConfirmed = matchingJobInfoRows.length > 0;\nconst confirmedJobInfo = matchingJobInfoRows[0] || null;\n\nlet status = 'created_unconfirmed';\n\nif (creationConfirmed && jobInfoConfirmed) {\n status = 'success';\n} else if (creationConfirmed && !jobInfoConfirmed) {\n status = 'partial_success_jobinfo_unconfirmed';\n}\n\nconst responseText =\n status === 'success'\n ? `Empleado creado correctamente en BambooHR y jobInfo agregado correctamente.\\n\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Nombre: ${createdName}\\n` +\n `Correo laboral: ${employee.workEmail || expected.workEmail}\\n` +\n `Employee Number: ${employee.employeeNumber || expected.employeeNumber || 'No disponible'}\\n` +\n `Estatus: ${employee.status || expected.status || 'No disponible'}\\n` +\n `Fecha de ingreso: ${employee.hireDate || expected.hireDate || 'No disponible'}\\n\\n` +\n `jobInfo registrado:\\n` +\n `Fecha: ${confirmedJobInfo.date || expectedJobInfo.date || 'No disponible'}\\n` +\n `Cargo: ${confirmedJobInfo.jobTitle || expectedJobInfo.jobTitle || 'No disponible'}\\n` +\n `Departamento: ${confirmedJobInfo.department || expectedJobInfo.department || 'No disponible'}\\n` +\n `División: ${confirmedJobInfo.division || expectedJobInfo.division || 'No disponible'}\\n` +\n `Ubicación: ${confirmedJobInfo.location || expectedJobInfo.location || 'No disponible'}`\n : creationConfirmed\n ? `Empleado creado correctamente en BambooHR, pero no pude confirmar que jobInfo haya quedado registrado.\\n\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Nombre: ${createdName}\\n` +\n `Correo laboral: ${employee.workEmail || expected.workEmail}\\n\\n` +\n `jobInfo esperado:\\n` +\n `Fecha: ${expectedJobInfo.date || 'No disponible'}\\n` +\n `Cargo: ${expectedJobInfo.jobTitle || 'No disponible'}\\n` +\n `Departamento: ${expectedJobInfo.department || 'No disponible'}\\n` +\n `División: ${expectedJobInfo.division || 'No disponible'}\\n` +\n `Ubicación: ${expectedJobInfo.location || 'No disponible'}`\n : `La creación fue enviada, pero no pude confirmar completamente el empleado.\\n\\n` +\n `ID detectado: ${employeeId || 'No disponible'}\\n` +\n `Nombre esperado: ${expected.firstName} ${expected.lastName}\\n` +\n `Correo esperado: ${expected.workEmail}`;\n\nreturn [{\n json: {\n request_id: normalizeContext.request_id,\n request_started_at: normalizeContext.request_started_at,\n\n status,\n can_continue: status === 'success',\n response_type: status === 'success' ? 'chat_message' : 'error_message',\n\n mode: normalizeContext.mode,\n intent: normalizeContext.intent,\n\n employee_id: employeeId,\n employee_name: createdName,\n employee_email: employee.workEmail || expected.workEmail,\n employee_number: employee.employeeNumber || expected.employeeNumber,\n\n created_employee: {\n id: employeeId,\n displayName: employee.displayName || null,\n firstName: employee.firstName || null,\n lastName: employee.lastName || null,\n workEmail: employee.workEmail || null,\n employeeNumber: employee.employeeNumber || null,\n status: employee.status || null,\n hireDate: employee.hireDate || null\n },\n\n expected_payload: expected,\n expected_jobinfo_payload: expectedJobInfo,\n\n creation_confirmed: creationConfirmed,\n jobinfo_confirmed: jobInfoConfirmed,\n jobinfo_rows_count: jobInfoRows.length,\n matching_jobinfo_rows_count: matchingJobInfoRows.length,\n confirmed_jobinfo_row: confirmedJobInfo,\n\n action: normalizeContext.action,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n ...normalizeContext.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: createdName,\n target_employee_email: employee.workEmail || expected.workEmail,\n\n payload_sent: expected,\n jobinfo_payload_sent: expectedJobInfo,\n\n created_employee: employee,\n jobinfo_confirmed: jobInfoConfirmed,\n jobinfo_rows_count: jobInfoRows.length,\n matching_jobinfo_rows_count: matchingJobInfoRows.length,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3280, + -2912 + ], + "id": "0e26b330-4ed7-436f-9479-1a94b41e0546", + "name": "Code - Format Create Employee Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const context = $json;\n\nconst expected = context.bamboohr_payload || {};\nconst createdEmployeeId = context.created_employee_id || context.employee_id || null;\n\nfunction normalizeText(value) {\n return String(value || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst requiredFields = [\n 'hireDate',\n 'jobTitle',\n 'department',\n 'division',\n 'location'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = expected[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!createdEmployeeId) {\n return {\n json: {\n ...context,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n response_text:\n 'Acción bloqueada. No tengo el ID del empleado creado, por lo tanto no puedo agregar jobInfo.',\n\n audit_event: {\n ...context.audit_event,\n authorization_result: 'blocked_missing_created_employee_id',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n ...context,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n missing_fields: missingFields,\n\n response_text:\n `Empleado creado, pero no puedo agregar jobInfo porque faltan estos campos: ${missingFields.join(', ')}.`,\n\n audit_event: {\n ...context.audit_event,\n target_employee_id: String(createdEmployeeId),\n authorization_result: 'blocked_missing_jobinfo_fields',\n execution_result: 'partial_created_missing_jobinfo_fields',\n missing_fields: missingFields,\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}/tables/jobInfo`\n }\n }\n };\n}\n\nconst jobinfoPayload = {\n date: normalizeText(expected.hireDate),\n jobTitle: normalizeText(expected.jobTitle),\n department: normalizeText(expected.department),\n division: normalizeText(expected.division),\n location: normalizeText(expected.location)\n};\n\nreturn {\n json: {\n ...context,\n\n status: 'pending_jobinfo_add',\n can_continue: true,\n response_type: 'chat_message',\n\n created_employee_id: String(createdEmployeeId),\n jobinfo_payload: jobinfoPayload,\n\n audit_event: {\n ...context.audit_event,\n target_employee_id: String(createdEmployeeId),\n jobinfo_payload: jobinfoPayload,\n authorization_result: 'confirmed',\n execution_result: 'pending_jobinfo_duplicate_check',\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}/tables/jobInfo`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 912, + -2960 + ], + "id": "2daa0105-0826-4286-9790-5bc6675e00cd", + "name": "Code - Build Created Employee jobInfo Payload" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1120, + -2960 + ], + "id": "e7e9e217-0241-47f8-ac3c-ef07e1f810e2", + "name": "HTTP - Get Created Employee jobInfo Before Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build Created Employee jobInfo Payload').item.json;\nconst expected = buildContext.jobinfo_payload || {};\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst duplicateRows = rows.filter(row => {\n return (\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location)\n );\n});\n\nif (duplicateRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'jobinfo_duplicate_found',\n can_continue: true,\n response_type: 'chat_message',\n\n created_employee_jobinfo_add_allowed: false,\n jobinfo_duplicate_found: true,\n duplicate_rows_count: duplicateRows.length,\n duplicate_rows: duplicateRows.slice(0, 5),\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_duplicate_jobinfo_not_added',\n execution_result: 'jobinfo_duplicate_found',\n duplicate_rows_count: duplicateRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.created_employee_id}/tables/jobInfo`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n created_employee_jobinfo_add_allowed: true,\n jobinfo_duplicate_found: false,\n existing_jobinfo_rows_count: rows.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate_jobinfo',\n execution_result: 'pending_jobinfo_add',\n existing_jobinfo_rows_count: rows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.created_employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1328, + -2960 + ], + "id": "def2cbd1-86eb-4a2e-a4ef-82fbcfeb7f7d", + "name": "Code - Guard Duplicate Created Employee jobInfo" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "ba1eb8e7-c51a-43cc-803c-67f946e12d3c", + "leftValue": "={{ $json.created_employee_jobinfo_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1536, + -2960 + ], + "id": "616500e3-b214-45cd-a555-a20bc0407877", + "name": "IF - Created Employee jobInfo Add Allowed?" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.jobinfo_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1936, + -3008 + ], + "id": "c579f25a-1d00-4e24-bfa8-863d3ac2e75a", + "name": "HTTP - Add Created Employee jobInfo Row", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build Created Employee jobInfo Payload').item.json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2656, + -2896 + ], + "id": "029599ab-03ed-4066-bebc-db360d9cf185", + "name": "HTTP - Get Created Employee jobInfo Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst action = data.action || {};\nconst payload = action.payload || {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.displayName ||\n data.employee_query ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.workEmail ||\n data.email ||\n null;\n\nconst responseText =\n `Desvinculación preparada correctamente para revisión.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n` +\n `Correo laboral: ${employeeEmail || 'No disponible'}\\n` +\n `Fecha de salida: ${payload.terminationDate || 'No disponible'}\\n` +\n `Motivo: ${payload.terminationReason || 'No disponible'}\\n` +\n `Tipo de terminación: ${payload.terminationType || 'No disponible'}\\n\\n` +\n `Estado: preparada, no ejecutada.\\n` +\n `Siguiente paso futuro: ejecutar desvinculación real solo con autorización final.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'prepared_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n termination_payload: payload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed_termination',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n termination_payload: payload,\n\n authorization_result: 'confirmed_termination_prepared',\n execution_result: 'prepared_not_executed',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3536, + -7040 + ], + "id": "b37a3f61-7d9e-42a2-beb2-760e0ffba872", + "name": "Code - Format Prepare Termination Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\nconst intent = $('Code - Intent Parser').item.json;\n\nconst rawQuery =\n intent.employee_query ||\n data.employee_query ||\n '';\n\nconst originalMessage =\n intent.original_message ||\n intent.message ||\n '';\n\nfunction extractEmployeeId(value) {\n const text = String(value || '').trim();\n\n // Caso simple: \"50802\"\n if (/^[0-9]{2,10}$/.test(text)) {\n return text;\n }\n\n // Caso: \"empleado 50802\", \"id 50802\", \"BambooHR ID 50802\"\n const match = text.match(/\\b(?:id|empleado|employee|bamboohr id|bamboohr)\\s*#?\\s*([0-9]{2,10})\\b/i);\n if (match && match[1]) {\n return match[1];\n }\n\n // Último recurso: buscar ID en el mensaje completo,\n // pero solo si aparece junto a una palabra de empleado/ID.\n const contextualMatch = String(originalMessage || '').match(/\\b(?:id|empleado|employee|bamboohr id|bamboohr)\\s*#?\\s*([0-9]{2,10})\\b/i);\n if (contextualMatch && contextualMatch[1]) {\n return contextualMatch[1];\n }\n\n return null;\n}\n\nconst directEmployeeId = extractEmployeeId(rawQuery) || extractEmployeeId(originalMessage);\n\nif (!directEmployeeId) {\n return {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n data.response_text ||\n `No encontré ningún empleado que coincida con \"${rawQuery}\". Puedes intentar con el ID BambooHR directo.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: rawQuery,\n direct_employee_id: null,\n\n authorization_result: 'not_evaluated_employee_not_found',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: true,\n direct_employee_id: String(directEmployeeId),\n\n status: 'pending_direct_employee_lookup',\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: rawQuery,\n direct_employee_id: String(directEmployeeId),\n\n authorization_result: 'pending_direct_employee_lookup',\n execution_result: 'pending_direct_employee_lookup',\n bamboohr_endpoint: `/api/v1/employees/${directEmployeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3872, + -4512 + ], + "id": "126b4979-c77e-4640-8b19-b5c9b2ff323c", + "name": "Code - Check Direct Employee ID Fallback" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "0deee06b-09ed-4625-9d1a-496ee8ed3b18", + "leftValue": "={{ $json.direct_employee_id_fallback_available }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -3104, + -4336 + ], + "id": "65ffcff8-b8b0-42be-b9b8-965773255145", + "name": "IF - Has Direct Employee ID?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.direct_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate,employeeNumber", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2704, + -4560 + ], + "id": "bb411b52-5759-4802-a6e7-c198f2f4ad2a", + "name": "HTTP - Get Employee By Direct ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const lookupContext = $('Code - Check Direct Employee ID Fallback').item.json;\nconst employee = $json || {};\n\nconst employeeId =\n employee.id ||\n lookupContext.direct_employee_id ||\n null;\n\nconst displayName =\n employee.displayName ||\n `${employee.firstName || ''} ${employee.lastName || ''}`.trim() ||\n `Empleado ${employeeId}`;\n\nif (!employeeId) {\n return {\n json: {\n ...lookupContext,\n\n employee_found: false,\n direct_employee_id_lookup_success: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n `Intenté buscar el empleado por ID directo, pero BambooHR no devolvió un empleado válido.`,\n\n audit_event: {\n ...lookupContext.audit_event,\n authorization_result: 'not_evaluated_direct_employee_lookup_failed',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: `/api/v1/employees/${lookupContext.direct_employee_id}`\n }\n }\n };\n}\n\nreturn {\n json: {\n ...lookupContext,\n ...employee,\n\n employee_found: true,\n direct_employee_id_lookup_success: true,\n\n employee_query: lookupContext.employee_query || lookupContext.direct_employee_id,\n\n resolved_employee_id: String(employeeId),\n resolved_employee_name: displayName,\n\n status: 'employee_resolved_by_direct_id',\n can_continue: true,\n response_type: 'chat_message',\n\n response_text: null,\n\n audit_event: {\n ...lookupContext.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: displayName,\n target_employee_email: employee.workEmail || null,\n\n authorization_result: 'direct_employee_lookup_success',\n execution_result: 'employee_resolved_by_direct_id',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2384, + -4560 + ], + "id": "1e200e58-2b3b-4e63-8615-35cc2fad5871", + "name": "Code - Normalize Direct Employee Resolve" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst action = data.action || {};\nconst payload = action.payload || {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.displayName ||\n data.employee_query ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.workEmail ||\n data.email ||\n null;\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst terminationDate = normalizeText(payload.terminationDate);\nconst terminationReason = normalizeText(payload.terminationReason);\nconst terminationType = normalizeText(payload.terminationType);\n\nconst missing = [];\n\nif (!employeeId) missing.push('employeeId');\nif (!terminationDate) missing.push('terminationDate');\nif (!terminationReason) missing.push('terminationReason');\nif (!terminationType) missing.push('terminationType');\n\nif (missing.length > 0) {\n return {\n json: {\n ...data,\n\n status: 'blocked_missing_termination_payload',\n can_continue: false,\n response_type: 'error_message',\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n missing_fields: missing,\n\n response_text:\n `No puedo preparar la desvinculación real porque faltan estos datos técnicos: ${missing.join(', ')}.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n authorization_result: 'blocked_missing_termination_payload',\n execution_result: 'blocked_missing_termination_payload',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n// Importante:\n// Este payload está preparado para employmentStatus.\n// Todavía NO se envía a BambooHR.\nconst terminationPayload = {\n date: terminationDate,\n employmentStatus: 'Terminated',\n comment: `Desvinculación registrada por BambooHR Agent. Motivo: ${terminationReason}. Tipo: ${terminationType}.`,\n terminationReason,\n terminationType\n};\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_payload_built',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_payload: terminationPayload,\n\n termination_data: {\n terminationDate,\n terminationReason,\n terminationType\n },\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_payload: terminationPayload,\n\n authorization_result: 'confirmed_termination_execute',\n execution_result: 'termination_payload_built',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2416, + -6064 + ], + "id": "d40fbbfe-b6d4-4b79-91d5-c2b3d72d7b71", + "name": "Code - Build Termination Payload" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2624, + -6064 + ], + "id": "1a907dec-d167-4e40-b2b9-ec17a2aae10e", + "name": "HTTP - Get employmentStatus Before Termination", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build Termination Payload').item.json;\nconst expected = buildContext.termination_payload || {};\nconst terminationData = buildContext.termination_data || {};\n\nfunction isEmptyObject(value) {\n return (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employmentStatus)) return body.employmentStatus;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(row => row && !isEmptyObject(row));\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction rowLooksTerminated(row) {\n const values = [\n row.employmentStatus,\n row.status,\n row.employmentStatusId,\n row.employeeStatus,\n row.comment\n ].map(normalizeValue);\n\n return values.some(value =>\n value.includes('terminated') ||\n value.includes('termination') ||\n value.includes('terminado') ||\n value.includes('terminacion') ||\n value.includes('desvinculado') ||\n value.includes('desvinculacion') ||\n value.includes('inactivo') ||\n value.includes('inactive')\n );\n}\n\nconst rows = normalizeRows(items);\n\nconst sameDateRows = rows.filter(row =>\n normalizeValue(row.date) === normalizeValue(expected.date)\n);\n\nconst sameDateTerminationRows = sameDateRows.filter(row => rowLooksTerminated(row));\n\nconst anyTerminationRows = rows.filter(row => rowLooksTerminated(row));\n\nif (sameDateTerminationRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'termination_duplicate_same_date',\n can_continue: false,\n response_type: 'error_message',\n\n termination_add_allowed: false,\n termination_duplicate_found: true,\n termination_duplicate_reason: 'same_date_termination_exists',\n\n existing_employment_status_rows_count: rows.length,\n duplicate_rows_count: sameDateTerminationRows.length,\n duplicate_rows: sameDateTerminationRows.slice(0, 5),\n\n response_text:\n `Bloqueado por seguridad. Ya existe una fila de desvinculación o terminación para este empleado con la misma fecha ${expected.date}.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'blocked_duplicate_termination_same_date',\n execution_result: 'blocked_duplicate_termination_same_date',\n duplicate_rows_count: sameDateTerminationRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n }];\n}\n\nif (anyTerminationRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'termination_existing_termination_found',\n can_continue: false,\n response_type: 'error_message',\n\n termination_add_allowed: false,\n termination_duplicate_found: true,\n termination_duplicate_reason: 'employee_already_has_termination_row',\n\n existing_employment_status_rows_count: rows.length,\n existing_termination_rows_count: anyTerminationRows.length,\n existing_termination_rows: anyTerminationRows.slice(0, 5),\n\n response_text:\n `Bloqueado por seguridad. Este empleado ya tiene una fila histórica que parece indicar terminación/desvinculación.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n` +\n `Fecha solicitada: ${expected.date}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR. Revisa primero el historial employmentStatus.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'blocked_existing_termination_row',\n execution_result: 'blocked_existing_termination_row',\n existing_termination_rows_count: anyTerminationRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n status: 'termination_guard_passed',\n can_continue: true,\n response_type: 'chat_message',\n\n termination_add_allowed: true,\n termination_duplicate_found: false,\n\n existing_employment_status_rows_count: rows.length,\n\n response_text:\n `Validación previa completada. No encontré una desvinculación existente en employmentStatus.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n` +\n `Fecha de salida: ${terminationData.terminationDate}\\n` +\n `Motivo: ${terminationData.terminationReason}\\n` +\n `Tipo: ${terminationData.terminationType}\\n\\n` +\n `Estado: listo para ejecución real, pero todavía no ejecutado.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'termination_guard_passed',\n execution_result: 'termination_guard_passed_no_post_executed',\n existing_employment_status_rows_count: rows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2832, + -6064 + ], + "id": "0cb3c744-ecaf-4359-a548-932d5ba880fb", + "name": "Code - Guard Duplicate Termination" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "6f6d7a55-d318-4d57-88c4-ead0362fabdf", + "leftValue": "={{ $json.termination_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 3040, + -6064 + ], + "id": "15a91be4-864e-4ba7-9089-32f219b13d9f", + "name": "IF - Termination Add Allowed?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: data.status || 'termination_blocked',\n can_continue: false,\n response_type: data.response_type || 'error_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_data: data.termination_data,\n\n termination_add_allowed: false,\n termination_duplicate_found: data.termination_duplicate_found || false,\n termination_duplicate_reason: data.termination_duplicate_reason || null,\n\n response_text:\n data.response_text ||\n `Desvinculación bloqueada por seguridad. No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result:\n data.audit_event?.authorization_result ||\n 'blocked_termination_guard',\n\n execution_result:\n data.audit_event?.execution_result ||\n 'blocked_termination_guard',\n\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3392, + -5936 + ], + "id": "d10bbd31-7e50-4d4b-9d1c-63ed04b16529", + "name": "Code - Format Termination Blocked Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst terminationData = data.termination_data || {};\nconst payload = data.termination_payload || {};\n\nconst responseText =\n `Desvinculación validada y lista para ejecución real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Correo laboral: ${data.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${terminationData.terminationDate || payload.date || 'No disponible'}\\n` +\n `Motivo: ${terminationData.terminationReason || payload.terminationReason || 'No disponible'}\\n` +\n `Tipo de terminación: ${terminationData.terminationType || payload.terminationType || 'No disponible'}\\n\\n` +\n `Validación: sin duplicado detectado en employmentStatus.\\n` +\n `Estado: lista para ejecutar, pero todavía NO ejecutada.\\n\\n` +\n `Siguiente paso: conectar el POST real a employmentStatus solo cuando confirmes continuar.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_ready_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: payload,\n termination_data: terminationData,\n\n termination_add_allowed: true,\n termination_duplicate_found: false,\n\n existing_employment_status_rows_count: data.existing_employment_status_rows_count || 0,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result: 'termination_ready_not_executed',\n execution_result: 'termination_ready_not_executed',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5248, + -6656 + ], + "id": "8e08c21f-1dda-4976-b852-6a571cb70c66", + "name": "Code - Format Termination Ready Response" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/meta/lists", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3424, + -6160 + ], + "id": "4ff27380-7851-4e58-9c25-64fbedc81bd9", + "name": "HTTP - Meta Lists For Termination", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const guardContext = $items('Code - Guard Duplicate Termination')[0].json;\n\nconst terminationData = guardContext.termination_data || {};\nconst requestedReason = terminationData.terminationReason || '';\nconst requestedType = terminationData.terminationType || '';\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getListName(list) {\n if (!list) return '';\n\n return String(\n list.name ||\n list.alias ||\n list.field ||\n list.fieldId ||\n list.id ||\n ''\n );\n}\n\nfunction getListValues(list) {\n if (!list) return [];\n\n if (Array.isArray(list.options)) return list.options;\n if (Array.isArray(list.values)) return list.values;\n if (Array.isArray(list.items)) return list.items;\n if (Array.isArray(list.listOptions)) return list.listOptions;\n if (Array.isArray(list)) return list;\n\n return [];\n}\n\nfunction getOptionId(option) {\n if (!option) return null;\n\n return (\n option.id ??\n option.value ??\n option.listValueId ??\n option.optionId ??\n option.employeeFieldValueId ??\n null\n );\n}\n\nfunction getOptionName(option) {\n if (!option) return '';\n\n return String(\n option.name ||\n option.label ||\n option.value ||\n option.text ||\n option.displayName ||\n ''\n );\n}\n\nfunction optionIsArchived(option) {\n return (\n normalize(option?.archived) === 'yes' ||\n normalize(option?.archived) === 'true' ||\n option?.archived === true\n );\n}\n\nfunction findListByExactName(lists, expectedName) {\n const target = normalize(expectedName);\n\n return lists.find(list => normalize(getListName(list)) === target) || null;\n}\n\nfunction findOptionByExactName(options, expectedName) {\n const target = normalize(expectedName);\n\n return options.find(option =>\n !optionIsArchived(option) &&\n normalize(getOptionName(option)) === target\n ) || null;\n}\n\nfunction findOptionByContains(options, candidates) {\n const normalizedCandidates = candidates\n .filter(Boolean)\n .map(normalize)\n .filter(Boolean);\n\n return options.find(option => {\n if (optionIsArchived(option)) return false;\n\n const optionName = normalize(getOptionName(option));\n\n return normalizedCandidates.some(candidate =>\n optionName === candidate ||\n optionName.includes(candidate) ||\n candidate.includes(optionName)\n );\n }) || null;\n}\n\nfunction resolveTerminationTypeOption(options, requestedType) {\n const type = normalize(requestedType);\n\n if (\n type.includes('voluntaria') ||\n type.includes('voluntary') ||\n type.includes('renuncia')\n ) {\n return findOptionByExactName(options, 'Resignation (Voluntary)');\n }\n\n if (\n type.includes('involuntaria') ||\n type.includes('involuntary') ||\n type.includes('despido') ||\n type.includes('desahucio') ||\n type.includes('termination')\n ) {\n return findOptionByExactName(options, 'Termination (Involuntary)');\n }\n\n if (\n type.includes('death') ||\n type.includes('fallecimiento') ||\n type.includes('muerte')\n ) {\n return findOptionByExactName(options, 'Death');\n }\n\n return findOptionByContains(options, [requestedType]);\n}\n\nfunction resolveTerminationReasonOption(options, requestedReason) {\n const reason = normalize(requestedReason);\n\n if (\n reason.includes('prueba qa') ||\n reason.includes('qa') ||\n reason.includes('test')\n ) {\n return findOptionByExactName(options, 'Otros');\n }\n\n if (\n reason.includes('otros') ||\n reason.includes('otro') ||\n reason.includes('other')\n ) {\n return findOptionByExactName(options, 'Otros');\n }\n\n if (\n reason.includes('renuncia') ||\n reason.includes('voluntaria')\n ) {\n return findOptionByExactName(options, 'Renuncia voluntaria');\n }\n\n if (\n reason.includes('periodo de prueba') ||\n reason.includes('período de prueba') ||\n reason.includes('prueba')\n ) {\n return findOptionByExactName(options, 'Periodo de Prueba');\n }\n\n if (reason.includes('desahucio')) {\n return findOptionByExactName(options, 'Desahucio');\n }\n\n if (\n reason.includes('proyecto') ||\n reason.includes('end of project') ||\n reason.includes('terminacion de proyecto') ||\n reason.includes('terminación de proyecto')\n ) {\n return findOptionByExactName(options, 'Terminación de Proyecto / End of Project');\n }\n\n return findOptionByContains(options, [requestedReason]);\n}\n\nconst lists = items\n .map(item => item.json)\n .filter(Boolean);\n\nconst employmentStatusList = findListByExactName(lists, 'Employment Status');\nconst terminationTypeList = findListByExactName(lists, 'Termination Type');\nconst terminationReasonList = findListByExactName(lists, 'Termination Reason');\n\nconst employmentStatusOptions = getListValues(employmentStatusList);\nconst terminationTypeOptions = getListValues(terminationTypeList);\nconst terminationReasonOptions = getListValues(terminationReasonList);\n\nconst terminatedOption =\n findOptionByExactName(employmentStatusOptions, 'Terminado') ||\n findOptionByExactName(employmentStatusOptions, 'Terminated');\n\nconst typeOption = resolveTerminationTypeOption(\n terminationTypeOptions,\n requestedType\n);\n\nconst reasonOption = resolveTerminationReasonOption(\n terminationReasonOptions,\n requestedReason\n);\n\nconst employmentStatusId = getOptionId(terminatedOption);\nconst terminationTypeId = getOptionId(typeOption);\nconst terminationReasonId = getOptionId(reasonOption);\n\nconst missing = [];\n\nif (!employmentStatusList) {\n missing.push('lista Employment Status');\n}\n\nif (employmentStatusList && (!terminatedOption || !employmentStatusId)) {\n missing.push('opción Terminado/Terminated en Employment Status');\n}\n\nif (!terminationTypeList) {\n missing.push('lista Termination Type');\n}\n\nif (terminationTypeList && (!typeOption || !terminationTypeId)) {\n missing.push(`tipo \"${requestedType}\"`);\n}\n\nif (!terminationReasonList) {\n missing.push('lista Termination Reason');\n}\n\nif (terminationReasonList && (!reasonOption || !terminationReasonId)) {\n missing.push(`motivo \"${requestedReason}\"`);\n}\n\nconst resolvedPayload = {\n date: guardContext.termination_payload?.date || '',\n employmentStatus: employmentStatusId ? String(employmentStatusId) : '',\n comment: guardContext.termination_payload?.comment || '',\n terminationReasonId: terminationReasonId ? String(terminationReasonId) : '',\n terminationTypeId: terminationTypeId ? String(terminationTypeId) : ''\n};\n\nconst mappingReady = missing.length === 0;\n\nreturn [{\n json: {\n ...guardContext,\n\n status: mappingReady\n ? 'termination_list_ids_resolved'\n : 'termination_list_ids_missing',\n\n can_continue: mappingReady,\n response_type: mappingReady ? 'chat_message' : 'error_message',\n\n termination_list_ids_ready: mappingReady,\n missing_termination_list_mappings: missing,\n\n termination_list_mapping: {\n employmentStatus: {\n requested_value: 'Terminado',\n list_found: Boolean(employmentStatusList),\n list_name: employmentStatusList ? getListName(employmentStatusList) : null,\n selected_option: terminatedOption || null,\n selected_id: employmentStatusId ? String(employmentStatusId) : null\n },\n terminationReason: {\n requested_value: requestedReason,\n list_found: Boolean(terminationReasonList),\n list_name: terminationReasonList ? getListName(terminationReasonList) : null,\n selected_option: reasonOption || null,\n selected_id: terminationReasonId ? String(terminationReasonId) : null\n },\n terminationType: {\n requested_value: requestedType,\n list_found: Boolean(terminationTypeList),\n list_name: terminationTypeList ? getListName(terminationTypeList) : null,\n selected_option: typeOption || null,\n selected_id: terminationTypeId ? String(terminationTypeId) : null\n }\n },\n\n termination_payload_resolved: resolvedPayload,\n\n debug_available_termination_lists: {\n employmentStatusListName: employmentStatusList ? getListName(employmentStatusList) : null,\n terminationReasonListName: terminationReasonList ? getListName(terminationReasonList) : null,\n terminationTypeListName: terminationTypeList ? getListName(terminationTypeList) : null,\n\n employmentStatusOptionsPreview: employmentStatusOptions.map(option => ({\n id: getOptionId(option),\n name: getOptionName(option),\n archived: option?.archived ?? null\n })),\n\n terminationReasonSelectedPreview: reasonOption\n ? {\n id: getOptionId(reasonOption),\n name: getOptionName(reasonOption),\n archived: reasonOption?.archived ?? null\n }\n : null,\n\n terminationTypeSelectedPreview: typeOption\n ? {\n id: getOptionId(typeOption),\n name: getOptionName(typeOption),\n archived: typeOption?.archived ?? null\n }\n : null\n },\n\n response_text: mappingReady\n ? `Listas de BambooHR resueltas correctamente para desvinculación.\\n\\n` +\n `Empleado: ${guardContext.employee_name}\\n` +\n `ID BambooHR: ${guardContext.employee_id}\\n\\n` +\n `Employment Status: ${getOptionName(terminatedOption)} (${employmentStatusId})\\n` +\n `Termination Reason: ${getOptionName(reasonOption)} (${terminationReasonId})\\n` +\n `Termination Type: ${getOptionName(typeOption)} (${terminationTypeId})\\n\\n` +\n `Estado: listo para construir POST real, todavía no ejecutado.`\n : `No puedo ejecutar la desvinculación real todavía porque faltan mapeos de listas BambooHR:\\n\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...guardContext.audit_event,\n\n termination_payload_resolved: resolvedPayload,\n termination_list_mapping_missing: missing,\n\n authorization_result: mappingReady\n ? 'termination_list_ids_resolved'\n : 'blocked_missing_termination_list_ids',\n\n execution_result: mappingReady\n ? 'termination_list_ids_resolved_no_post_executed'\n : 'blocked_missing_termination_list_ids',\n\n bamboohr_endpoint: `/api/v1/employees/${guardContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3936, + -6160 + ], + "id": "78197681-8820-498e-b8f8-0261dd8f1143", + "name": "Code - Resolve Termination List IDs" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "881e31f9-bb31-4c3e-bcb5-db38325edc43", + "leftValue": "={{ $json.termination_list_ids_ready }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4576, + -6144 + ], + "id": "8c667f56-545e-4547-8984-6e076473d87f", + "name": "IF - Termination List IDs Ready?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst missing = data.missing_termination_list_mappings || [];\n\nconst responseText =\n `Desvinculación bloqueada antes del POST real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Fecha de salida: ${data.termination_data?.terminationDate || data.termination_payload?.date || 'No disponible'}\\n\\n` +\n `Faltan estos mapeos de listas BambooHR:\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n` +\n `Próximo paso: revisar meta/lists y usar valores exactos de BambooHR para motivo/tipo.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_mapping_blocked',\n can_continue: false,\n response_type: 'error_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_payload_resolved: data.termination_payload_resolved,\n termination_data: data.termination_data,\n\n termination_list_ids_ready: false,\n missing_termination_list_mappings: missing,\n termination_list_mapping: data.termination_list_mapping,\n debug_available_termination_lists: data.debug_available_termination_lists,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result: 'blocked_missing_termination_list_ids',\n execution_result: 'blocked_missing_termination_list_ids',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4912, + -6032 + ], + "id": "17ef9234-002d-499f-9cfb-5ad063575e51", + "name": "Code - Format Termination Mapping Blocked Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst payload = data.termination_payload_resolved || {};\nconst mapping = data.termination_list_mapping || {};\n\nconst responseText =\n `Desvinculación validada con IDs de BambooHR y lista para POST real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Correo laboral: ${data.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${payload.date || 'No disponible'}\\n\\n` +\n `IDs resueltos:\\n` +\n `employmentStatus: ${payload.employmentStatus || 'No disponible'}\\n` +\n `terminationReasonId: ${payload.terminationReasonId || 'No disponible'}\\n` +\n `terminationTypeId: ${payload.terminationTypeId || 'No disponible'}\\n\\n` +\n `Estado: lista para POST real, pero todavía NO ejecutada.\\n` +\n `Siguiente paso: crear HTTP POST final solo si autorizas continuar.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_ready_with_ids_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_payload_resolved: payload,\n termination_data: data.termination_data,\n\n termination_list_ids_ready: true,\n termination_list_mapping: mapping,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n termination_payload_resolved: payload,\n termination_list_mapping: mapping,\n\n authorization_result: 'termination_ready_with_ids_not_executed',\n execution_result: 'termination_ready_with_ids_not_executed',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4912, + -6832 + ], + "id": "23bf3fd4-32e0-44a1-8d9d-b9cf1d556482", + "name": "Code - Format Termination Ready With IDs Response" + }, + { + "parameters": { + "jsCode": "function normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getListName(list) {\n if (!list) return '';\n\n return String(\n list.name ||\n list.alias ||\n list.field ||\n list.fieldId ||\n list.id ||\n ''\n );\n}\n\nfunction getListValues(list) {\n if (!list) return [];\n\n if (Array.isArray(list.options)) return list.options;\n if (Array.isArray(list.values)) return list.values;\n if (Array.isArray(list.items)) return list.items;\n if (Array.isArray(list.listOptions)) return list.listOptions;\n if (Array.isArray(list)) return list;\n\n return [];\n}\n\nfunction getOptionId(option) {\n if (!option) return null;\n\n return (\n option.id ??\n option.value ??\n option.listValueId ??\n option.optionId ??\n option.employeeFieldValueId ??\n null\n );\n}\n\nfunction getOptionName(option) {\n if (!option) return '';\n\n return String(\n option.name ||\n option.label ||\n option.value ||\n option.text ||\n option.displayName ||\n ''\n );\n}\n\nfunction optionIsArchived(option) {\n return (\n normalize(option?.archived) === 'yes' ||\n normalize(option?.archived) === 'true' ||\n option?.archived === true\n );\n}\n\nfunction findRelevantLists(lists) {\n return lists.filter(list => {\n const name = normalize(getListName(list));\n\n return (\n name.includes('employment') ||\n name.includes('status') ||\n name.includes('termination') ||\n name.includes('terminacion') ||\n name.includes('terminación') ||\n name.includes('reason') ||\n name.includes('type') ||\n name.includes('motivo') ||\n name.includes('tipo')\n );\n });\n}\n\nfunction formatOptions(options) {\n return options.map(option => ({\n id: getOptionId(option),\n name: getOptionName(option),\n archived: option?.archived ?? null,\n manageable: option?.manageable ?? null,\n raw: option\n }));\n}\n\nconst lists = items\n .map(item => item.json)\n .filter(Boolean);\n\nconst relevantLists = findRelevantLists(lists);\n\nreturn relevantLists.map(list => {\n const options = getListValues(list);\n\n return {\n json: {\n list_id: list.id ?? null,\n field_id: list.fieldId ?? null,\n list_name: getListName(list),\n manageable: list.manageable ?? null,\n multiple: list.multiple ?? null,\n options_count: options.length,\n active_options: formatOptions(options.filter(option => !optionIsArchived(option))),\n archived_options: formatOptions(options.filter(option => optionIsArchived(option)))\n }\n };\n});" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3728, + -6560 + ], + "id": "8789ce90-06ba-47f7-8617-6fdf11eb674e", + "name": "Code - Inspect Termination Lists" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst payload = data.termination_payload_resolved || {};\nconst mapping = data.termination_list_mapping || {};\n\nconst employeeId = data.employee_id || data.resolved_employee_id || data.id || null;\nconst employeeName = data.employee_name || data.resolved_employee_name || data.displayName || 'Empleado no disponible';\nconst employeeEmail = data.employee_email || data.workEmail || null;\n\nconst postUrl = `/api/v1/employees/${employeeId}/tables/employmentStatus`;\n\nconst terminationReasonIdNumber = Number(payload.terminationReasonId);\nconst terminationTypeIdNumber = Number(payload.terminationTypeId);\n\nconst postBody = {\n date: payload.date,\n\n // Para el POST real BambooHR espera el valor del estatus.\n // Usamos \"Terminated\" porque es el valor de sistema para ejecutar la baja real.\n employmentStatus: 'Terminated',\n\n comment: payload.comment,\n\n // Estos campos sí van como IDs.\n terminationReasonId: Number.isFinite(terminationReasonIdNumber)\n ? terminationReasonIdNumber\n : payload.terminationReasonId,\n\n terminationTypeId: Number.isFinite(terminationTypeIdNumber)\n ? terminationTypeIdNumber\n : payload.terminationTypeId\n};\n\nconst missing = [];\n\nif (!employeeId) missing.push('employee_id');\nif (!postBody.date) missing.push('date');\nif (!postBody.employmentStatus) missing.push('employmentStatus');\nif (!postBody.terminationReasonId) missing.push('terminationReasonId');\nif (!postBody.terminationTypeId) missing.push('terminationTypeId');\n\nif (missing.length > 0) {\n return {\n json: {\n ...data,\n\n status: 'termination_post_preview_blocked_missing_fields',\n can_continue: false,\n response_type: 'error_message',\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_post_ready: false,\n termination_post_url: postUrl,\n termination_post_body: postBody,\n missing_post_fields: missing,\n\n response_text:\n `No puedo preparar el POST real porque faltan campos requeridos: ${missing.join(', ')}.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_post_body: postBody,\n\n authorization_result: 'blocked_missing_termination_post_fields',\n execution_result: 'blocked_missing_termination_post_fields',\n bamboohr_endpoint: postUrl\n }\n }\n };\n}\n\nconst responseText =\n `PREVIEW DEL POST REAL DE DESVINCULACIÓN\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Correo laboral: ${employeeEmail || 'No disponible'}\\n\\n` +\n `Endpoint:\\n` +\n `${postUrl}\\n\\n` +\n `Body que se enviaría:\\n` +\n JSON.stringify(postBody, null, 2) +\n `\\n\\nMapeo usado:\\n` +\n `Employment Status resuelto en listas: ${mapping.employmentStatus?.selected_option?.name || 'No disponible'} (${mapping.employmentStatus?.selected_id || 'No disponible'})\\n` +\n `Employment Status enviado al POST: Terminated\\n` +\n `Termination Reason: ${mapping.terminationReason?.selected_option?.name || 'No disponible'} (${mapping.terminationReason?.selected_id || 'No disponible'})\\n` +\n `Termination Type: ${mapping.terminationType?.selected_option?.name || 'No disponible'} (${mapping.terminationType?.selected_id || 'No disponible'})\\n\\n` +\n `Estado: listo para POST real, pero todavía NO ejecutado.`;\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_post_preview_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_post_ready: true,\n termination_post_url: postUrl,\n termination_post_body: postBody,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_post_body: postBody,\n\n authorization_result: 'termination_post_preview_ready',\n execution_result: 'termination_post_preview_ready_no_post_executed',\n bamboohr_endpoint: postUrl\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5072, + -6304 + ], + "id": "980043b0-9878-4e53-a8eb-275bb15ea54a", + "name": "Code - Preview Termination POST Payload" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n data.displayName ||\n 'Empleado no disponible';\n\nconst requiredToken = employeeId\n ? `EXECUTE_TERMINATION_${employeeId}`\n : null;\n\nconst finalExecutionApproved =\n data.final_execution_approved === true ||\n String(data.final_execution_approved || '').trim().toLowerCase() === 'true';\n\nconst finalExecutionToken =\n String(data.final_execution_token || '').trim();\n\nconst confirmationStatus =\n String(data.confirmation_status || '').trim().toLowerCase();\n\nconst allowed =\n data.termination_post_ready === true &&\n confirmationStatus === 'confirmed_termination_execute' &&\n finalExecutionApproved === true &&\n requiredToken &&\n finalExecutionToken === requiredToken;\n\nif (!allowed) {\n const missing = [];\n\n if (data.termination_post_ready !== true) {\n missing.push('termination_post_ready debe ser true');\n }\n\n if (confirmationStatus !== 'confirmed_termination_execute') {\n missing.push('confirmation_status debe ser confirmed_termination_execute');\n }\n\n if (finalExecutionApproved !== true) {\n missing.push('final_execution_approved debe ser true');\n }\n\n if (!requiredToken || finalExecutionToken !== requiredToken) {\n missing.push(`final_execution_token debe ser ${requiredToken || 'EXECUTE_TERMINATION_'}`);\n }\n\n return {\n json: {\n ...data,\n\n status: 'termination_final_execution_blocked',\n can_continue: false,\n response_type: 'confirmation_request',\n\n employee_id: employeeId,\n employee_name: employeeName,\n\n termination_final_execution_allowed: false,\n final_execution_required_token: requiredToken,\n final_execution_missing_requirements: missing,\n\n response_text:\n `POST real bloqueado por compuerta final de seguridad.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n\\n` +\n `Para ejecutar la desvinculación real, deben cumplirse estos requisitos:\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n final_execution_required_token: requiredToken,\n final_execution_missing_requirements: missing,\n\n authorization_result: 'blocked_final_execution_gate',\n execution_result: 'blocked_final_execution_gate',\n bamboohr_endpoint: data.termination_post_url || null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_final_execution_approved',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n\n termination_final_execution_allowed: true,\n final_execution_required_token: requiredToken,\n\n response_text:\n `Compuerta final aprobada. La siguiente operación será el POST real en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId}`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n\n final_execution_required_token: requiredToken,\n\n authorization_result: 'final_execution_gate_approved',\n execution_result: 'final_execution_gate_approved_pending_post',\n bamboohr_endpoint: data.termination_post_url || null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6000, + -6240 + ], + "id": "2ee40aa4-e2b9-444c-b1fa-71e7e1b45eba", + "name": "Code - Final Termination Execution Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "18b2f79d-1e91-4396-914b-5a66a76b8bdd", + "leftValue": "={{ $json.termination_final_execution_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 6528, + -6192 + ], + "id": "d08f4bba-5eb2-4b68-a436-8fec1b4dd56b", + "name": "IF - Final Termination Execution Approved?" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.termination_post_body }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 7040, + -6320 + ], + "id": "1ac2c7d9-384d-4f91-acc1-ad3897fe4936", + "name": "HTTP - Add Employee employmentStatus Row", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const previewContext = $items('Code - Preview Termination POST Payload')[0].json;\nconst postResponse = $json || {};\n\nconst employeeId =\n previewContext.employee_id ||\n previewContext.resolved_employee_id ||\n previewContext.id ||\n null;\n\nreturn {\n json: {\n ...previewContext,\n\n status: 'termination_post_sent',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n\n termination_post_sent: true,\n termination_post_response: postResponse,\n\n response_text:\n `POST de desvinculación enviado a BambooHR. Verificando resultado en employmentStatus...\\n\\n` +\n `Empleado: ${previewContext.employee_name}\\n` +\n `ID BambooHR: ${employeeId}`,\n\n audit_event: {\n ...previewContext.audit_event,\n\n termination_post_response: postResponse,\n\n authorization_result: 'termination_post_sent',\n execution_result: 'termination_post_sent_pending_verification',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 7376, + -6384 + ], + "id": "29c88cfe-c0e4-4ad5-8ada-e699254eb8ef", + "name": "Code - Normalize Termination POST Result" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 7632, + -6384 + ], + "id": "53594380-f95e-4a48-aad9-0fd8b4605999", + "name": "HTTP - Get employmentStatus After Termination", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const postContext = $items('Code - Normalize Termination POST Result')[0].json;\nconst expected = postContext.termination_post_body || {};\n\nfunction isEmptyObject(value) {\n return (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employmentStatus)) return body.employmentStatus;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(row => row && !isEmptyObject(row));\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst matchingRows = rows.filter(row => {\n return (\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n (\n normalizeValue(row.employmentStatus) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.employmentStatusId) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.status) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.comment).includes('desvinculacion registrada por bamboohr agent') ||\n normalizeValue(row.comment).includes('desvinculación registrada por bamboohr agent')\n )\n );\n});\n\nconst executionConfirmed = matchingRows.length > 0;\nconst confirmedRow = matchingRows[0] || null;\n\nconst status = executionConfirmed\n ? 'termination_executed_confirmed'\n : 'termination_post_sent_unconfirmed';\n\nconst responseText = executionConfirmed\n ? `Desvinculación ejecutada y confirmada en BambooHR.\\n\\n` +\n `Empleado: ${postContext.employee_name}\\n` +\n `ID BambooHR: ${postContext.employee_id}\\n` +\n `Correo laboral: ${postContext.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${expected.date || 'No disponible'}\\n` +\n `Employment Status ID: ${expected.employmentStatus || 'No disponible'}\\n` +\n `Termination Reason ID: ${expected.terminationReasonId || 'No disponible'}\\n` +\n `Termination Type ID: ${expected.terminationTypeId || 'No disponible'}\\n\\n` +\n `Fila confirmada en employmentStatus: ${confirmedRow ? JSON.stringify(confirmedRow, null, 2) : 'No disponible'}`\n : `El POST de desvinculación fue enviado, pero no pude confirmar la fila en employmentStatus.\\n\\n` +\n `Empleado: ${postContext.employee_name}\\n` +\n `ID BambooHR: ${postContext.employee_id}\\n` +\n `Fecha esperada: ${expected.date || 'No disponible'}\\n\\n` +\n `Revisa manualmente el historial employmentStatus en BambooHR antes de reintentar.`;\n\nreturn [{\n json: {\n request_id: postContext.request_id,\n request_started_at: postContext.request_started_at,\n\n status,\n can_continue: executionConfirmed,\n response_type: executionConfirmed ? 'chat_message' : 'error_message',\n\n mode: postContext.mode,\n intent: postContext.intent,\n\n employee_id: postContext.employee_id,\n employee_name: postContext.employee_name,\n employee_email: postContext.employee_email,\n\n action: postContext.action,\n\n termination_post_body: expected,\n termination_post_sent: true,\n termination_execution_confirmed: executionConfirmed,\n\n employment_status_rows_count: rows.length,\n matching_employment_status_rows_count: matchingRows.length,\n confirmed_employment_status_row: confirmedRow,\n\n response_text: responseText,\n\n audit_event: {\n ...postContext.audit_event,\n\n termination_post_body: expected,\n termination_post_sent: true,\n termination_execution_confirmed: executionConfirmed,\n\n employment_status_rows_count: rows.length,\n matching_employment_status_rows_count: matchingRows.length,\n confirmed_employment_status_row: confirmedRow,\n\n authorization_result: 'confirmed_termination_execute',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${postContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8112, + -6384 + ], + "id": "a51c54a1-42a3-4c4a-b272-f30515c58758", + "name": "Code - Format Termination Execution Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst auditEvent = data.audit_event || {};\nconst action = data.action || auditEvent.action || {};\nconst payload =\n data.termination_post_body ||\n data.termination_payload_resolved ||\n data.termination_payload ||\n action.payload ||\n null;\n\nconst requestId =\n data.request_id ||\n auditEvent.request_id ||\n null;\n\nconst requestStartedAt =\n data.request_started_at ||\n auditEvent.request_started_at ||\n null;\n\nconst requestFinishedAt =\n data.request_finished_at ||\n auditEvent.request_finished_at ||\n new Date().toISOString();\n\nconst responseTimeMs =\n data.response_time_ms ??\n auditEvent.response_time_ms ??\n (\n requestStartedAt\n ? Date.now() - new Date(requestStartedAt).getTime()\n : null\n );\n\nconst responseTimeSeconds =\n responseTimeMs !== null && responseTimeMs !== undefined\n ? Number((Number(responseTimeMs) / 1000).toFixed(2))\n : null;\n\nconst auditRow = {\n request_id: requestId,\n\n request_started_at: requestStartedAt,\n request_finished_at: requestFinishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n user_email:\n data.user_email ||\n auditEvent.user_email ||\n null,\n\n user_name:\n data.user_name ||\n auditEvent.user_name ||\n null,\n\n channel:\n data.channel ||\n auditEvent.channel ||\n null,\n\n environment:\n data.environment ||\n auditEvent.environment ||\n null,\n\n mode:\n data.mode ||\n auditEvent.mode ||\n null,\n\n intent:\n data.intent ||\n auditEvent.intent ||\n null,\n\n risk_level:\n data.risk_level ||\n auditEvent.risk_level ||\n null,\n\n target_employee_id:\n data.employee_id ||\n auditEvent.target_employee_id ||\n null,\n\n target_employee_name:\n data.employee_name ||\n auditEvent.target_employee_name ||\n null,\n\n target_employee_email:\n data.employee_email ||\n auditEvent.target_employee_email ||\n null,\n\n action_type:\n action.type ||\n auditEvent.action?.type ||\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n authorization_result:\n auditEvent.authorization_result ||\n data.status ||\n null,\n\n execution_result:\n auditEvent.execution_result ||\n data.status ||\n null,\n\n bamboohr_endpoint:\n auditEvent.bamboohr_endpoint ||\n data.termination_post_url ||\n null,\n\n payload,\n audit_event: auditEvent,\n response_text: data.response_text || null,\n full_response: data\n};\n\nreturn {\n json: {\n ...data,\n audit_log_ready: true,\n audit_log_row: auditRow\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8352, + -6384 + ], + "id": "77e22707-d849-435a-ba40-fe9a5f7be2b0", + "name": "Code - Prepare Audit Log" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_audit_log", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Prefer", + "value": "return=representation" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.audit_log_row }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 8592, + -6368 + ], + "id": "48bf0222-122b-4664-82c5-8dc3914ed685", + "name": "Supabase - Insert Audit Log", + "alwaysOutputData": true + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const responseContext = $items('Code - Prepare Audit Log')[0].json;\nconst auditInsertResult = $json;\n\nreturn {\n json: {\n ...responseContext,\n\n audit_log_inserted: true,\n audit_log_insert_result: auditInsertResult,\n\n audit_event: {\n ...responseContext.audit_event,\n audit_log_inserted: true,\n audit_log_insert_result: auditInsertResult\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8800, + -6368 + ], + "id": "9ac40a4e-ca07-4431-88db-cfc80265cc7f", + "name": "Code - Restore Response After Audit" + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?user_email=eq.' + encodeURIComponent($json.user_email) + '&is_active=eq.true&select=*' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -8304, + -2704 + ], + "id": "46fc792b-dd60-4083-a4df-9dfbdf90c3ab", + "name": "Supabase - Get User Permissions", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const requestContext = $items('Code - Validate Intent')[0].json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase();\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) return value.map(String);\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n if (Array.isArray(parsed)) return parsed.map(String);\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction getRowsFromSupabase(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.data)) return body.data;\n if (body && typeof body === 'object' && Object.keys(body).length > 0) {\n return [body];\n }\n\n return [];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction riskRank(value) {\n const normalized = normalize(value);\n\n const ranks = {\n low: 1,\n medium: 2,\n high: 3,\n critical: 4\n };\n\n return ranks[normalized] || 0;\n}\n\nfunction includesOrWildcard(list, value) {\n const normalizedList = parseArray(list).map(normalize);\n const normalizedValue = normalize(value);\n\n return (\n normalizedList.includes('*') ||\n normalizedList.includes(normalizedValue)\n );\n}\n\nconst permissionRows = getRowsFromSupabase(items);\nconst permission = permissionRows[0] || null;\n\nconst userEmail = requestContext.user_email || null;\nconst mode = requestContext.mode || 'unknown';\nconst intent = requestContext.intent || 'unknown';\nconst riskLevel = requestContext.risk_level || 'unknown';\n\nlet authorizationAllowed = false;\nlet authorizationResult = 'blocked_user_not_authorized';\nlet authorizationReason = '';\n\nif (!userEmail) {\n authorizationResult = 'blocked_missing_user_email';\n authorizationReason = 'No se recibió user_email en la solicitud.';\n} else if (!permission) {\n authorizationResult = 'blocked_user_not_found_in_permissions';\n authorizationReason = `El usuario ${userEmail} no existe o no está activo en bamboohr_agent_user_permissions.`;\n} else if (permission.is_active !== true) {\n authorizationResult = 'blocked_user_inactive';\n authorizationReason = `El usuario ${userEmail} existe, pero está inactivo.`;\n} else if (normalize(permission.role) === 'super_admin') {\n authorizationAllowed = true;\n authorizationResult = 'authorized_super_admin';\n authorizationReason = 'Usuario super_admin autorizado.';\n} else {\n const modeAllowed = includesOrWildcard(permission.allowed_modes, mode);\n const intentAllowed = includesOrWildcard(permission.allowed_intents, intent);\n\n const requestedRiskRank = riskRank(riskLevel);\n const maxRiskRank = riskRank(permission.max_risk_level);\n\n const riskAllowed =\n requestedRiskRank > 0 &&\n maxRiskRank > 0 &&\n requestedRiskRank <= maxRiskRank;\n\n const criticalAllowed =\n normalize(riskLevel) !== 'critical' ||\n permission.can_execute_critical === true;\n\n authorizationAllowed =\n modeAllowed &&\n intentAllowed &&\n riskAllowed &&\n criticalAllowed;\n\n if (!modeAllowed) {\n authorizationResult = 'blocked_mode_not_allowed';\n authorizationReason = `El usuario no tiene permitido el modo \"${mode}\".`;\n } else if (!intentAllowed) {\n authorizationResult = 'blocked_intent_not_allowed';\n authorizationReason = `El usuario no tiene permitido el intent \"${intent}\".`;\n } else if (!riskAllowed) {\n authorizationResult = 'blocked_risk_level_not_allowed';\n authorizationReason = `El riesgo \"${riskLevel}\" excede el máximo permitido \"${permission.max_risk_level}\".`;\n } else if (!criticalAllowed) {\n authorizationResult = 'blocked_critical_action_not_allowed';\n authorizationReason = 'El usuario no puede ejecutar acciones críticas.';\n } else {\n authorizationResult = 'authorized_by_permission_table';\n authorizationReason = 'Usuario autorizado por tabla de permisos.';\n }\n}\n\nif (!authorizationAllowed) {\n return [{\n json: {\n ...requestContext,\n\n can_continue: false,\n authorization_allowed: false,\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n authorization_user_permission: permission,\n\n response_type: 'error_message',\n response_text:\n `Solicitud bloqueada por autorización.\\n\\n` +\n `Usuario: ${userEmail || 'No disponible'}\\n` +\n `Modo: ${mode}\\n` +\n `Intent: ${intent}\\n` +\n `Riesgo: ${riskLevel}\\n\\n` +\n `Motivo: ${authorizationReason}\\n\\n` +\n `No se consultó ni modificó BambooHR.`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: requestContext.user_name || null,\n channel: requestContext.channel || null,\n environment: requestContext.environment || null,\n\n employee_query: requestContext.employee_query || null,\n action: requestContext.action || null,\n\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n execution_result: authorizationResult,\n\n bamboohr_endpoint: null\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...requestContext,\n\n can_continue: true,\n authorization_allowed: true,\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n authorization_user_permission: permission,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: requestContext.user_name || null,\n channel: requestContext.channel || null,\n environment: requestContext.environment || null,\n\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8080, + -2944 + ], + "id": "5ca00b0d-75b2-4ed4-b8a2-e9c609b8c112", + "name": "Code - Authorization Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "151bdf68-4050-4510-99b8-a73c39554ba3", + "leftValue": "={{ $json.authorization_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -7872, + -2944 + ], + "id": "c3ca5e91-a459-44b6-ad32-188f9c829e5c", + "name": "IF - User Authorized?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst authContext = $items('Code - Authorization Gate')[0]?.json || {};\nconst permission =\n data.authorization_user_permission ||\n authContext.authorization_user_permission ||\n null;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n\n if (Array.isArray(parsed)) {\n return parsed\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n 'trinidad': 'trinidad y tobago',\n 'trinidad and tobago': 'trinidad y tobago',\n 'tt': 'trinidad y tobago',\n 't&t': 'trinidad y tobago'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction getEmployeeCountry(record) {\n return (\n record.employee_country ||\n record.resolved_employee_country ||\n record.country ||\n record.location ||\n record.resolved_employee_location ||\n record.employee_location ||\n record.data?.location ||\n null\n );\n}\n\nconst userEmail =\n data.user_email ||\n authContext.user_email ||\n null;\n\nconst role = permission?.role || null;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n data.displayName ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.employee_email ||\n data.workEmail ||\n null;\n\nconst employeeCountryRaw = getEmployeeCountry(data);\nconst employeeCountry = normalizeCountry(employeeCountryRaw);\n\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\nconst allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\nconst isSuperAdmin = normalize(role) === 'super_admin';\n\nconst hasAllCountriesAccess =\n allowedCountriesRaw.length === 0 ||\n allowedCountries.includes('*') ||\n allowedCountries.includes('all') ||\n allowedCountries.includes('todos');\n\nlet scopeAllowed = false;\nlet scopeResult = 'blocked_employee_scope';\nlet scopeReason = '';\n\nif (!permission) {\n scopeAllowed = false;\n scopeResult = 'blocked_missing_permission_context';\n scopeReason = 'No se encontró contexto de permisos del usuario.';\n} else if (isSuperAdmin) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_super_admin';\n scopeReason = 'Usuario super_admin con acceso a todos los empleados.';\n} else if (hasAllCountriesAccess) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_all_countries';\n scopeReason = 'El usuario tiene acceso a todos los países.';\n} else if (!employeeCountry) {\n scopeAllowed = false;\n scopeResult = 'blocked_employee_country_missing';\n scopeReason =\n `No pude verificar el país/ubicación del empleado ${employeeName}. ` +\n `Por seguridad, la solicitud queda bloqueada.`;\n} else if (allowedCountries.includes(employeeCountry)) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_country_match';\n scopeReason =\n `El empleado pertenece a \"${employeeCountryRaw}\" y el usuario tiene permiso para ese país.`;\n} else {\n scopeAllowed = false;\n scopeResult = 'blocked_employee_country_not_allowed';\n scopeReason =\n `El usuario ${userEmail} no tiene permiso para empleados de \"${employeeCountryRaw || 'No disponible'}\". ` +\n `Países permitidos: ${allowedCountriesRaw.join(', ') || 'Ninguno'}.`;\n}\n\nif (!scopeAllowed) {\n return {\n json: {\n ...data,\n\n can_continue: false,\n employee_scope_allowed: false,\n employee_scope_result: scopeResult,\n employee_scope_reason: scopeReason,\n\n employee_id: employeeId ? String(employeeId) : null,\n employee_name: employeeName,\n employee_email: employeeEmail,\n employee_country: employeeCountryRaw,\n\n response_type: 'error_message',\n response_text:\n `Solicitud bloqueada por alcance de empleado.\\n\\n` +\n `Usuario: ${userEmail || 'No disponible'}\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n` +\n `País/ubicación empleado: ${employeeCountryRaw || 'No disponible'}\\n\\n` +\n `Motivo: ${scopeReason}\\n\\n` +\n `No se consultó ni modificó BambooHR.`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n user_email: userEmail,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null,\n\n mode: data.mode || null,\n intent: data.intent || null,\n risk_level: data.risk_level || null,\n\n target_employee_id: employeeId ? String(employeeId) : null,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n target_employee_country: employeeCountryRaw,\n\n action: data.action || null,\n\n authorization_result: scopeResult,\n authorization_reason: scopeReason,\n execution_result: scopeResult,\n\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n can_continue: true,\n employee_scope_allowed: true,\n employee_scope_result: scopeResult,\n employee_scope_reason: scopeReason,\n\n employee_id: employeeId ? String(employeeId) : null,\n employee_name: employeeName,\n employee_email: employeeEmail,\n employee_country: employeeCountryRaw,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n user_email: userEmail,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null,\n\n mode: data.mode || null,\n intent: data.intent || null,\n risk_level: data.risk_level || null,\n\n target_employee_id: employeeId ? String(employeeId) : null,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n target_employee_country: employeeCountryRaw,\n\n authorization_result: scopeResult,\n authorization_reason: scopeReason\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -960, + -5648 + ], + "id": "58501789-c3aa-4161-b738-6b7d66be80a3", + "name": "Code - Employee Scope Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "93936b27-03a9-4730-a8d7-84d12b08fb40", + "leftValue": "={{ $json.employee_scope_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -752, + -5648 + ], + "id": "f4885736-891b-4bc4-bee7-8e4328861e8a", + "name": "IF - Employee Scope Allowed?" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -7184, + -3408 + ], + "id": "272ad4e3-8d68-49a4-b744-b2d430c09a49", + "name": "HTTP - Employees Directory Report", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const requestContext = $items('Code - Authorization Gate')[0].json;\n\nconst permission =\n requestContext.authorization_user_permission ||\n null;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n\n if (Array.isArray(parsed)) {\n return parsed\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n 'trinidad': 'trinidad y tobago',\n 'trinidad and tobago': 'trinidad y tobago',\n 'tt': 'trinidad y tobago',\n 't&t': 'trinidad y tobago'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction getDirectoryEmployees(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n\n if (Array.isArray(body.employees)) return body.employees;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.rows)) return body.rows;\n\n if (body && typeof body === 'object' && Object.keys(body).length > 0) {\n return [body];\n }\n\n return [];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction getEmployeeCountry(employee) {\n return (\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n 'Sin país/ubicación'\n );\n}\n\nfunction getEmployeeDepartment(employee) {\n return (\n employee.department ||\n employee.area ||\n employee.employee_department ||\n 'Sin departamento'\n );\n}\n\nfunction isActiveEmployee(employee) {\n const status = String(employee.status || '').trim();\n\n // El endpoint directory normalmente ya devuelve empleados activos.\n // Si status viene vacío, lo tratamos como incluido.\n if (!status) return true;\n\n return normalize(status) === 'active' || normalize(status) === 'activo';\n}\n\nfunction userHasAllCountryAccess(permission) {\n if (!permission) return false;\n\n if (normalize(permission.role) === 'super_admin') return true;\n\n const allowedCountriesRaw = parseArray(permission.allowed_employee_countries);\n const allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\n return (\n allowedCountriesRaw.length === 0 ||\n allowedCountries.includes('*') ||\n allowedCountries.includes('all') ||\n allowedCountries.includes('todos')\n );\n}\n\nfunction employeeIsAllowedByCountry(employee, permission) {\n if (!permission) return false;\n\n if (userHasAllCountryAccess(permission)) return true;\n\n const allowedCountriesRaw = parseArray(permission.allowed_employee_countries);\n const allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\n const employeeCountry = normalizeCountry(getEmployeeCountry(employee));\n\n return allowedCountries.includes(employeeCountry);\n}\n\nfunction countBy(employees, getKeyFn) {\n const counts = new Map();\n\n for (const employee of employees) {\n const key = String(getKeyFn(employee) || 'Sin dato').trim() || 'Sin dato';\n counts.set(key, (counts.get(key) || 0) + 1);\n }\n\n return [...counts.entries()]\n .map(([name, total]) => ({\n name,\n total\n }))\n .sort((a, b) => {\n if (b.total !== a.total) return b.total - a.total;\n return a.name.localeCompare(b.name);\n });\n}\n\nfunction formatTable(rows, label) {\n if (!rows.length) {\n return `No hay datos disponibles para ${label}.`;\n }\n\n return rows\n .map((row, index) => `${index + 1}. ${row.name}: ${row.total}`)\n .join('\\n');\n}\n\nconst allEmployees = getDirectoryEmployees(items);\nconst activeEmployees = allEmployees.filter(isActiveEmployee);\n\nconst scopedEmployees = activeEmployees.filter(employee =>\n employeeIsAllowedByCountry(employee, permission)\n);\n\nconst reportIntent = requestContext.intent || 'generate_report';\n\nconst countryCounts = countBy(scopedEmployees, getEmployeeCountry);\nconst departmentCounts = countBy(scopedEmployees, getEmployeeDepartment);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nlet reportTitle = 'Reporte de Headcount';\nlet reportBody = '';\n\nif (reportIntent === 'headcount_by_country') {\n reportTitle = 'Headcount por país / ubicación';\n reportBody = formatTable(countryCounts, 'país / ubicación');\n} else if (reportIntent === 'headcount_by_department') {\n reportTitle = 'Headcount por departamento';\n reportBody = formatTable(departmentCounts, 'departamento');\n} else {\n reportTitle = 'Reporte general de Headcount';\n\n reportBody =\n `Por país / ubicación:\\n` +\n `${formatTable(countryCounts, 'país / ubicación')}\\n\\n` +\n `Por departamento:\\n` +\n `${formatTable(departmentCounts, 'departamento')}`;\n}\n\nconst scopeText = hasAllCountries\n ? 'Alcance: todos los países.'\n : `Alcance: ${allowedCountriesRaw.join(', ') || 'Sin países permitidos'}.`;\n\nconst responseText =\n `${reportTitle}\\n\\n` +\n `Total empleados incluidos: ${scopedEmployees.length}\\n` +\n `Total empleados activos/directorio leídos: ${activeEmployees.length}\\n` +\n `${scopeText}\\n\\n` +\n `${reportBody}`;\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'report_generated',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: reportIntent,\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n\n report_country_counts: countryCounts,\n report_department_counts: departmentCounts,\n\n response_text: responseText,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode: requestContext.mode,\n intent: requestContext.intent,\n risk_level: requestContext.risk_level,\n\n user_email: requestContext.user_email,\n user_name: requestContext.user_name,\n channel: requestContext.channel,\n environment: requestContext.environment,\n\n authorization_result:\n requestContext.authorization_result ||\n 'authorized_report',\n\n authorization_reason:\n requestContext.authorization_reason ||\n 'Reporte autorizado.',\n\n execution_result: 'report_generated',\n\n report_type: reportIntent,\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n report_country_counts: countryCounts,\n report_department_counts: departmentCounts,\n\n bamboohr_endpoint: '/api/v1/employees/directory'\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -6928, + -3408 + ], + "id": "7dc7f8d1-84bd-4dff-9c54-7bfe4c7e7f41", + "name": "Code - Format Headcount Report" + }, + { + "parameters": { + "httpMethod": "POST", + "path": "bamboohr-agent-google-chat", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -9728, + -2736 + ], + "id": "f689f4e1-63a4-436c-a15f-9859a9b8a0fd", + "name": "Webhook - Google Chat Incoming", + "webhookId": "9a013eb7-1d67-4abf-b98b-22bb1d65b323" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nfunction getNested(obj, path, fallback = null) {\n try {\n return path\n .split('.')\n .reduce((current, key) => {\n if (current === undefined || current === null) return undefined;\n return current[key];\n }, obj) ?? fallback;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction cleanMessageText(value) {\n return String(value || '')\n .replace(/]+>/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst eventType =\n input.type ||\n input.eventType ||\n getNested(input, 'body.type') ||\n null;\n\nconst body =\n input.body && typeof input.body === 'object'\n ? input.body\n : input;\n\nconst rawText =\n getNested(body, 'message.text') ||\n getNested(body, 'message.argumentText') ||\n getNested(body, 'text') ||\n getNested(body, 'argumentText') ||\n '';\n\nconst message = cleanMessageText(rawText);\n\nconst senderEmail =\n getNested(body, 'message.sender.email') ||\n getNested(body, 'user.email') ||\n getNested(body, 'sender.email') ||\n null;\n\nconst senderName =\n getNested(body, 'message.sender.displayName') ||\n getNested(body, 'user.displayName') ||\n getNested(body, 'sender.displayName') ||\n senderEmail ||\n 'Usuario Google Chat';\n\nconst spaceName =\n getNested(body, 'space.name') ||\n getNested(body, 'message.space.name') ||\n null;\n\nconst spaceDisplayName =\n getNested(body, 'space.displayName') ||\n getNested(body, 'message.space.displayName') ||\n null;\n\nconst messageName =\n getNested(body, 'message.name') ||\n null;\n\nconst threadName =\n getNested(body, 'message.thread.name') ||\n getNested(body, 'thread.name') ||\n null;\n\nconst requestId =\n `gchat_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nif (!message) {\n return {\n json: {\n request_id: requestId,\n request_started_at: new Date().toISOString(),\n\n user_email: senderEmail,\n user_name: senderName,\n\n channel: 'google_chat',\n environment: 'development',\n\n message: '',\n original_message: '',\n\n google_chat_event_type: eventType,\n google_chat_space_name: spaceName,\n google_chat_space_display_name: spaceDisplayName,\n google_chat_message_name: messageName,\n google_chat_thread_name: threadName,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No recibí texto para procesar. Escribe una consulta o acción para BambooHR.'\n }\n };\n}\n\nreturn {\n json: {\n request_id: requestId,\n request_started_at: new Date().toISOString(),\n\n user_email: senderEmail,\n user_name: senderName,\n\n channel: 'google_chat',\n environment: 'development',\n\n message,\n original_message: message,\n\n google_chat_event_type: eventType,\n google_chat_space_name: spaceName,\n google_chat_space_display_name: spaceDisplayName,\n google_chat_message_name: messageName,\n google_chat_thread_name: threadName\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -9408, + -2736 + ], + "id": "ca5fb655-16b5-4211-a983-ced7e5adfec5", + "name": "Code - Normalize Google Chat Input" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { \"text\": $json.response_text || \"Solicitud procesada.\" } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 16368, + -2448 + ], + "id": "351687ba-d3c4-490f-aea4-966c1fccacaa", + "name": "Respond - Google Chat" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "de41bff6-1867-406f-866d-9ddd37c9278b", + "leftValue": "={{ $json.channel || $json.audit_event?.channel || $json.data?.channel }}", + "rightValue": "google_chat", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 16032, + -2448 + ], + "id": "8ae4c143-2864-41f7-ac23-c207d86520b1", + "name": "IF - Is Google Chat Channel?" + } + ], + "connections": { + "HTTP - Employees Directory": { + "main": [ + [ + { + "node": "Split Out - Employees", + "type": "main", + "index": 0 + } + ] + ] + }, + "BambooHR Agent Core": { + "main": [ + [ + { + "node": "Set - Incoming Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set - Incoming Message": { + "main": [ + [ + { + "node": "Code - Request Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Intent Parser": { + "main": [ + [ + { + "node": "Code - Validate Intent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Validate Intent": { + "main": [ + [ + { + "node": "IF - Can Continue?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Mode Router": { + "main": [ + [ + { + "node": "HTTP - Employees Directory", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Switch - Action Needs Existing Employee?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Employees Directory Report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Out - Employees": { + "main": [ + [ + { + "node": "Code - Resolve Employee", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Resolve Employee": { + "main": [ + [ + { + "node": "IF - Employee Found?", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee By ID": { + "main": [ + [ + { + "node": "Code - Format Consulta Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Consulta Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Can Continue?": { + "main": [ + [ + { + "node": "Supabase - Get User Permissions", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Employee Found?": { + "main": [ + [ + { + "node": "Code - Employee Scope Gate", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Check Direct Employee ID Fallback", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Request Context": { + "main": [ + [ + { + "node": "Code - Intent Parser", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Consulta Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee By ID", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Switch - Historical Table Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee Missing Fields", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee jobInfo Table": { + "main": [ + [ + { + "node": "Code - Format jobInfo Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format jobInfo Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee Missing Fields": { + "main": [ + [ + { + "node": "Code - Format Missing Fields Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Missing Fields Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee Files": { + "main": [ + [ + { + "node": "Code - Format Employee Files Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Employee Files Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Historical Table Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee jobInfo Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee employmentStatus Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Sensitive Consulta Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee employmentStatus Table": { + "main": [ + [ + { + "node": "Code - Format employmentStatus Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format employmentStatus Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Sensitive Consulta Guard": { + "main": [ + [ + { + "node": "IF - Sensitive Access Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Sensitive Access Approved?": { + "main": [ + [ + { + "node": "HTTP - Get Employee compensation Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee compensation Table": { + "main": [ + [ + { + "node": "Code - Format compensation Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format compensation Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Agent Response": { + "main": [ + [ + { + "node": "IF - Is Google Chat Channel?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Post Resolve Mode": { + "main": [ + [ + { + "node": "Switch - Consulta Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Action Confirmation Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Action Confirmation Guard": { + "main": [ + [ + { + "node": "IF - Action Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Action Approved?": { + "main": [ + [ + { + "node": "Switch - Action Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee For Action": { + "main": [ + [ + { + "node": "Code - Build Update Employee Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Update Employee Payload": { + "main": [ + [ + { + "node": "HTTP - Update Employee Basic", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Update Employee Basic": { + "main": [ + [ + { + "node": "HTTP - Get Employee After Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee After Update": { + "main": [ + [ + { + "node": "Code - Format Action Update Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Action Update Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Action Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee For Action", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Build jobInfo Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Prepare Termination Response", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Build Termination Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build jobInfo Payload": { + "main": [ + [ + { + "node": "HTTP - Get jobInfo Before Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Employee jobInfo Row": { + "main": [ + [ + { + "node": "HTTP - Get jobInfo After Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get jobInfo After Add": { + "main": [ + [ + { + "node": "Code - Format jobInfo Action Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format jobInfo Action Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get jobInfo Before Add": { + "main": [ + [ + { + "node": "Code - Guard Duplicate jobInfo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate jobInfo": { + "main": [ + [ + { + "node": "IF - jobInfo Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - jobInfo Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Add Employee jobInfo Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Action Needs Existing Employee?": { + "main": [ + [ + { + "node": "HTTP - Employees Directory", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Create Employee Confirmation Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Create Employee Confirmation Guard": { + "main": [ + [ + { + "node": "IF - Create Employee Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Create Employee Approved?": { + "main": [ + [ + { + "node": "Code - Build Create Employee Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Create Employee Payload": { + "main": [ + [ + { + "node": "HTTP - Employees Directory For Create Check", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Employees Directory For Create Check": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Create Employee", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Create Employee": { + "main": [ + [ + { + "node": "IF - Create Employee Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Create Employee Allowed?": { + "main": [ + [ + { + "node": "HTTP - Create Employee", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Create Employee": { + "main": [ + [ + { + "node": "Code - Normalize Created Employee ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Created Employee ID": { + "main": [ + [ + { + "node": "Code - Build Created Employee jobInfo Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee By ID": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee jobInfo Table", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Create Employee Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Created Employee jobInfo Payload": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee jobInfo Before Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee jobInfo Before Add": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Created Employee jobInfo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Created Employee jobInfo": { + "main": [ + [ + { + "node": "IF - Created Employee jobInfo Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Created Employee jobInfo Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Add Created Employee jobInfo Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Created Employee By ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Created Employee jobInfo Row": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee By ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee jobInfo Table": { + "main": [ + [ + { + "node": "Code - Format Create Employee Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Prepare Termination Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Check Direct Employee ID Fallback": { + "main": [ + [ + { + "node": "IF - Has Direct Employee ID?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Has Direct Employee ID?": { + "main": [ + [ + { + "node": "HTTP - Get Employee By Direct ID", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee By Direct ID": { + "main": [ + [ + { + "node": "Code - Normalize Direct Employee Resolve", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Direct Employee Resolve": { + "main": [ + [ + { + "node": "Code - Employee Scope Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Termination Payload": { + "main": [ + [ + { + "node": "HTTP - Get employmentStatus Before Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get employmentStatus Before Termination": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Termination": { + "main": [ + [ + { + "node": "IF - Termination Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Termination Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Meta Lists For Termination", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Ready Response": { + "main": [ + [] + ] + }, + "Code - Format Termination Blocked Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Meta Lists For Termination": { + "main": [ + [ + { + "node": "Code - Resolve Termination List IDs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Resolve Termination List IDs": { + "main": [ + [ + { + "node": "IF - Termination List IDs Ready?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Termination List IDs Ready?": { + "main": [ + [ + { + "node": "Code - Preview Termination POST Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Mapping Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Mapping Blocked Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Ready With IDs Response": { + "main": [ + [] + ] + }, + "Code - Inspect Termination Lists": { + "main": [ + [] + ] + }, + "Code - Preview Termination POST Payload": { + "main": [ + [ + { + "node": "Code - Final Termination Execution Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Final Termination Execution Gate": { + "main": [ + [ + { + "node": "IF - Final Termination Execution Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Final Termination Execution Approved?": { + "main": [ + [ + { + "node": "HTTP - Add Employee employmentStatus Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Employee employmentStatus Row": { + "main": [ + [ + { + "node": "Code - Normalize Termination POST Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Termination POST Result": { + "main": [ + [ + { + "node": "HTTP - Get employmentStatus After Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get employmentStatus After Termination": { + "main": [ + [ + { + "node": "Code - Format Termination Execution Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Execution Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Prepare Audit Log": { + "main": [ + [ + { + "node": "Supabase - Insert Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Insert Audit Log": { + "main": [ + [ + { + "node": "Code - Restore Response After Audit", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Restore Response After Audit": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Get User Permissions": { + "main": [ + [ + { + "node": "Code - Authorization Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Authorization Gate": { + "main": [ + [ + { + "node": "IF - User Authorized?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - User Authorized?": { + "main": [ + [ + { + "node": "Switch - Mode Router", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Employee Scope Gate": { + "main": [ + [ + { + "node": "IF - Employee Scope Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Employee Scope Allowed?": { + "main": [ + [ + { + "node": "Switch - Post Resolve Mode", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Employees Directory Report": { + "main": [ + [ + { + "node": "Code - Format Headcount Report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Headcount Report": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook - Google Chat Incoming": { + "main": [ + [ + { + "node": "Code - Normalize Google Chat Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Google Chat Input": { + "main": [ + [ + { + "node": "Code - Request Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Is Google Chat Channel?": { + "main": [ + [ + { + "node": "Respond - Google Chat", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "staticData": null, + "meta": null, + "versionId": "8d2bf921-fa4f-4771-a4ff-fc8b4be0a7e3", + "activeVersionId": "8d2bf921-fa4f-4771-a4ff-fc8b4be0a7e3", + "versionCounter": 1055, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-06-25T13:03:04.448Z", + "createdAt": "2026-06-25T13:03:04.448Z", + "role": "workflow:owner", + "workflowId": "5VOiadzdgbs2Qq1N", + "projectId": "PJpTANzTXIFibWsW", + "project": { + "updatedAt": "2026-04-22T14:25:09.686Z", + "createdAt": "2026-04-22T14:22:54.790Z", + "id": "PJpTANzTXIFibWsW", + "name": "Isaac Aracena ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-06-27T13:31:04.000Z", + "createdAt": "2026-06-27T13:30:36.912Z", + "versionId": "8d2bf921-fa4f-4771-a4ff-fc8b4be0a7e3", + "workflowId": "5VOiadzdgbs2Qq1N", + "nodes": [ + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -6000, + -4672 + ], + "id": "276ea916-3fab-46f5-a708-38ce0cce5bbe", + "name": "HTTP - Employees Directory", + "retryOnFail": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -9728, + -2512 + ], + "id": "5c339d4f-7a43-497c-813c-10a0fe9edc8a", + "name": "BambooHR Agent Core" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "{\n \"user_email\": \"rrhh.guatemala.test@gomezleemarketing.com\",\n \"user_name\": \"RRHH Guatemala Test\",\n \"channel\": \"manual_test\",\n \"environment\": \"development\",\n \"message\": \"Dame el headcount por país\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -9520, + -2512 + ], + "id": "ca88cf95-285f-4389-84c9-458198f82f94", + "name": "Set - Incoming Message" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nconst originalMessage =\n input.message ||\n input.original_message ||\n '';\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .trim();\n}\n\nfunction cleanEmployeeQuery(value) {\n return String(value || '')\n .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractEmployeeQuery(message) {\n const rawOriginal = String(message || '').trim();\n\n const raw = rawOriginal\n .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n const normalizedRaw = normalize(raw);\n\n // Si es creación/preparación de empleado nuevo, NO buscamos empleado existente.\n if (\n (\n normalizedRaw.includes('prepara') ||\n normalizedRaw.includes('preparar') ||\n normalizedRaw.includes('crea') ||\n normalizedRaw.includes('crear') ||\n normalizedRaw.includes('agrega') ||\n normalizedRaw.includes('agregar')\n ) &&\n (\n normalizedRaw.includes('empleado') ||\n normalizedRaw.includes('colaborador')\n ) &&\n (\n normalizedRaw.includes('creacion') ||\n normalizedRaw.includes('nuevo') ||\n normalizedRaw.includes('correo') ||\n normalizedRaw.includes('email') ||\n normalizedRaw.includes('fecha de ingreso')\n ) &&\n !(\n normalizedRaw.includes('desvinculacion') ||\n normalizedRaw.includes('terminacion') ||\n normalizedRaw.includes('salida')\n )\n ) {\n return null;\n }\n\n const emailMatch = rawOriginal.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n if (emailMatch) {\n return emailMatch[0].trim();\n }\n\n // Caso especial para jobInfo:\n // \"Agrega jobInfo a Batman con fecha...\"\n // Debe capturar \"Batman\", no \"Gestor de Experiencia\".\n const jobInfoEmployeePatterns = [\n /\\bjobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bjob info\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bagrega\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bagregar\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\bañade\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\banade\\s+jobinfo\\s+a\\s+(.+?)\\s+con\\s+/i\n ];\n\n for (const pattern of jobInfoEmployeePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso especial para desvinculación:\n // \"Prepara desvinculación de Batman con fecha...\"\n const terminationEmployeePatterns = [\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+de\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+del\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:prepara|preparar)\\s+(?:la\\s+)?(?:desvinculacion|desvinculación|terminacion|terminación|salida)\\s+de\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+a\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+empleado\\s+(.+?)\\s+con\\s+/i,\n /\\b(?:desvincular|desvincula|terminar|termina)\\s+al\\s+empleado\\s+(.+?)\\s+con\\s+/i\n ];\n\n for (const pattern of terminationEmployeePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso: \"empleado Batman\", \"colaborador Batman\", \"persona Batman\"\n const directPatterns = [\n /empleado\\s+(.+)$/i,\n /colaborador\\s+(.+)$/i,\n /persona\\s+(.+)$/i\n ];\n\n for (const pattern of directPatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n // Caso: \"de Batman\", \"del empleado Batman\", \"para Batman\", \"sobre Batman\"\n // No usamos \"a\" de forma general porque rompe frases como \"cargo Gestor de Experiencia\".\n const connectorMatches = [\n ...raw.matchAll(/\\b(de|del|para|sobre)\\b\\s+([^¿?¡!.,;:]+?)(?=\\s+\\b(de|del|para|sobre|con|fecha|cargo|puesto|departamento|division|división|ubicacion|ubicación|supervisor|jefe|motivo|tipo)\\b|$)/gi)\n ];\n\n if (connectorMatches.length > 0) {\n const lastMatch = connectorMatches[connectorMatches.length - 1];\n let candidate = cleanEmployeeQuery(lastMatch[2]);\n\n candidate = candidate\n .replace(/^la\\s+/i, '')\n .replace(/^el\\s+/i, '')\n .replace(/^los\\s+/i, '')\n .replace(/^las\\s+/i, '')\n .replace(/^empleado\\s+/i, '')\n .replace(/^colaborador\\s+/i, '')\n .replace(/\\s+(a|por|en|con|fecha|cargo|puesto|departamento|division|división|ubicacion|ubicación|supervisor|jefe|motivo|tipo)\\s+.+$/i, '')\n .trim();\n\n const candidateNormalized = normalize(candidate);\n\n const blockedCandidates = [\n 'cargo',\n 'cargo actual',\n 'puesto',\n 'posicion',\n 'departamento',\n 'division',\n 'ubicacion',\n 'supervisor',\n 'correo',\n 'telefono',\n 'extension',\n 'estatus',\n 'fecha de ingreso',\n 'fecha de salida',\n 'informacion general',\n 'datos generales',\n 'historial',\n 'historial de cargo',\n 'datos faltantes',\n 'campos faltantes',\n 'informacion faltante',\n 'datos incompletos',\n 'archivos',\n 'documentos',\n 'salario',\n 'sueldo',\n 'compensacion',\n 'jobinfo',\n 'job info',\n 'experiencia',\n 'desvinculacion',\n 'terminacion',\n 'salida',\n 'motivo',\n 'tipo'\n ];\n\n if (candidate && !blockedCandidates.includes(candidateNormalized)) {\n return candidate;\n }\n }\n\n // Caso: \"¿En qué departamento está Batman?\"\n const estaMatch = raw.match(/\\b(?:esta|está)\\s+(.+)$/i);\n if (estaMatch && estaMatch[1]) {\n return cleanEmployeeQuery(estaMatch[1]);\n }\n\n // Caso: \"¿Qué datos faltantes tiene Batman?\"\n const tieneMatch = raw.match(/\\b(?:tiene|tendrá|tendra)\\s+(.+)$/i);\n if (tieneMatch && tieneMatch[1]) {\n return cleanEmployeeQuery(tieneMatch[1]);\n }\n\n return null;\n}\n\nfunction extractActionValue(message, field) {\n const raw = String(message || '').trim();\n\n if (field === 'workEmail') {\n const emailMatch = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n return emailMatch ? emailMatch[0].trim() : null;\n }\n\n if (field === 'workPhone') {\n const phonePatterns = [\n /\\b(?:telefono|teléfono|phone|celular|movil|móvil|telefono laboral|teléfono laboral)\\b\\s*(?:a|por|en|=|:)?\\s*([0-9+\\-\\s().]{7,25})\\b/i,\n /\\b(?:a|por|en|=|:)\\s*([0-9+\\-\\s().]{7,25})\\s*$/i\n ];\n\n for (const pattern of phonePatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/\\s+/g, '')\n .trim();\n }\n }\n\n return null;\n }\n\n if (field === 'workPhoneExtension') {\n const extensionPatterns = [\n /\\b(?:extension|extensión|ext laboral|extension laboral)\\b\\s*(?:a|por|en|=|:)?\\s*([0-9]{1,10})\\b/i,\n /\\b(?:a|por|en|=|:)\\s*([0-9]{1,10})\\s*$/i\n ];\n\n for (const pattern of extensionPatterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1]).trim();\n }\n }\n\n return null;\n }\n\n return null;\n}\n\nfunction escapeRegex(value) {\n return String(value || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction extractValueAfterLabel(message, labels) {\n const raw = String(message || '').trim();\n\n for (const label of labels) {\n const escapedLabel = escapeRegex(label);\n\n const pattern = new RegExp(\n `(?:^|\\\\s|,)${escapedLabel}\\\\s*(?:es|=|:)?\\\\s*([^,;\\\\n]+)`,\n 'i'\n );\n\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractDateValue(message) {\n const raw = String(message || '').trim();\n\n const isoDateMatch = raw.match(/\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/);\n if (isoDateMatch && isoDateMatch[1]) {\n return isoDateMatch[1];\n }\n\n return null;\n}\n\nfunction extractJobInfoPayload(message) {\n const date = extractDateValue(message);\n\n const jobTitle = extractValueAfterLabel(message, [\n 'cargo',\n 'puesto',\n 'posicion',\n 'posición',\n 'job title'\n ]);\n\n const department = extractValueAfterLabel(message, [\n 'departamento',\n 'area',\n 'área'\n ]);\n\n const division = extractValueAfterLabel(message, [\n 'division',\n 'división',\n 'unidad de negocio',\n 'bu'\n ]);\n\n const location = extractValueAfterLabel(message, [\n 'ubicacion',\n 'ubicación',\n 'pais',\n 'país',\n 'location'\n ]);\n\n const reportsTo = extractValueAfterLabel(message, [\n 'supervisor',\n 'jefe',\n 'reporta a',\n 'reports to',\n 'reportsTo'\n ]);\n\n return {\n date,\n jobTitle,\n department,\n division,\n location,\n reportsTo\n };\n}\n\nfunction extractEmployeeFullNameForCreate(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:crear|crea|agregar|agrega|preparar|prepara)(?:\\s+(?:la\\s+)?(?:creacion|creación)\\s+de)?\\s+(?:nuevo\\s+)?empleado\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:crear|crea|agregar|agrega|preparar|prepara)(?:\\s+(?:la\\s+)?(?:creacion|creación)\\s+de)?\\s+(?:nuevo\\s+)?colaborador\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:creacion|creación)\\s+de\\s+(?:nuevo\\s+)?empleado\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i,\n /\\b(?:creacion|creación)\\s+de\\s+(?:nuevo\\s+)?colaborador\\s+(.+?)(?=\\s+con\\s+|\\s*,|$)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return cleanEmployeeQuery(match[1]);\n }\n }\n\n return null;\n}\n\nfunction splitFullName(fullName) {\n const parts = String(fullName || '')\n .replace(/\\s+/g, ' ')\n .trim()\n .split(' ')\n .filter(Boolean);\n\n if (parts.length === 0) {\n return {\n firstName: null,\n lastName: null\n };\n }\n\n if (parts.length === 1) {\n return {\n firstName: parts[0],\n lastName: null\n };\n }\n\n return {\n firstName: parts[0],\n lastName: parts.slice(1).join(' ')\n };\n}\n\nfunction extractEmailValue(message) {\n const raw = String(message || '').trim();\n const emailMatch = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n return emailMatch ? emailMatch[0].trim() : null;\n}\n\nfunction extractHireDateValue(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:fecha de ingreso|fecha ingreso|ingreso|hire date)\\b\\s*(?:es|=|:)?\\s*(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/i,\n /\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return match[1];\n }\n }\n\n return null;\n}\n\nfunction extractCreateEmployeePayload(message) {\n const fullName = extractEmployeeFullNameForCreate(message);\n const nameParts = splitFullName(fullName);\n\n const workEmail = extractEmailValue(message);\n\n const location = extractValueAfterLabel(message, [\n 'pais',\n 'país',\n 'ubicacion',\n 'ubicación',\n 'location'\n ]);\n\n const department = extractValueAfterLabel(message, [\n 'departamento',\n 'area',\n 'área'\n ]);\n\n const division = extractValueAfterLabel(message, [\n 'division',\n 'división',\n 'unidad de negocio',\n 'bu'\n ]);\n\n const jobTitle = extractValueAfterLabel(message, [\n 'cargo',\n 'puesto',\n 'posicion',\n 'posición',\n 'job title'\n ]);\n\n const hireDate = extractHireDateValue(message);\n\n return {\n fullName,\n firstName: nameParts.firstName,\n lastName: nameParts.lastName,\n workEmail,\n location,\n department,\n division,\n jobTitle,\n hireDate\n };\n}\n\nfunction extractTerminationDateValue(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:fecha de salida|fecha salida|fecha de desvinculacion|fecha de desvinculación|fecha de terminacion|fecha de terminación|salida|desvinculacion|desvinculación|terminacion|terminación)\\b\\s*(?:es|=|:)?\\s*(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/i,\n /\\b(20[0-9]{2}-[0-9]{2}-[0-9]{2})\\b/\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return match[1];\n }\n }\n\n return null;\n}\n\nfunction extractTerminationReason(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:motivo|razon|razón|reason)\\b\\s*(?:es|=|:)?\\s*([^,;\\n]+)/i,\n /\\b(?:por motivo de|por razon de|por razón de)\\s+([^,;\\n]+)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractTerminationType(message) {\n const raw = String(message || '').trim();\n\n const patterns = [\n /\\b(?:tipo de terminacion|tipo de terminación|tipo de salida|tipo)\\b\\s*(?:es|=|:)?\\s*([^,;\\n]+)/i,\n /\\b(?:terminacion|terminación|salida|desvinculacion|desvinculación)\\s+(voluntaria|involuntaria|renuncia|desahucio|despido|mutuo acuerdo)\\b/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n return String(match[1])\n .replace(/[.]+$/g, '')\n .trim();\n }\n }\n\n return null;\n}\n\nfunction extractTerminationPayload(message) {\n return {\n terminationDate: extractTerminationDateValue(message),\n terminationReason: extractTerminationReason(message),\n terminationType: extractTerminationType(message)\n };\n}\n\nconst message = normalize(originalMessage);\nconst employeeQuery = extractEmployeeQuery(originalMessage);\n\nlet mode = 'unknown';\nlet intent = 'unknown';\nlet fields = [];\nlet filters = {};\nlet action = null;\nlet tableName = null;\nlet riskLevel = 'unknown';\nlet requiresConfirmation = false;\nlet confidence = 0.2;\nlet status = 'needs_clarification';\nlet responseText = 'No pude interpretar la solicitud. Por favor indica si deseas consultar, modificar, crear o generar un reporte.';\n\n// -------------------------\n// CONSULTAS DE EMPLEADO\n// -------------------------\n\nif (\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('posicion') ||\n message.includes('job title') ||\n message.includes('titulo')\n) {\n fields = ['jobTitle'];\n}\n\nif (\n message.includes('departamento') ||\n message.includes('area')\n) {\n fields = ['department'];\n}\n\nif (\n message.includes('division') ||\n message.includes('unidad de negocio') ||\n message.includes('business unit') ||\n message.includes('bu')\n) {\n fields = ['division'];\n}\n\nif (\n message.includes('ubicacion') ||\n message.includes('location') ||\n message.includes('pais') ||\n message.includes('ciudad')\n) {\n fields = ['location'];\n}\n\nif (\n message.includes('supervisor') ||\n message.includes('jefe') ||\n message.includes('reporta')\n) {\n if (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) {\n fields = ['supervisorEmail'];\n } else {\n fields = ['supervisor'];\n }\n}\n\nif (\n (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) &&\n !message.includes('supervisor')\n) {\n fields = ['workEmail'];\n}\n\nif (\n message.includes('extension') ||\n message.includes('ext laboral') ||\n message.includes('extension laboral')\n) {\n fields = ['workPhoneExtension'];\n} else if (\n message.includes('telefono') ||\n message.includes('phone') ||\n message.includes('celular')\n) {\n fields = ['workPhone'];\n}\n\nif (\n message.includes('estatus') ||\n message.includes('status') ||\n message.includes('estado laboral') ||\n message.includes('activo') ||\n message.includes('inactivo')\n) {\n fields = ['status'];\n}\n\nif (\n message.includes('fecha de ingreso') ||\n message.includes('fecha ingreso') ||\n message.includes('ingreso') ||\n message.includes('hire date') ||\n message.includes('contratacion')\n) {\n fields = ['hireDate'];\n}\n\nif (\n message.includes('informacion general') ||\n message.includes('info general') ||\n message.includes('resumen') ||\n message.includes('perfil') ||\n message.includes('datos generales')\n) {\n fields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'workEmail',\n 'status',\n 'hireDate'\n ];\n}\n\nif (fields.length > 0) {\n mode = 'consulta';\n intent = 'get_employee_field';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo la consulta, pero necesito que indiques el empleado. Ejemplo: \"¿Cuál es el cargo actual de Batman?\"';\n}\n\n// -------------------------\n// CONSULTA DE DATOS FALTANTES\n// -------------------------\n\nif (\n message.includes('datos faltantes') ||\n message.includes('campos faltantes') ||\n message.includes('informacion faltante') ||\n message.includes('datos incompletos') ||\n message.includes('campos incompletos') ||\n message.includes('que le falta')\n) {\n mode = 'consulta';\n intent = 'get_employee_missing_fields';\n\n fields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'supervisorEmail',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'status',\n 'hireDate'\n ];\n\n filters = {\n check_missing_fields: true\n };\n\n tableName = null;\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas revisar datos faltantes, pero necesito que indiques el empleado. Ejemplo: \"¿Qué datos faltantes tiene Batman?\".';\n}\n\n// -------------------------\n// CONSULTA DE ARCHIVOS DEL EMPLEADO\n// -------------------------\n\nif (\n message.includes('archivo') ||\n message.includes('archivos') ||\n message.includes('documento') ||\n message.includes('documentos') ||\n message.includes('file') ||\n message.includes('files') ||\n message.includes('adjunto') ||\n message.includes('adjuntos')\n) {\n mode = 'consulta';\n intent = 'get_employee_files';\n\n fields = [];\n\n filters = {\n list_employee_files: true\n };\n\n tableName = null;\n riskLevel = 'medium';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar archivos de un empleado, pero necesito que indiques el empleado. Ejemplo: \"¿Qué archivos tiene Batman?\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA jobInfo\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios')\n ) &&\n (\n message.includes('jobinfo') ||\n message.includes('job info') ||\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('posicion') ||\n message.includes('departamento') ||\n message.includes('division')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'jobInfo';\n\n filters = {\n table_name: 'jobInfo'\n };\n\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial jobInfo, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de cargo de Batman\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA employmentStatus\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios')\n ) &&\n (\n message.includes('employmentstatus') ||\n message.includes('employment status') ||\n message.includes('estatus laboral') ||\n message.includes('estado laboral') ||\n message.includes('historial laboral') ||\n message.includes('activo') ||\n message.includes('inactivo') ||\n message.includes('desvinculacion') ||\n message.includes('terminacion')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'employmentStatus';\n\n filters = {\n table_name: 'employmentStatus'\n };\n\n riskLevel = 'medium';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial employmentStatus, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de estatus laboral de Batman\".';\n}\n\n// -------------------------\n// CONSULTA HISTÓRICA compensation\n// -------------------------\n\nif (\n (\n message.includes('historial') ||\n message.includes('historico') ||\n message.includes('cambios') ||\n message.includes('consulta') ||\n message.includes('consultar') ||\n message.includes('ver') ||\n message.includes('mostrar') ||\n message.includes('muestrame')\n ) &&\n (\n message.includes('compensation') ||\n message.includes('compensacion') ||\n message.includes('salario') ||\n message.includes('sueldo') ||\n message.includes('pago') ||\n message.includes('remuneracion')\n )\n) {\n mode = 'consulta';\n intent = 'get_employee_table';\n fields = [];\n tableName = 'compensation';\n\n filters = {\n table_name: 'compensation',\n sensitive: true\n };\n\n riskLevel = 'high';\n requiresConfirmation = false;\n confidence = employeeQuery ? 0.95 : 0.75;\n status = employeeQuery ? 'ready' : 'needs_clarification';\n\n responseText = employeeQuery\n ? null\n : 'Entiendo que deseas consultar historial de compensación, pero necesito que indiques el empleado. Ejemplo: \"Muéstrame el historial de salario de Batman\".';\n}\n\n// -------------------------\n// ACCIONES BÁSICAS\n// -------------------------\n\nif (\n message.includes('actualiza') ||\n message.includes('actualizar') ||\n message.includes('cambia') ||\n message.includes('cambiar') ||\n message.includes('modifica') ||\n message.includes('modificar') ||\n message.includes('agrega') ||\n message.includes('agregar') ||\n message.includes('añade') ||\n message.includes('anade') ||\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('crea') ||\n message.includes('crear') ||\n message.includes('desvincula') ||\n message.includes('desvincular') ||\n message.includes('termina') ||\n message.includes('terminar')\n) {\n mode = 'accion';\n requiresConfirmation = true;\n\n // ---------------------------------\n // Acción: ejecutar desvinculación real de empleado\n // ---------------------------------\n\n if (\n (\n message.includes('ejecuta') ||\n message.includes('ejecutar') ||\n message.includes('registra') ||\n message.includes('registrar') ||\n message.includes('aplica') ||\n message.includes('aplicar')\n ) &&\n (\n message.includes('desvinculacion') ||\n message.includes('desvinculación') ||\n message.includes('terminacion') ||\n message.includes('terminación') ||\n message.includes('salida')\n )\n ) {\n intent = 'terminate_employee';\n riskLevel = 'critical';\n\n const terminationPayload = extractTerminationPayload(originalMessage);\n\n action = {\n type: 'terminate_employee',\n label: 'ejecutar desvinculación real de empleado',\n payload: terminationPayload\n };\n\n const missingRequired = [];\n\n if (!employeeQuery) missingRequired.push('empleado');\n if (!terminationPayload.terminationDate) missingRequired.push('fecha de salida');\n if (!terminationPayload.terminationReason) missingRequired.push('motivo');\n if (!terminationPayload.terminationType) missingRequired.push('tipo de terminación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText =\n 'Entiendo que deseas ejecutar una desvinculación real, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas ejecutar la desvinculación real de ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Ejecuta desvinculación de empleado 50802 con fecha 2026-07-15, motivo prueba QA, tipo terminación voluntaria\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: preparar desvinculación de empleado\n // ---------------------------------\n\n else if (\n (\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('desvincula') ||\n message.includes('desvincular') ||\n message.includes('termina') ||\n message.includes('terminar')\n ) &&\n (\n message.includes('desvinculacion') ||\n message.includes('desvinculación') ||\n message.includes('terminacion') ||\n message.includes('terminación') ||\n message.includes('salida')\n )\n ) {\n intent = 'prepare_terminate_employee';\n riskLevel = 'high';\n\n const terminationPayload = extractTerminationPayload(originalMessage);\n\n action = {\n type: 'prepare_terminate_employee',\n label: 'preparar desvinculación de empleado',\n payload: terminationPayload\n };\n\n const missingRequired = [];\n\n if (!employeeQuery) missingRequired.push('empleado');\n if (!terminationPayload.terminationDate) missingRequired.push('fecha de salida');\n if (!terminationPayload.terminationReason) missingRequired.push('motivo');\n if (!terminationPayload.terminationType) missingRequired.push('tipo de terminación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText =\n 'Entiendo que deseas preparar una desvinculación, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas preparar la desvinculación de ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Prepara desvinculación de BambooHR Agent QA No Nomina con fecha 2026-07-15, motivo prueba QA, tipo terminación voluntaria\".`;\n } else {\n responseText = null;\n }\n }\n // ---------------------------------\n // Acción: preparar creación de empleado\n // ---------------------------------\n\n else if (\n (\n message.includes('prepara') ||\n message.includes('preparar') ||\n message.includes('crea') ||\n message.includes('crear') ||\n message.includes('agrega') ||\n message.includes('agregar')\n ) &&\n (\n message.includes('empleado') ||\n message.includes('colaborador')\n ) &&\n (\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('cargo') ||\n message.includes('departamento') ||\n message.includes('division') ||\n message.includes('ubicacion') ||\n message.includes('pais') ||\n message.includes('ingreso')\n )\n ) {\n intent = 'prepare_create_employee';\n riskLevel = 'high';\n\n const createEmployeePayload = extractCreateEmployeePayload(originalMessage);\n\n action = {\n type: 'prepare_create_employee',\n label: 'preparar creación de empleado',\n payload: createEmployeePayload\n };\n\n const missingRequired = [];\n\n if (!createEmployeePayload.firstName) missingRequired.push('nombre');\n if (!createEmployeePayload.lastName) missingRequired.push('apellido');\n if (!createEmployeePayload.workEmail) missingRequired.push('correo laboral');\n if (!createEmployeePayload.location) missingRequired.push('país/ubicación');\n if (!createEmployeePayload.department) missingRequired.push('departamento');\n if (!createEmployeePayload.division) missingRequired.push('división');\n if (!createEmployeePayload.jobTitle) missingRequired.push('cargo');\n if (!createEmployeePayload.hireDate) missingRequired.push('fecha de ingreso');\n\n confidence =\n missingRequired.length === 0\n ? 0.9\n : 0.7;\n\n status =\n missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas preparar la creación de un empleado, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Prepara creación de empleado Clark Kent con correo clark.kent@test.com, país Colombia, departamento Field Force, división Whirlpool, cargo Gestor de Experiencia, fecha de ingreso 2026-07-01\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: agregar fila jobInfo\n // ---------------------------------\n\n else if (\n message.includes('jobinfo') ||\n message.includes('job info') ||\n (\n (\n message.includes('agrega') ||\n message.includes('agregar') ||\n message.includes('añade') ||\n message.includes('anade')\n ) &&\n (\n message.includes('cargo') ||\n message.includes('puesto') ||\n message.includes('departamento') ||\n message.includes('division') ||\n message.includes('ubicacion')\n )\n )\n ) {\n intent = 'add_employee_table_row';\n riskLevel = 'medium';\n\n const jobInfoPayload = extractJobInfoPayload(originalMessage);\n\n action = {\n type: 'add_table_row',\n table: 'jobInfo',\n label: 'historial jobInfo',\n payload: jobInfoPayload\n };\n\n const missingRequired = [];\n\n if (!jobInfoPayload.date) missingRequired.push('fecha');\n if (!jobInfoPayload.jobTitle) missingRequired.push('cargo');\n if (!jobInfoPayload.department) missingRequired.push('departamento');\n if (!jobInfoPayload.division) missingRequired.push('división');\n if (!jobInfoPayload.location) missingRequired.push('ubicación');\n\n confidence =\n employeeQuery && missingRequired.length === 0\n ? 0.9\n : employeeQuery\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && missingRequired.length === 0\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText = 'Entiendo que deseas agregar una fila jobInfo, pero necesito que indiques cuál empleado.';\n } else if (missingRequired.length > 0) {\n responseText =\n `Entiendo que deseas agregar jobInfo para ${employeeQuery}, pero faltan estos datos: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo: \"Agrega jobInfo a Batman con fecha 2026-06-25, cargo Gestor de Experiencia, departamento Field Force, división Whirlpool, ubicación Colombia\".`;\n } else {\n responseText = null;\n }\n }\n\n // ---------------------------------\n // Acción: actualizar campos básicos\n // ---------------------------------\n\n else {\n intent = 'update_employee_basic';\n riskLevel = 'medium';\n\n let detectedField = null;\n let detectedLabel = null;\n\n if (\n message.includes('extension') ||\n message.includes('ext laboral') ||\n message.includes('extension laboral')\n ) {\n detectedField = 'workPhoneExtension';\n detectedLabel = 'extensión laboral';\n } else if (\n message.includes('telefono laboral') ||\n message.includes('telefono') ||\n message.includes('phone') ||\n message.includes('celular') ||\n message.includes('movil')\n ) {\n detectedField = 'workPhone';\n detectedLabel = 'teléfono laboral';\n } else if (\n message.includes('correo laboral') ||\n message.includes('email laboral') ||\n message.includes('correo') ||\n message.includes('email') ||\n message.includes('mail')\n ) {\n detectedField = 'workEmail';\n detectedLabel = 'correo laboral';\n }\n\n if (detectedField) {\n const actionValue = extractActionValue(originalMessage, detectedField);\n\n action = {\n type: 'update_field',\n field: detectedField,\n label: detectedLabel,\n value: actionValue\n };\n }\n\n confidence =\n employeeQuery && action && action.value\n ? 0.9\n : employeeQuery && action\n ? 0.75\n : 0.65;\n\n status =\n employeeQuery && action && action.value\n ? 'ready'\n : 'needs_clarification';\n\n if (!employeeQuery) {\n responseText = 'Entiendo que deseas modificar datos de un empleado, pero necesito que indiques cuál empleado.';\n } else if (!action) {\n responseText = 'Entiendo que deseas modificar datos del empleado, pero no identifiqué qué campo deseas actualizar. Por ahora puedo actualizar extensión laboral, teléfono laboral, correo laboral, agregar una fila jobInfo, preparar creación de empleado o preparar desvinculación.';\n } else if (!action.value) {\n responseText = `Entiendo que deseas actualizar el campo \"${action.label}\", pero necesito que indiques el nuevo valor.`;\n } else {\n responseText = null;\n }\n }\n}\n\n// -------------------------\n// REPORTES BÁSICOS\n// -------------------------\n\nif (\n message.includes('headcount') ||\n message.includes('cantidad de empleados') ||\n message.includes('reporte') ||\n message.includes('empleados por pais') ||\n message.includes('empleados por departamento')\n) {\n mode = 'reporte';\n intent = 'generate_report';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 0.9;\n status = 'ready';\n responseText = null;\n\n if (message.includes('pais')) {\n intent = 'headcount_by_country';\n }\n\n if (message.includes('departamento')) {\n intent = 'headcount_by_department';\n }\n\n filters = {\n status: 'Active'\n };\n}\n\n// -------------------------\n// SALIDA FINAL DEL PARSER\n// -------------------------\n\nreturn {\n json: {\n ...input,\n original_message: originalMessage,\n mode,\n intent,\n employee_query: employeeQuery,\n fields,\n filters,\n action,\n table_name: tableName,\n risk_level: riskLevel,\n requires_confirmation: requiresConfirmation,\n confidence,\n status,\n response_text: responseText\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8992, + -2512 + ], + "id": "16424bed-7d3c-4d1d-8da7-8b8c1437a9f6", + "name": "Code - Intent Parser" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nif (!data.mode || data.mode === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text: 'No pude interpretar la solicitud. Por favor indica si deseas consultar, modificar, crear, desvincular o generar un reporte.'\n }\n };\n}\n\nif (!data.intent || data.intent === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text: 'No pude identificar la acción o consulta exacta. Por favor envía más detalles.'\n }\n };\n}\n\nif ((data.confidence || 0) < 0.8) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'No tengo suficiente confianza para ejecutar esta solicitud. Por favor confirma con más detalle.'\n }\n };\n}\n\n// Estas intenciones necesitan un empleado existente en BambooHR.\n// prepare_create_employee NO va aquí porque el empleado todavía no existe.\n// prepare_terminate_employee SÍ va aquí porque se prepara desvinculación de empleado existente.\n// terminate_employee SÍ va aquí porque ejecutaría desvinculación real sobre empleado existente.\nconst intentsThatNeedExistingEmployee = [\n 'get_employee_field',\n 'get_employee_table',\n 'get_employee_files',\n 'get_employee_missing_fields',\n 'update_employee_basic',\n 'add_employee_table_row',\n 'prepare_terminate_employee',\n 'terminate_employee'\n];\n\nif (\n intentsThatNeedExistingEmployee.includes(data.intent) &&\n !data.employee_query\n) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'Necesito que indiques el empleado para poder continuar.'\n }\n };\n}\n\n// Validación extra para creación/preparación de empleado.\n// Esta acción NO necesita employee_query, pero sí necesita payload.\nif (data.intent === 'prepare_create_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!payload.firstName) missingRequired.push('nombre');\n if (!payload.lastName) missingRequired.push('apellido');\n if (!payload.workEmail) missingRequired.push('correo laboral');\n if (!payload.location) missingRequired.push('país/ubicación');\n if (!payload.department) missingRequired.push('departamento');\n if (!payload.division) missingRequired.push('división');\n if (!payload.jobTitle) missingRequired.push('cargo');\n if (!payload.hireDate) missingRequired.push('fecha de ingreso');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para preparar la creación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\n// Validación extra para preparación de desvinculación.\n// Esta acción SÍ necesita employee_query porque aplica sobre un empleado existente.\n// No ejecuta cambios reales en BambooHR.\nif (data.intent === 'prepare_terminate_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!data.employee_query) missingRequired.push('empleado');\n if (!payload.terminationDate) missingRequired.push('fecha de salida');\n if (!payload.terminationReason) missingRequired.push('motivo');\n if (!payload.terminationType) missingRequired.push('tipo de terminación');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para preparar la desvinculación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\n// Validación extra para ejecución real de desvinculación.\n// Esta acción SÍ necesita employee_query porque aplica sobre un empleado existente.\n// Esta acción requiere confirmación final estricta más adelante en Action Confirmation Guard.\nif (data.intent === 'terminate_employee') {\n const payload = data.action?.payload || {};\n\n const missingRequired = [];\n\n if (!data.employee_query) missingRequired.push('empleado');\n if (!payload.terminationDate) missingRequired.push('fecha de salida');\n if (!payload.terminationReason) missingRequired.push('motivo');\n if (!payload.terminationType) missingRequired.push('tipo de terminación');\n\n if (missingRequired.length > 0) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n `Faltan datos para ejecutar la desvinculación del empleado: ${missingRequired.join(', ')}.`\n }\n };\n }\n}\n\nreturn {\n json: {\n ...data,\n can_continue: true\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8704, + -2512 + ], + "id": "3f7772d0-ca2c-446f-a790-84881562ab84", + "name": "Code - Validate Intent" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.mode }}", + "rightValue": "=consulta", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "086e1819-29be-42b0-8905-c014a247cbb3" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "consulta" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "edd4011c-89a6-4a2f-90a5-4e3dbaea83d4", + "leftValue": "={{ $json.mode }}", + "rightValue": "accion", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "accion" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2dc0f609-f938-4d47-99a7-60358616ec94", + "leftValue": "={{ $json.mode }}", + "rightValue": "reporte", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "reporte" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + -7536, + -3808 + ], + "id": "1c7b9cd5-b635-4012-9b7e-e5086d2ca66c", + "name": "Switch - Mode Router" + }, + { + "parameters": { + "fieldToSplitOut": "employees", + "options": {} + }, + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + -5760, + -4672 + ], + "id": "88fcede6-4eea-4b65-b320-1a86d1da10ed", + "name": "Split Out - Employees" + }, + { + "parameters": { + "jsCode": "const intent = $node[\"Code - Intent Parser\"].json;\nconst queryRaw = intent.employee_query || '';\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .trim();\n}\n\nconst query = normalize(queryRaw);\n\nconst matches = items.filter(item => {\n const e = item.json;\n\n const searchable = normalize([\n e.id,\n e.employeeNumber,\n e.displayName,\n e.firstName,\n e.lastName,\n e.preferredName,\n e.workEmail,\n e.jobTitle,\n e.department,\n e.location,\n e.division\n ].join(' '));\n\n return searchable.includes(query);\n});\n\nif (matches.length === 0) {\n return [{\n json: {\n employee_found: false,\n employee_query: queryRaw,\n error: `No encontré ningún empleado que coincida con \"${queryRaw}\".`,\n response_text: `No encontré ningún empleado que coincida con \"${queryRaw}\".`\n }\n }];\n}\n\nif (matches.length > 1) {\n return [{\n json: {\n employee_found: false,\n employee_query: queryRaw,\n multiple_matches: true,\n matches: matches.slice(0, 5).map(item => ({\n id: item.json.id,\n displayName: item.json.displayName,\n preferredName: item.json.preferredName,\n workEmail: item.json.workEmail,\n jobTitle: item.json.jobTitle,\n location: item.json.location\n })),\n response_text: `Encontré varios empleados que coinciden con \"${queryRaw}\". Por favor especifica mejor el nombre o correo.`\n }\n }];\n}\n\nreturn [{\n json: {\n ...matches[0].json,\n employee_found: true,\n employee_query: queryRaw,\n resolved_employee_id: matches[0].json.id,\n resolved_employee_name: matches[0].json.displayName\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -5552, + -4672 + ], + "id": "8fb4ee82-f72d-46f3-b872-2b4bbb51849b", + "name": "Code - Resolve Employee" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5376, + -10384 + ], + "id": "7cbe6b31-7e91-43d8-9d72-35d4be5a9efe", + "name": "HTTP - Get Employee By ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst employee = $json;\nconst requestedFields = intent.fields || [];\n\nconst labels = {\n jobTitle: 'cargo actual',\n department: 'departamento',\n division: 'división',\n location: 'ubicación',\n supervisor: 'supervisor',\n supervisorEmail: 'correo del supervisor',\n workEmail: 'correo laboral',\n workPhone: 'teléfono laboral',\n workPhoneExtension: 'extensión laboral',\n status: 'estatus',\n hireDate: 'fecha de ingreso'\n};\n\nfunction formatPersonName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) {\n return 'No disponible';\n }\n\n if (raw.includes(',')) {\n const [lastNames, firstNames] = raw.split(',').map(part => part.trim());\n\n if (firstNames && lastNames) {\n return `${firstNames} ${lastNames}`;\n }\n }\n\n return raw;\n}\n\nfunction formatFieldValue(field, value) {\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n if (field === 'supervisor') {\n return formatPersonName(value);\n }\n\n return value;\n}\n\nconst lines = requestedFields.map(field => {\n const label = labels[field] || field;\n const value = formatFieldValue(field, employee[field]);\n return `- ${label}: ${value}`;\n});\n\nconst responseText = `Consulta completada para ${employee.displayName}:\\n\\n${lines.join('\\n')}`;\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employee.id,\n employee_name: employee.displayName,\n requested_fields: requestedFields,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employee.id,\n target_employee_name: employee.displayName,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${employee.id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5632, + -10384 + ], + "id": "aa9a1e5a-b5ef-449e-b5ee-f369185ecba0", + "name": "Code - Format Consulta Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst finishedAt = new Date().toISOString();\n\nconst auditEvent = data.audit_event || {};\n\nconst requestId =\n data.request_id ||\n auditEvent.request_id ||\n null;\n\nconst startedAt =\n data.request_started_at ||\n auditEvent.request_started_at ||\n null;\n\nconst responseTimeMs =\n startedAt\n ? Date.now() - new Date(startedAt).getTime()\n : null;\n\nconst responseTimeSeconds =\n responseTimeMs !== null\n ? Number((responseTimeMs / 1000).toFixed(2))\n : null;\n\nconst status =\n auditEvent.execution_result ||\n data.status ||\n (data.can_continue === false ? 'needs_clarification' : 'success');\n\nconst canContinue =\n data.can_continue !== undefined\n ? data.can_continue\n : status === 'success';\n\nconst responseType =\n data.response_type ||\n (\n status === 'pending_confirmation'\n ? 'confirmation_request'\n : canContinue === false\n ? 'clarification_request'\n : 'chat_message'\n );\n\nconst responseText =\n data.response_text ||\n 'Solicitud procesada correctamente.';\n\nconst mode =\n data.mode ||\n auditEvent.mode ||\n 'unknown';\n\nconst intent =\n data.intent ||\n auditEvent.intent ||\n 'unknown';\n\nconst riskLevel =\n data.risk_level ||\n auditEvent.risk_level ||\n 'unknown';\n\nconst userEmail =\n data.user_email ||\n auditEvent.user_email ||\n null;\n\nconst userName =\n data.user_name ||\n auditEvent.user_name ||\n null;\n\nconst channel =\n data.channel ||\n auditEvent.channel ||\n data.data?.channel ||\n null;\n\nconst environment =\n data.environment ||\n auditEvent.environment ||\n data.data?.environment ||\n null;\n\nconst googleChatEventType =\n data.google_chat_event_type ||\n auditEvent.google_chat_event_type ||\n null;\n\nconst googleChatSpaceName =\n data.google_chat_space_name ||\n auditEvent.google_chat_space_name ||\n null;\n\nconst googleChatSpaceDisplayName =\n data.google_chat_space_display_name ||\n auditEvent.google_chat_space_display_name ||\n null;\n\nconst googleChatMessageName =\n data.google_chat_message_name ||\n auditEvent.google_chat_message_name ||\n null;\n\nconst googleChatThreadName =\n data.google_chat_thread_name ||\n auditEvent.google_chat_thread_name ||\n null;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n auditEvent.target_employee_id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n auditEvent.target_employee_name ||\n null;\n\nconst employeeEmail =\n data.employee_email ||\n auditEvent.target_employee_email ||\n null;\n\nreturn {\n json: {\n request_id: requestId,\n status,\n can_continue: canContinue,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: userName,\n channel,\n environment,\n\n google_chat_event_type: googleChatEventType,\n google_chat_space_name: googleChatSpaceName,\n google_chat_space_display_name: googleChatSpaceDisplayName,\n google_chat_message_name: googleChatMessageName,\n google_chat_thread_name: googleChatThreadName,\n\n response_type: responseType,\n response_text: responseText,\n\n request_started_at: startedAt,\n request_finished_at: finishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n data: {\n employee_id: employeeId,\n\n employee_name: employeeName,\n\n employee_email: employeeEmail,\n\n requested_fields:\n data.requested_fields ||\n data.fields ||\n [],\n\n employee_query:\n data.employee_query ||\n null,\n\n multiple_matches:\n data.multiple_matches ||\n false,\n\n matches:\n data.matches ||\n [],\n\n table_name:\n data.table_name ||\n data.filters?.table_name ||\n null,\n\n rows_count:\n data.rows_count ??\n null,\n\n rows_preview:\n data.rows_preview ||\n [],\n\n checked_fields:\n data.checked_fields ||\n [],\n\n missing_fields:\n data.missing_fields ||\n [],\n\n present_fields:\n data.present_fields ||\n [],\n\n files_count:\n data.files_count ??\n null,\n\n categories_count:\n data.categories_count ??\n null,\n\n categories_with_files_count:\n data.categories_with_files_count ??\n null,\n\n empty_categories_count:\n data.empty_categories_count ??\n null,\n\n file_categories:\n data.file_categories ||\n [],\n\n files_preview:\n data.files_preview ||\n [],\n\n action:\n data.action ||\n auditEvent.action ||\n null,\n\n requires_confirmation:\n data.requires_confirmation ??\n auditEvent.requires_confirmation ??\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n confirmation_message:\n data.confirmation_message ||\n null,\n\n action_approved:\n data.action_approved ||\n false,\n\n report_type:\n data.report_type ||\n auditEvent.report_type ||\n null,\n\n report_total_employees_in_scope:\n data.report_total_employees_in_scope ??\n auditEvent.report_total_employees_in_scope ??\n null,\n\n report_total_active_employees_read:\n data.report_total_active_employees_read ??\n auditEvent.report_total_active_employees_read ??\n null,\n\n report_country_counts:\n data.report_country_counts ||\n auditEvent.report_country_counts ||\n [],\n\n report_department_counts:\n data.report_department_counts ||\n auditEvent.report_department_counts ||\n [],\n\n authorization_allowed:\n data.authorization_allowed ??\n null,\n\n authorization_result:\n data.authorization_result ||\n auditEvent.authorization_result ||\n null,\n\n authorization_reason:\n data.authorization_reason ||\n auditEvent.authorization_reason ||\n null,\n\n employee_scope_allowed:\n data.employee_scope_allowed ??\n null,\n\n employee_scope_result:\n data.employee_scope_result ||\n auditEvent.employee_scope_result ||\n null,\n\n employee_scope_reason:\n data.employee_scope_reason ||\n auditEvent.employee_scope_reason ||\n null\n },\n\n audit_event: {\n ...auditEvent,\n\n request_id: requestId,\n request_started_at: startedAt,\n request_finished_at: finishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: userName,\n channel,\n environment,\n\n google_chat_event_type: googleChatEventType,\n google_chat_space_name: googleChatSpaceName,\n google_chat_space_display_name: googleChatSpaceDisplayName,\n google_chat_message_name: googleChatMessageName,\n google_chat_thread_name: googleChatThreadName,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action:\n data.action ||\n auditEvent.action ||\n null,\n\n requires_confirmation:\n data.requires_confirmation ??\n auditEvent.requires_confirmation ??\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n authorization_result:\n data.authorization_result ||\n auditEvent.authorization_result ||\n null,\n\n authorization_reason:\n data.authorization_reason ||\n auditEvent.authorization_reason ||\n null,\n\n execution_result: status,\n\n bamboohr_endpoint:\n auditEvent.bamboohr_endpoint ||\n data.bamboohr_endpoint ||\n null,\n\n response_type: responseType,\n response_text: responseText\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 15824, + -2448 + ], + "id": "083583e6-d83b-4e76-8201-aa6ff7d89969", + "name": "Code - Agent Response" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "cf41f3fa-64a6-4765-a81d-78187e7f4d87", + "leftValue": "={{ $json.can_continue }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -8496, + -2512 + ], + "id": "8dfb0930-863b-4b86-905c-c457933efe42", + "name": "IF - Can Continue?" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "71d4f894-3e36-418b-a407-18339757859f", + "leftValue": "={{ $json.employee_found }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -4992, + -4672 + ], + "id": "b6726c6b-7238-4cd1-92e8-a08306b2333f", + "name": "IF - Employee Found?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nconst now = new Date().toISOString();\n\nconst message =\n input.message ||\n input.original_message ||\n '';\n\nconst requestId =\n input.request_id ||\n `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nreturn {\n json: {\n ...input,\n\n request_id: requestId,\n request_started_at: input.request_started_at || now,\n\n user_email: input.user_email || null,\n user_name: input.user_name || null,\n\n channel: input.channel || 'manual_test',\n environment: input.environment || 'development',\n\n message,\n original_message: message,\n\n confirmation_status: input.confirmation_status || null,\n\n final_execution_approved: input.final_execution_approved === true,\n final_execution_token: input.final_execution_token || null,\n\n source: input.channel || 'manual_test',\n language: 'es',\n agent_name: 'BambooHR Agent Core'\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -9232, + -2512 + ], + "id": "082b337a-96ee-4cc6-960f-06351672df90", + "name": "Code - Request Context" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_field", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "2ca218f7-3ab7-413b-9fc9-1579597fd1f6" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "campos_basicos" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "db94de48-e808-468d-a14c-db0a464474f3", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_table", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "tabla_historica" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2b29eaff-c720-47b1-b1a2-4b2701bf7f47", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_missing_fields", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "datos_faltantes" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "5ddde9c9-eb33-4da6-8c75-aad88dc235bc", + "leftValue": "={{ $('Code - Intent Parser').item.json.intent }}", + "rightValue": "get_employee_files", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "archivos_empleado" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 3488, + -9696 + ], + "id": "7dfbc88b-1c28-40e8-b3d3-fcd3d24bf606", + "name": "Switch - Consulta Type" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5360, + -10112 + ], + "id": "85414cf5-e17f-4329-a455-09d82d6ef856", + "name": "HTTP - Get Employee jobInfo Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.jobInfo)) {\n return body.jobInfo;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 8).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const jobTitle = getValue(row, ['jobTitle', 'job_title', 'title', 'position']);\n const department = getValue(row, ['department']);\n const division = getValue(row, ['division']);\n const location = getValue(row, ['location']);\n const reportsTo = getValue(row, ['reportsTo', 'supervisor']);\n\n const details = [];\n\n if (jobTitle) details.push(`cargo: ${jobTitle}`);\n if (department) details.push(`departamento: ${department}`);\n if (division) details.push(`división: ${division}`);\n if (location) details.push(`ubicación: ${location}`);\n if (reportsTo) details.push(`supervisor: ${reportsTo}`);\n\n return {\n date,\n jobTitle,\n department,\n division,\n location,\n reportsTo,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial jobInfo para ${resolved.resolved_employee_name}.`\n : `Historial jobInfo de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n\n table_name: 'jobInfo',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5568, + -10112 + ], + "id": "1e4de479-02a5-4342-85ad-76afa2c0325b", + "name": "Code - Format jobInfo Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5296, + -9872 + ], + "id": "ff9c78b4-d017-465c-a49c-6131a5feb863", + "name": "HTTP - Get Employee Missing Fields", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst employee = $json;\n\nconst fieldsToCheck = intent.fields || [];\n\nconst labels = {\n jobTitle: 'cargo actual',\n department: 'departamento',\n division: 'división',\n location: 'ubicación',\n supervisor: 'supervisor',\n supervisorEmail: 'correo del supervisor',\n workEmail: 'correo laboral',\n workPhone: 'teléfono laboral',\n workPhoneExtension: 'extensión laboral',\n status: 'estatus',\n hireDate: 'fecha de ingreso'\n};\n\nfunction isMissing(value) {\n if (value === null || value === undefined) return true;\n\n if (typeof value === 'string' && value.trim() === '') return true;\n\n if (Array.isArray(value) && value.length === 0) return true;\n\n return false;\n}\n\nfunction formatPersonName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) {\n return 'No disponible';\n }\n\n if (raw.includes(',')) {\n const [lastNames, firstNames] = raw.split(',').map(part => part.trim());\n\n if (firstNames && lastNames) {\n return `${firstNames} ${lastNames}`;\n }\n }\n\n return raw;\n}\n\nfunction formatFieldValue(field, value) {\n if (isMissing(value)) {\n return 'No disponible';\n }\n\n if (field === 'supervisor') {\n return formatPersonName(value);\n }\n\n return value;\n}\n\nconst checkedFields = fieldsToCheck.map(field => {\n const value = employee[field];\n\n return {\n field,\n label: labels[field] || field,\n value: formatFieldValue(field, value),\n is_missing: isMissing(value)\n };\n});\n\nconst missingFields = checkedFields.filter(field => field.is_missing);\nconst presentFields = checkedFields.filter(field => !field.is_missing);\n\nlet responseText;\n\nif (missingFields.length === 0) {\n responseText =\n `No encontré datos faltantes principales para ${employee.displayName}.\\n\\n` +\n `Campos revisados: ${checkedFields.map(field => field.label).join(', ')}.`;\n} else {\n responseText =\n `Datos faltantes principales para ${employee.displayName}:\\n\\n` +\n missingFields.map(field => `- ${field.label}`).join('\\n') +\n `\\n\\nCampos revisados: ${checkedFields.length}.`;\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employee.id,\n employee_name: employee.displayName,\n\n checked_fields: checkedFields,\n missing_fields: missingFields,\n present_fields: presentFields,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employee.id,\n target_employee_name: employee.displayName,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${employee.id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5504, + -9872 + ], + "id": "b1f47728-451a-429a-b5b5-4c093c7126f5", + "name": "Code - Format Missing Fields Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/files/view", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5232, + -9456 + ], + "id": "1d0187c2-1970-4e4c-821d-1cc53e4f1b1e", + "name": "HTTP - Get Employee Files", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\nconst body = $json;\n\nfunction asArray(value) {\n return Array.isArray(value) ? value : [];\n}\n\nfunction getCategories(payload) {\n if (Array.isArray(payload?.categories)) {\n return payload.categories;\n }\n\n if (Array.isArray(payload?.data?.categories)) {\n return payload.data.categories;\n }\n\n if (Array.isArray(payload)) {\n return payload;\n }\n\n return [];\n}\n\nfunction normalizeFile(file, category) {\n return {\n file_id:\n file.id ||\n file.fileId ||\n file.file_id ||\n null,\n\n file_name:\n file.name ||\n file.fileName ||\n file.filename ||\n file.displayName ||\n 'Archivo sin nombre',\n\n category_id:\n category.id ||\n category.categoryId ||\n null,\n\n category_name:\n category.name ||\n category.category ||\n 'Sin categoría',\n\n uploaded_at:\n file.createdAt ||\n file.dateCreated ||\n file.uploadedAt ||\n file.lastChanged ||\n null,\n\n shared_with_employee:\n file.sharedWithEmployee ||\n file.shareWithEmployee ||\n file.share ||\n null\n };\n}\n\nconst categories = getCategories(body);\n\nconst normalizedCategories = categories.map(category => {\n const files = asArray(category.files).map(file => normalizeFile(file, category));\n\n return {\n category_id: category.id || category.categoryId || null,\n category_name: category.name || category.category || 'Sin categoría',\n files_count: files.length,\n files\n };\n});\n\nconst files = normalizedCategories.flatMap(category => category.files);\n\nconst categoriesWithFiles = normalizedCategories.filter(category => category.files_count > 0);\nconst emptyCategories = normalizedCategories.filter(category => category.files_count === 0);\n\nlet responseText;\n\nif (files.length === 0) {\n responseText =\n `No encontré archivos cargados para ${resolved.resolved_employee_name}.\\n\\n` +\n `Categorías revisadas: ${normalizedCategories.length}.`;\n} else {\n const previewLines = categoriesWithFiles.flatMap(category => {\n const header = `\\n${category.category_name}:`;\n\n const fileLines = category.files.slice(0, 10).map(file => {\n const fileIdText = file.file_id ? `ID ${file.file_id}` : 'sin ID';\n return `- ${file.file_name} (${fileIdText})`;\n });\n\n return [header, ...fileLines];\n });\n\n responseText =\n `Archivos encontrados para ${resolved.resolved_employee_name}:\\n` +\n previewLines.join('\\n') +\n `\\n\\nTotal de archivos: ${files.length}. Categorías con archivos: ${categoriesWithFiles.length}.`;\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n\n files_count: files.length,\n categories_count: normalizedCategories.length,\n categories_with_files_count: categoriesWithFiles.length,\n\n file_categories: normalizedCategories,\n files_preview: files.slice(0, 20),\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/files/view`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5552, + -9424 + ], + "id": "09b8e86c-e027-4aea-83f1-2f38aa97762e", + "name": "Code - Format Employee Files Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "jobInfo", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "99156341-6fb2-496f-a38e-c3dc0ffd8e4d" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "jobInfo" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "a918d770-05f0-44df-b9e3-87026a62f19b", + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "employmentStatus", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "employmentStatus" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "52cb1493-f179-4f1e-a037-cb251a6257ee", + "leftValue": "={{ $('Code - Intent Parser').item.json.table_name }}", + "rightValue": "compensation", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "compensation" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 4048, + -9472 + ], + "id": "60a08fe1-dec8-47cd-ba67-a2c904c4e908", + "name": "Switch - Historical Table Type" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5248, + -9168 + ], + "id": "9d794497-e444-4d66-877e-5f24274e2e83", + "name": "HTTP - Get Employee employmentStatus Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Resolve Employee').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.employmentStatus)) {\n return body.employmentStatus;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 10).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const employmentStatus = getValue(row, [\n 'employmentStatus',\n 'status',\n 'employment_status'\n ]);\n\n const comment = getValue(row, [\n 'comment',\n 'comments',\n 'note',\n 'notes'\n ]);\n\n const terminationReasonId = getValue(row, ['terminationReasonId']);\n const terminationTypeId = getValue(row, ['terminationTypeId']);\n const terminationRehireId = getValue(row, ['terminationRehireId']);\n const terminationRegrettableId = getValue(row, ['terminationRegrettableId']);\n\n const details = [];\n\n if (employmentStatus) details.push(`estatus: ${employmentStatus}`);\n if (comment) details.push(`comentario: ${comment}`);\n if (terminationReasonId) details.push(`terminationReasonId: ${terminationReasonId}`);\n if (terminationTypeId) details.push(`terminationTypeId: ${terminationTypeId}`);\n if (terminationRehireId) details.push(`terminationRehireId: ${terminationRehireId}`);\n if (terminationRegrettableId) details.push(`terminationRegrettableId: ${terminationRegrettableId}`);\n\n return {\n date,\n employmentStatus,\n comment,\n terminationReasonId,\n terminationTypeId,\n terminationRehireId,\n terminationRegrettableId,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial employmentStatus para ${resolved.resolved_employee_name}.`\n : `Historial employmentStatus de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n \n table_name: 'employmentStatus',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5456, + -9168 + ], + "id": "12ef617a-50b6-4f75-ad0b-3937bc90208f", + "name": "Code - Format employmentStatus Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $json;\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com'\n];\n\nconst userEmail = String(intent.user_email || '').toLowerCase();\nconst environment = String(intent.environment || 'development').toLowerCase();\n\nconst isAllowedUser = allowedEmails.includes(userEmail);\nconst isAllowedEnvironment = environment === 'development';\n\nif (!isAllowedUser || !isAllowedEnvironment) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n table_name: intent.table_name,\n employee_query: intent.employee_query,\n\n resolved_employee_id: resolved.resolved_employee_id || null,\n resolved_employee_name: resolved.resolved_employee_name || null,\n\n response_text:\n 'Acceso denegado. La consulta de compensación es sensible y requiere permisos autorizados.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id || null,\n target_employee_name: resolved.resolved_employee_name || null,\n\n authorization_result: 'denied',\n execution_result: 'blocked',\n bamboohr_endpoint: null,\n reason: 'compensation_query_not_authorized'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...resolved,\n\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n sensitive_access_approved: true,\n authorization_result: 'approved',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id || null,\n target_employee_name: resolved.resolved_employee_name || null,\n\n authorization_result: 'approved',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4768, + -8912 + ], + "id": "c194cd70-64e3-4633-a807-f35b6fa5cafb", + "name": "Code - Sensitive Consulta Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2f8d7899-2fd6-4ae1-8db3-64e2990c861d", + "leftValue": "={{ $json.sensitive_access_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4976, + -8912 + ], + "id": "5e58de3e-e6de-4675-9e87-d53de026cfc8", + "name": "IF - Sensitive Access Approved?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}/tables/compensation", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5536, + -8928 + ], + "id": "637770fc-c09d-41c6-b7da-1907ffd2919c", + "name": "HTTP - Get Employee compensation Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolved = $('Code - Sensitive Consulta Guard').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) {\n return [];\n }\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) {\n return body;\n }\n\n if (Array.isArray(body.rows)) {\n return body.rows;\n }\n\n if (Array.isArray(body.data)) {\n return body.data;\n }\n\n if (Array.isArray(body.compensation)) {\n return body.compensation;\n }\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return (\n row.date ||\n row.effectiveDate ||\n row.startDate ||\n row.createdAt ||\n row.lastChanged ||\n ''\n );\n}\n\nfunction getValue(row, keys) {\n for (const key of keys) {\n if (row[key] !== undefined && row[key] !== null && row[key] !== '') {\n return row[key];\n }\n }\n\n return null;\n}\n\nconst rows = normalizeRows(items);\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst previewRows = sortedRows.slice(0, 10).map(row => {\n const date = getDateValue(row) || 'Sin fecha';\n\n const payRate = getValue(row, ['payRate', 'rate', 'salary']);\n const payType = getValue(row, ['payType']);\n const payPer = getValue(row, ['payPer']);\n const paySchedule = getValue(row, ['paySchedule']);\n const currency = getValue(row, ['currency']);\n const changeReason = getValue(row, ['changeReason', 'reason', 'comment']);\n\n const details = [];\n\n if (payRate) details.push(`monto: ${payRate}`);\n if (currency) details.push(`moneda: ${currency}`);\n if (payType) details.push(`tipo: ${payType}`);\n if (payPer) details.push(`periodicidad: ${payPer}`);\n if (paySchedule) details.push(`calendario: ${paySchedule}`);\n if (changeReason) details.push(`razón: ${changeReason}`);\n\n return {\n date,\n payRate,\n currency,\n payType,\n payPer,\n paySchedule,\n changeReason,\n line: `- ${date}: ${details.length ? details.join(' · ') : 'Sin detalles principales'}`\n };\n});\n\nconst responseText =\n rows.length === 0\n ? `No encontré historial de compensación para ${resolved.resolved_employee_name}.`\n : `Historial de compensación de ${resolved.resolved_employee_name}:\\n\\n${previewRows.map(row => row.line).join('\\n')}`;\n\nreturn [{\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: resolved.resolved_employee_id,\n employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n table_name: 'compensation',\n rows_count: rows.length,\n rows_preview: previewRows,\n\n response_text: responseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: resolved.resolved_employee_id,\n target_employee_name: resolved.resolved_employee_name,\n employee_query: intent.employee_query || resolved.employee_query || null,\n\n authorization_result: 'approved',\n execution_result: 'success',\n bamboohr_endpoint: `/api/v1/employees/${resolved.resolved_employee_id}/tables/compensation`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5744, + -8928 + ], + "id": "9ce7e538-f5e9-4948-a592-e57816fb7d67", + "name": "Code - Format compensation Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $('Code - Intent Parser').item.json.mode }}", + "rightValue": "consulta", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "0796c736-66ff-4c28-99c1-3353d10a57ee" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "consulta" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "0725dec0-a413-4fd1-a8ad-eafa40fccf6e", + "leftValue": "={{ $('Code - Intent Parser').item.json.mode }}", + "rightValue": "accion", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "accion" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 928, + -5936 + ], + "id": "e79dd7ae-db69-489d-8d77-6a96c9ad7aaa", + "name": "Switch - Post Resolve Mode" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst resolvedEmployee = $json;\n\nconst action = intent.action || null;\n\nconst confirmationStatus =\n intent.confirmation_status ||\n intent.confirmationStatus ||\n null;\n\nfunction isMissingActionValue(action) {\n if (!action) return true;\n\n if (action.type === 'update_field') {\n return !action.value;\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n\n return (\n !payload.date ||\n !payload.jobTitle ||\n !payload.department ||\n !payload.division ||\n !payload.location\n );\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n\n return (\n !payload.terminationDate ||\n !payload.terminationReason ||\n !payload.terminationType\n );\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n\n return (\n !payload.terminationDate ||\n !payload.terminationReason ||\n !payload.terminationType\n );\n }\n\n return false;\n}\n\nfunction buildMissingValueMessage(action) {\n if (!action) {\n return 'No pude identificar la acción solicitada.';\n }\n\n if (action.type === 'update_field') {\n return `Entiendo que deseas actualizar el campo \"${action.label}\", pero necesito que indiques el nuevo valor.`;\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.date) missing.push('fecha');\n if (!payload.jobTitle) missing.push('cargo');\n if (!payload.department) missing.push('departamento');\n if (!payload.division) missing.push('división');\n if (!payload.location) missing.push('ubicación');\n\n return `Entiendo que deseas agregar jobInfo, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.terminationDate) missing.push('fecha de salida');\n if (!payload.terminationReason) missing.push('motivo');\n if (!payload.terminationType) missing.push('tipo de terminación');\n\n return `Entiendo que deseas preparar una desvinculación, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n const missing = [];\n\n if (!payload.terminationDate) missing.push('fecha de salida');\n if (!payload.terminationReason) missing.push('motivo');\n if (!payload.terminationType) missing.push('tipo de terminación');\n\n return `Entiendo que deseas ejecutar una desvinculación, pero faltan estos datos: ${missing.join(', ')}.`;\n }\n\n return 'La acción solicitada no tiene los datos completos.';\n}\n\nfunction buildConfirmationMessage(action, employeeName) {\n if (action.type === 'update_field') {\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: actualizar ${action.label}\\n` +\n `Nuevo valor: ${action.value}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`\n );\n }\n\n if (action.type === 'add_table_row' && action.table === 'jobInfo') {\n const payload = action.payload || {};\n\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: agregar fila en jobInfo\\n` +\n `Fecha: ${payload.date}\\n` +\n `Cargo: ${payload.jobTitle}\\n` +\n `Departamento: ${payload.department}\\n` +\n `División: ${payload.division}\\n` +\n `Ubicación: ${payload.location}` +\n (payload.reportsTo ? `\\nSupervisor: ${payload.reportsTo}` : '') +\n `\\n\\nPara continuar, responde: CONFIRMAR`\n );\n }\n\n if (action.type === 'prepare_terminate_employee') {\n const payload = action.payload || {};\n\n return (\n `CONFIRMACIÓN ESTRICTA REQUERIDA\\n\\n` +\n `Esta acción prepara una desvinculación de empleado. No se ejecutará todavía, pero debe tratarse como acción crítica.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: preparar desvinculación\\n` +\n `Fecha de salida: ${payload.terminationDate}\\n` +\n `Motivo: ${payload.terminationReason}\\n` +\n `Tipo de terminación: ${payload.terminationType}\\n\\n` +\n `Para continuar, responde exactamente: CONFIRMAR DESVINCULACIÓN`\n );\n }\n\n if (action.type === 'terminate_employee') {\n const payload = action.payload || {};\n\n return (\n `CONFIRMACIÓN FINAL DE DESVINCULACIÓN REAL\\n\\n` +\n `Esta acción SÍ intentará registrar la desvinculación real del empleado en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: ejecutar desvinculación real\\n` +\n `Fecha de salida: ${payload.terminationDate}\\n` +\n `Motivo: ${payload.terminationReason}\\n` +\n `Tipo de terminación: ${payload.terminationType}\\n\\n` +\n `Para ejecutar la desvinculación real, responde exactamente: EJECUTAR DESVINCULACIÓN`\n );\n }\n\n return (\n `Confirma esta acción antes de ejecutarla:\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Acción: ${action.label || action.type}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`\n );\n}\n\nconst employeeFound =\n resolvedEmployee.employee_found !== false &&\n Boolean(resolvedEmployee.resolved_employee_id || resolvedEmployee.id);\n\nif (!employeeFound) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action,\n\n response_text:\n resolvedEmployee.response_text ||\n 'No pude resolver el empleado para ejecutar la acción.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: intent.employee_query,\n action,\n\n authorization_result: 'not_evaluated_employee_not_found',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst employeeId =\n resolvedEmployee.resolved_employee_id ||\n resolvedEmployee.id;\n\nconst employeeName =\n resolvedEmployee.resolved_employee_name ||\n resolvedEmployee.displayName ||\n intent.employee_query ||\n 'Empleado no disponible';\n\nif (!action) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action: null,\n\n response_text: 'No pude identificar la acción solicitada.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action: null,\n authorization_result: 'not_evaluated_missing_action',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (isMissingActionValue(action)) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n action,\n\n response_text: buildMissingValueMessage(action),\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n authorization_result: 'not_evaluated_missing_action_value',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst confirmationMessage = buildConfirmationMessage(action, employeeName);\n\nconst normalizedConfirmationStatus =\n String(confirmationStatus || '')\n .trim()\n .toLowerCase();\n\nconst isStrictTerminationConfirmation =\n action.type === 'prepare_terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed_termination';\n\nconst isStrictTerminationExecutionConfirmation =\n action.type === 'terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed_termination_execute';\n\nconst isNormalConfirmation =\n action.type !== 'prepare_terminate_employee' &&\n action.type !== 'terminate_employee' &&\n normalizedConfirmationStatus === 'confirmed';\n\nconst actionApproved =\n isStrictTerminationConfirmation ||\n isStrictTerminationExecutionConfirmation ||\n isNormalConfirmation;\n\nif (intent.requires_confirmation && !actionApproved) {\n return {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'pending_confirmation',\n can_continue: false,\n response_type: 'confirmation_request',\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: 'pending',\n confirmation_message: confirmationMessage,\n\n action_approved: false,\n\n response_text: confirmationMessage,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'pending',\n\n authorization_result: 'pending_confirmation',\n execution_result: 'pending_confirmation',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst approvedConfirmationStatus =\n action.type === 'prepare_terminate_employee'\n ? 'confirmed_termination'\n : action.type === 'terminate_employee'\n ? 'confirmed_termination_execute'\n : 'confirmed';\n\nconst approvedResponseText =\n action.type === 'prepare_terminate_employee'\n ? `Preparación de desvinculación aprobada para revisión.\\n\\nEmpleado: ${employeeName}\\nFecha de salida: ${action.payload.terminationDate}`\n : action.type === 'terminate_employee'\n ? `Desvinculación real aprobada para ejecución.\\n\\nEmpleado: ${employeeName}\\nFecha de salida: ${action.payload.terminationDate}`\n : `Acción aprobada para ejecución.\\n\\nEmpleado: ${employeeName}`;\n\nreturn {\n json: {\n ...intent,\n ...resolvedEmployee,\n\n status: 'action_approved',\n can_continue: true,\n response_type: 'chat_message',\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: approvedConfirmationStatus,\n\n action_approved: true,\n\n response_text: approvedResponseText,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n requires_confirmation: true,\n confirmation_status: approvedConfirmationStatus,\n\n authorization_result: 'confirmed',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1456, + -5664 + ], + "id": "f769b2b5-7055-4052-9b39-f1b3b85e8b4e", + "name": "Code - Action Confirmation Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "47d98c0f-89fc-4af8-a67f-cb155c209c18", + "leftValue": "={{ $json.action_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1760, + -5664 + ], + "id": "64ab7961-a9fb-435d-b109-c82bb40e2a3a", + "name": "IF - Action Approved?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.resolved_employee_id }}?fields=displayName,firstName,lastName,preferredName,workPhoneExtension,workPhone,workEmail,status,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4016, + -8320 + ], + "id": "6da4eae1-939c-4aba-a674-040f44736cd1", + "name": "HTTP - Get Employee For Action", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst actionContext = $('Code - Action Confirmation Guard').item.json;\nconst currentEmployee = $json;\n\nconst action = actionContext.action || intent.action || null;\n\nconst allowedFields = [\n 'workPhoneExtension',\n 'workPhone',\n 'workEmail'\n];\n\nif (!action || !action.field) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'failed',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n response_text: 'No se pudo ejecutar la acción porque no se identificó el campo a actualizar.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n action,\n authorization_result: 'failed_validation',\n execution_result: 'failed',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (!allowedFields.includes(action.field)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El campo \"${action.field}\" todavía no está permitido para actualización automática.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_field_not_allowed',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n\nfunction isValidEmail(value) {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value || '').trim());\n}\n\nfunction isValidPhone(value) {\n const cleanValue = String(value || '').replace(/[^\\d+]/g, '');\n return cleanValue.length >= 7 && cleanValue.length <= 20;\n}\n\nif (action.field === 'workEmail' && !isValidEmail(action.value)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El correo \"${action.value}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_invalid_email',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (action.field === 'workPhone' && !isValidPhone(action.value)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El teléfono \"${action.value}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n target_employee_id: actionContext.resolved_employee_id || currentEmployee.id || null,\n target_employee_name: actionContext.resolved_employee_name || currentEmployee.displayName || null,\n action,\n authorization_result: 'blocked_invalid_phone',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n\nconst employeeId =\n currentEmployee.id ||\n actionContext.resolved_employee_id;\n\nconst employeeName =\n currentEmployee.displayName ||\n actionContext.resolved_employee_name;\n\nconst oldValue =\n currentEmployee[action.field] ?? null;\n\nconst newValue =\n action.value;\n\nconst bamboohrPayload = {\n [action.field]: newValue\n};\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_query: intent.employee_query || actionContext.employee_query || null,\n\n action,\n old_value: oldValue,\n new_value: newValue,\n bamboohr_payload: bamboohrPayload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n action,\n old_value: oldValue,\n new_value: newValue,\n\n authorization_result: 'confirmed',\n execution_result: 'pending_execution',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4224, + -8320 + ], + "id": "2be39c4d-e645-4ec4-aa70-f2cf39e02792", + "name": "Code - Build Update Employee Payload" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4432, + -8320 + ], + "id": "71c5c87a-8a1d-4ab4-9827-049d361bf210", + "name": "HTTP - Update Employee Basic", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build Update Employee Payload').item.json.employee_id }}?fields=displayName,firstName,lastName,preferredName,workPhoneExtension,workPhone,workEmail,status,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4640, + -8320 + ], + "id": "ae76c11f-72af-4d49-8eee-6ff3d6bfa73e", + "name": "HTTP - Get Employee After Update", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst updateContext = $('Code - Build Update Employee Payload').item.json;\nconst updatedEmployee = $json;\n\nconst action = updateContext.action;\nconst field = action.field;\n\nconst confirmedValue =\n updatedEmployee[field] ?? null;\n\nconst updateSuccess =\n String(confirmedValue ?? '') === String(updateContext.new_value ?? '');\n\nconst status =\n updateSuccess ? 'success' : 'failed';\n\nconst responseText = updateSuccess\n ? `Acción ejecutada correctamente.\\n\\nEmpleado: ${updateContext.employee_name}\\nCampo: ${action.label || field}\\nValor anterior: ${updateContext.old_value ?? 'No disponible'}\\nNuevo valor confirmado: ${confirmedValue}`\n : `La acción fue enviada, pero no pude confirmar que el valor haya quedado actualizado.\\n\\nEmpleado: ${updateContext.employee_name}\\nCampo: ${action.label || field}\\nValor esperado: ${updateContext.new_value}\\nValor leído después: ${confirmedValue ?? 'No disponible'}`;\n\nreturn {\n json: {\n request_id: updateContext.request_id,\n request_started_at: updateContext.request_started_at,\n\n status,\n can_continue: updateSuccess,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: updateSuccess ? 'chat_message' : 'error_message',\n\n employee_id: updateContext.employee_id,\n employee_name: updateContext.employee_name,\n employee_query: updateContext.employee_query,\n\n action,\n old_value: updateContext.old_value,\n new_value: updateContext.new_value,\n confirmed_value: confirmedValue,\n update_success: updateSuccess,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n request_id: updateContext.request_id,\n request_started_at: updateContext.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: updateContext.employee_id,\n target_employee_name: updateContext.employee_name,\n\n action,\n old_value: updateContext.old_value,\n new_value: updateContext.new_value,\n confirmed_value: confirmedValue,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${updateContext.employee_id}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4848, + -8320 + ], + "id": "840decf9-3b17-4e54-9d0e-bf7d6cb1ac47", + "name": "Code - Format Action Update Response" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.action.type }}", + "rightValue": "update_field", + "operator": { + "type": "string", + "operation": "equals" + }, + "id": "0888cdba-54a5-45a1-8c8a-c70a374f70f9" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "update_field" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "e26d89c6-4ab0-442e-935d-319ae9872a90", + "leftValue": "={{ $json.action.type }}", + "rightValue": "add_table_row", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "add_job_info" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "8b65f994-feff-4b67-bac4-b7cc17e8ca2f", + "leftValue": "={{ $json.action.type }}", + "rightValue": "prepare_terminate_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "prepare_terminate" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "035e76a8-dbbc-435d-9b73-527bfb9ec03a", + "leftValue": "={{ $json.action.type }}", + "rightValue": "terminate_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "terminate_employee" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 2032, + -6288 + ], + "id": "6236bf70-4f09-49b9-b11c-6d8f4d1cb88a", + "name": "Switch - Action Type" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst actionContext = $('Code - Action Confirmation Guard').item.json;\n\nconst action = actionContext.action || intent.action || null;\nconst payload = action?.payload || {};\n\nconst requiredFields = [\n 'date',\n 'jobTitle',\n 'department',\n 'division',\n 'location'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = payload[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!action || action.type !== 'add_table_row' || action.table !== 'jobInfo') {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: 'Acción bloqueada. La acción recibida no corresponde a una fila jobInfo válida.',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n\n action,\n authorization_result: 'blocked_invalid_action_type',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n missing_fields: missingFields,\n\n response_text: `Acción bloqueada. Faltan campos obligatorios para jobInfo: ${missingFields.join(', ')}.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id || null,\n target_employee_name: actionContext.resolved_employee_name || null,\n\n action,\n missing_fields: missingFields,\n authorization_result: 'blocked_missing_required_fields',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst bamboohrPayload = {\n date: String(payload.date).trim(),\n jobTitle: String(payload.jobTitle).trim(),\n department: String(payload.department).trim(),\n division: String(payload.division).trim(),\n location: String(payload.location).trim()\n};\n\n// reportsTo es opcional. Solo lo enviamos si viene en el mensaje.\nif (payload.reportsTo && String(payload.reportsTo).trim() !== '') {\n bamboohrPayload.reportsTo = String(payload.reportsTo).trim();\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n\n employee_id: actionContext.resolved_employee_id,\n employee_name: actionContext.resolved_employee_name,\n employee_query: intent.employee_query || actionContext.employee_query || null,\n\n action,\n table_name: 'jobInfo',\n bamboohr_payload: bamboohrPayload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: actionContext.resolved_employee_id,\n target_employee_name: actionContext.resolved_employee_name,\n\n action,\n table_name: 'jobInfo',\n payload_sent: bamboohrPayload,\n\n authorization_result: 'confirmed',\n execution_result: 'pending_execution',\n bamboohr_endpoint: `/api/v1/employees/${actionContext.resolved_employee_id}/tables/jobInfo`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3728, + -7344 + ], + "id": "05bc0198-f2b3-4fc5-9dd2-b5041ec20b7c", + "name": "Code - Build jobInfo Payload" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": " application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 4896, + -7360 + ], + "id": "586fff08-ecb5-4bbd-a9cd-e7b4b8fcc53d", + "name": "HTTP - Add Employee jobInfo Row", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build jobInfo Payload').item.json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 5104, + -7360 + ], + "id": "5d8696e1-7c73-4ac1-9fdf-e0887e138dd0", + "name": "HTTP - Get jobInfo After Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst buildContext = $('Code - Build jobInfo Payload').item.json;\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction getDateValue(row) {\n return row.date || row.effectiveDate || row.startDate || '';\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\nconst expected = buildContext.bamboohr_payload || {};\n\nconst matchingRows = rows.filter(row => {\n const baseMatch =\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location);\n\n const reportsToMatch =\n !expected.reportsTo ||\n normalizeValue(row.reportsTo) === normalizeValue(expected.reportsTo);\n\n return baseMatch && reportsToMatch;\n});\n\nconst addSuccess = matchingRows.length > 0;\n\nconst sortedRows = [...rows].sort((a, b) => {\n const dateA = new Date(getDateValue(a)).getTime() || 0;\n const dateB = new Date(getDateValue(b)).getTime() || 0;\n return dateB - dateA;\n});\n\nconst latestRowsPreview = sortedRows.slice(0, 5).map(row => {\n const lineParts = [\n `cargo: ${row.jobTitle || 'No disponible'}`,\n `departamento: ${row.department || 'No disponible'}`,\n `división: ${row.division || 'No disponible'}`,\n `ubicación: ${row.location || 'No disponible'}`\n ];\n\n if (row.reportsTo) {\n lineParts.push(`supervisor: ${row.reportsTo}`);\n }\n\n return {\n id: row.id || null,\n date: row.date || null,\n jobTitle: row.jobTitle || null,\n department: row.department || null,\n division: row.division || null,\n location: row.location || null,\n reportsTo: row.reportsTo || null,\n line: `- ${row.date || 'Sin fecha'}: ${lineParts.join(' · ')}`\n };\n});\n\nconst status = addSuccess ? 'success' : 'failed';\n\nconst successResponseText =\n `Fila jobInfo agregada correctamente.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Fecha: ${expected.date}\\n` +\n `Cargo: ${expected.jobTitle}\\n` +\n `Departamento: ${expected.department}\\n` +\n `División: ${expected.division}\\n` +\n `Ubicación: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor: ${expected.reportsTo}` : '');\n\nconst failedResponseText =\n `La fila jobInfo fue enviada, pero no pude confirmar que haya quedado registrada.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Fecha esperada: ${expected.date}\\n` +\n `Cargo esperado: ${expected.jobTitle}\\n` +\n `Departamento esperado: ${expected.department}\\n` +\n `División esperada: ${expected.division}\\n` +\n `Ubicación esperada: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor esperado: ${expected.reportsTo}` : '');\n\nconst responseText = addSuccess\n ? successResponseText\n : failedResponseText;\n\nreturn [{\n json: {\n request_id: buildContext.request_id,\n request_started_at: buildContext.request_started_at,\n\n status,\n can_continue: addSuccess,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: addSuccess ? 'chat_message' : 'error_message',\n\n employee_id: buildContext.employee_id,\n employee_name: buildContext.employee_name,\n employee_query: buildContext.employee_query,\n\n action: buildContext.action,\n table_name: 'jobInfo',\n\n rows_count: rows.length,\n rows_preview: latestRowsPreview,\n matching_rows_count: matchingRows.length,\n matching_rows: matchingRows.slice(0, 3),\n\n add_success: addSuccess,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n request_id: buildContext.request_id,\n request_started_at: buildContext.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n target_employee_id: buildContext.employee_id,\n target_employee_name: buildContext.employee_name,\n\n action: buildContext.action,\n table_name: 'jobInfo',\n payload_sent: expected,\n\n matching_rows_count: matchingRows.length,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5312, + -7360 + ], + "id": "ffe1ba5e-dea1-45cf-9ea7-61c500305d12", + "name": "Code - Format jobInfo Action Response" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": " application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3936, + -7344 + ], + "id": "092d00f5-c521-4f29-bb6e-012f32f8c013", + "name": "HTTP - Get jobInfo Before Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build jobInfo Payload').item.json;\nconst expected = buildContext.bamboohr_payload || {};\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst duplicateRows = rows.filter(row => {\n const baseMatch =\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location);\n\n const reportsToMatch =\n !expected.reportsTo ||\n normalizeValue(row.reportsTo) === normalizeValue(expected.reportsTo);\n\n return baseMatch && reportsToMatch;\n});\n\nif (duplicateRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n jobinfo_add_allowed: false,\n duplicate_found: true,\n duplicate_rows_count: duplicateRows.length,\n duplicate_rows: duplicateRows.slice(0, 5),\n\n response_text:\n `Acción bloqueada para evitar duplicado.\\n\\n` +\n `Ya existe una fila jobInfo igual para ${buildContext.employee_name}:\\n` +\n `Fecha: ${expected.date}\\n` +\n `Cargo: ${expected.jobTitle}\\n` +\n `Departamento: ${expected.department}\\n` +\n `División: ${expected.division}\\n` +\n `Ubicación: ${expected.location}` +\n (expected.reportsTo ? `\\nSupervisor: ${expected.reportsTo}` : ''),\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'blocked_duplicate_jobinfo',\n execution_result: 'blocked',\n duplicate_rows_count: duplicateRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/jobInfo`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n jobinfo_add_allowed: true,\n duplicate_found: false,\n existing_rows_count: rows.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate',\n execution_result: 'pending_execution',\n existing_rows_count: rows.length\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4144, + -7344 + ], + "id": "f5dad92b-e9f1-4102-a1a2-321da2c3883d", + "name": "Code - Guard Duplicate jobInfo" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "7afeadb4-f3c6-4d53-bdcb-7b831d839ea9", + "leftValue": "={{ $json.jobinfo_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4352, + -7344 + ], + "id": "d0f71030-cafe-407f-835e-f7970bd45723", + "name": "IF - jobInfo Add Allowed?" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ $json.intent }}", + "rightValue": "=prepare_create_employee", + "operator": { + "type": "string", + "operation": "notEquals" + }, + "id": "95be9ad6-1771-4db3-ad0c-ac31b5876af6" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "existing_employee" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "155e24cd-d390-4596-90e8-0c40a02a384a", + "leftValue": "={{ $json.intent }}", + "rightValue": "prepare_create_employee", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "new_employee" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + -7168, + -3760 + ], + "id": "447cf65f-c061-4e70-8181-977b66288101", + "name": "Switch - Action Needs Existing Employee?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const intent = $('Code - Intent Parser').item.json;\nconst action = intent.action || null;\nconst payload = action?.payload || {};\n\nconst confirmationStatus =\n intent.confirmation_status ||\n intent.confirmationStatus ||\n null;\n\nfunction isValidEmail(value) {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value || '').trim());\n}\n\nconst missingRequired = [];\n\nif (!payload.firstName) missingRequired.push('nombre');\nif (!payload.lastName) missingRequired.push('apellido');\nif (!payload.workEmail) missingRequired.push('correo laboral');\nif (!payload.location) missingRequired.push('país/ubicación');\nif (!payload.department) missingRequired.push('departamento');\nif (!payload.division) missingRequired.push('división');\nif (!payload.jobTitle) missingRequired.push('cargo');\nif (!payload.hireDate) missingRequired.push('fecha de ingreso');\n\nif (payload.workEmail && !isValidEmail(payload.workEmail)) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'blocked',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'error_message',\n\n action,\n\n response_text: `Acción bloqueada. El correo \"${payload.workEmail}\" no parece tener un formato válido.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'blocked_invalid_email',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingRequired.length > 0) {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'needs_clarification',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'clarification_request',\n\n action,\n missing_fields: missingRequired,\n\n response_text:\n `Faltan datos para preparar la creación del empleado: ${missingRequired.join(', ')}.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'not_evaluated',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst confirmationMessage =\n `Confirma esta acción de alto riesgo antes de ejecutarla:\\n\\n` +\n `Acción: preparar creación de empleado\\n` +\n `Nombre: ${payload.firstName}\\n` +\n `Apellido: ${payload.lastName}\\n` +\n `Correo laboral: ${payload.workEmail}\\n` +\n `País/ubicación: ${payload.location}\\n` +\n `Departamento: ${payload.department}\\n` +\n `División: ${payload.division}\\n` +\n `Cargo: ${payload.jobTitle}\\n` +\n `Fecha de ingreso: ${payload.hireDate}\\n\\n` +\n `Para continuar, responde: CONFIRMAR`;\n\nif (intent.requires_confirmation && confirmationStatus !== 'confirmed') {\n return {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'pending_confirmation',\n can_continue: false,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'confirmation_request',\n\n action,\n requires_confirmation: true,\n confirmation_status: 'pending',\n confirmation_message: confirmationMessage,\n\n response_text: confirmationMessage,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'pending_confirmation',\n execution_result: 'pending_confirmation',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n status: 'pending_execution',\n can_continue: true,\n\n mode: intent.mode,\n intent: intent.intent,\n response_type: 'chat_message',\n\n action,\n create_employee_payload: payload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text:\n `Creación de empleado aprobada para ejecución.\\n\\n` +\n `Empleado: ${payload.firstName} ${payload.lastName}\\n` +\n `Correo laboral: ${payload.workEmail}`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: 'high',\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n action,\n authorization_result: 'confirmed',\n execution_result: 'authorized_pending_execution',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -6848, + -3760 + ], + "id": "0f430b61-50b6-4d78-9e43-a910091ccaea", + "name": "Code - Create Employee Confirmation Guard" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "62f21642-ab19-4ac3-9794-65ca466072bf", + "leftValue": "={{ $json.action_approved }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -1504, + -2800 + ], + "id": "13549ab1-68a8-4630-b6d9-e08fec43950b", + "name": "IF - Create Employee Approved?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const guard = $json;\nconst action = guard.action || null;\nconst payload = guard.create_employee_payload || action?.payload || {};\n\nfunction normalizeText(value) {\n return String(value || '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction normalizeEmail(value) {\n return String(value || '').trim().toLowerCase();\n}\n\nfunction buildEmployeeNumber(payload) {\n if (payload.employeeNumber) {\n return normalizeText(payload.employeeNumber);\n }\n\n const baseName = `${payload.firstName || ''}${payload.lastName || ''}`\n .replace(/[^a-zA-Z0-9]/g, '')\n .toUpperCase()\n .slice(0, 12);\n\n const stamp = new Date()\n .toISOString()\n .replace(/[-:TZ.]/g, '')\n .slice(0, 12);\n\n return `QA-${baseName || 'EMP'}-${stamp}`;\n}\n\nconst requiredFields = [\n 'firstName',\n 'lastName',\n 'workEmail',\n 'location',\n 'department',\n 'division',\n 'jobTitle',\n 'hireDate'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = payload[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!action || action.type !== 'prepare_create_employee') {\n return {\n json: {\n ...guard,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n response_text: 'Acción bloqueada. La acción recibida no corresponde a creación de empleado.',\n\n audit_event: {\n ...guard.audit_event,\n authorization_result: 'blocked_invalid_action_type',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n ...guard,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n missing_fields: missingFields,\n\n response_text: `Acción bloqueada. Faltan campos obligatorios para crear el empleado: ${missingFields.join(', ')}.`,\n\n audit_event: {\n ...guard.audit_event,\n authorization_result: 'blocked_missing_required_fields',\n execution_result: 'blocked',\n missing_fields: missingFields,\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nconst employeeNumber = buildEmployeeNumber(payload);\n\nconst bamboohrPayload = {\n firstName: normalizeText(payload.firstName),\n lastName: normalizeText(payload.lastName),\n employeeNumber,\n workEmail: normalizeEmail(payload.workEmail),\n status: 'Active',\n hireDate: normalizeText(payload.hireDate),\n jobTitle: normalizeText(payload.jobTitle),\n department: normalizeText(payload.department),\n division: normalizeText(payload.division),\n location: normalizeText(payload.location)\n};\n\nreturn {\n json: {\n ...guard,\n\n status: 'pending_execution',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_name: `${bamboohrPayload.firstName} ${bamboohrPayload.lastName}`,\n employee_email: bamboohrPayload.workEmail,\n employee_number: employeeNumber,\n\n create_employee_payload: payload,\n bamboohr_payload: bamboohrPayload,\n\n audit_event: {\n ...guard.audit_event,\n target_employee_name: `${bamboohrPayload.firstName} ${bamboohrPayload.lastName}`,\n target_employee_email: bamboohrPayload.workEmail,\n employee_number: employeeNumber,\n payload_sent: bamboohrPayload,\n authorization_result: 'confirmed',\n execution_result: 'pending_duplicate_check',\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -912, + -2816 + ], + "id": "ebcaa650-d8dd-4e78-8859-188576daa1bf", + "name": "Code - Build Create Employee Payload" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -672, + -2816 + ], + "id": "45eb91e3-e407-4896-9436-ba03ff68e397", + "name": "HTTP - Employees Directory For Create Check", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const buildContext = $('Code - Build Create Employee Payload').item.json;\nconst expected = buildContext.bamboohr_payload || {};\nconst body = $json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getEmployees(payload) {\n if (Array.isArray(payload?.employees)) return payload.employees;\n if (Array.isArray(payload?.data?.employees)) return payload.data.employees;\n if (Array.isArray(payload)) return payload;\n return [];\n}\n\nconst employees = getEmployees(body);\n\nconst expectedEmail = normalize(expected.workEmail);\nconst expectedEmployeeNumber = normalize(expected.employeeNumber);\nconst expectedName = normalize(`${expected.firstName} ${expected.lastName}`);\n\nconst duplicateMatches = employees.filter(employee => {\n const employeeEmail = normalize(employee.workEmail || employee.email);\n const employeeNumber = normalize(employee.employeeNumber);\n const employeeName = normalize(\n employee.displayName ||\n `${employee.firstName || ''} ${employee.lastName || ''}`\n );\n\n return (\n (expectedEmail && employeeEmail && employeeEmail === expectedEmail) ||\n (expectedEmployeeNumber && employeeNumber && employeeNumber === expectedEmployeeNumber) ||\n (expectedName && employeeName && employeeName === expectedName)\n );\n});\n\nif (duplicateMatches.length > 0) {\n return {\n json: {\n ...buildContext,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n create_employee_allowed: false,\n duplicate_found: true,\n duplicate_matches_count: duplicateMatches.length,\n duplicate_matches: duplicateMatches.slice(0, 5).map(employee => ({\n id: employee.id || null,\n displayName: employee.displayName || null,\n firstName: employee.firstName || null,\n lastName: employee.lastName || null,\n workEmail: employee.workEmail || employee.email || null,\n employeeNumber: employee.employeeNumber || null,\n status: employee.status || null\n })),\n\n response_text:\n `Acción bloqueada para evitar duplicado.\\n\\n` +\n `Ya existe un empleado que coincide con alguno de estos datos:\\n` +\n `Nombre: ${expected.firstName} ${expected.lastName}\\n` +\n `Correo laboral: ${expected.workEmail}\\n` +\n `Employee Number: ${expected.employeeNumber}`,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'blocked_duplicate_employee',\n execution_result: 'blocked',\n duplicate_matches_count: duplicateMatches.length,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...buildContext,\n\n create_employee_allowed: true,\n duplicate_found: false,\n existing_employees_checked: employees.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate',\n execution_result: 'pending_execution',\n existing_employees_checked: employees.length,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -368, + -2816 + ], + "id": "cea55536-999d-4c58-ae6e-f3475e830443", + "name": "Code - Guard Duplicate Create Employee" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "8ce9e043-60da-4ff7-8049-aa93788519d4", + "leftValue": "={{ $json.create_employee_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -32, + -2816 + ], + "id": "03b8b208-cfaf-490a-9174-0ab1b9c467e3", + "name": "IF - Create Employee Allowed?" + }, + { + "parameters": { + "method": "POST", + "url": "https://glm.bamboohr.com/api/v1/employees", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.bamboohr_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 304, + -2960 + ], + "id": "0353f2d9-9322-460f-b0cd-cc424552d96c", + "name": "HTTP - Create Employee", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const buildContext = $('Code - Build Create Employee Payload').item.json;\nconst createResponse = $json;\n\nfunction extractIdFromLocation(location) {\n const raw = String(location || '').trim();\n const match = raw.match(/\\/employees\\/([0-9]+)/i);\n return match ? match[1] : null;\n}\n\nconst createdEmployeeId =\n createResponse.id ||\n createResponse.employeeId ||\n createResponse.employee_id ||\n createResponse.data?.id ||\n createResponse.data?.employeeId ||\n extractIdFromLocation(createResponse.location || createResponse.Location || createResponse.headers?.location) ||\n null;\n\nif (!createdEmployeeId) {\n return {\n json: {\n ...buildContext,\n\n status: 'created_unconfirmed',\n can_continue: false,\n response_type: 'error_message',\n\n create_response: createResponse,\n created_employee_id: null,\n\n response_text:\n `La solicitud de creación fue enviada, pero no pude identificar el ID del empleado creado.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `Correo laboral: ${buildContext.employee_email}\\n\\n` +\n `Revisa la respuesta del nodo HTTP - Create Employee antes de continuar.`,\n\n audit_event: {\n ...buildContext.audit_event,\n execution_result: 'created_unconfirmed',\n authorization_result: 'confirmed',\n create_response: createResponse,\n bamboohr_endpoint: '/api/v1/employees'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...buildContext,\n\n status: 'created_pending_verification',\n can_continue: true,\n response_type: 'chat_message',\n\n create_response: createResponse,\n created_employee_id: String(createdEmployeeId),\n\n audit_event: {\n ...buildContext.audit_event,\n target_employee_id: String(createdEmployeeId),\n execution_result: 'created_pending_verification',\n authorization_result: 'confirmed',\n create_response: createResponse,\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 704, + -2960 + ], + "id": "8fa184d0-c655-406a-af9e-46a1a9fbef53", + "name": "Code - Normalize Created Employee ID" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}?fields=displayName,firstName,lastName,workEmail,status,employeeNumber,hireDate,jobTitle,department,division,location", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2448, + -2896 + ], + "id": "37befaf4-8dd5-4172-97f8-336d12633fe2", + "name": "HTTP - Get Created Employee By ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const normalizeContext = $('Code - Normalize Created Employee ID').item.json;\nconst jobInfoContext = $('Code - Build Created Employee jobInfo Payload').item.json;\nconst employee = $('HTTP - Get Created Employee By ID').item.json || {};\n\nconst expected = normalizeContext.bamboohr_payload || {};\nconst expectedJobInfo = jobInfoContext.jobinfo_payload || {};\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nconst jobInfoRows = normalizeRows(items);\n\nconst employeeId =\n employee.id ||\n normalizeContext.created_employee_id ||\n jobInfoContext.created_employee_id ||\n null;\n\nconst createdName =\n employee.displayName ||\n `${employee.firstName || expected.firstName || ''} ${employee.lastName || expected.lastName || ''}`.trim();\n\nconst emailMatches =\n normalize(employee.workEmail) === normalize(expected.workEmail);\n\nconst firstNameMatches =\n normalize(employee.firstName) === normalize(expected.firstName);\n\nconst lastNameMatches =\n normalize(employee.lastName) === normalize(expected.lastName);\n\nconst creationConfirmed =\n Boolean(employeeId) &&\n (\n emailMatches ||\n (firstNameMatches && lastNameMatches)\n );\n\nconst matchingJobInfoRows = jobInfoRows.filter(row => {\n return (\n normalize(row.date) === normalize(expectedJobInfo.date) &&\n normalize(row.jobTitle) === normalize(expectedJobInfo.jobTitle) &&\n normalize(row.department) === normalize(expectedJobInfo.department) &&\n normalize(row.division) === normalize(expectedJobInfo.division) &&\n normalize(row.location) === normalize(expectedJobInfo.location)\n );\n});\n\nconst jobInfoConfirmed = matchingJobInfoRows.length > 0;\nconst confirmedJobInfo = matchingJobInfoRows[0] || null;\n\nlet status = 'created_unconfirmed';\n\nif (creationConfirmed && jobInfoConfirmed) {\n status = 'success';\n} else if (creationConfirmed && !jobInfoConfirmed) {\n status = 'partial_success_jobinfo_unconfirmed';\n}\n\nconst responseText =\n status === 'success'\n ? `Empleado creado correctamente en BambooHR y jobInfo agregado correctamente.\\n\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Nombre: ${createdName}\\n` +\n `Correo laboral: ${employee.workEmail || expected.workEmail}\\n` +\n `Employee Number: ${employee.employeeNumber || expected.employeeNumber || 'No disponible'}\\n` +\n `Estatus: ${employee.status || expected.status || 'No disponible'}\\n` +\n `Fecha de ingreso: ${employee.hireDate || expected.hireDate || 'No disponible'}\\n\\n` +\n `jobInfo registrado:\\n` +\n `Fecha: ${confirmedJobInfo.date || expectedJobInfo.date || 'No disponible'}\\n` +\n `Cargo: ${confirmedJobInfo.jobTitle || expectedJobInfo.jobTitle || 'No disponible'}\\n` +\n `Departamento: ${confirmedJobInfo.department || expectedJobInfo.department || 'No disponible'}\\n` +\n `División: ${confirmedJobInfo.division || expectedJobInfo.division || 'No disponible'}\\n` +\n `Ubicación: ${confirmedJobInfo.location || expectedJobInfo.location || 'No disponible'}`\n : creationConfirmed\n ? `Empleado creado correctamente en BambooHR, pero no pude confirmar que jobInfo haya quedado registrado.\\n\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Nombre: ${createdName}\\n` +\n `Correo laboral: ${employee.workEmail || expected.workEmail}\\n\\n` +\n `jobInfo esperado:\\n` +\n `Fecha: ${expectedJobInfo.date || 'No disponible'}\\n` +\n `Cargo: ${expectedJobInfo.jobTitle || 'No disponible'}\\n` +\n `Departamento: ${expectedJobInfo.department || 'No disponible'}\\n` +\n `División: ${expectedJobInfo.division || 'No disponible'}\\n` +\n `Ubicación: ${expectedJobInfo.location || 'No disponible'}`\n : `La creación fue enviada, pero no pude confirmar completamente el empleado.\\n\\n` +\n `ID detectado: ${employeeId || 'No disponible'}\\n` +\n `Nombre esperado: ${expected.firstName} ${expected.lastName}\\n` +\n `Correo esperado: ${expected.workEmail}`;\n\nreturn [{\n json: {\n request_id: normalizeContext.request_id,\n request_started_at: normalizeContext.request_started_at,\n\n status,\n can_continue: status === 'success',\n response_type: status === 'success' ? 'chat_message' : 'error_message',\n\n mode: normalizeContext.mode,\n intent: normalizeContext.intent,\n\n employee_id: employeeId,\n employee_name: createdName,\n employee_email: employee.workEmail || expected.workEmail,\n employee_number: employee.employeeNumber || expected.employeeNumber,\n\n created_employee: {\n id: employeeId,\n displayName: employee.displayName || null,\n firstName: employee.firstName || null,\n lastName: employee.lastName || null,\n workEmail: employee.workEmail || null,\n employeeNumber: employee.employeeNumber || null,\n status: employee.status || null,\n hireDate: employee.hireDate || null\n },\n\n expected_payload: expected,\n expected_jobinfo_payload: expectedJobInfo,\n\n creation_confirmed: creationConfirmed,\n jobinfo_confirmed: jobInfoConfirmed,\n jobinfo_rows_count: jobInfoRows.length,\n matching_jobinfo_rows_count: matchingJobInfoRows.length,\n confirmed_jobinfo_row: confirmedJobInfo,\n\n action: normalizeContext.action,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n ...normalizeContext.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: createdName,\n target_employee_email: employee.workEmail || expected.workEmail,\n\n payload_sent: expected,\n jobinfo_payload_sent: expectedJobInfo,\n\n created_employee: employee,\n jobinfo_confirmed: jobInfoConfirmed,\n jobinfo_rows_count: jobInfoRows.length,\n matching_jobinfo_rows_count: matchingJobInfoRows.length,\n\n authorization_result: 'confirmed',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3280, + -2912 + ], + "id": "0e26b330-4ed7-436f-9479-1a94b41e0546", + "name": "Code - Format Create Employee Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const context = $json;\n\nconst expected = context.bamboohr_payload || {};\nconst createdEmployeeId = context.created_employee_id || context.employee_id || null;\n\nfunction normalizeText(value) {\n return String(value || '').replace(/\\s+/g, ' ').trim();\n}\n\nconst requiredFields = [\n 'hireDate',\n 'jobTitle',\n 'department',\n 'division',\n 'location'\n];\n\nconst missingFields = requiredFields.filter(field => {\n const value = expected[field];\n return value === null || value === undefined || String(value).trim() === '';\n});\n\nif (!createdEmployeeId) {\n return {\n json: {\n ...context,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n response_text:\n 'Acción bloqueada. No tengo el ID del empleado creado, por lo tanto no puedo agregar jobInfo.',\n\n audit_event: {\n ...context.audit_event,\n authorization_result: 'blocked_missing_created_employee_id',\n execution_result: 'blocked',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nif (missingFields.length > 0) {\n return {\n json: {\n ...context,\n\n status: 'blocked',\n can_continue: false,\n response_type: 'error_message',\n\n missing_fields: missingFields,\n\n response_text:\n `Empleado creado, pero no puedo agregar jobInfo porque faltan estos campos: ${missingFields.join(', ')}.`,\n\n audit_event: {\n ...context.audit_event,\n target_employee_id: String(createdEmployeeId),\n authorization_result: 'blocked_missing_jobinfo_fields',\n execution_result: 'partial_created_missing_jobinfo_fields',\n missing_fields: missingFields,\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}/tables/jobInfo`\n }\n }\n };\n}\n\nconst jobinfoPayload = {\n date: normalizeText(expected.hireDate),\n jobTitle: normalizeText(expected.jobTitle),\n department: normalizeText(expected.department),\n division: normalizeText(expected.division),\n location: normalizeText(expected.location)\n};\n\nreturn {\n json: {\n ...context,\n\n status: 'pending_jobinfo_add',\n can_continue: true,\n response_type: 'chat_message',\n\n created_employee_id: String(createdEmployeeId),\n jobinfo_payload: jobinfoPayload,\n\n audit_event: {\n ...context.audit_event,\n target_employee_id: String(createdEmployeeId),\n jobinfo_payload: jobinfoPayload,\n authorization_result: 'confirmed',\n execution_result: 'pending_jobinfo_duplicate_check',\n bamboohr_endpoint: `/api/v1/employees/${createdEmployeeId}/tables/jobInfo`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 912, + -2960 + ], + "id": "2daa0105-0826-4286-9790-5bc6675e00cd", + "name": "Code - Build Created Employee jobInfo Payload" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1120, + -2960 + ], + "id": "e7e9e217-0241-47f8-ac3c-ef07e1f810e2", + "name": "HTTP - Get Created Employee jobInfo Before Add", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build Created Employee jobInfo Payload').item.json;\nconst expected = buildContext.jobinfo_payload || {};\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.jobInfo)) return body.jobInfo;\n\n return [body];\n }\n\n return items.map(item => item.json);\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst duplicateRows = rows.filter(row => {\n return (\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n normalizeValue(row.jobTitle) === normalizeValue(expected.jobTitle) &&\n normalizeValue(row.department) === normalizeValue(expected.department) &&\n normalizeValue(row.division) === normalizeValue(expected.division) &&\n normalizeValue(row.location) === normalizeValue(expected.location)\n );\n});\n\nif (duplicateRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'jobinfo_duplicate_found',\n can_continue: true,\n response_type: 'chat_message',\n\n created_employee_jobinfo_add_allowed: false,\n jobinfo_duplicate_found: true,\n duplicate_rows_count: duplicateRows.length,\n duplicate_rows: duplicateRows.slice(0, 5),\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_duplicate_jobinfo_not_added',\n execution_result: 'jobinfo_duplicate_found',\n duplicate_rows_count: duplicateRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.created_employee_id}/tables/jobInfo`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n created_employee_jobinfo_add_allowed: true,\n jobinfo_duplicate_found: false,\n existing_jobinfo_rows_count: rows.length,\n\n audit_event: {\n ...buildContext.audit_event,\n authorization_result: 'confirmed_no_duplicate_jobinfo',\n execution_result: 'pending_jobinfo_add',\n existing_jobinfo_rows_count: rows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.created_employee_id}/tables/jobInfo`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1328, + -2960 + ], + "id": "def2cbd1-86eb-4a2e-a4ef-82fbcfeb7f7d", + "name": "Code - Guard Duplicate Created Employee jobInfo" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "ba1eb8e7-c51a-43cc-803c-67f946e12d3c", + "leftValue": "={{ $json.created_employee_jobinfo_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1536, + -2960 + ], + "id": "616500e3-b214-45cd-a555-a20bc0407877", + "name": "IF - Created Employee jobInfo Add Allowed?" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.jobinfo_payload) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1936, + -3008 + ], + "id": "c579f25a-1d00-4e24-bfa8-863d3ac2e75a", + "name": "HTTP - Add Created Employee jobInfo Row", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $('Code - Build Created Employee jobInfo Payload').item.json.created_employee_id }}/tables/jobInfo", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2656, + -2896 + ], + "id": "029599ab-03ed-4066-bebc-db360d9cf185", + "name": "HTTP - Get Created Employee jobInfo Table", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst action = data.action || {};\nconst payload = action.payload || {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.displayName ||\n data.employee_query ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.workEmail ||\n data.email ||\n null;\n\nconst responseText =\n `Desvinculación preparada correctamente para revisión.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n` +\n `Correo laboral: ${employeeEmail || 'No disponible'}\\n` +\n `Fecha de salida: ${payload.terminationDate || 'No disponible'}\\n` +\n `Motivo: ${payload.terminationReason || 'No disponible'}\\n` +\n `Tipo de terminación: ${payload.terminationType || 'No disponible'}\\n\\n` +\n `Estado: preparada, no ejecutada.\\n` +\n `Siguiente paso futuro: ejecutar desvinculación real solo con autorización final.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'prepared_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n termination_payload: payload,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed_termination',\n action_approved: true,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n termination_payload: payload,\n\n authorization_result: 'confirmed_termination_prepared',\n execution_result: 'prepared_not_executed',\n bamboohr_endpoint: null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3536, + -7040 + ], + "id": "b37a3f61-7d9e-42a2-beb2-760e0ffba872", + "name": "Code - Format Prepare Termination Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\nconst intent = $('Code - Intent Parser').item.json;\n\nconst rawQuery =\n intent.employee_query ||\n data.employee_query ||\n '';\n\nconst originalMessage =\n intent.original_message ||\n intent.message ||\n '';\n\nfunction extractEmployeeId(value) {\n const text = String(value || '').trim();\n\n // Caso simple: \"50802\"\n if (/^[0-9]{2,10}$/.test(text)) {\n return text;\n }\n\n // Caso: \"empleado 50802\", \"id 50802\", \"BambooHR ID 50802\"\n const match = text.match(/\\b(?:id|empleado|employee|bamboohr id|bamboohr)\\s*#?\\s*([0-9]{2,10})\\b/i);\n if (match && match[1]) {\n return match[1];\n }\n\n // Último recurso: buscar ID en el mensaje completo,\n // pero solo si aparece junto a una palabra de empleado/ID.\n const contextualMatch = String(originalMessage || '').match(/\\b(?:id|empleado|employee|bamboohr id|bamboohr)\\s*#?\\s*([0-9]{2,10})\\b/i);\n if (contextualMatch && contextualMatch[1]) {\n return contextualMatch[1];\n }\n\n return null;\n}\n\nconst directEmployeeId = extractEmployeeId(rawQuery) || extractEmployeeId(originalMessage);\n\nif (!directEmployeeId) {\n return {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n data.response_text ||\n `No encontré ningún empleado que coincida con \"${rawQuery}\". Puedes intentar con el ID BambooHR directo.`,\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: rawQuery,\n direct_employee_id: null,\n\n authorization_result: 'not_evaluated_employee_not_found',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: true,\n direct_employee_id: String(directEmployeeId),\n\n status: 'pending_direct_employee_lookup',\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n request_id: intent.request_id,\n request_started_at: intent.request_started_at,\n\n mode: intent.mode,\n intent: intent.intent,\n risk_level: intent.risk_level,\n\n user_email: intent.user_email,\n user_name: intent.user_name,\n channel: intent.channel,\n environment: intent.environment,\n\n employee_query: rawQuery,\n direct_employee_id: String(directEmployeeId),\n\n authorization_result: 'pending_direct_employee_lookup',\n execution_result: 'pending_direct_employee_lookup',\n bamboohr_endpoint: `/api/v1/employees/${directEmployeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3872, + -4512 + ], + "id": "126b4979-c77e-4640-8b19-b5c9b2ff323c", + "name": "Code - Check Direct Employee ID Fallback" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "0deee06b-09ed-4625-9d1a-496ee8ed3b18", + "leftValue": "={{ $json.direct_employee_id_fallback_available }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -3104, + -4336 + ], + "id": "65ffcff8-b8b0-42be-b9b8-965773255145", + "name": "IF - Has Direct Employee ID?" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.direct_employee_id }}?fields=displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate,employeeNumber", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2704, + -4560 + ], + "id": "bb411b52-5759-4802-a6e7-c198f2f4ad2a", + "name": "HTTP - Get Employee By Direct ID", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const lookupContext = $('Code - Check Direct Employee ID Fallback').item.json;\nconst employee = $json || {};\n\nconst employeeId =\n employee.id ||\n lookupContext.direct_employee_id ||\n null;\n\nconst displayName =\n employee.displayName ||\n `${employee.firstName || ''} ${employee.lastName || ''}`.trim() ||\n `Empleado ${employeeId}`;\n\nif (!employeeId) {\n return {\n json: {\n ...lookupContext,\n\n employee_found: false,\n direct_employee_id_lookup_success: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n `Intenté buscar el empleado por ID directo, pero BambooHR no devolvió un empleado válido.`,\n\n audit_event: {\n ...lookupContext.audit_event,\n authorization_result: 'not_evaluated_direct_employee_lookup_failed',\n execution_result: 'needs_clarification',\n bamboohr_endpoint: `/api/v1/employees/${lookupContext.direct_employee_id}`\n }\n }\n };\n}\n\nreturn {\n json: {\n ...lookupContext,\n ...employee,\n\n employee_found: true,\n direct_employee_id_lookup_success: true,\n\n employee_query: lookupContext.employee_query || lookupContext.direct_employee_id,\n\n resolved_employee_id: String(employeeId),\n resolved_employee_name: displayName,\n\n status: 'employee_resolved_by_direct_id',\n can_continue: true,\n response_type: 'chat_message',\n\n response_text: null,\n\n audit_event: {\n ...lookupContext.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: displayName,\n target_employee_email: employee.workEmail || null,\n\n authorization_result: 'direct_employee_lookup_success',\n execution_result: 'employee_resolved_by_direct_id',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2384, + -4560 + ], + "id": "1e200e58-2b3b-4e63-8615-35cc2fad5871", + "name": "Code - Normalize Direct Employee Resolve" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst action = data.action || {};\nconst payload = action.payload || {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.displayName ||\n data.employee_query ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.workEmail ||\n data.email ||\n null;\n\nfunction normalizeText(value) {\n return String(value || '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst terminationDate = normalizeText(payload.terminationDate);\nconst terminationReason = normalizeText(payload.terminationReason);\nconst terminationType = normalizeText(payload.terminationType);\n\nconst missing = [];\n\nif (!employeeId) missing.push('employeeId');\nif (!terminationDate) missing.push('terminationDate');\nif (!terminationReason) missing.push('terminationReason');\nif (!terminationType) missing.push('terminationType');\n\nif (missing.length > 0) {\n return {\n json: {\n ...data,\n\n status: 'blocked_missing_termination_payload',\n can_continue: false,\n response_type: 'error_message',\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n missing_fields: missing,\n\n response_text:\n `No puedo preparar la desvinculación real porque faltan estos datos técnicos: ${missing.join(', ')}.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n authorization_result: 'blocked_missing_termination_payload',\n execution_result: 'blocked_missing_termination_payload',\n bamboohr_endpoint: null\n }\n }\n };\n}\n\n// Importante:\n// Este payload está preparado para employmentStatus.\n// Todavía NO se envía a BambooHR.\nconst terminationPayload = {\n date: terminationDate,\n employmentStatus: 'Terminated',\n comment: `Desvinculación registrada por BambooHR Agent. Motivo: ${terminationReason}. Tipo: ${terminationType}.`,\n terminationReason,\n terminationType\n};\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_payload_built',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_payload: terminationPayload,\n\n termination_data: {\n terminationDate,\n terminationReason,\n terminationType\n },\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_payload: terminationPayload,\n\n authorization_result: 'confirmed_termination_execute',\n execution_result: 'termination_payload_built',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2416, + -6064 + ], + "id": "d40fbbfe-b6d4-4b79-91d5-c2b3d72d7b71", + "name": "Code - Build Termination Payload" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2624, + -6064 + ], + "id": "1a907dec-d167-4e40-b2b9-ec17a2aae10e", + "name": "HTTP - Get employmentStatus Before Termination", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const buildContext = $('Code - Build Termination Payload').item.json;\nconst expected = buildContext.termination_payload || {};\nconst terminationData = buildContext.termination_data || {};\n\nfunction isEmptyObject(value) {\n return (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employmentStatus)) return body.employmentStatus;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(row => row && !isEmptyObject(row));\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction rowLooksTerminated(row) {\n const values = [\n row.employmentStatus,\n row.status,\n row.employmentStatusId,\n row.employeeStatus,\n row.comment\n ].map(normalizeValue);\n\n return values.some(value =>\n value.includes('terminated') ||\n value.includes('termination') ||\n value.includes('terminado') ||\n value.includes('terminacion') ||\n value.includes('desvinculado') ||\n value.includes('desvinculacion') ||\n value.includes('inactivo') ||\n value.includes('inactive')\n );\n}\n\nconst rows = normalizeRows(items);\n\nconst sameDateRows = rows.filter(row =>\n normalizeValue(row.date) === normalizeValue(expected.date)\n);\n\nconst sameDateTerminationRows = sameDateRows.filter(row => rowLooksTerminated(row));\n\nconst anyTerminationRows = rows.filter(row => rowLooksTerminated(row));\n\nif (sameDateTerminationRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'termination_duplicate_same_date',\n can_continue: false,\n response_type: 'error_message',\n\n termination_add_allowed: false,\n termination_duplicate_found: true,\n termination_duplicate_reason: 'same_date_termination_exists',\n\n existing_employment_status_rows_count: rows.length,\n duplicate_rows_count: sameDateTerminationRows.length,\n duplicate_rows: sameDateTerminationRows.slice(0, 5),\n\n response_text:\n `Bloqueado por seguridad. Ya existe una fila de desvinculación o terminación para este empleado con la misma fecha ${expected.date}.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'blocked_duplicate_termination_same_date',\n execution_result: 'blocked_duplicate_termination_same_date',\n duplicate_rows_count: sameDateTerminationRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n }];\n}\n\nif (anyTerminationRows.length > 0) {\n return [{\n json: {\n ...buildContext,\n\n status: 'termination_existing_termination_found',\n can_continue: false,\n response_type: 'error_message',\n\n termination_add_allowed: false,\n termination_duplicate_found: true,\n termination_duplicate_reason: 'employee_already_has_termination_row',\n\n existing_employment_status_rows_count: rows.length,\n existing_termination_rows_count: anyTerminationRows.length,\n existing_termination_rows: anyTerminationRows.slice(0, 5),\n\n response_text:\n `Bloqueado por seguridad. Este empleado ya tiene una fila histórica que parece indicar terminación/desvinculación.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n` +\n `Fecha solicitada: ${expected.date}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR. Revisa primero el historial employmentStatus.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'blocked_existing_termination_row',\n execution_result: 'blocked_existing_termination_row',\n existing_termination_rows_count: anyTerminationRows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...buildContext,\n\n status: 'termination_guard_passed',\n can_continue: true,\n response_type: 'chat_message',\n\n termination_add_allowed: true,\n termination_duplicate_found: false,\n\n existing_employment_status_rows_count: rows.length,\n\n response_text:\n `Validación previa completada. No encontré una desvinculación existente en employmentStatus.\\n\\n` +\n `Empleado: ${buildContext.employee_name}\\n` +\n `ID BambooHR: ${buildContext.employee_id}\\n` +\n `Fecha de salida: ${terminationData.terminationDate}\\n` +\n `Motivo: ${terminationData.terminationReason}\\n` +\n `Tipo: ${terminationData.terminationType}\\n\\n` +\n `Estado: listo para ejecución real, pero todavía no ejecutado.`,\n\n audit_event: {\n ...buildContext.audit_event,\n\n authorization_result: 'termination_guard_passed',\n execution_result: 'termination_guard_passed_no_post_executed',\n existing_employment_status_rows_count: rows.length,\n bamboohr_endpoint: `/api/v1/employees/${buildContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2832, + -6064 + ], + "id": "0cb3c744-ecaf-4359-a548-932d5ba880fb", + "name": "Code - Guard Duplicate Termination" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "6f6d7a55-d318-4d57-88c4-ead0362fabdf", + "leftValue": "={{ $json.termination_add_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 3040, + -6064 + ], + "id": "15a91be4-864e-4ba7-9089-32f219b13d9f", + "name": "IF - Termination Add Allowed?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: data.status || 'termination_blocked',\n can_continue: false,\n response_type: data.response_type || 'error_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_data: data.termination_data,\n\n termination_add_allowed: false,\n termination_duplicate_found: data.termination_duplicate_found || false,\n termination_duplicate_reason: data.termination_duplicate_reason || null,\n\n response_text:\n data.response_text ||\n `Desvinculación bloqueada por seguridad. No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result:\n data.audit_event?.authorization_result ||\n 'blocked_termination_guard',\n\n execution_result:\n data.audit_event?.execution_result ||\n 'blocked_termination_guard',\n\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3392, + -5936 + ], + "id": "d10bbd31-7e50-4d4b-9d1c-63ed04b16529", + "name": "Code - Format Termination Blocked Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst terminationData = data.termination_data || {};\nconst payload = data.termination_payload || {};\n\nconst responseText =\n `Desvinculación validada y lista para ejecución real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Correo laboral: ${data.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${terminationData.terminationDate || payload.date || 'No disponible'}\\n` +\n `Motivo: ${terminationData.terminationReason || payload.terminationReason || 'No disponible'}\\n` +\n `Tipo de terminación: ${terminationData.terminationType || payload.terminationType || 'No disponible'}\\n\\n` +\n `Validación: sin duplicado detectado en employmentStatus.\\n` +\n `Estado: lista para ejecutar, pero todavía NO ejecutada.\\n\\n` +\n `Siguiente paso: conectar el POST real a employmentStatus solo cuando confirmes continuar.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_ready_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: payload,\n termination_data: terminationData,\n\n termination_add_allowed: true,\n termination_duplicate_found: false,\n\n existing_employment_status_rows_count: data.existing_employment_status_rows_count || 0,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result: 'termination_ready_not_executed',\n execution_result: 'termination_ready_not_executed',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5248, + -6656 + ], + "id": "8e08c21f-1dda-4976-b852-6a571cb70c66", + "name": "Code - Format Termination Ready Response" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/meta/lists", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 3424, + -6160 + ], + "id": "4ff27380-7851-4e58-9c25-64fbedc81bd9", + "name": "HTTP - Meta Lists For Termination", + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const guardContext = $items('Code - Guard Duplicate Termination')[0].json;\n\nconst terminationData = guardContext.termination_data || {};\nconst requestedReason = terminationData.terminationReason || '';\nconst requestedType = terminationData.terminationType || '';\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getListName(list) {\n if (!list) return '';\n\n return String(\n list.name ||\n list.alias ||\n list.field ||\n list.fieldId ||\n list.id ||\n ''\n );\n}\n\nfunction getListValues(list) {\n if (!list) return [];\n\n if (Array.isArray(list.options)) return list.options;\n if (Array.isArray(list.values)) return list.values;\n if (Array.isArray(list.items)) return list.items;\n if (Array.isArray(list.listOptions)) return list.listOptions;\n if (Array.isArray(list)) return list;\n\n return [];\n}\n\nfunction getOptionId(option) {\n if (!option) return null;\n\n return (\n option.id ??\n option.value ??\n option.listValueId ??\n option.optionId ??\n option.employeeFieldValueId ??\n null\n );\n}\n\nfunction getOptionName(option) {\n if (!option) return '';\n\n return String(\n option.name ||\n option.label ||\n option.value ||\n option.text ||\n option.displayName ||\n ''\n );\n}\n\nfunction optionIsArchived(option) {\n return (\n normalize(option?.archived) === 'yes' ||\n normalize(option?.archived) === 'true' ||\n option?.archived === true\n );\n}\n\nfunction findListByExactName(lists, expectedName) {\n const target = normalize(expectedName);\n\n return lists.find(list => normalize(getListName(list)) === target) || null;\n}\n\nfunction findOptionByExactName(options, expectedName) {\n const target = normalize(expectedName);\n\n return options.find(option =>\n !optionIsArchived(option) &&\n normalize(getOptionName(option)) === target\n ) || null;\n}\n\nfunction findOptionByContains(options, candidates) {\n const normalizedCandidates = candidates\n .filter(Boolean)\n .map(normalize)\n .filter(Boolean);\n\n return options.find(option => {\n if (optionIsArchived(option)) return false;\n\n const optionName = normalize(getOptionName(option));\n\n return normalizedCandidates.some(candidate =>\n optionName === candidate ||\n optionName.includes(candidate) ||\n candidate.includes(optionName)\n );\n }) || null;\n}\n\nfunction resolveTerminationTypeOption(options, requestedType) {\n const type = normalize(requestedType);\n\n if (\n type.includes('voluntaria') ||\n type.includes('voluntary') ||\n type.includes('renuncia')\n ) {\n return findOptionByExactName(options, 'Resignation (Voluntary)');\n }\n\n if (\n type.includes('involuntaria') ||\n type.includes('involuntary') ||\n type.includes('despido') ||\n type.includes('desahucio') ||\n type.includes('termination')\n ) {\n return findOptionByExactName(options, 'Termination (Involuntary)');\n }\n\n if (\n type.includes('death') ||\n type.includes('fallecimiento') ||\n type.includes('muerte')\n ) {\n return findOptionByExactName(options, 'Death');\n }\n\n return findOptionByContains(options, [requestedType]);\n}\n\nfunction resolveTerminationReasonOption(options, requestedReason) {\n const reason = normalize(requestedReason);\n\n if (\n reason.includes('prueba qa') ||\n reason.includes('qa') ||\n reason.includes('test')\n ) {\n return findOptionByExactName(options, 'Otros');\n }\n\n if (\n reason.includes('otros') ||\n reason.includes('otro') ||\n reason.includes('other')\n ) {\n return findOptionByExactName(options, 'Otros');\n }\n\n if (\n reason.includes('renuncia') ||\n reason.includes('voluntaria')\n ) {\n return findOptionByExactName(options, 'Renuncia voluntaria');\n }\n\n if (\n reason.includes('periodo de prueba') ||\n reason.includes('período de prueba') ||\n reason.includes('prueba')\n ) {\n return findOptionByExactName(options, 'Periodo de Prueba');\n }\n\n if (reason.includes('desahucio')) {\n return findOptionByExactName(options, 'Desahucio');\n }\n\n if (\n reason.includes('proyecto') ||\n reason.includes('end of project') ||\n reason.includes('terminacion de proyecto') ||\n reason.includes('terminación de proyecto')\n ) {\n return findOptionByExactName(options, 'Terminación de Proyecto / End of Project');\n }\n\n return findOptionByContains(options, [requestedReason]);\n}\n\nconst lists = items\n .map(item => item.json)\n .filter(Boolean);\n\nconst employmentStatusList = findListByExactName(lists, 'Employment Status');\nconst terminationTypeList = findListByExactName(lists, 'Termination Type');\nconst terminationReasonList = findListByExactName(lists, 'Termination Reason');\n\nconst employmentStatusOptions = getListValues(employmentStatusList);\nconst terminationTypeOptions = getListValues(terminationTypeList);\nconst terminationReasonOptions = getListValues(terminationReasonList);\n\nconst terminatedOption =\n findOptionByExactName(employmentStatusOptions, 'Terminado') ||\n findOptionByExactName(employmentStatusOptions, 'Terminated');\n\nconst typeOption = resolveTerminationTypeOption(\n terminationTypeOptions,\n requestedType\n);\n\nconst reasonOption = resolveTerminationReasonOption(\n terminationReasonOptions,\n requestedReason\n);\n\nconst employmentStatusId = getOptionId(terminatedOption);\nconst terminationTypeId = getOptionId(typeOption);\nconst terminationReasonId = getOptionId(reasonOption);\n\nconst missing = [];\n\nif (!employmentStatusList) {\n missing.push('lista Employment Status');\n}\n\nif (employmentStatusList && (!terminatedOption || !employmentStatusId)) {\n missing.push('opción Terminado/Terminated en Employment Status');\n}\n\nif (!terminationTypeList) {\n missing.push('lista Termination Type');\n}\n\nif (terminationTypeList && (!typeOption || !terminationTypeId)) {\n missing.push(`tipo \"${requestedType}\"`);\n}\n\nif (!terminationReasonList) {\n missing.push('lista Termination Reason');\n}\n\nif (terminationReasonList && (!reasonOption || !terminationReasonId)) {\n missing.push(`motivo \"${requestedReason}\"`);\n}\n\nconst resolvedPayload = {\n date: guardContext.termination_payload?.date || '',\n employmentStatus: employmentStatusId ? String(employmentStatusId) : '',\n comment: guardContext.termination_payload?.comment || '',\n terminationReasonId: terminationReasonId ? String(terminationReasonId) : '',\n terminationTypeId: terminationTypeId ? String(terminationTypeId) : ''\n};\n\nconst mappingReady = missing.length === 0;\n\nreturn [{\n json: {\n ...guardContext,\n\n status: mappingReady\n ? 'termination_list_ids_resolved'\n : 'termination_list_ids_missing',\n\n can_continue: mappingReady,\n response_type: mappingReady ? 'chat_message' : 'error_message',\n\n termination_list_ids_ready: mappingReady,\n missing_termination_list_mappings: missing,\n\n termination_list_mapping: {\n employmentStatus: {\n requested_value: 'Terminado',\n list_found: Boolean(employmentStatusList),\n list_name: employmentStatusList ? getListName(employmentStatusList) : null,\n selected_option: terminatedOption || null,\n selected_id: employmentStatusId ? String(employmentStatusId) : null\n },\n terminationReason: {\n requested_value: requestedReason,\n list_found: Boolean(terminationReasonList),\n list_name: terminationReasonList ? getListName(terminationReasonList) : null,\n selected_option: reasonOption || null,\n selected_id: terminationReasonId ? String(terminationReasonId) : null\n },\n terminationType: {\n requested_value: requestedType,\n list_found: Boolean(terminationTypeList),\n list_name: terminationTypeList ? getListName(terminationTypeList) : null,\n selected_option: typeOption || null,\n selected_id: terminationTypeId ? String(terminationTypeId) : null\n }\n },\n\n termination_payload_resolved: resolvedPayload,\n\n debug_available_termination_lists: {\n employmentStatusListName: employmentStatusList ? getListName(employmentStatusList) : null,\n terminationReasonListName: terminationReasonList ? getListName(terminationReasonList) : null,\n terminationTypeListName: terminationTypeList ? getListName(terminationTypeList) : null,\n\n employmentStatusOptionsPreview: employmentStatusOptions.map(option => ({\n id: getOptionId(option),\n name: getOptionName(option),\n archived: option?.archived ?? null\n })),\n\n terminationReasonSelectedPreview: reasonOption\n ? {\n id: getOptionId(reasonOption),\n name: getOptionName(reasonOption),\n archived: reasonOption?.archived ?? null\n }\n : null,\n\n terminationTypeSelectedPreview: typeOption\n ? {\n id: getOptionId(typeOption),\n name: getOptionName(typeOption),\n archived: typeOption?.archived ?? null\n }\n : null\n },\n\n response_text: mappingReady\n ? `Listas de BambooHR resueltas correctamente para desvinculación.\\n\\n` +\n `Empleado: ${guardContext.employee_name}\\n` +\n `ID BambooHR: ${guardContext.employee_id}\\n\\n` +\n `Employment Status: ${getOptionName(terminatedOption)} (${employmentStatusId})\\n` +\n `Termination Reason: ${getOptionName(reasonOption)} (${terminationReasonId})\\n` +\n `Termination Type: ${getOptionName(typeOption)} (${terminationTypeId})\\n\\n` +\n `Estado: listo para construir POST real, todavía no ejecutado.`\n : `No puedo ejecutar la desvinculación real todavía porque faltan mapeos de listas BambooHR:\\n\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...guardContext.audit_event,\n\n termination_payload_resolved: resolvedPayload,\n termination_list_mapping_missing: missing,\n\n authorization_result: mappingReady\n ? 'termination_list_ids_resolved'\n : 'blocked_missing_termination_list_ids',\n\n execution_result: mappingReady\n ? 'termination_list_ids_resolved_no_post_executed'\n : 'blocked_missing_termination_list_ids',\n\n bamboohr_endpoint: `/api/v1/employees/${guardContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3936, + -6160 + ], + "id": "78197681-8820-498e-b8f8-0261dd8f1143", + "name": "Code - Resolve Termination List IDs" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "881e31f9-bb31-4c3e-bcb5-db38325edc43", + "leftValue": "={{ $json.termination_list_ids_ready }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 4576, + -6144 + ], + "id": "8c667f56-545e-4547-8984-6e076473d87f", + "name": "IF - Termination List IDs Ready?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst missing = data.missing_termination_list_mappings || [];\n\nconst responseText =\n `Desvinculación bloqueada antes del POST real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Fecha de salida: ${data.termination_data?.terminationDate || data.termination_payload?.date || 'No disponible'}\\n\\n` +\n `Faltan estos mapeos de listas BambooHR:\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n` +\n `Próximo paso: revisar meta/lists y usar valores exactos de BambooHR para motivo/tipo.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_mapping_blocked',\n can_continue: false,\n response_type: 'error_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_payload_resolved: data.termination_payload_resolved,\n termination_data: data.termination_data,\n\n termination_list_ids_ready: false,\n missing_termination_list_mappings: missing,\n termination_list_mapping: data.termination_list_mapping,\n debug_available_termination_lists: data.debug_available_termination_lists,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n authorization_result: 'blocked_missing_termination_list_ids',\n execution_result: 'blocked_missing_termination_list_ids',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4912, + -6032 + ], + "id": "17ef9234-002d-499f-9cfb-5ad063575e51", + "name": "Code - Format Termination Mapping Blocked Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst payload = data.termination_payload_resolved || {};\nconst mapping = data.termination_list_mapping || {};\n\nconst responseText =\n `Desvinculación validada con IDs de BambooHR y lista para POST real.\\n\\n` +\n `Empleado: ${data.employee_name || 'No disponible'}\\n` +\n `ID BambooHR: ${data.employee_id || 'No disponible'}\\n` +\n `Correo laboral: ${data.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${payload.date || 'No disponible'}\\n\\n` +\n `IDs resueltos:\\n` +\n `employmentStatus: ${payload.employmentStatus || 'No disponible'}\\n` +\n `terminationReasonId: ${payload.terminationReasonId || 'No disponible'}\\n` +\n `terminationTypeId: ${payload.terminationTypeId || 'No disponible'}\\n\\n` +\n `Estado: lista para POST real, pero todavía NO ejecutada.\\n` +\n `Siguiente paso: crear HTTP POST final solo si autorizas continuar.`;\n\nreturn {\n json: {\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n status: 'termination_ready_with_ids_not_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: data.mode,\n intent: data.intent,\n\n employee_id: data.employee_id,\n employee_name: data.employee_name,\n employee_email: data.employee_email,\n\n action: data.action,\n termination_payload: data.termination_payload,\n termination_payload_resolved: payload,\n termination_data: data.termination_data,\n\n termination_list_ids_ready: true,\n termination_list_mapping: mapping,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n termination_payload_resolved: payload,\n termination_list_mapping: mapping,\n\n authorization_result: 'termination_ready_with_ids_not_executed',\n execution_result: 'termination_ready_with_ids_not_executed',\n bamboohr_endpoint: `/api/v1/employees/${data.employee_id}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4912, + -6832 + ], + "id": "23bf3fd4-32e0-44a1-8d9d-b9cf1d556482", + "name": "Code - Format Termination Ready With IDs Response" + }, + { + "parameters": { + "jsCode": "function normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getListName(list) {\n if (!list) return '';\n\n return String(\n list.name ||\n list.alias ||\n list.field ||\n list.fieldId ||\n list.id ||\n ''\n );\n}\n\nfunction getListValues(list) {\n if (!list) return [];\n\n if (Array.isArray(list.options)) return list.options;\n if (Array.isArray(list.values)) return list.values;\n if (Array.isArray(list.items)) return list.items;\n if (Array.isArray(list.listOptions)) return list.listOptions;\n if (Array.isArray(list)) return list;\n\n return [];\n}\n\nfunction getOptionId(option) {\n if (!option) return null;\n\n return (\n option.id ??\n option.value ??\n option.listValueId ??\n option.optionId ??\n option.employeeFieldValueId ??\n null\n );\n}\n\nfunction getOptionName(option) {\n if (!option) return '';\n\n return String(\n option.name ||\n option.label ||\n option.value ||\n option.text ||\n option.displayName ||\n ''\n );\n}\n\nfunction optionIsArchived(option) {\n return (\n normalize(option?.archived) === 'yes' ||\n normalize(option?.archived) === 'true' ||\n option?.archived === true\n );\n}\n\nfunction findRelevantLists(lists) {\n return lists.filter(list => {\n const name = normalize(getListName(list));\n\n return (\n name.includes('employment') ||\n name.includes('status') ||\n name.includes('termination') ||\n name.includes('terminacion') ||\n name.includes('terminación') ||\n name.includes('reason') ||\n name.includes('type') ||\n name.includes('motivo') ||\n name.includes('tipo')\n );\n });\n}\n\nfunction formatOptions(options) {\n return options.map(option => ({\n id: getOptionId(option),\n name: getOptionName(option),\n archived: option?.archived ?? null,\n manageable: option?.manageable ?? null,\n raw: option\n }));\n}\n\nconst lists = items\n .map(item => item.json)\n .filter(Boolean);\n\nconst relevantLists = findRelevantLists(lists);\n\nreturn relevantLists.map(list => {\n const options = getListValues(list);\n\n return {\n json: {\n list_id: list.id ?? null,\n field_id: list.fieldId ?? null,\n list_name: getListName(list),\n manageable: list.manageable ?? null,\n multiple: list.multiple ?? null,\n options_count: options.length,\n active_options: formatOptions(options.filter(option => !optionIsArchived(option))),\n archived_options: formatOptions(options.filter(option => optionIsArchived(option)))\n }\n };\n});" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3728, + -6560 + ], + "id": "8789ce90-06ba-47f7-8617-6fdf11eb674e", + "name": "Code - Inspect Termination Lists" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst payload = data.termination_payload_resolved || {};\nconst mapping = data.termination_list_mapping || {};\n\nconst employeeId = data.employee_id || data.resolved_employee_id || data.id || null;\nconst employeeName = data.employee_name || data.resolved_employee_name || data.displayName || 'Empleado no disponible';\nconst employeeEmail = data.employee_email || data.workEmail || null;\n\nconst postUrl = `/api/v1/employees/${employeeId}/tables/employmentStatus`;\n\nconst terminationReasonIdNumber = Number(payload.terminationReasonId);\nconst terminationTypeIdNumber = Number(payload.terminationTypeId);\n\nconst postBody = {\n date: payload.date,\n\n // Para el POST real BambooHR espera el valor del estatus.\n // Usamos \"Terminated\" porque es el valor de sistema para ejecutar la baja real.\n employmentStatus: 'Terminated',\n\n comment: payload.comment,\n\n // Estos campos sí van como IDs.\n terminationReasonId: Number.isFinite(terminationReasonIdNumber)\n ? terminationReasonIdNumber\n : payload.terminationReasonId,\n\n terminationTypeId: Number.isFinite(terminationTypeIdNumber)\n ? terminationTypeIdNumber\n : payload.terminationTypeId\n};\n\nconst missing = [];\n\nif (!employeeId) missing.push('employee_id');\nif (!postBody.date) missing.push('date');\nif (!postBody.employmentStatus) missing.push('employmentStatus');\nif (!postBody.terminationReasonId) missing.push('terminationReasonId');\nif (!postBody.terminationTypeId) missing.push('terminationTypeId');\n\nif (missing.length > 0) {\n return {\n json: {\n ...data,\n\n status: 'termination_post_preview_blocked_missing_fields',\n can_continue: false,\n response_type: 'error_message',\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_post_ready: false,\n termination_post_url: postUrl,\n termination_post_body: postBody,\n missing_post_fields: missing,\n\n response_text:\n `No puedo preparar el POST real porque faltan campos requeridos: ${missing.join(', ')}.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_post_body: postBody,\n\n authorization_result: 'blocked_missing_termination_post_fields',\n execution_result: 'blocked_missing_termination_post_fields',\n bamboohr_endpoint: postUrl\n }\n }\n };\n}\n\nconst responseText =\n `PREVIEW DEL POST REAL DE DESVINCULACIÓN\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId}\\n` +\n `Correo laboral: ${employeeEmail || 'No disponible'}\\n\\n` +\n `Endpoint:\\n` +\n `${postUrl}\\n\\n` +\n `Body que se enviaría:\\n` +\n JSON.stringify(postBody, null, 2) +\n `\\n\\nMapeo usado:\\n` +\n `Employment Status resuelto en listas: ${mapping.employmentStatus?.selected_option?.name || 'No disponible'} (${mapping.employmentStatus?.selected_id || 'No disponible'})\\n` +\n `Employment Status enviado al POST: Terminated\\n` +\n `Termination Reason: ${mapping.terminationReason?.selected_option?.name || 'No disponible'} (${mapping.terminationReason?.selected_id || 'No disponible'})\\n` +\n `Termination Type: ${mapping.terminationType?.selected_option?.name || 'No disponible'} (${mapping.terminationType?.selected_id || 'No disponible'})\\n\\n` +\n `Estado: listo para POST real, pero todavía NO ejecutado.`;\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_post_preview_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n termination_post_ready: true,\n termination_post_url: postUrl,\n termination_post_body: postBody,\n\n response_text: responseText,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n termination_post_body: postBody,\n\n authorization_result: 'termination_post_preview_ready',\n execution_result: 'termination_post_preview_ready_no_post_executed',\n bamboohr_endpoint: postUrl\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5072, + -6304 + ], + "id": "980043b0-9878-4e53-a8eb-275bb15ea54a", + "name": "Code - Preview Termination POST Payload" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n data.displayName ||\n 'Empleado no disponible';\n\nconst requiredToken = employeeId\n ? `EXECUTE_TERMINATION_${employeeId}`\n : null;\n\nconst finalExecutionApproved =\n data.final_execution_approved === true ||\n String(data.final_execution_approved || '').trim().toLowerCase() === 'true';\n\nconst finalExecutionToken =\n String(data.final_execution_token || '').trim();\n\nconst confirmationStatus =\n String(data.confirmation_status || '').trim().toLowerCase();\n\nconst allowed =\n data.termination_post_ready === true &&\n confirmationStatus === 'confirmed_termination_execute' &&\n finalExecutionApproved === true &&\n requiredToken &&\n finalExecutionToken === requiredToken;\n\nif (!allowed) {\n const missing = [];\n\n if (data.termination_post_ready !== true) {\n missing.push('termination_post_ready debe ser true');\n }\n\n if (confirmationStatus !== 'confirmed_termination_execute') {\n missing.push('confirmation_status debe ser confirmed_termination_execute');\n }\n\n if (finalExecutionApproved !== true) {\n missing.push('final_execution_approved debe ser true');\n }\n\n if (!requiredToken || finalExecutionToken !== requiredToken) {\n missing.push(`final_execution_token debe ser ${requiredToken || 'EXECUTE_TERMINATION_'}`);\n }\n\n return {\n json: {\n ...data,\n\n status: 'termination_final_execution_blocked',\n can_continue: false,\n response_type: 'confirmation_request',\n\n employee_id: employeeId,\n employee_name: employeeName,\n\n termination_final_execution_allowed: false,\n final_execution_required_token: requiredToken,\n final_execution_missing_requirements: missing,\n\n response_text:\n `POST real bloqueado por compuerta final de seguridad.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n\\n` +\n `Para ejecutar la desvinculación real, deben cumplirse estos requisitos:\\n` +\n `${missing.map(item => `- ${item}`).join('\\n')}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n\n final_execution_required_token: requiredToken,\n final_execution_missing_requirements: missing,\n\n authorization_result: 'blocked_final_execution_gate',\n execution_result: 'blocked_final_execution_gate',\n bamboohr_endpoint: data.termination_post_url || null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n status: 'termination_final_execution_approved',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n employee_name: employeeName,\n\n termination_final_execution_allowed: true,\n final_execution_required_token: requiredToken,\n\n response_text:\n `Compuerta final aprobada. La siguiente operación será el POST real en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId}`,\n\n audit_event: {\n ...data.audit_event,\n\n target_employee_id: String(employeeId),\n target_employee_name: employeeName,\n\n final_execution_required_token: requiredToken,\n\n authorization_result: 'final_execution_gate_approved',\n execution_result: 'final_execution_gate_approved_pending_post',\n bamboohr_endpoint: data.termination_post_url || null\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6000, + -6240 + ], + "id": "2ee40aa4-e2b9-444c-b1fa-71e7e1b45eba", + "name": "Code - Final Termination Execution Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "18b2f79d-1e91-4396-914b-5a66a76b8bdd", + "leftValue": "={{ $json.termination_final_execution_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 6528, + -6192 + ], + "id": "d08f4bba-5eb2-4b68-a436-8fec1b4dd56b", + "name": "IF - Final Termination Execution Approved?" + }, + { + "parameters": { + "method": "POST", + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.termination_post_body }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 7040, + -6320 + ], + "id": "1ac2c7d9-384d-4f91-acc1-ad3897fe4936", + "name": "HTTP - Add Employee employmentStatus Row", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const previewContext = $items('Code - Preview Termination POST Payload')[0].json;\nconst postResponse = $json || {};\n\nconst employeeId =\n previewContext.employee_id ||\n previewContext.resolved_employee_id ||\n previewContext.id ||\n null;\n\nreturn {\n json: {\n ...previewContext,\n\n status: 'termination_post_sent',\n can_continue: true,\n response_type: 'chat_message',\n\n employee_id: String(employeeId),\n\n termination_post_sent: true,\n termination_post_response: postResponse,\n\n response_text:\n `POST de desvinculación enviado a BambooHR. Verificando resultado en employmentStatus...\\n\\n` +\n `Empleado: ${previewContext.employee_name}\\n` +\n `ID BambooHR: ${employeeId}`,\n\n audit_event: {\n ...previewContext.audit_event,\n\n termination_post_response: postResponse,\n\n authorization_result: 'termination_post_sent',\n execution_result: 'termination_post_sent_pending_verification',\n bamboohr_endpoint: `/api/v1/employees/${employeeId}/tables/employmentStatus`\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 7376, + -6384 + ], + "id": "29c88cfe-c0e4-4ad5-8ada-e699254eb8ef", + "name": "Code - Normalize Termination POST Result" + }, + { + "parameters": { + "url": "=https://glm.bamboohr.com/api/v1/employees/{{ $json.employee_id }}/tables/employmentStatus", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 7632, + -6384 + ], + "id": "53594380-f95e-4a48-aad9-0fd8b4605999", + "name": "HTTP - Get employmentStatus After Termination", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const postContext = $items('Code - Normalize Termination POST Result')[0].json;\nconst expected = postContext.termination_post_body || {};\n\nfunction isEmptyObject(value) {\n return (\n value &&\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length === 0\n );\n}\n\nfunction normalizeRows(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.rows)) return body.rows;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employmentStatus)) return body.employmentStatus;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(row => row && !isEmptyObject(row));\n}\n\nfunction normalizeValue(value) {\n return String(value || '')\n .trim()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nconst rows = normalizeRows(items);\n\nconst matchingRows = rows.filter(row => {\n return (\n normalizeValue(row.date) === normalizeValue(expected.date) &&\n (\n normalizeValue(row.employmentStatus) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.employmentStatusId) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.status) === normalizeValue(expected.employmentStatus) ||\n normalizeValue(row.comment).includes('desvinculacion registrada por bamboohr agent') ||\n normalizeValue(row.comment).includes('desvinculación registrada por bamboohr agent')\n )\n );\n});\n\nconst executionConfirmed = matchingRows.length > 0;\nconst confirmedRow = matchingRows[0] || null;\n\nconst status = executionConfirmed\n ? 'termination_executed_confirmed'\n : 'termination_post_sent_unconfirmed';\n\nconst responseText = executionConfirmed\n ? `Desvinculación ejecutada y confirmada en BambooHR.\\n\\n` +\n `Empleado: ${postContext.employee_name}\\n` +\n `ID BambooHR: ${postContext.employee_id}\\n` +\n `Correo laboral: ${postContext.employee_email || 'No disponible'}\\n` +\n `Fecha de salida: ${expected.date || 'No disponible'}\\n` +\n `Employment Status ID: ${expected.employmentStatus || 'No disponible'}\\n` +\n `Termination Reason ID: ${expected.terminationReasonId || 'No disponible'}\\n` +\n `Termination Type ID: ${expected.terminationTypeId || 'No disponible'}\\n\\n` +\n `Fila confirmada en employmentStatus: ${confirmedRow ? JSON.stringify(confirmedRow, null, 2) : 'No disponible'}`\n : `El POST de desvinculación fue enviado, pero no pude confirmar la fila en employmentStatus.\\n\\n` +\n `Empleado: ${postContext.employee_name}\\n` +\n `ID BambooHR: ${postContext.employee_id}\\n` +\n `Fecha esperada: ${expected.date || 'No disponible'}\\n\\n` +\n `Revisa manualmente el historial employmentStatus en BambooHR antes de reintentar.`;\n\nreturn [{\n json: {\n request_id: postContext.request_id,\n request_started_at: postContext.request_started_at,\n\n status,\n can_continue: executionConfirmed,\n response_type: executionConfirmed ? 'chat_message' : 'error_message',\n\n mode: postContext.mode,\n intent: postContext.intent,\n\n employee_id: postContext.employee_id,\n employee_name: postContext.employee_name,\n employee_email: postContext.employee_email,\n\n action: postContext.action,\n\n termination_post_body: expected,\n termination_post_sent: true,\n termination_execution_confirmed: executionConfirmed,\n\n employment_status_rows_count: rows.length,\n matching_employment_status_rows_count: matchingRows.length,\n confirmed_employment_status_row: confirmedRow,\n\n response_text: responseText,\n\n audit_event: {\n ...postContext.audit_event,\n\n termination_post_body: expected,\n termination_post_sent: true,\n termination_execution_confirmed: executionConfirmed,\n\n employment_status_rows_count: rows.length,\n matching_employment_status_rows_count: matchingRows.length,\n confirmed_employment_status_row: confirmedRow,\n\n authorization_result: 'confirmed_termination_execute',\n execution_result: status,\n bamboohr_endpoint: `/api/v1/employees/${postContext.employee_id}/tables/employmentStatus`\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8112, + -6384 + ], + "id": "a51c54a1-42a3-4c4a-b272-f30515c58758", + "name": "Code - Format Termination Execution Response" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst auditEvent = data.audit_event || {};\nconst action = data.action || auditEvent.action || {};\nconst payload =\n data.termination_post_body ||\n data.termination_payload_resolved ||\n data.termination_payload ||\n action.payload ||\n null;\n\nconst requestId =\n data.request_id ||\n auditEvent.request_id ||\n null;\n\nconst requestStartedAt =\n data.request_started_at ||\n auditEvent.request_started_at ||\n null;\n\nconst requestFinishedAt =\n data.request_finished_at ||\n auditEvent.request_finished_at ||\n new Date().toISOString();\n\nconst responseTimeMs =\n data.response_time_ms ??\n auditEvent.response_time_ms ??\n (\n requestStartedAt\n ? Date.now() - new Date(requestStartedAt).getTime()\n : null\n );\n\nconst responseTimeSeconds =\n responseTimeMs !== null && responseTimeMs !== undefined\n ? Number((Number(responseTimeMs) / 1000).toFixed(2))\n : null;\n\nconst auditRow = {\n request_id: requestId,\n\n request_started_at: requestStartedAt,\n request_finished_at: requestFinishedAt,\n response_time_ms: responseTimeMs,\n response_time_seconds: responseTimeSeconds,\n\n user_email:\n data.user_email ||\n auditEvent.user_email ||\n null,\n\n user_name:\n data.user_name ||\n auditEvent.user_name ||\n null,\n\n channel:\n data.channel ||\n auditEvent.channel ||\n null,\n\n environment:\n data.environment ||\n auditEvent.environment ||\n null,\n\n mode:\n data.mode ||\n auditEvent.mode ||\n null,\n\n intent:\n data.intent ||\n auditEvent.intent ||\n null,\n\n risk_level:\n data.risk_level ||\n auditEvent.risk_level ||\n null,\n\n target_employee_id:\n data.employee_id ||\n auditEvent.target_employee_id ||\n null,\n\n target_employee_name:\n data.employee_name ||\n auditEvent.target_employee_name ||\n null,\n\n target_employee_email:\n data.employee_email ||\n auditEvent.target_employee_email ||\n null,\n\n action_type:\n action.type ||\n auditEvent.action?.type ||\n null,\n\n confirmation_status:\n data.confirmation_status ||\n auditEvent.confirmation_status ||\n null,\n\n authorization_result:\n auditEvent.authorization_result ||\n data.status ||\n null,\n\n execution_result:\n auditEvent.execution_result ||\n data.status ||\n null,\n\n bamboohr_endpoint:\n auditEvent.bamboohr_endpoint ||\n data.termination_post_url ||\n null,\n\n payload,\n audit_event: auditEvent,\n response_text: data.response_text || null,\n full_response: data\n};\n\nreturn {\n json: {\n ...data,\n audit_log_ready: true,\n audit_log_row: auditRow\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8352, + -6384 + ], + "id": "77e22707-d849-435a-ba40-fe9a5f7be2b0", + "name": "Code - Prepare Audit Log" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_audit_log", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Prefer", + "value": "return=representation" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.audit_log_row }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 8592, + -6368 + ], + "id": "48bf0222-122b-4664-82c5-8dc3914ed685", + "name": "Supabase - Insert Audit Log", + "alwaysOutputData": true + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const responseContext = $items('Code - Prepare Audit Log')[0].json;\nconst auditInsertResult = $json;\n\nreturn {\n json: {\n ...responseContext,\n\n audit_log_inserted: true,\n audit_log_insert_result: auditInsertResult,\n\n audit_event: {\n ...responseContext.audit_event,\n audit_log_inserted: true,\n audit_log_insert_result: auditInsertResult\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 8800, + -6368 + ], + "id": "9ac40a4e-ca07-4431-88db-cfc80265cc7f", + "name": "Code - Restore Response After Audit" + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?user_email=eq.' + encodeURIComponent($json.user_email) + '&is_active=eq.true&select=*' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -8304, + -2704 + ], + "id": "46fc792b-dd60-4083-a4df-9dfbdf90c3ab", + "name": "Supabase - Get User Permissions", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const requestContext = $items('Code - Validate Intent')[0].json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase();\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) return value.map(String);\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n if (Array.isArray(parsed)) return parsed.map(String);\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction getRowsFromSupabase(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.data)) return body.data;\n if (body && typeof body === 'object' && Object.keys(body).length > 0) {\n return [body];\n }\n\n return [];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction riskRank(value) {\n const normalized = normalize(value);\n\n const ranks = {\n low: 1,\n medium: 2,\n high: 3,\n critical: 4\n };\n\n return ranks[normalized] || 0;\n}\n\nfunction includesOrWildcard(list, value) {\n const normalizedList = parseArray(list).map(normalize);\n const normalizedValue = normalize(value);\n\n return (\n normalizedList.includes('*') ||\n normalizedList.includes(normalizedValue)\n );\n}\n\nconst permissionRows = getRowsFromSupabase(items);\nconst permission = permissionRows[0] || null;\n\nconst userEmail = requestContext.user_email || null;\nconst mode = requestContext.mode || 'unknown';\nconst intent = requestContext.intent || 'unknown';\nconst riskLevel = requestContext.risk_level || 'unknown';\n\nlet authorizationAllowed = false;\nlet authorizationResult = 'blocked_user_not_authorized';\nlet authorizationReason = '';\n\nif (!userEmail) {\n authorizationResult = 'blocked_missing_user_email';\n authorizationReason = 'No se recibió user_email en la solicitud.';\n} else if (!permission) {\n authorizationResult = 'blocked_user_not_found_in_permissions';\n authorizationReason = `El usuario ${userEmail} no existe o no está activo en bamboohr_agent_user_permissions.`;\n} else if (permission.is_active !== true) {\n authorizationResult = 'blocked_user_inactive';\n authorizationReason = `El usuario ${userEmail} existe, pero está inactivo.`;\n} else if (normalize(permission.role) === 'super_admin') {\n authorizationAllowed = true;\n authorizationResult = 'authorized_super_admin';\n authorizationReason = 'Usuario super_admin autorizado.';\n} else {\n const modeAllowed = includesOrWildcard(permission.allowed_modes, mode);\n const intentAllowed = includesOrWildcard(permission.allowed_intents, intent);\n\n const requestedRiskRank = riskRank(riskLevel);\n const maxRiskRank = riskRank(permission.max_risk_level);\n\n const riskAllowed =\n requestedRiskRank > 0 &&\n maxRiskRank > 0 &&\n requestedRiskRank <= maxRiskRank;\n\n const criticalAllowed =\n normalize(riskLevel) !== 'critical' ||\n permission.can_execute_critical === true;\n\n authorizationAllowed =\n modeAllowed &&\n intentAllowed &&\n riskAllowed &&\n criticalAllowed;\n\n if (!modeAllowed) {\n authorizationResult = 'blocked_mode_not_allowed';\n authorizationReason = `El usuario no tiene permitido el modo \"${mode}\".`;\n } else if (!intentAllowed) {\n authorizationResult = 'blocked_intent_not_allowed';\n authorizationReason = `El usuario no tiene permitido el intent \"${intent}\".`;\n } else if (!riskAllowed) {\n authorizationResult = 'blocked_risk_level_not_allowed';\n authorizationReason = `El riesgo \"${riskLevel}\" excede el máximo permitido \"${permission.max_risk_level}\".`;\n } else if (!criticalAllowed) {\n authorizationResult = 'blocked_critical_action_not_allowed';\n authorizationReason = 'El usuario no puede ejecutar acciones críticas.';\n } else {\n authorizationResult = 'authorized_by_permission_table';\n authorizationReason = 'Usuario autorizado por tabla de permisos.';\n }\n}\n\nif (!authorizationAllowed) {\n return [{\n json: {\n ...requestContext,\n\n can_continue: false,\n authorization_allowed: false,\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n authorization_user_permission: permission,\n\n response_type: 'error_message',\n response_text:\n `Solicitud bloqueada por autorización.\\n\\n` +\n `Usuario: ${userEmail || 'No disponible'}\\n` +\n `Modo: ${mode}\\n` +\n `Intent: ${intent}\\n` +\n `Riesgo: ${riskLevel}\\n\\n` +\n `Motivo: ${authorizationReason}\\n\\n` +\n `No se consultó ni modificó BambooHR.`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: requestContext.user_name || null,\n channel: requestContext.channel || null,\n environment: requestContext.environment || null,\n\n employee_query: requestContext.employee_query || null,\n action: requestContext.action || null,\n\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n execution_result: authorizationResult,\n\n bamboohr_endpoint: null\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...requestContext,\n\n can_continue: true,\n authorization_allowed: true,\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason,\n authorization_user_permission: permission,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode,\n intent,\n risk_level: riskLevel,\n\n user_email: userEmail,\n user_name: requestContext.user_name || null,\n channel: requestContext.channel || null,\n environment: requestContext.environment || null,\n\n authorization_result: authorizationResult,\n authorization_reason: authorizationReason\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -8080, + -2944 + ], + "id": "5ca00b0d-75b2-4ed4-b8a2-e9c609b8c112", + "name": "Code - Authorization Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "151bdf68-4050-4510-99b8-a73c39554ba3", + "leftValue": "={{ $json.authorization_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -7872, + -2944 + ], + "id": "c3ca5e91-a459-44b6-ad32-188f9c829e5c", + "name": "IF - User Authorized?" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const data = $json;\n\nconst authContext = $items('Code - Authorization Gate')[0]?.json || {};\nconst permission =\n data.authorization_user_permission ||\n authContext.authorization_user_permission ||\n null;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n\n if (Array.isArray(parsed)) {\n return parsed\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n 'trinidad': 'trinidad y tobago',\n 'trinidad and tobago': 'trinidad y tobago',\n 'tt': 'trinidad y tobago',\n 't&t': 'trinidad y tobago'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction getEmployeeCountry(record) {\n return (\n record.employee_country ||\n record.resolved_employee_country ||\n record.country ||\n record.location ||\n record.resolved_employee_location ||\n record.employee_location ||\n record.data?.location ||\n null\n );\n}\n\nconst userEmail =\n data.user_email ||\n authContext.user_email ||\n null;\n\nconst role = permission?.role || null;\n\nconst employeeId =\n data.employee_id ||\n data.resolved_employee_id ||\n data.id ||\n null;\n\nconst employeeName =\n data.employee_name ||\n data.resolved_employee_name ||\n data.displayName ||\n 'Empleado no disponible';\n\nconst employeeEmail =\n data.employee_email ||\n data.workEmail ||\n null;\n\nconst employeeCountryRaw = getEmployeeCountry(data);\nconst employeeCountry = normalizeCountry(employeeCountryRaw);\n\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\nconst allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\nconst isSuperAdmin = normalize(role) === 'super_admin';\n\nconst hasAllCountriesAccess =\n allowedCountriesRaw.length === 0 ||\n allowedCountries.includes('*') ||\n allowedCountries.includes('all') ||\n allowedCountries.includes('todos');\n\nlet scopeAllowed = false;\nlet scopeResult = 'blocked_employee_scope';\nlet scopeReason = '';\n\nif (!permission) {\n scopeAllowed = false;\n scopeResult = 'blocked_missing_permission_context';\n scopeReason = 'No se encontró contexto de permisos del usuario.';\n} else if (isSuperAdmin) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_super_admin';\n scopeReason = 'Usuario super_admin con acceso a todos los empleados.';\n} else if (hasAllCountriesAccess) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_all_countries';\n scopeReason = 'El usuario tiene acceso a todos los países.';\n} else if (!employeeCountry) {\n scopeAllowed = false;\n scopeResult = 'blocked_employee_country_missing';\n scopeReason =\n `No pude verificar el país/ubicación del empleado ${employeeName}. ` +\n `Por seguridad, la solicitud queda bloqueada.`;\n} else if (allowedCountries.includes(employeeCountry)) {\n scopeAllowed = true;\n scopeResult = 'employee_scope_allowed_country_match';\n scopeReason =\n `El empleado pertenece a \"${employeeCountryRaw}\" y el usuario tiene permiso para ese país.`;\n} else {\n scopeAllowed = false;\n scopeResult = 'blocked_employee_country_not_allowed';\n scopeReason =\n `El usuario ${userEmail} no tiene permiso para empleados de \"${employeeCountryRaw || 'No disponible'}\". ` +\n `Países permitidos: ${allowedCountriesRaw.join(', ') || 'Ninguno'}.`;\n}\n\nif (!scopeAllowed) {\n return {\n json: {\n ...data,\n\n can_continue: false,\n employee_scope_allowed: false,\n employee_scope_result: scopeResult,\n employee_scope_reason: scopeReason,\n\n employee_id: employeeId ? String(employeeId) : null,\n employee_name: employeeName,\n employee_email: employeeEmail,\n employee_country: employeeCountryRaw,\n\n response_type: 'error_message',\n response_text:\n `Solicitud bloqueada por alcance de empleado.\\n\\n` +\n `Usuario: ${userEmail || 'No disponible'}\\n` +\n `Empleado: ${employeeName}\\n` +\n `ID BambooHR: ${employeeId || 'No disponible'}\\n` +\n `País/ubicación empleado: ${employeeCountryRaw || 'No disponible'}\\n\\n` +\n `Motivo: ${scopeReason}\\n\\n` +\n `No se consultó ni modificó BambooHR.`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n user_email: userEmail,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null,\n\n mode: data.mode || null,\n intent: data.intent || null,\n risk_level: data.risk_level || null,\n\n target_employee_id: employeeId ? String(employeeId) : null,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n target_employee_country: employeeCountryRaw,\n\n action: data.action || null,\n\n authorization_result: scopeResult,\n authorization_reason: scopeReason,\n execution_result: scopeResult,\n\n bamboohr_endpoint: null\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n can_continue: true,\n employee_scope_allowed: true,\n employee_scope_result: scopeResult,\n employee_scope_reason: scopeReason,\n\n employee_id: employeeId ? String(employeeId) : null,\n employee_name: employeeName,\n employee_email: employeeEmail,\n employee_country: employeeCountryRaw,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n request_id: data.request_id,\n request_started_at: data.request_started_at,\n\n user_email: userEmail,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null,\n\n mode: data.mode || null,\n intent: data.intent || null,\n risk_level: data.risk_level || null,\n\n target_employee_id: employeeId ? String(employeeId) : null,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n target_employee_country: employeeCountryRaw,\n\n authorization_result: scopeResult,\n authorization_reason: scopeReason\n }\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -960, + -5648 + ], + "id": "58501789-c3aa-4161-b738-6b7d66be80a3", + "name": "Code - Employee Scope Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "93936b27-03a9-4730-a8d7-84d12b08fb40", + "leftValue": "={{ $json.employee_scope_allowed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -752, + -5648 + ], + "id": "f4885736-891b-4bc4-bee7-8e4328861e8a", + "name": "IF - Employee Scope Allowed?" + }, + { + "parameters": { + "url": "https://glm.bamboohr.com/api/v1/employees/directory", + "authentication": "genericCredentialType", + "genericAuthType": "httpBasicAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -7184, + -3408 + ], + "id": "272ad4e3-8d68-49a4-b744-b2d430c09a49", + "name": "HTTP - Employees Directory Report", + "alwaysOutputData": true, + "credentials": { + "httpBasicAuth": { + "id": "7VrpNZ2jBLmiJ35q", + "name": "BambooHR GLM Full Access" + } + } + }, + { + "parameters": { + "jsCode": "const requestContext = $items('Code - Authorization Gate')[0].json;\n\nconst permission =\n requestContext.authorization_user_permission ||\n null;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'string') {\n const trimmed = value.trim();\n\n if (!trimmed) return [];\n\n try {\n const parsed = JSON.parse(trimmed);\n\n if (Array.isArray(parsed)) {\n return parsed\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n } catch (error) {\n // Si no es JSON, seguimos con split por coma.\n }\n\n return trimmed\n .split(',')\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n return [];\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n 'trinidad': 'trinidad y tobago',\n 'trinidad and tobago': 'trinidad y tobago',\n 'tt': 'trinidad y tobago',\n 't&t': 'trinidad y tobago'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction getDirectoryEmployees(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (Array.isArray(body)) return body;\n\n if (Array.isArray(body.employees)) return body.employees;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.rows)) return body.rows;\n\n if (body && typeof body === 'object' && Object.keys(body).length > 0) {\n return [body];\n }\n\n return [];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction getEmployeeCountry(employee) {\n return (\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n 'Sin país/ubicación'\n );\n}\n\nfunction getEmployeeDepartment(employee) {\n return (\n employee.department ||\n employee.area ||\n employee.employee_department ||\n 'Sin departamento'\n );\n}\n\nfunction isActiveEmployee(employee) {\n const status = String(employee.status || '').trim();\n\n // El endpoint directory normalmente ya devuelve empleados activos.\n // Si status viene vacío, lo tratamos como incluido.\n if (!status) return true;\n\n return normalize(status) === 'active' || normalize(status) === 'activo';\n}\n\nfunction userHasAllCountryAccess(permission) {\n if (!permission) return false;\n\n if (normalize(permission.role) === 'super_admin') return true;\n\n const allowedCountriesRaw = parseArray(permission.allowed_employee_countries);\n const allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\n return (\n allowedCountriesRaw.length === 0 ||\n allowedCountries.includes('*') ||\n allowedCountries.includes('all') ||\n allowedCountries.includes('todos')\n );\n}\n\nfunction employeeIsAllowedByCountry(employee, permission) {\n if (!permission) return false;\n\n if (userHasAllCountryAccess(permission)) return true;\n\n const allowedCountriesRaw = parseArray(permission.allowed_employee_countries);\n const allowedCountries = allowedCountriesRaw.map(normalizeCountry);\n\n const employeeCountry = normalizeCountry(getEmployeeCountry(employee));\n\n return allowedCountries.includes(employeeCountry);\n}\n\nfunction countBy(employees, getKeyFn) {\n const counts = new Map();\n\n for (const employee of employees) {\n const key = String(getKeyFn(employee) || 'Sin dato').trim() || 'Sin dato';\n counts.set(key, (counts.get(key) || 0) + 1);\n }\n\n return [...counts.entries()]\n .map(([name, total]) => ({\n name,\n total\n }))\n .sort((a, b) => {\n if (b.total !== a.total) return b.total - a.total;\n return a.name.localeCompare(b.name);\n });\n}\n\nfunction formatTable(rows, label) {\n if (!rows.length) {\n return `No hay datos disponibles para ${label}.`;\n }\n\n return rows\n .map((row, index) => `${index + 1}. ${row.name}: ${row.total}`)\n .join('\\n');\n}\n\nconst allEmployees = getDirectoryEmployees(items);\nconst activeEmployees = allEmployees.filter(isActiveEmployee);\n\nconst scopedEmployees = activeEmployees.filter(employee =>\n employeeIsAllowedByCountry(employee, permission)\n);\n\nconst reportIntent = requestContext.intent || 'generate_report';\n\nconst countryCounts = countBy(scopedEmployees, getEmployeeCountry);\nconst departmentCounts = countBy(scopedEmployees, getEmployeeDepartment);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nlet reportTitle = 'Reporte de Headcount';\nlet reportBody = '';\n\nif (reportIntent === 'headcount_by_country') {\n reportTitle = 'Headcount por país / ubicación';\n reportBody = formatTable(countryCounts, 'país / ubicación');\n} else if (reportIntent === 'headcount_by_department') {\n reportTitle = 'Headcount por departamento';\n reportBody = formatTable(departmentCounts, 'departamento');\n} else {\n reportTitle = 'Reporte general de Headcount';\n\n reportBody =\n `Por país / ubicación:\\n` +\n `${formatTable(countryCounts, 'país / ubicación')}\\n\\n` +\n `Por departamento:\\n` +\n `${formatTable(departmentCounts, 'departamento')}`;\n}\n\nconst scopeText = hasAllCountries\n ? 'Alcance: todos los países.'\n : `Alcance: ${allowedCountriesRaw.join(', ') || 'Sin países permitidos'}.`;\n\nconst responseText =\n `${reportTitle}\\n\\n` +\n `Total empleados incluidos: ${scopedEmployees.length}\\n` +\n `Total empleados activos/directorio leídos: ${activeEmployees.length}\\n` +\n `${scopeText}\\n\\n` +\n `${reportBody}`;\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'report_generated',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: reportIntent,\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n\n report_country_counts: countryCounts,\n report_department_counts: departmentCounts,\n\n response_text: responseText,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n request_id: requestContext.request_id,\n request_started_at: requestContext.request_started_at,\n\n mode: requestContext.mode,\n intent: requestContext.intent,\n risk_level: requestContext.risk_level,\n\n user_email: requestContext.user_email,\n user_name: requestContext.user_name,\n channel: requestContext.channel,\n environment: requestContext.environment,\n\n authorization_result:\n requestContext.authorization_result ||\n 'authorized_report',\n\n authorization_reason:\n requestContext.authorization_reason ||\n 'Reporte autorizado.',\n\n execution_result: 'report_generated',\n\n report_type: reportIntent,\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n report_country_counts: countryCounts,\n report_department_counts: departmentCounts,\n\n bamboohr_endpoint: '/api/v1/employees/directory'\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -6928, + -3408 + ], + "id": "7dc7f8d1-84bd-4dff-9c54-7bfe4c7e7f41", + "name": "Code - Format Headcount Report" + }, + { + "parameters": { + "httpMethod": "POST", + "path": "bamboohr-agent-google-chat", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -9728, + -2736 + ], + "id": "f689f4e1-63a4-436c-a15f-9859a9b8a0fd", + "name": "Webhook - Google Chat Incoming", + "webhookId": "9a013eb7-1d67-4abf-b98b-22bb1d65b323" + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const input = $json;\n\nfunction getNested(obj, path, fallback = null) {\n try {\n return path\n .split('.')\n .reduce((current, key) => {\n if (current === undefined || current === null) return undefined;\n return current[key];\n }, obj) ?? fallback;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction cleanMessageText(value) {\n return String(value || '')\n .replace(/]+>/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nconst eventType =\n input.type ||\n input.eventType ||\n getNested(input, 'body.type') ||\n null;\n\nconst body =\n input.body && typeof input.body === 'object'\n ? input.body\n : input;\n\nconst rawText =\n getNested(body, 'message.text') ||\n getNested(body, 'message.argumentText') ||\n getNested(body, 'text') ||\n getNested(body, 'argumentText') ||\n '';\n\nconst message = cleanMessageText(rawText);\n\nconst senderEmail =\n getNested(body, 'message.sender.email') ||\n getNested(body, 'user.email') ||\n getNested(body, 'sender.email') ||\n null;\n\nconst senderName =\n getNested(body, 'message.sender.displayName') ||\n getNested(body, 'user.displayName') ||\n getNested(body, 'sender.displayName') ||\n senderEmail ||\n 'Usuario Google Chat';\n\nconst spaceName =\n getNested(body, 'space.name') ||\n getNested(body, 'message.space.name') ||\n null;\n\nconst spaceDisplayName =\n getNested(body, 'space.displayName') ||\n getNested(body, 'message.space.displayName') ||\n null;\n\nconst messageName =\n getNested(body, 'message.name') ||\n null;\n\nconst threadName =\n getNested(body, 'message.thread.name') ||\n getNested(body, 'thread.name') ||\n null;\n\nconst requestId =\n `gchat_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nif (!message) {\n return {\n json: {\n request_id: requestId,\n request_started_at: new Date().toISOString(),\n\n user_email: senderEmail,\n user_name: senderName,\n\n channel: 'google_chat',\n environment: 'development',\n\n message: '',\n original_message: '',\n\n google_chat_event_type: eventType,\n google_chat_space_name: spaceName,\n google_chat_space_display_name: spaceDisplayName,\n google_chat_message_name: messageName,\n google_chat_thread_name: threadName,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No recibí texto para procesar. Escribe una consulta o acción para BambooHR.'\n }\n };\n}\n\nreturn {\n json: {\n request_id: requestId,\n request_started_at: new Date().toISOString(),\n\n user_email: senderEmail,\n user_name: senderName,\n\n channel: 'google_chat',\n environment: 'development',\n\n message,\n original_message: message,\n\n google_chat_event_type: eventType,\n google_chat_space_name: spaceName,\n google_chat_space_display_name: spaceDisplayName,\n google_chat_message_name: messageName,\n google_chat_thread_name: threadName\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -9408, + -2736 + ], + "id": "ca5fb655-16b5-4211-a983-ced7e5adfec5", + "name": "Code - Normalize Google Chat Input" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { \"text\": $json.response_text || \"Solicitud procesada.\" } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 16368, + -2448 + ], + "id": "351687ba-d3c4-490f-aea4-966c1fccacaa", + "name": "Respond - Google Chat" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "de41bff6-1867-406f-866d-9ddd37c9278b", + "leftValue": "={{ $json.channel || $json.audit_event?.channel || $json.data?.channel }}", + "rightValue": "google_chat", + "operator": { + "type": "string", + "operation": "equals", + "name": "filter.operator.equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 16032, + -2448 + ], + "id": "8ae4c143-2864-41f7-ac23-c207d86520b1", + "name": "IF - Is Google Chat Channel?" + } + ], + "connections": { + "HTTP - Employees Directory": { + "main": [ + [ + { + "node": "Split Out - Employees", + "type": "main", + "index": 0 + } + ] + ] + }, + "BambooHR Agent Core": { + "main": [ + [ + { + "node": "Set - Incoming Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set - Incoming Message": { + "main": [ + [ + { + "node": "Code - Request Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Intent Parser": { + "main": [ + [ + { + "node": "Code - Validate Intent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Validate Intent": { + "main": [ + [ + { + "node": "IF - Can Continue?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Mode Router": { + "main": [ + [ + { + "node": "HTTP - Employees Directory", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Switch - Action Needs Existing Employee?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Employees Directory Report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Out - Employees": { + "main": [ + [ + { + "node": "Code - Resolve Employee", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Resolve Employee": { + "main": [ + [ + { + "node": "IF - Employee Found?", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee By ID": { + "main": [ + [ + { + "node": "Code - Format Consulta Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Consulta Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Can Continue?": { + "main": [ + [ + { + "node": "Supabase - Get User Permissions", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Employee Found?": { + "main": [ + [ + { + "node": "Code - Employee Scope Gate", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Check Direct Employee ID Fallback", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Request Context": { + "main": [ + [ + { + "node": "Code - Intent Parser", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Consulta Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee By ID", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Switch - Historical Table Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee Missing Fields", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee jobInfo Table": { + "main": [ + [ + { + "node": "Code - Format jobInfo Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format jobInfo Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee Missing Fields": { + "main": [ + [ + { + "node": "Code - Format Missing Fields Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Missing Fields Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee Files": { + "main": [ + [ + { + "node": "Code - Format Employee Files Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Employee Files Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Historical Table Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee jobInfo Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Employee employmentStatus Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Sensitive Consulta Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee employmentStatus Table": { + "main": [ + [ + { + "node": "Code - Format employmentStatus Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format employmentStatus Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Sensitive Consulta Guard": { + "main": [ + [ + { + "node": "IF - Sensitive Access Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Sensitive Access Approved?": { + "main": [ + [ + { + "node": "HTTP - Get Employee compensation Table", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee compensation Table": { + "main": [ + [ + { + "node": "Code - Format compensation Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format compensation Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Agent Response": { + "main": [ + [ + { + "node": "IF - Is Google Chat Channel?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Post Resolve Mode": { + "main": [ + [ + { + "node": "Switch - Consulta Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Action Confirmation Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Action Confirmation Guard": { + "main": [ + [ + { + "node": "IF - Action Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Action Approved?": { + "main": [ + [ + { + "node": "Switch - Action Type", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee For Action": { + "main": [ + [ + { + "node": "Code - Build Update Employee Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Update Employee Payload": { + "main": [ + [ + { + "node": "HTTP - Update Employee Basic", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Update Employee Basic": { + "main": [ + [ + { + "node": "HTTP - Get Employee After Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee After Update": { + "main": [ + [ + { + "node": "Code - Format Action Update Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Action Update Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Action Type": { + "main": [ + [ + { + "node": "HTTP - Get Employee For Action", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Build jobInfo Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Prepare Termination Response", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Build Termination Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build jobInfo Payload": { + "main": [ + [ + { + "node": "HTTP - Get jobInfo Before Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Employee jobInfo Row": { + "main": [ + [ + { + "node": "HTTP - Get jobInfo After Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get jobInfo After Add": { + "main": [ + [ + { + "node": "Code - Format jobInfo Action Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format jobInfo Action Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get jobInfo Before Add": { + "main": [ + [ + { + "node": "Code - Guard Duplicate jobInfo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate jobInfo": { + "main": [ + [ + { + "node": "IF - jobInfo Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - jobInfo Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Add Employee jobInfo Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch - Action Needs Existing Employee?": { + "main": [ + [ + { + "node": "HTTP - Employees Directory", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Create Employee Confirmation Guard", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Create Employee Confirmation Guard": { + "main": [ + [ + { + "node": "IF - Create Employee Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Create Employee Approved?": { + "main": [ + [ + { + "node": "Code - Build Create Employee Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Create Employee Payload": { + "main": [ + [ + { + "node": "HTTP - Employees Directory For Create Check", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Employees Directory For Create Check": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Create Employee", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Create Employee": { + "main": [ + [ + { + "node": "IF - Create Employee Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Create Employee Allowed?": { + "main": [ + [ + { + "node": "HTTP - Create Employee", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Create Employee": { + "main": [ + [ + { + "node": "Code - Normalize Created Employee ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Created Employee ID": { + "main": [ + [ + { + "node": "Code - Build Created Employee jobInfo Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee By ID": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee jobInfo Table", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Create Employee Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Created Employee jobInfo Payload": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee jobInfo Before Add", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee jobInfo Before Add": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Created Employee jobInfo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Created Employee jobInfo": { + "main": [ + [ + { + "node": "IF - Created Employee jobInfo Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Created Employee jobInfo Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Add Created Employee jobInfo Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "HTTP - Get Created Employee By ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Created Employee jobInfo Row": { + "main": [ + [ + { + "node": "HTTP - Get Created Employee By ID", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Created Employee jobInfo Table": { + "main": [ + [ + { + "node": "Code - Format Create Employee Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Prepare Termination Response": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Check Direct Employee ID Fallback": { + "main": [ + [ + { + "node": "IF - Has Direct Employee ID?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Has Direct Employee ID?": { + "main": [ + [ + { + "node": "HTTP - Get Employee By Direct ID", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get Employee By Direct ID": { + "main": [ + [ + { + "node": "Code - Normalize Direct Employee Resolve", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Direct Employee Resolve": { + "main": [ + [ + { + "node": "Code - Employee Scope Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Build Termination Payload": { + "main": [ + [ + { + "node": "HTTP - Get employmentStatus Before Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get employmentStatus Before Termination": { + "main": [ + [ + { + "node": "Code - Guard Duplicate Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Guard Duplicate Termination": { + "main": [ + [ + { + "node": "IF - Termination Add Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Termination Add Allowed?": { + "main": [ + [ + { + "node": "HTTP - Meta Lists For Termination", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Ready Response": { + "main": [ + [] + ] + }, + "Code - Format Termination Blocked Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Meta Lists For Termination": { + "main": [ + [ + { + "node": "Code - Resolve Termination List IDs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Resolve Termination List IDs": { + "main": [ + [ + { + "node": "IF - Termination List IDs Ready?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Termination List IDs Ready?": { + "main": [ + [ + { + "node": "Code - Preview Termination POST Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Format Termination Mapping Blocked Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Mapping Blocked Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Ready With IDs Response": { + "main": [ + [] + ] + }, + "Code - Inspect Termination Lists": { + "main": [ + [] + ] + }, + "Code - Preview Termination POST Payload": { + "main": [ + [ + { + "node": "Code - Final Termination Execution Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Final Termination Execution Gate": { + "main": [ + [ + { + "node": "IF - Final Termination Execution Approved?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Final Termination Execution Approved?": { + "main": [ + [ + { + "node": "HTTP - Add Employee employmentStatus Row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Add Employee employmentStatus Row": { + "main": [ + [ + { + "node": "Code - Normalize Termination POST Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Termination POST Result": { + "main": [ + [ + { + "node": "HTTP - Get employmentStatus After Termination", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Get employmentStatus After Termination": { + "main": [ + [ + { + "node": "Code - Format Termination Execution Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Termination Execution Response": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Prepare Audit Log": { + "main": [ + [ + { + "node": "Supabase - Insert Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Insert Audit Log": { + "main": [ + [ + { + "node": "Code - Restore Response After Audit", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Restore Response After Audit": { + "main": [ + [ + { + "node": "Code - Agent Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Get User Permissions": { + "main": [ + [ + { + "node": "Code - Authorization Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Authorization Gate": { + "main": [ + [ + { + "node": "IF - User Authorized?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - User Authorized?": { + "main": [ + [ + { + "node": "Switch - Mode Router", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Employee Scope Gate": { + "main": [ + [ + { + "node": "IF - Employee Scope Allowed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Employee Scope Allowed?": { + "main": [ + [ + { + "node": "Switch - Post Resolve Mode", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP - Employees Directory Report": { + "main": [ + [ + { + "node": "Code - Format Headcount Report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Format Headcount Report": { + "main": [ + [ + { + "node": "Code - Prepare Audit Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook - Google Chat Incoming": { + "main": [ + [ + { + "node": "Code - Normalize Google Chat Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalize Google Chat Input": { + "main": [ + [ + { + "node": "Code - Request Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Is Google Chat Channel?": { + "main": [ + [ + { + "node": "Respond - Google Chat", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Version 8d2bf921", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-06-27T13:31:04.030Z", + "id": 1922, + "workflowId": "5VOiadzdgbs2Qq1N", + "versionId": "8d2bf921-fa4f-4771-a4ff-fc8b4be0a7e3", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file