18540 lines
1.6 MiB
Plaintext
18540 lines
1.6 MiB
Plaintext
{
|
||
"updatedAt": "2026-07-02T22:03:49.998Z",
|
||
"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": [
|
||
-4128,
|
||
-7072
|
||
],
|
||
"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": [
|
||
-20688,
|
||
-2400
|
||
],
|
||
"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": [
|
||
-20480,
|
||
-2400
|
||
],
|
||
"id": "ca88cf95-285f-4389-84c9-458198f82f94",
|
||
"name": "Set - Incoming Message"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\n\nfunction normalizeGeminiText(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeGeminiFieldName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) return null;\n\n const normalized = normalizeGeminiText(raw);\n\n const fieldMap = {\n // Cargo / puesto\n cargo: 'jobTitle',\n puesto: 'jobTitle',\n posicion: 'jobTitle',\n posición: 'jobTitle',\n titulo: 'jobTitle',\n 'titulo laboral': 'jobTitle',\n 'título laboral': 'jobTitle',\n job: 'jobTitle',\n jobtitle: 'jobTitle',\n 'job title': 'jobTitle',\n jobTitle: 'jobTitle',\n\n // Departamento\n departamento: 'department',\n area: 'department',\n área: 'department',\n department: 'department',\n\n // División\n division: 'division',\n división: 'division',\n unidad: 'division',\n bu: 'division',\n\n // Ubicación / país\n ubicacion: 'location',\n ubicación: 'location',\n pais: 'location',\n país: 'location',\n localidad: 'location',\n location: 'location',\n\n // Supervisor\n supervisor: 'supervisor',\n jefe: 'supervisor',\n manager: 'supervisor',\n lider: 'supervisor',\n líder: 'supervisor',\n\n // Correo laboral\n correo: 'workEmail',\n email: 'workEmail',\n 'correo laboral': 'workEmail',\n 'email laboral': 'workEmail',\n 'correo de trabajo': 'workEmail',\n 'email de trabajo': 'workEmail',\n workemail: 'workEmail',\n workEmail: 'workEmail',\n 'work email': 'workEmail',\n\n // Teléfono laboral\n telefono: 'workPhone',\n teléfono: 'workPhone',\n 'telefono laboral': 'workPhone',\n 'teléfono laboral': 'workPhone',\n 'telefono de trabajo': 'workPhone',\n 'teléfono de trabajo': 'workPhone',\n workphone: 'workPhone',\n workPhone: 'workPhone',\n 'work phone': 'workPhone',\n\n // Extensión laboral\n extension: 'workPhoneExtension',\n extensión: 'workPhoneExtension',\n 'extension laboral': 'workPhoneExtension',\n 'extensión laboral': 'workPhoneExtension',\n 'extension de trabajo': 'workPhoneExtension',\n 'extensión de trabajo': 'workPhoneExtension',\n workphoneextension: 'workPhoneExtension',\n workPhoneExtension: 'workPhoneExtension',\n 'work phone extension': 'workPhoneExtension',\n\n // Estatus laboral\n estatus: 'employmentStatus',\n estado: 'employmentStatus',\n 'estatus laboral': 'employmentStatus',\n 'estado laboral': 'employmentStatus',\n employmentstatus: 'employmentStatus',\n employmentStatus: 'employmentStatus',\n 'employment status': 'employmentStatus',\n\n // Fecha de ingreso\n ingreso: 'hireDate',\n 'fecha ingreso': 'hireDate',\n 'fecha de ingreso': 'hireDate',\n 'fecha contratacion': 'hireDate',\n 'fecha contratación': 'hireDate',\n 'fecha de contratacion': 'hireDate',\n 'fecha de contratación': 'hireDate',\n hiredate: 'hireDate',\n hireDate: 'hireDate',\n 'hire date': 'hireDate',\n\n // Nombre\n nombre: 'displayName',\n 'nombre completo': 'displayName',\n displayname: 'displayName',\n displayName: 'displayName',\n 'display name': 'displayName',\n\n // ID / número empleado\n id: 'id',\n employeeid: 'id',\n employeeId: 'id',\n 'employee id': 'id',\n\n numeroempleado: 'employeeNumber',\n 'numero empleado': 'employeeNumber',\n 'número empleado': 'employeeNumber',\n 'numero de empleado': 'employeeNumber',\n 'número de empleado': 'employeeNumber',\n employeenumber: 'employeeNumber',\n employeeNumber: 'employeeNumber',\n 'employee number': 'employeeNumber'\n };\n\n if (fieldMap[raw]) return fieldMap[raw];\n if (fieldMap[normalized]) return fieldMap[normalized];\n\n const canonicalFields = [\n 'id',\n 'displayName',\n 'firstName',\n 'lastName',\n 'preferredName',\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'supervisorEmail',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'employmentStatus',\n 'status',\n 'hireDate',\n 'employeeNumber'\n ];\n\n const canonicalMatch = canonicalFields.find(field => field.toLowerCase() === normalized.toLowerCase());\n\n return canonicalMatch || raw;\n}\n\nfunction normalizeGeminiRequestedFields(value) {\n let rawFields = [];\n\n if (Array.isArray(value)) {\n rawFields = value;\n } else if (typeof value === 'string' && value.trim()) {\n rawFields = value\n .split(/,| y | e | and /i)\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n const normalizedFields = [];\n\n for (const field of rawFields) {\n const normalizedField = normalizeGeminiFieldName(field);\n\n if (!normalizedField) continue;\n\n if (!normalizedFields.includes(normalizedField)) {\n normalizedFields.push(normalizedField);\n }\n }\n\n return normalizedFields;\n}\n\nfunction normalizeGeminiActionObject(action) {\n if (!action || typeof action !== 'object') {\n return action || null;\n }\n\n const normalizedAction = {\n ...action\n };\n\n if (normalizedAction.field) {\n normalizedAction.field = normalizeGeminiFieldName(normalizedAction.field);\n }\n\n if (normalizedAction.payload && typeof normalizedAction.payload === 'object') {\n const normalizedPayload = {};\n\n for (const [key, value] of Object.entries(normalizedAction.payload)) {\n const normalizedKey = normalizeGeminiFieldName(key) || key;\n normalizedPayload[normalizedKey] = value;\n }\n\n normalizedAction.payload = normalizedPayload;\n }\n\n return normalizedAction;\n}\n\n\n// ----------------------------------------------------\n// PRIORIDAD ABSOLUTA: REPORTES GUARDADOS / CUSTOM REPORTS\n// ----------------------------------------------------\n// Este bloque evita que Gemini clasifique mal frases como:\n// \"Ejecuta el reporte guardado reporte inventado xyz\"\n// como list_saved_reports.\n// Si el usuario usa verbo de ejecución + reporte, forzamos run_saved_report.\n\nfunction extractForcedSavedReportPage(messageText) {\n const raw = String(messageText || '').trim();\n\n const pageMatch =\n raw.match(/\\bp[aá]gina\\s+([a-zA-Z0-9_-]+)\\b/i) ||\n raw.match(/\\bpage\\s+([a-zA-Z0-9_-]+)\\b/i);\n\n if (!pageMatch || !pageMatch[1]) {\n return {\n page: 1,\n requested_page_raw: null\n };\n }\n\n const rawPage = String(pageMatch[1]).trim();\n const pageNumber = Number(rawPage);\n\n if (!Number.isFinite(pageNumber) || pageNumber < 1) {\n return {\n page: 1,\n requested_page_raw: rawPage\n };\n }\n\n return {\n page: Math.floor(pageNumber),\n requested_page_raw: rawPage\n };\n}\n\nfunction extractForcedSavedReportId(messageText) {\n const raw = String(messageText || '').trim();\n\n // Caso fuerte: \"ID 13\", \"id #13\"\n const explicitIdMatch = raw.match(/\\bID\\s*#?\\s*([0-9]{1,10})\\b/i);\n\n if (explicitIdMatch && explicitIdMatch[1]) {\n return explicitIdMatch[1].trim();\n }\n\n // Caso aceptado: \"reporte guardado 13\"\n // Evitamos confundir \"página 2\" como ID.\n const reportIdMatch = raw.match(/\\breporte\\s+guardado\\s+([0-9]{1,10})(?!\\s*(?:p[aá]gina|page))\\b/i);\n\n if (reportIdMatch && reportIdMatch[1]) {\n return reportIdMatch[1].trim();\n }\n\n return null;\n}\n\nfunction extractForcedSavedReportNameQuery(messageText, reportId) {\n if (reportId) {\n return null;\n }\n\n let text = String(messageText || '').trim();\n\n if (!text) return null;\n\n text = text\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos página.\n text = text.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n text = text.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n\n // Quitamos verbos de ejecución/consulta.\n text = text.replace(/\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar|mu[eé]strame|muestrame|dame)\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO quitamos la palabra \"reporte\"\n // si forma parte del nombre real, como \"Reporte de Activos\".\n text = text.replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breportes\\s+guardados\\b/gi, ' ');\n text = text.replace(/\\bcustom reports?\\b/gi, ' ');\n text = text.replace(/\\breportes?\\s+custom\\b/gi, ' ');\n text = text.replace(/\\bsaved reports?\\b/gi, ' ');\n text = text.replace(/\\bde\\s+bamboohr\\b/gi, ' ');\n text = text.replace(/\\ben\\s+bamboohr\\b/gi, ' ');\n text = text.replace(/\\bbamboohr\\b/gi, ' ');\n\n // Quitamos ID si aparece por seguridad.\n text = text.replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ');\n text = text.replace(/\\b#\\s*\\d+\\b/gi, ' ');\n\n text = text\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!text) return null;\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte'\n ];\n\n const normalized = normalizeGeminiText(text);\n\n if (blocked.includes(normalized)) {\n return null;\n }\n\n return text;\n}\n\nconst forcedSavedReportOriginalMessage =\n input.message ||\n input.original_message ||\n '';\n\nconst forcedSavedReportNormalizedMessage =\n normalizeGeminiText(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasExecutionVerb =\n /\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar)\\b/i\n .test(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasListVerb =\n /\\b(lista|listar|listame|lístame|muestra|muestrame|muéstrame|dame|ver)\\b/i\n .test(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasReportTerm =\n forcedSavedReportNormalizedMessage.includes('reporte guardado') ||\n forcedSavedReportNormalizedMessage.includes('reportes guardados') ||\n forcedSavedReportNormalizedMessage.includes('custom report') ||\n forcedSavedReportNormalizedMessage.includes('custom reports') ||\n forcedSavedReportNormalizedMessage.includes('saved report') ||\n forcedSavedReportNormalizedMessage.includes('saved reports') ||\n forcedSavedReportNormalizedMessage.includes('reporte ');\n\nconst forcedSavedReportIsPureListRequest =\n !forcedSavedReportHasExecutionVerb &&\n forcedSavedReportHasListVerb &&\n (\n forcedSavedReportNormalizedMessage.includes('reportes guardados') ||\n forcedSavedReportNormalizedMessage.includes('reportes disponibles') ||\n forcedSavedReportNormalizedMessage.includes('lista de reportes') ||\n forcedSavedReportNormalizedMessage.includes('listar reportes') ||\n forcedSavedReportNormalizedMessage.includes('custom reports') ||\n forcedSavedReportNormalizedMessage.includes('reportes custom') ||\n forcedSavedReportNormalizedMessage.includes('saved reports') ||\n forcedSavedReportNormalizedMessage.includes('reportes configurados') ||\n forcedSavedReportNormalizedMessage.includes('reportes existentes')\n );\n\nconst forcedSavedReportIsRunRequest =\n forcedSavedReportHasExecutionVerb &&\n forcedSavedReportHasReportTerm;\n\nif (forcedSavedReportIsRunRequest || forcedSavedReportIsPureListRequest) {\n const forcedReportId = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportId(forcedSavedReportOriginalMessage)\n : null;\n\n const forcedPageInfo = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportPage(forcedSavedReportOriginalMessage)\n : {\n page: 1,\n requested_page_raw: null\n };\n\n const forcedReportNameQuery = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportNameQuery(forcedSavedReportOriginalMessage, forcedReportId)\n : null;\n\n const forcedIntent = forcedSavedReportIsRunRequest\n ? 'run_saved_report'\n : 'list_saved_reports';\n\n const forcedRiskLevel = forcedSavedReportIsRunRequest\n ? 'high'\n : 'low';\n\n const forcedFilters = forcedSavedReportIsRunRequest\n ? {\n ...(forcedReportId ? { report_id: forcedReportId } : {}),\n ...(forcedReportNameQuery ? { report_name_query: forcedReportNameQuery } : {}),\n saved_report_page: forcedPageInfo.page,\n requested_page_raw: forcedPageInfo.requested_page_raw\n }\n : {};\n\n const forcedStatus =\n forcedSavedReportIsPureListRequest ||\n forcedReportId ||\n forcedReportNameQuery\n ? 'ready'\n : 'needs_clarification';\n\n const forcedCanContinue =\n forcedStatus === 'ready';\n\n const forcedResponseText = forcedCanContinue\n ? null\n : 'Entiendo que deseas ejecutar un reporte guardado, pero necesito que indiques el ID o el nombre del reporte. Ejemplo: \"Ejecuta el reporte guardado ID 13\" o \"Ejecuta el reporte guardado Reporte de Activos\".';\n\n return {\n json: {\n ...input,\n\n mode: 'reporte',\n intent: forcedIntent,\n report_type: forcedIntent,\n risk_level: forcedRiskLevel,\n\n confidence: forcedCanContinue ? 1 : 0.75,\n requires_confirmation: false,\n\n employee_query: null,\n employee_id: null,\n requested_fields: [],\n fields: [],\n\n filters: forcedFilters,\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw,\n\n can_continue: forcedCanContinue,\n status: forcedStatus,\n response_text: forcedResponseText,\n response_type: forcedCanContinue ? 'chat_message' : 'error_message',\n\n parser_source: 'deterministic_saved_report_priority',\n\n data: {\n ...(input.data || {}),\n\n report_type: forcedIntent,\n\n filters: {\n ...(input.data?.filters || {}),\n ...forcedFilters\n },\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw\n },\n\n audit_event: {\n ...(input.audit_event || {}),\n\n original_message: forcedSavedReportOriginalMessage,\n\n parser_source: 'deterministic_saved_report_priority',\n\n mode: 'reporte',\n intent: forcedIntent,\n report_type: forcedIntent,\n risk_level: forcedRiskLevel,\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw,\n\n authorization_result: 'pending_authorization',\n execution_result: 'interpreted_by_saved_report_priority_parser'\n }\n }\n };\n}\n\n\nconst geminiInput = $json;\n\nif (\n geminiInput.gemini_interpretation_valid === true &&\n geminiInput.gemini_interpretation &&\n geminiInput.use_deterministic_parser_fallback !== true\n) {\n const g = geminiInput.gemini_interpretation;\n\n const modeMap = {\n query: 'consulta',\n consulta: 'consulta',\n consultar: 'consulta',\n\n action: 'accion',\n accion: 'accion',\n acción: 'accion',\n modificar: 'accion',\n actualizar: 'accion',\n editar: 'accion',\n crear: 'accion',\n desvincular: 'accion',\n\n report: 'reporte',\n reporte: 'reporte',\n reportes: 'reporte',\n\n confirmation: 'confirmation',\n confirmacion: 'confirmation',\n confirmación: 'confirmation',\n confirmar: 'confirmation',\n cancelar: 'confirmation',\n\n system: 'system',\n unknown: 'unknown'\n };\n\n const normalizedMode =\n modeMap[String(g.mode || '').trim().toLowerCase()] ||\n 'unknown';\n\n const employeeQuery =\n g.employee_query ||\n g.employee_id ||\n null;\n\n const normalizedRequestedFields = normalizeGeminiRequestedFields(\n g.requested_fields ||\n g.fields ||\n []\n );\n\n const normalizedAction = normalizeGeminiActionObject(g.action || null);\n\n return {\n json: {\n ...geminiInput,\n\n mode: normalizedMode,\n intent: g.intent,\n confidence: g.confidence,\n risk_level: g.risk_level,\n\n requires_confirmation: g.requires_confirmation,\n\n employee_query: employeeQuery,\n employee_id: g.employee_id || null,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n table_name: g.table_name || null,\n filters: g.filters || {},\n report_type: g.report_type || null,\n\n action: normalizedAction,\n\n can_continue: true,\n status: 'interpreted_by_gemini',\n\n response_text: null,\n response_type: 'chat_message',\n\n parser_source: 'gemini',\n\n data: {\n ...(geminiInput.data || {}),\n\n employee_id: g.employee_id || null,\n employee_query: employeeQuery,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n table_name: g.table_name || null,\n filters: g.filters || {},\n report_type: g.report_type || null,\n\n action: normalizedAction,\n\n requires_confirmation: g.requires_confirmation,\n confidence: g.confidence,\n status: 'interpreted_by_gemini'\n },\n\n audit_event: {\n ...(geminiInput.audit_event || {}),\n\n original_message:\n geminiInput.original_message ||\n geminiInput.message ||\n null,\n\n parser_source: 'gemini',\n\n gemini_interpretation_valid: true,\n gemini_interpreted_mode: normalizedMode,\n gemini_interpreted_intent: g.intent,\n gemini_confidence: g.confidence,\n gemini_risk_level: g.risk_level,\n\n gemini_original_requested_fields:\n g.requested_fields ||\n g.fields ||\n [],\n\n gemini_normalized_requested_fields: normalizedRequestedFields,\n\n mode: normalizedMode,\n intent: g.intent,\n risk_level: g.risk_level,\n\n employee_query: employeeQuery,\n employee_id: g.employee_id || null,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n action: normalizedAction,\n\n authorization_result: 'pending_authorization',\n execution_result: 'interpreted_by_gemini'\n }\n }\n };\n}\n\nif (\n $json.confirmed_from_google_chat === true &&\n $json.confirmation_status === 'confirmed' &&\n (\n $json.data?.action ||\n $json.action\n )\n) {\n const action =\n normalizeGeminiActionObject(\n $json.data?.action ||\n $json.action\n );\n\n const employeeId =\n $json.data?.employee_id ||\n $json.employee_id ||\n $json.resolved_employee_id ||\n $json.audit_event?.target_employee_id ||\n null;\n\n const employeeName =\n $json.data?.employee_name ||\n $json.employee_name ||\n $json.resolved_employee_name ||\n $json.audit_event?.target_employee_name ||\n null;\n\n const employeeEmail =\n $json.data?.employee_email ||\n $json.employee_email ||\n $json.resolved_employee_email ||\n $json.audit_event?.target_employee_email ||\n null;\n\n return {\n json: {\n ...$json,\n\n mode: $json.mode || 'accion',\n intent: $json.intent || 'update_employee_basic',\n risk_level: $json.risk_level || 'medium',\n\n can_continue: true,\n confidence: 1,\n status: 'ready',\n response_text: null,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n\n data: {\n ...($json.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n confidence: 1,\n status: 'ready'\n },\n\n audit_event: {\n ...($json.audit_event || {}),\n\n mode: $json.mode || 'accion',\n intent: $json.intent || 'update_employee_basic',\n risk_level: $json.risk_level || 'medium',\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n\n authorization_result: 'confirmation_confirmed_by_user',\n execution_result: 'confirmed_pending_execution'\n }\n }\n };\n}\n\nif (input.skip_core === true || input.can_continue === false) {\n return {\n json: {\n ...input,\n mode: input.mode || 'system',\n intent: input.intent || 'system_event',\n risk_level: input.risk_level || 'low',\n confidence: 1,\n status: 'ready',\n can_continue: false,\n response_text:\n input.response_text ||\n 'Evento recibido, pero no requiere procesamiento del core BambooHR.'\n }\n };\n}\n\n\nconst originalMessage =\n input.message ||\n input.original_message ||\n '';\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/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 / REPORTES GUARDADOS\n// -------------------------\nconst asksSavedReports =\n message.includes('reportes guardados') ||\n message.includes('reportes disponibles') ||\n message.includes('reporte disponible') ||\n message.includes('lista de reportes') ||\n message.includes('listar reportes') ||\n message.includes('listame los reportes') ||\n message.includes('lístame los reportes') ||\n message.includes('muestrame los reportes') ||\n message.includes('muéstrame los reportes') ||\n message.includes('custom reports disponibles') ||\n message.includes('reportes custom disponibles') ||\n message.includes('saved reports') ||\n message.includes('reportes configurados') ||\n message.includes('reportes existentes');\n\nconst savedReportIdMatch = originalMessage.match(/\\b(?:id|ID)?\\s*#?\\s*([0-9]{1,10})\\b/);\n\nconst asksRunSavedReport =\n (\n message.includes('ejecuta') ||\n message.includes('ejecutar') ||\n message.includes('corre') ||\n message.includes('correr') ||\n message.includes('genera') ||\n message.includes('generar') ||\n message.includes('abre') ||\n message.includes('abrir') ||\n message.includes('consulta') ||\n message.includes('consultar')\n ) &&\n (\n message.includes('reporte guardado') ||\n message.includes('reportes guardados') ||\n message.includes('custom report') ||\n message.includes('custom reports') ||\n message.includes('saved report') ||\n message.includes('saved reports') ||\n message.includes('reporte id') ||\n message.includes('report id') ||\n\n // IMPORTANTE:\n // Esto permite frases naturales como:\n // \"Ejecuta el reporte de activos página 1\"\n // \"Ejecuta el reporte correos\"\n // \"Corre el reporte Reporte de Activos\"\n message.includes('reporte ')\n );\n\nfunction extractSavedReportNameQuery(messageText) {\n let text = String(messageText || '').trim();\n\n if (!text) return null;\n\n text = text\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos verbos comunes.\n text = text.replace(/\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar|mu[eé]strame|muestrame|dame)\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO quitamos \"reporte\" si forma parte del nombre real.\n text = text.replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breportes\\s+guardados\\b/gi, ' ');\n text = text.replace(/\\bcustom reports?\\b/gi, ' ');\n text = text.replace(/\\bsaved reports?\\b/gi, ' ');\n\n // Quitamos ID si aparece.\n text = text.replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ');\n text = text.replace(/\\b#\\s*\\d+\\b/gi, ' ');\n\n // Quitamos página.\n text = text.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n text = text.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n\n // Limpieza de artículos sueltos al inicio.\n text = text\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!text) return null;\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte'\n ];\n\n const normalized = normalize(text);\n\n if (blocked.includes(normalized)) {\n return null;\n }\n\n return text;\n}\n\nconst asksActiveInactive =\n (\n message.includes('activos') &&\n message.includes('inactivos')\n ) ||\n message.includes('activo e inactivo') ||\n message.includes('activos e inactivos') ||\n message.includes('empleados activos e inactivos') ||\n message.includes('colaboradores activos e inactivos');\n\nconst asksHeadcount =\n message.includes('headcount') ||\n message.includes('cantidad de empleados') ||\n message.includes('numero de empleados') ||\n message.includes('número de empleados') ||\n message.includes('cuantos empleados') ||\n message.includes('cuántos empleados') ||\n message.includes('total empleados') ||\n message.includes('total de empleados') ||\n message.includes('empleados por pais') ||\n message.includes('empleados por país') ||\n message.includes('empleados por ubicacion') ||\n message.includes('empleados por ubicación') ||\n message.includes('empleados por departamento') ||\n message.includes('headcount por pais') ||\n message.includes('headcount por país') ||\n message.includes('headcount por departamento');\n\nif (asksRunSavedReport) {\n const reportId =\n savedReportIdMatch && savedReportIdMatch[1]\n ? savedReportIdMatch[1]\n : null;\n\n const reportNameQuery = reportId\n ? null\n : extractSavedReportNameQuery(originalMessage);\n\n mode = 'reporte';\n intent = 'run_saved_report';\n riskLevel = 'high';\n requiresConfirmation = false;\n\n confidence =\n reportId || reportNameQuery\n ? 0.95\n : 0.75;\n\n status =\n reportId || reportNameQuery\n ? 'ready'\n : 'needs_clarification';\n\n responseText =\n reportId || reportNameQuery\n ? null\n : 'Entiendo que deseas ejecutar un reporte guardado, pero necesito que indiques el ID o el nombre del reporte. Ejemplo: \"Ejecuta el reporte guardado ID 13\" o \"Ejecuta el reporte Reporte de Activos\".';\n\n fields = [];\n\n filters = {\n ...(reportId ? { report_id: reportId } : {}),\n ...(reportNameQuery ? { report_name_query: reportNameQuery } : {})\n };\n\n tableName = null;\n action = null;\n}\n\nelse if (asksSavedReports) {\n // Reportes guardados SIEMPRE primero.\n // Esto evita que \"Muéstrame los reportes guardados\" caiga como headcount general.\n mode = 'reporte';\n intent = 'list_saved_reports';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 1;\n status = 'ready';\n responseText = null;\n\n fields = [];\n filters = {};\n tableName = null;\n action = null;\n}\n \n// Activos / inactivos.\nelse if (asksActiveInactive) {\n mode = 'reporte';\n intent = 'employees_active_inactive';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 0.95;\n status = 'ready';\n responseText = null;\n\n fields = [];\n filters = {\n include_inactive: true\n };\n\n tableName = null;\n action = null;\n}\n\n// Headcount.\n// OJO: aquí NO usamos message.includes('reporte') solo,\n// porque eso rompía reportes guardados/custom reports.\nelse if (asksHeadcount) {\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 fields = [];\n tableName = null;\n action = null;\n\n if (\n message.includes('pais') ||\n message.includes('país') ||\n message.includes('ubicacion') ||\n message.includes('ubicación')\n ) {\n intent = 'headcount_by_country';\n }\n\n if (\n message.includes('departamento') ||\n message.includes('area') ||\n message.includes('área') ||\n message.includes('depto')\n ) {\n intent = 'headcount_by_department';\n }\n\n if (\n (\n message.includes('pais') ||\n message.includes('país') ||\n message.includes('ubicacion') ||\n message.includes('ubicación')\n ) &&\n (\n message.includes('departamento') ||\n message.includes('area') ||\n message.includes('área') ||\n message.includes('depto')\n )\n ) {\n intent = 'headcount_by_country_department';\n }\n\n filters = {\n status: 'Active'\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": [
|
||
-10320,
|
||
-2304
|
||
],
|
||
"id": "16424bed-7d3c-4d1d-8da7-8b8c1437a9f6",
|
||
"name": "Code - Intent Parser"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n PASS-THROUGH DE ACCIÓN CONFIRMADA DESDE GOOGLE CHAT\n\n Este bloque es clave:\n Cuando el usuario responde CONFIRMAR, el mensaje real que llega al parser es \"CONFIRMAR\",\n pero la acción original ya viene restaurada desde la tabla de confirmaciones pendientes.\n\n Por eso NO debemos volver a validar confidence, employee_query ni reinterpretar \"CONFIRMAR\".\n Debemos dejar pasar la acción restaurada.\n*/\nif (\n data.confirmed_from_google_chat === true &&\n data.confirmation_status === 'confirmed' &&\n (\n data.data?.action ||\n data.action\n )\n) {\n const action =\n data.data?.action ||\n data.action;\n\n const employeeId =\n data.data?.employee_id ||\n data.employee_id ||\n data.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n null;\n\n const employeeName =\n data.data?.employee_name ||\n data.employee_name ||\n data.resolved_employee_name ||\n data.audit_event?.target_employee_name ||\n null;\n\n const employeeEmail =\n data.data?.employee_email ||\n data.employee_email ||\n data.resolved_employee_email ||\n data.audit_event?.target_employee_email ||\n null;\n\n return {\n json: {\n ...data,\n\n can_continue: true,\n confidence: data.confidence || 1,\n status: 'ready',\n\n response_text: null,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n action,\n\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n data: {\n ...(data.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n confidence: data.confidence || 1,\n status: 'ready'\n },\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\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n authorization_result:\n data.audit_event?.authorization_result ||\n 'confirmation_confirmed_by_user',\n\n execution_result: 'validated_confirmed_action'\n }\n }\n };\n}\n\nif (data.skip_core === true || data.can_continue === false) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'Evento recibido, pero no requiere procesamiento del core BambooHR.'\n }\n };\n}\n\nif (!data.mode || data.mode === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n '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:\n '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": [
|
||
-9808,
|
||
-2288
|
||
],
|
||
"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": [
|
||
-5664,
|
||
-6208
|
||
],
|
||
"id": "1c7b9cd5-b635-4012-9b7e-e5086d2ca66c",
|
||
"name": "Switch - Mode Router"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"fieldToSplitOut": "employees",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.splitOut",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-3888,
|
||
-7072
|
||
],
|
||
"id": "88fcede6-4eea-4b65-b320-1a86d1da10ed",
|
||
"name": "Split Out - Employees"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const intent = $node[\"Code - Intent Parser\"].json;\n\nconst queryRaw =\n intent.employee_query ||\n intent.employee_id ||\n intent.resolved_employee_id ||\n intent.data?.employee_id ||\n intent.audit_event?.target_employee_id ||\n '';\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\nif (!query) {\n return [{\n json: {\n ...intent,\n\n employee_found: false,\n employee_query: null,\n multiple_matches: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n 'Necesito que indiques el empleado para poder continuar. Puedes usar nombre, correo o ID BambooHR.',\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n authorization_result: 'employee_query_missing',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\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 ...intent,\n\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 audit_event: {\n ...(intent.audit_event || {}),\n\n employee_query: queryRaw,\n authorization_result: 'employee_not_found',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\n\nif (matches.length > 1) {\n return [{\n json: {\n ...intent,\n\n employee_found: false,\n employee_query: queryRaw,\n multiple_matches: true,\n\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\n response_text:\n `Encontré varios empleados que coinciden con \"${queryRaw}\". Por favor especifica mejor el nombre, correo o ID BambooHR.`,\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n employee_query: queryRaw,\n matches_count: matches.length,\n authorization_result: 'multiple_employee_matches',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\n\nconst employee = matches[0].json;\n\nreturn [{\n json: {\n ...intent,\n ...employee,\n\n employee_found: true,\n employee_query: queryRaw,\n\n resolved_employee_id: String(employee.id),\n resolved_employee_name: employee.displayName,\n resolved_employee_email: employee.workEmail || null,\n\n status: 'employee_resolved',\n can_continue: true,\n response_text: null,\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n target_employee_id: String(employee.id),\n target_employee_name: employee.displayName,\n target_employee_email: employee.workEmail || null,\n\n authorization_result: 'employee_resolved',\n execution_result: 'employee_resolved'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3680,
|
||
-7072
|
||
],
|
||
"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": [
|
||
8240,
|
||
-13984
|
||
],
|
||
"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 =\n $items('Code - Intent Parser')?.[0]?.json ||\n {};\n\nconst employee = $json || {};\n\nconst requestedFields =\n intent.requested_fields ||\n intent.fields ||\n [];\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 employeeId =\n employee.id ||\n intent.employee_id ||\n intent.resolved_employee_id ||\n null;\n\nconst employeeName =\n employee.displayName ||\n intent.employee_name ||\n intent.resolved_employee_name ||\n `Empleado ${employeeId || 'no identificado'}`;\n\nlet finalRequestedFields = requestedFields;\n\nif (!Array.isArray(finalRequestedFields)) {\n finalRequestedFields = [];\n}\n\nfinalRequestedFields = finalRequestedFields.filter(Boolean);\n\nif (finalRequestedFields.length === 0) {\n finalRequestedFields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'workEmail'\n ];\n}\n\nconst lines = finalRequestedFields.map(field => {\n const label = labels[field] || field;\n const value = formatFieldValue(field, employee[field]);\n\n return `- ${label}: ${value}`;\n});\n\nconst responseText =\n `Consulta completada para ${employeeName}:\\n\\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: employeeId,\n employee_name: employeeName,\n requested_fields: finalRequestedFields,\n\n response_text: responseText,\n response_type: 'chat_message',\n can_continue: true,\n status: 'success',\n\n audit_event: {\n ...(intent.audit_event || {}),\n\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 requested_fields: finalRequestedFields,\n\n authorization_result:\n intent.audit_event?.authorization_result ||\n 'authorized',\n\n execution_result: 'success',\n\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
8496,
|
||
-13984
|
||
],
|
||
"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": [
|
||
19488,
|
||
-4256
|
||
],
|
||
"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": [
|
||
-9600,
|
||
-2288
|
||
],
|
||
"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": [
|
||
-3120,
|
||
-7072
|
||
],
|
||
"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: input.original_message || message,\n\n confirmation_status: input.confirmation_status || null,\n\n action_approved:\n input.action_approved === true ||\n String(input.action_approved || '').toLowerCase() === 'true',\n\n confirmed_from_google_chat:\n input.confirmed_from_google_chat === true ||\n String(input.confirmed_from_google_chat || '').toLowerCase() === 'true',\n\n final_execution_approved:\n input.final_execution_approved === true ||\n String(input.final_execution_approved || '').toLowerCase() === 'true',\n\n final_execution_token: input.final_execution_token || null,\n\n google_chat_event_type: input.google_chat_event_type || null,\n google_chat_space_name: input.google_chat_space_name || null,\n google_chat_space_display_name: input.google_chat_space_display_name || null,\n google_chat_message_name: input.google_chat_message_name || null,\n google_chat_thread_name: input.google_chat_thread_name || null,\n\n is_confirmation_reply: input.is_confirmation_reply || false,\n confirmation_reply_type: input.confirmation_reply_type || null,\n confirmation_reply_text: input.confirmation_reply_text || null,\n confirmation_id: input.confirmation_id || null,\n pending_confirmation_row_id: input.pending_confirmation_row_id || null,\n confirmation_resolution: input.confirmation_resolution || 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": [
|
||
-12240,
|
||
-2592
|
||
],
|
||
"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": [
|
||
5968,
|
||
-12768
|
||
],
|
||
"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": [
|
||
7840,
|
||
-13184
|
||
],
|
||
"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": [
|
||
8048,
|
||
-13184
|
||
],
|
||
"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": [
|
||
7776,
|
||
-12944
|
||
],
|
||
"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": [
|
||
7984,
|
||
-12944
|
||
],
|
||
"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": [
|
||
7712,
|
||
-12528
|
||
],
|
||
"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": [
|
||
8032,
|
||
-12496
|
||
],
|
||
"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": [
|
||
6528,
|
||
-12544
|
||
],
|
||
"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": [
|
||
7728,
|
||
-12240
|
||
],
|
||
"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": [
|
||
7936,
|
||
-12240
|
||
],
|
||
"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": [
|
||
7248,
|
||
-11984
|
||
],
|
||
"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": [
|
||
7456,
|
||
-11984
|
||
],
|
||
"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": [
|
||
8016,
|
||
-12000
|
||
],
|
||
"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": [
|
||
8224,
|
||
-12000
|
||
],
|
||
"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": [
|
||
1408,
|
||
-8704
|
||
],
|
||
"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": [
|
||
3840,
|
||
-8416
|
||
],
|
||
"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": [
|
||
4144,
|
||
-8416
|
||
],
|
||
"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": [
|
||
6080,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6288,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6496,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6704,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6912,
|
||
-11200
|
||
],
|
||
"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": [
|
||
4416,
|
||
-9040
|
||
],
|
||
"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": [
|
||
5792,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6960,
|
||
-10240
|
||
],
|
||
"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": [
|
||
7168,
|
||
-10240
|
||
],
|
||
"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": [
|
||
7376,
|
||
-10240
|
||
],
|
||
"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": [
|
||
6000,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6208,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6416,
|
||
-10224
|
||
],
|
||
"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": [
|
||
-4784,
|
||
-6112
|
||
],
|
||
"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": [
|
||
-4464,
|
||
-6112
|
||
],
|
||
"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": [
|
||
224,
|
||
-3072
|
||
],
|
||
"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": [
|
||
624,
|
||
-3088
|
||
],
|
||
"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": [
|
||
6224,
|
||
-8544
|
||
],
|
||
"id": "b37a3f61-7d9e-42a2-beb2-760e0ffba872",
|
||
"name": "Code - Format Prepare Termination Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n if (nodeItems && nodeItems.length > 0) {\n return nodeItems[0].json || {};\n }\n } catch (error) {\n return {};\n }\n\n return {};\n}\n\nconst intent = getNodeJson('Code - Intent Parser');\n\nconst action =\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n data.audit_event?.action ||\n intent.audit_event?.action ||\n null;\n\nconst actionObject =\n action && typeof action === 'object'\n ? action\n : {};\n\nconst rawQuery =\n data.employee_query ||\n data.data?.employee_query ||\n intent.employee_query ||\n intent.data?.employee_query ||\n '';\n\nconst possibleDirectId =\n data.employee_id ||\n data.data?.employee_id ||\n data.resolved_employee_id ||\n data.data?.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n intent.employee_id ||\n intent.data?.employee_id ||\n intent.resolved_employee_id ||\n intent.data?.resolved_employee_id ||\n intent.audit_event?.target_employee_id ||\n actionObject.employee_id ||\n actionObject.employeeId ||\n actionObject.target_employee_id ||\n null;\n\nconst originalMessage =\n data.original_message ||\n data.message ||\n intent.original_message ||\n intent.message ||\n '';\n\nfunction extractEmployeeId(value) {\n const text = String(value || '').trim();\n\n if (!text) {\n return null;\n }\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 return null;\n}\n\nconst directEmployeeId =\n extractEmployeeId(possibleDirectId) ||\n extractEmployeeId(rawQuery) ||\n extractEmployeeId(originalMessage);\n\nconst mergedAuditEvent = {\n ...(intent.audit_event || {}),\n ...(data.audit_event || {})\n};\n\nif (!directEmployeeId) {\n return {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: false,\n direct_employee_id: null,\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 ...mergedAuditEvent,\n\n request_id: data.request_id || intent.request_id || mergedAuditEvent.request_id || null,\n request_started_at: data.request_started_at || intent.request_started_at || mergedAuditEvent.request_started_at || null,\n\n mode: data.mode || intent.mode || mergedAuditEvent.mode || null,\n intent: data.intent || intent.intent || mergedAuditEvent.intent || null,\n risk_level: data.risk_level || intent.risk_level || mergedAuditEvent.risk_level || null,\n\n user_email: data.user_email || intent.user_email || mergedAuditEvent.user_email || null,\n user_name: data.user_name || intent.user_name || mergedAuditEvent.user_name || null,\n channel: data.channel || intent.channel || mergedAuditEvent.channel || null,\n environment: data.environment || intent.environment || mergedAuditEvent.environment || null,\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 // Muy importante para que los nodos posteriores no busquen con query vacía\n employee_query: rawQuery || String(directEmployeeId),\n\n status: 'pending_direct_employee_lookup',\n can_continue: true,\n response_type: 'chat_message',\n\n response_text: null,\n\n action:\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n null,\n\n action_approved:\n data.action_approved === true ||\n data.data?.action_approved === true ||\n intent.action_approved === true ||\n intent.data?.action_approved === true,\n\n confirmed_from_google_chat:\n data.confirmed_from_google_chat === true ||\n data.data?.confirmed_from_google_chat === true ||\n intent.confirmed_from_google_chat === true ||\n intent.data?.confirmed_from_google_chat === true,\n\n confirmed_action_execution:\n data.confirmed_action_execution === true ||\n data.data?.confirmed_action_execution === true ||\n intent.confirmed_action_execution === true ||\n intent.data?.confirmed_action_execution === true,\n\n confirmation_status:\n data.confirmation_status ||\n data.data?.confirmation_status ||\n intent.confirmation_status ||\n intent.data?.confirmation_status ||\n null,\n\n audit_event: {\n ...mergedAuditEvent,\n\n request_id: data.request_id || intent.request_id || mergedAuditEvent.request_id || null,\n request_started_at: data.request_started_at || intent.request_started_at || mergedAuditEvent.request_started_at || null,\n\n mode: data.mode || intent.mode || mergedAuditEvent.mode || null,\n intent: data.intent || intent.intent || mergedAuditEvent.intent || null,\n risk_level: data.risk_level || intent.risk_level || mergedAuditEvent.risk_level || null,\n\n user_email: data.user_email || intent.user_email || mergedAuditEvent.user_email || null,\n user_name: data.user_name || intent.user_name || mergedAuditEvent.user_name || null,\n channel: data.channel || intent.channel || mergedAuditEvent.channel || null,\n environment: data.environment || intent.environment || mergedAuditEvent.environment || null,\n\n employee_query: rawQuery || String(directEmployeeId),\n direct_employee_id: String(directEmployeeId),\n\n target_employee_id:\n data.employee_id ||\n data.data?.employee_id ||\n data.resolved_employee_id ||\n mergedAuditEvent.target_employee_id ||\n String(directEmployeeId),\n\n target_employee_name:\n data.employee_name ||\n data.data?.employee_name ||\n data.resolved_employee_name ||\n mergedAuditEvent.target_employee_name ||\n null,\n\n target_employee_email:\n data.employee_email ||\n data.data?.employee_email ||\n data.resolved_employee_email ||\n mergedAuditEvent.target_employee_email ||\n null,\n\n action:\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n mergedAuditEvent.action ||\n null,\n\n confirmation_status:\n data.confirmation_status ||\n data.data?.confirmation_status ||\n intent.confirmation_status ||\n intent.data?.confirmation_status ||\n mergedAuditEvent.confirmation_status ||\n null,\n\n action_approved:\n data.action_approved === true ||\n data.data?.action_approved === true ||\n intent.action_approved === true ||\n intent.data?.action_approved === true ||\n mergedAuditEvent.action_approved === true,\n\n confirmed_from_google_chat:\n data.confirmed_from_google_chat === true ||\n data.data?.confirmed_from_google_chat === true ||\n intent.confirmed_from_google_chat === true ||\n intent.data?.confirmed_from_google_chat === true ||\n mergedAuditEvent.confirmed_from_google_chat === true,\n\n confirmed_action_execution:\n data.confirmed_action_execution === true ||\n data.data?.confirmed_action_execution === true ||\n intent.confirmed_action_execution === true ||\n intent.data?.confirmed_action_execution === true ||\n mergedAuditEvent.confirmed_action_execution === true,\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": [
|
||
-1920,
|
||
-6352
|
||
],
|
||
"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": [
|
||
-1152,
|
||
-6176
|
||
],
|
||
"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": [
|
||
64,
|
||
-7152
|
||
],
|
||
"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": [
|
||
384,
|
||
-7152
|
||
],
|
||
"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": [
|
||
6080,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6288,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6496,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6704,
|
||
-6464
|
||
],
|
||
"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": [
|
||
7056,
|
||
-6336
|
||
],
|
||
"id": "d10bbd31-7e50-4d4b-9d1c-63ed04b16529",
|
||
"name": "Code - Format Termination Blocked 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": [
|
||
7088,
|
||
-6560
|
||
],
|
||
"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": [
|
||
7600,
|
||
-6560
|
||
],
|
||
"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": [
|
||
8240,
|
||
-6544
|
||
],
|
||
"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": [
|
||
8576,
|
||
-6432
|
||
],
|
||
"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 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": [
|
||
8736,
|
||
-6704
|
||
],
|
||
"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_<employee_id>'}`);\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": [
|
||
9664,
|
||
-6640
|
||
],
|
||
"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": [
|
||
10192,
|
||
-6592
|
||
],
|
||
"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": [
|
||
10704,
|
||
-6720
|
||
],
|
||
"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": [
|
||
11040,
|
||
-6784
|
||
],
|
||
"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": [
|
||
11296,
|
||
-6784
|
||
],
|
||
"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": [
|
||
11776,
|
||
-6784
|
||
],
|
||
"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": [
|
||
12016,
|
||
-6784
|
||
],
|
||
"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": [
|
||
12256,
|
||
-6768
|
||
],
|
||
"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": [
|
||
12464,
|
||
-6768
|
||
],
|
||
"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": [
|
||
-9216,
|
||
-2384
|
||
],
|
||
"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": [
|
||
-8912,
|
||
-2624
|
||
],
|
||
"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": [
|
||
-6832,
|
||
-2768
|
||
],
|
||
"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": [
|
||
848,
|
||
-8560
|
||
],
|
||
"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": [
|
||
1056,
|
||
-8560
|
||
],
|
||
"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": [
|
||
-4592,
|
||
-5200
|
||
],
|
||
"id": "272ad4e3-8d68-49a4-b744-b2d430c09a49",
|
||
"name": "HTTP - Employees Directory Report",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "/**\n * Code - Format Headcount Report\n *\n * Responsabilidad:\n * - Recibir empleados desde BambooHR Directory.\n * - Aplicar permisos por país.\n * - Aplicar filtro solicitado por el usuario, si existe.\n * - Formatear reportes de headcount por país, departamento o total.\n */\n\nconst 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 .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .flatMap(item => parseArray(item))\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'object') {\n return Object.values(value)\n .flatMap(item => parseArray(item))\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 .flatMap(item => parseArray(item))\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (parsed && typeof parsed === 'object') {\n return Object.values(parsed)\n .flatMap(item => parseArray(item))\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 [String(value).trim()].filter(Boolean);\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n // República Dominicana\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep dom.': 'republica dominicana',\n 'rep. dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n 'dominicana': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n\n // Panamá\n 'panama': 'panama',\n 'panamá': 'panama',\n\n // Guatemala\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n // Colombia\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n // Nicaragua\n 'nicaragua': 'nicaragua',\n\n // Honduras\n 'honduras': 'honduras',\n\n // Costa Rica\n 'costa rica': 'costa rica',\n 'cr': 'costa rica',\n\n // Trinidad and Tobago\n 'trinidad': 'trinidad and tobago',\n 'trinidad y tobago': 'trinidad and tobago',\n 'trinidad and tobago': 'trinidad and tobago',\n 'tt': 'trinidad and tobago',\n 't&t': 'trinidad and tobago',\n\n // El Salvador\n 'el salvador': 'el salvador',\n 'salvador': 'el salvador',\n\n // Puerto Rico\n 'puerto rico': 'puerto rico',\n 'pr': 'puerto rico',\n\n // Jamaica\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n // Venezuela\n 'venezuela': 'venezuela',\n\n // Barbados\n 'barbados': 'barbados',\n\n // Mexico\n 'mexico': 'mexico',\n 'méxico': 'mexico',\n\n // Ecuador\n 'ecuador': 'ecuador',\n\n // Guyana\n 'guyana': 'guyana',\n\n // Peru\n 'peru': 'peru',\n 'perú': 'peru',\n\n // Saint Thomas\n 'saint thomas': 'saint thomas',\n 'st thomas': 'saint thomas',\n 'st. thomas': 'saint thomas',\n\n // St. Croix\n 'saint croix': 'st. croix',\n 'st croix': 'st. croix',\n 'st. croix': 'st. croix'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction displayCountryName(value) {\n const country = normalizeCountry(value);\n\n const displayNames = {\n 'republica dominicana': 'República Dominicana',\n 'panama': 'Panamá',\n 'guatemala': 'Guatemala',\n 'colombia': 'Colombia',\n 'nicaragua': 'Nicaragua',\n 'honduras': 'Honduras',\n 'costa rica': 'Costa Rica',\n 'trinidad and tobago': 'Trinidad and Tobago',\n 'el salvador': 'El Salvador',\n 'puerto rico': 'Puerto Rico',\n 'jamaica': 'Jamaica',\n 'venezuela': 'Venezuela',\n 'barbados': 'Barbados',\n 'mexico': 'Mexico',\n 'ecuador': 'Ecuador',\n 'guyana': 'Guyana',\n 'peru': 'Peru',\n 'saint thomas': 'Saint Thomas',\n 'st. croix': 'St. Croix',\n 'sin pais ubicacion': 'Sin país/ubicación',\n 'sin pais/ubicacion': 'Sin país/ubicación'\n };\n\n return displayNames[country] || String(value || 'Sin país/ubicación').trim();\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, options = {}) {\n const counts = new Map();\n\n for (const employee of employees) {\n const rawKey = String(getKeyFn(employee) || 'Sin dato').trim() || 'Sin dato';\n const normalizedKey = options.normalizeCountryKey\n ? normalizeCountry(rawKey)\n : rawKey;\n\n const displayKey = options.normalizeCountryKey\n ? displayCountryName(rawKey)\n : rawKey;\n\n if (!counts.has(normalizedKey)) {\n counts.set(normalizedKey, {\n name: displayKey,\n total: 0\n });\n }\n\n counts.get(normalizedKey).total += 1;\n }\n\n return [...counts.values()]\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\nfunction getReportFilters(context) {\n return (\n context.filters ||\n context.data?.filters ||\n context.audit_event?.filters ||\n {}\n );\n}\n\nfunction extractRequestedCountriesFromFilters(filters) {\n if (!filters) return [];\n\n const possibleValues = [\n filters.country,\n filters.countries,\n filters.pais,\n filters.país,\n filters.paises,\n filters.países,\n filters.location,\n filters.locations,\n filters.ubicacion,\n filters.ubicación,\n filters.employee_country,\n filters.employee_location,\n filters.country_name,\n filters.countryName,\n filters.location_name,\n filters.locationName\n ];\n\n return parseArray(possibleValues)\n .map(normalizeCountry)\n .filter(Boolean);\n}\n\nfunction inferCountriesFromMessage(message) {\n const normalizedMessage = normalize(message);\n\n const knownCountryPatterns = [\n { pattern: 'republica dominicana', country: 'republica dominicana' },\n { pattern: 'dominican republic', country: 'republica dominicana' },\n { pattern: 'rep dominicana', country: 'republica dominicana' },\n { pattern: 'rep dom', country: 'republica dominicana' },\n { pattern: 'dominicana', country: 'republica dominicana' },\n { pattern: ' rd ', country: 'republica dominicana' },\n\n { pattern: 'panama', country: 'panama' },\n { pattern: 'guatemala', country: 'guatemala' },\n { pattern: 'colombia', country: 'colombia' },\n { pattern: 'nicaragua', country: 'nicaragua' },\n { pattern: 'honduras', country: 'honduras' },\n { pattern: 'costa rica', country: 'costa rica' },\n { pattern: 'trinidad and tobago', country: 'trinidad and tobago' },\n { pattern: 'trinidad y tobago', country: 'trinidad and tobago' },\n { pattern: 'el salvador', country: 'el salvador' },\n { pattern: 'puerto rico', country: 'puerto rico' },\n { pattern: 'jamaica', country: 'jamaica' },\n { pattern: 'venezuela', country: 'venezuela' },\n { pattern: 'barbados', country: 'barbados' },\n { pattern: 'mexico', country: 'mexico' },\n { pattern: 'ecuador', country: 'ecuador' },\n { pattern: 'guyana', country: 'guyana' },\n { pattern: 'peru', country: 'peru' },\n { pattern: 'saint thomas', country: 'saint thomas' },\n { pattern: 'st thomas', country: 'saint thomas' },\n { pattern: 'saint croix', country: 'st. croix' },\n { pattern: 'st croix', country: 'st. croix' }\n ];\n\n const paddedMessage = ` ${normalizedMessage} `;\n\n const countries = [];\n\n for (const item of knownCountryPatterns) {\n if (paddedMessage.includes(` ${item.pattern} `) || normalizedMessage.includes(item.pattern)) {\n countries.push(item.country);\n }\n }\n\n return [...new Set(countries)];\n}\n\nfunction filterEmployeesByRequestedCountries(employees, requestedCountries) {\n if (!requestedCountries || requestedCountries.length === 0) {\n return employees;\n }\n\n const normalizedRequestedCountries = requestedCountries.map(normalizeCountry);\n\n return employees.filter(employee => {\n const employeeCountry = normalizeCountry(getEmployeeCountry(employee));\n return normalizedRequestedCountries.includes(employeeCountry);\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 filters = getReportFilters(requestContext);\n\nconst requestedCountriesFromFilters =\n extractRequestedCountriesFromFilters(filters);\n\nconst requestedCountriesFromMessage =\n inferCountriesFromMessage(\n requestContext.original_message ||\n requestContext.message ||\n requestContext.audit_event?.original_message ||\n ''\n );\n\nconst requestedCountries = [\n ...new Set([\n ...requestedCountriesFromFilters,\n ...requestedCountriesFromMessage\n ])\n];\n\nconst filteredEmployees = filterEmployeesByRequestedCountries(\n scopedEmployees,\n requestedCountries\n);\n\nconst originalUserMessage = String(\n requestContext.original_message ||\n requestContext.message ||\n requestContext.audit_event?.original_message ||\n ''\n);\n\nconst normalizedOriginalUserMessage = normalize(originalUserMessage);\n\nconst asksHowMany =\n normalizedOriginalUserMessage.includes('cuantos') ||\n normalizedOriginalUserMessage.includes('cuantas') ||\n normalizedOriginalUserMessage.includes('cantidad') ||\n normalizedOriginalUserMessage.includes('total') ||\n normalizedOriginalUserMessage.includes('headcount');\n\nconst asksByCountry =\n normalizedOriginalUserMessage.includes('por pais') ||\n normalizedOriginalUserMessage.includes('por ubicacion') ||\n normalizedOriginalUserMessage.includes('por location');\n\nconst asksByDepartment =\n normalizedOriginalUserMessage.includes('por departamento') ||\n normalizedOriginalUserMessage.includes('por area') ||\n normalizedOriginalUserMessage.includes('por depto');\n\nconst earlyCountryFilterRaw =\n requestContext.filters?.country ||\n requestContext.filters?.pais ||\n requestContext.filters?.location ||\n requestContext.country_filter ||\n requestContext.requested_country ||\n requestContext.report_country ||\n null;\n\nconst earlyHasCountryFilter =\n Array.isArray(earlyCountryFilterRaw)\n ? earlyCountryFilterRaw.length > 0\n : String(earlyCountryFilterRaw || '').trim() !== '';\n\nlet reportIntent =\n requestContext.report_type ||\n requestContext.intent ||\n 'generate_report';\n\n// Si el usuario pregunta “cuántos empleados tenemos en X país”,\n// respondemos solo el total de ese país, no el desglose por departamentos.\nif (\n earlyHasCountryFilter &&\n asksHowMany &&\n !asksByDepartment &&\n !asksByCountry\n) {\n reportIntent = 'headcount_total';\n}\n\n// Si el usuario pregunta “cuántos empleados tenemos en BambooHR”,\n// respondemos solo el total general, no todo el ranking.\nif (\n !earlyHasCountryFilter &&\n asksHowMany &&\n !asksByDepartment &&\n !asksByCountry &&\n (\n normalizedOriginalUserMessage.includes('bamboohr') ||\n normalizedOriginalUserMessage.includes('tenemos')\n )\n) {\n reportIntent = 'headcount_total';\n}\n\n// Si explícitamente pide “por país”, respetamos desglose por país.\nif (asksByCountry) {\n reportIntent = 'headcount_by_country';\n}\n\n// Si explícitamente pide “por departamento”, respetamos desglose por departamento.\nif (asksByDepartment) {\n reportIntent = 'headcount_by_department';\n}\n\nconst countryCounts = countBy(filteredEmployees, getEmployeeCountry, {\n normalizeCountryKey: true\n});\n\nconst departmentCounts = countBy(filteredEmployees, getEmployeeDepartment);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nconst hasCountryFilter = requestedCountries.length > 0;\nconst requestedCountryLabel = hasCountryFilter\n ? requestedCountries.map(displayCountryName).join(', ')\n : null;\n\nlet reportTitle = 'Reporte de Headcount';\nlet reportBody = '';\n\nif (\n reportIntent === 'headcount_total' ||\n reportIntent === 'employee_count_total' ||\n reportIntent === 'total_headcount'\n) {\n reportTitle = hasCountryFilter\n ? `Headcount total - ${requestedCountryLabel}`\n : 'Headcount total';\n\n reportBody = hasCountryFilter\n ? `${requestedCountryLabel}: ${filteredEmployees.length}`\n : `Total empleados activos/directorio: ${filteredEmployees.length}`;\n} else if (reportIntent === 'headcount_by_country') {\n reportTitle = hasCountryFilter\n ? `Headcount por país / ubicación - ${requestedCountryLabel}`\n : 'Headcount por país / ubicación';\n\n reportBody = formatTable(countryCounts, 'país / ubicación');\n} else if (reportIntent === 'headcount_by_department') {\n reportTitle = hasCountryFilter\n ? `Headcount por departamento - ${requestedCountryLabel}`\n : 'Headcount por departamento';\n\n reportBody = formatTable(departmentCounts, 'departamento');\n} else {\n reportTitle = hasCountryFilter\n ? `Reporte general de Headcount - ${requestedCountryLabel}`\n : '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 filterText = hasCountryFilter\n ? `Filtro solicitado: ${requestedCountryLabel}.`\n : null;\n\nconst responseText =\n `${reportTitle}\\n\\n` +\n `Total empleados incluidos: ${filteredEmployees.length}\\n` +\n `Total empleados activos/directorio leídos: ${activeEmployees.length}\\n` +\n `${scopeText}` +\n `${filterText ? `\\n${filterText}` : ''}\\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_filters: filters,\n report_requested_countries: requestedCountries,\n report_requested_country_label: requestedCountryLabel,\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_employees_after_filters: filteredEmployees.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_filters: filters,\n report_requested_countries: requestedCountries,\n report_requested_country_label: requestedCountryLabel,\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_employees_after_filters: filteredEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n\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": [
|
||
-4336,
|
||
-5200
|
||
],
|
||
"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": [
|
||
-22128,
|
||
-2880
|
||
],
|
||
"id": "f689f4e1-63a4-436c-a15f-9859a9b8a0fd",
|
||
"name": "Webhook - Google Chat Incoming",
|
||
"webhookId": "9a013eb7-1d67-4abf-b98b-22bb1d65b323",
|
||
"alwaysOutputData": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\n\nfunction safeJsonParse(value) {\n if (typeof value !== 'string') return value;\n\n const trimmed = value.trim();\n\n if (!trimmed) return value;\n\n try {\n return JSON.parse(trimmed);\n } catch (error) {\n return value;\n }\n}\n\nfunction getNested(obj, path, fallback = null) {\n try {\n const value = path\n .split('.')\n .reduce((current, key) => {\n if (current === undefined || current === null) return undefined;\n return current[key];\n }, obj);\n\n return value === undefined || value === null ? fallback : value;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction firstNonEmpty(values) {\n for (const value of values) {\n if (value === undefined || value === null) continue;\n\n if (Array.isArray(value) && value.length > 0) {\n return value;\n }\n\n if (\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length > 0\n ) {\n return value;\n }\n\n const stringValue = String(value).trim();\n\n if (stringValue) return value;\n }\n\n return null;\n}\n\nfunction cleanMessageText(value) {\n return String(value || '')\n .replace(/<users\\/[^>]+>/g, '')\n .replace(/^@\\S+\\s+/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAttachmentArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value;\n }\n\n if (Array.isArray(value?.attachments)) {\n return value.attachments;\n }\n\n if (Array.isArray(value?.attachment)) {\n return value.attachment;\n }\n\n return [];\n}\n\nlet bodyCandidate = input.body;\nbodyCandidate = safeJsonParse(bodyCandidate);\n\nlet body =\n bodyCandidate && typeof bodyCandidate === 'object'\n ? bodyCandidate\n : input;\n\nif (body && typeof body.body === 'string') {\n const parsedInnerBody = safeJsonParse(body.body);\n\n if (parsedInnerBody && typeof parsedInnerBody === 'object') {\n body = parsedInnerBody;\n }\n}\n\nif (\n body &&\n typeof body === 'object' &&\n !body.message &&\n !body.type &&\n (body.data || body.payload)\n) {\n const nestedCandidate = safeJsonParse(body.data || body.payload);\n\n if (nestedCandidate && typeof nestedCandidate === 'object') {\n body = nestedCandidate;\n }\n}\n\n// Google Chat como Workspace Add-on usa body.chat.messagePayload.\n// Google Chat API clásico usa body.message directamente.\n// Este normalizador soporta ambos.\nconst chatPayload =\n getNested(body, 'chat.messagePayload') ||\n getNested(input, 'body.chat.messagePayload') ||\n null;\n\nconst chatMessage =\n getNested(body, 'chat.messagePayload.message') ||\n getNested(input, 'body.chat.messagePayload.message') ||\n getNested(body, 'message') ||\n getNested(input, 'body.message') ||\n getNested(input, 'message') ||\n null;\n\nconst chatUser =\n getNested(body, 'chat.user') ||\n getNested(input, 'body.chat.user') ||\n getNested(body, 'user') ||\n getNested(input, 'body.user') ||\n getNested(input, 'user') ||\n null;\n\nconst chatSpace =\n getNested(body, 'chat.messagePayload.space') ||\n getNested(input, 'body.chat.messagePayload.space') ||\n getNested(body, 'space') ||\n getNested(body, 'message.space') ||\n getNested(input, 'body.space') ||\n getNested(input, 'body.message.space') ||\n null;\n\nconst eventType = firstNonEmpty([\n getNested(body, 'type'),\n getNested(body, 'eventType'),\n getNested(body, 'chat.type'),\n getNested(body, 'chat.eventType'),\n getNested(body, 'chat.messagePayload.type'),\n getNested(input, 'type'),\n getNested(input, 'eventType'),\n getNested(input, 'body.type'),\n getNested(input, 'body.eventType'),\n getNested(input, 'body.chat.type'),\n getNested(input, 'body.chat.eventType'),\n\n // Si no viene tipo explícito, pero viene messagePayload, lo tratamos como MESSAGE.\n chatMessage ? 'MESSAGE' : null\n]);\n\nconst rawText = firstNonEmpty([\n getNested(chatMessage, 'argumentText'),\n getNested(chatMessage, 'text'),\n getNested(chatMessage, 'formattedText'),\n\n getNested(chatPayload, 'message.argumentText'),\n getNested(chatPayload, 'message.text'),\n getNested(chatPayload, 'message.formattedText'),\n\n getNested(body, 'chat.messagePayload.message.argumentText'),\n getNested(body, 'chat.messagePayload.message.text'),\n getNested(body, 'chat.messagePayload.message.formattedText'),\n\n getNested(input, 'body.chat.messagePayload.message.argumentText'),\n getNested(input, 'body.chat.messagePayload.message.text'),\n getNested(input, 'body.chat.messagePayload.message.formattedText'),\n\n getNested(body, 'message.argumentText'),\n getNested(body, 'message.text'),\n getNested(body, 'argumentText'),\n getNested(body, 'text'),\n\n getNested(input, 'message.argumentText'),\n getNested(input, 'message.text'),\n getNested(input, 'argumentText'),\n getNested(input, 'text'),\n\n getNested(input, 'body.message.argumentText'),\n getNested(input, 'body.message.text'),\n getNested(input, 'body.argumentText'),\n getNested(input, 'body.text')\n]);\n\nconst message = cleanMessageText(rawText);\n\nconst senderEmail = firstNonEmpty([\n getNested(chatUser, 'email'),\n getNested(chatMessage, 'sender.email'),\n\n getNested(body, 'chat.user.email'),\n getNested(body, 'chat.messagePayload.message.sender.email'),\n\n getNested(input, 'body.chat.user.email'),\n getNested(input, 'body.chat.messagePayload.message.sender.email'),\n\n getNested(body, 'message.sender.email'),\n getNested(body, 'user.email'),\n getNested(body, 'sender.email'),\n getNested(body, 'message.user.email'),\n\n getNested(input, 'message.sender.email'),\n getNested(input, 'user.email'),\n getNested(input, 'sender.email'),\n getNested(input, 'body.message.sender.email'),\n getNested(input, 'body.user.email')\n]);\n\nconst senderName = firstNonEmpty([\n getNested(chatUser, 'displayName'),\n getNested(chatMessage, 'sender.displayName'),\n\n getNested(body, 'chat.user.displayName'),\n getNested(body, 'chat.messagePayload.message.sender.displayName'),\n\n getNested(input, 'body.chat.user.displayName'),\n getNested(input, 'body.chat.messagePayload.message.sender.displayName'),\n\n getNested(body, 'message.sender.displayName'),\n getNested(body, 'user.displayName'),\n getNested(body, 'sender.displayName'),\n getNested(body, 'message.user.displayName'),\n\n getNested(input, 'message.sender.displayName'),\n getNested(input, 'user.displayName'),\n getNested(input, 'sender.displayName'),\n getNested(input, 'body.message.sender.displayName'),\n getNested(input, 'body.user.displayName'),\n\n senderEmail,\n 'Usuario Google Chat'\n]);\n\nconst spaceName = firstNonEmpty([\n getNested(chatSpace, 'name'),\n\n getNested(body, 'chat.messagePayload.space.name'),\n getNested(body, 'chat.messagePayload.message.space.name'),\n\n getNested(input, 'body.chat.messagePayload.space.name'),\n getNested(input, 'body.chat.messagePayload.message.space.name'),\n\n getNested(body, 'space.name'),\n getNested(body, 'message.space.name'),\n getNested(input, 'space.name'),\n getNested(input, 'message.space.name'),\n getNested(input, 'body.space.name'),\n getNested(input, 'body.message.space.name')\n]);\n\nconst spaceDisplayName = firstNonEmpty([\n getNested(chatSpace, 'displayName'),\n getNested(chatSpace, 'spaceUri'),\n\n getNested(body, 'chat.messagePayload.space.displayName'),\n getNested(body, 'chat.messagePayload.space.spaceUri'),\n\n getNested(input, 'body.chat.messagePayload.space.displayName'),\n getNested(input, 'body.chat.messagePayload.space.spaceUri'),\n\n getNested(body, 'space.displayName'),\n getNested(body, 'message.space.displayName'),\n getNested(input, 'space.displayName'),\n getNested(input, 'message.space.displayName'),\n getNested(input, 'body.space.displayName'),\n getNested(input, 'body.message.spaceDisplayName')\n]);\n\nconst messageName = firstNonEmpty([\n getNested(chatMessage, 'name'),\n\n getNested(body, 'chat.messagePayload.message.name'),\n getNested(input, 'body.chat.messagePayload.message.name'),\n\n getNested(body, 'message.name'),\n getNested(input, 'message.name'),\n getNested(input, 'body.message.name')\n]);\n\nconst threadName = firstNonEmpty([\n getNested(chatMessage, 'thread.name'),\n\n getNested(body, 'chat.messagePayload.message.thread.name'),\n getNested(input, 'body.chat.messagePayload.message.thread.name'),\n\n getNested(body, 'message.thread.name'),\n getNested(body, 'thread.name'),\n getNested(input, 'message.thread.name'),\n getNested(input, 'thread.name'),\n getNested(input, 'body.message.thread.name'),\n getNested(input, 'body.thread.name')\n]);\n\nconst rawAttachments = normalizeAttachmentArray(\n firstNonEmpty([\n getNested(chatMessage, 'attachment'),\n getNested(chatMessage, 'attachments'),\n\n getNested(chatPayload, 'message.attachment'),\n getNested(chatPayload, 'message.attachments'),\n\n getNested(body, 'chat.messagePayload.message.attachment'),\n getNested(body, 'chat.messagePayload.message.attachments'),\n\n getNested(input, 'body.chat.messagePayload.message.attachment'),\n getNested(input, 'body.chat.messagePayload.message.attachments'),\n\n getNested(body, 'message.attachment'),\n getNested(body, 'message.attachments'),\n\n getNested(input, 'body.message.attachment'),\n getNested(input, 'body.message.attachments')\n ])\n);\n\nconst googleChatAttachments = rawAttachments.map((attachment, index) => {\n const contentName =\n attachment.contentName ||\n attachment.filename ||\n attachment.fileName ||\n attachment.name ||\n `archivo_${index + 1}`;\n\n const contentType =\n attachment.contentType ||\n attachment.mimeType ||\n attachment.mime_type ||\n '';\n\n const attachmentResourceName =\n attachment.attachmentDataRef?.resourceName ||\n attachment.attachmentDataRef?.resource_name ||\n attachment.attachment_data_ref?.resourceName ||\n attachment.attachment_data_ref?.resource_name ||\n null;\n\n const driveFileId =\n attachment.driveDataRef?.driveFileId ||\n attachment.driveDataRef?.drive_file_id ||\n attachment.drive_data_ref?.driveFileId ||\n attachment.drive_data_ref?.drive_file_id ||\n null;\n\n const normalizedContentName = String(contentName || '').toLowerCase();\n const normalizedContentType = String(contentType || '').toLowerCase();\n\n const isPdf =\n normalizedContentType.includes('pdf') ||\n normalizedContentName.endsWith('.pdf');\n\n return {\n index,\n raw_attachment: attachment,\n\n content_name: contentName,\n content_type: contentType,\n\n attachment_resource_name: attachmentResourceName,\n drive_file_id: driveFileId,\n\n is_pdf: isPdf,\n source: attachmentResourceName\n ? 'google_chat_attachment'\n : driveFileId\n ? 'google_drive_attachment'\n : 'unknown'\n };\n});\n\nconst googleChatPdfAttachments = googleChatAttachments.filter(item => item.is_pdf === true);\n\nconst hasGoogleChatAttachments = googleChatAttachments.length > 0;\nconst hasGoogleChatPdfAttachments = googleChatPdfAttachments.length > 0;\n\nconst effectiveMessage =\n message ||\n (\n hasGoogleChatPdfAttachments\n ? 'Subir documento PDF adjunto'\n : hasGoogleChatAttachments\n ? 'Procesar archivo adjunto'\n : ''\n );\n\nconst requestId =\n `gchat_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nconst baseOutput = {\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 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 google_chat_debug: {\n input_keys: Object.keys(input || {}),\n body_keys:\n body && typeof body === 'object'\n ? Object.keys(body)\n : [],\n\n chat_payload_keys:\n chatPayload && typeof chatPayload === 'object'\n ? Object.keys(chatPayload)\n : [],\n\n chat_message_keys:\n chatMessage && typeof chatMessage === 'object'\n ? Object.keys(chatMessage)\n : [],\n\n raw_text_detected: rawText || null,\n message_detected: message || null,\n effective_message_detected: effectiveMessage || null,\n\n sender_email_detected: senderEmail || null,\n sender_name_detected: senderName || null,\n\n event_type_detected: eventType || null,\n\n attachment_count: googleChatAttachments.length,\n pdf_attachment_count: googleChatPdfAttachments.length,\n attachments_detected: googleChatAttachments,\n\n body_preview:\n body && typeof body === 'object'\n ? JSON.stringify(body).slice(0, 2000)\n : String(body || '').slice(0, 2000)\n }\n};\n\nif (eventType === 'ADDED_TO_SPACE') {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_added_to_space',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n 'Hola. Soy GLM BambooHR Agent. Puedes pedirme consultas y reportes de BambooHR, por ejemplo: \"Dame el headcount por país\".'\n }\n };\n}\n\nif (eventType === 'REMOVED_FROM_SPACE') {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_removed_from_space',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n 'La app fue removida del espacio.'\n }\n };\n}\n\nif (!effectiveMessage) {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_empty_message',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No recibí texto ni PDF para procesar. Escribe una consulta o adjunta un PDF indicando empleado y tipo de documento.'\n }\n };\n}\n\nreturn {\n json: {\n ...baseOutput,\n\n message: effectiveMessage,\n original_message: effectiveMessage,\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n skip_core: false\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20912,
|
||
-2880
|
||
],
|
||
"id": "ca5fb655-16b5-4211-a983-ced7e5adfec5",
|
||
"name": "Code - Normalize Google Chat Input"
|
||
},
|
||
{
|
||
"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": [
|
||
21120,
|
||
-4288
|
||
],
|
||
"id": "8ae4c143-2864-41f7-ac23-c207d86520b1",
|
||
"name": "IF - Is Google Chat Channel?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstNodeJson(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n if (nodeItems && nodeItems.length > 0) {\n return nodeItems[0].json || {};\n }\n } catch (error) {\n return {};\n }\n\n return {};\n}\n\nconst normalizedGoogleChat =\n getFirstNodeJson('Code - Normalize Google Chat Input');\n\nconst spaceName =\n data.google_chat_space_name ||\n data.audit_event?.google_chat_space_name ||\n normalizedGoogleChat.google_chat_space_name ||\n null;\n\nconst threadName =\n data.google_chat_thread_name ||\n data.audit_event?.google_chat_thread_name ||\n normalizedGoogleChat.google_chat_thread_name ||\n null;\n\nconst responseText =\n data.response_text ||\n data.audit_event?.response_text ||\n data.pending_confirmation_payload?.confirmation_prompt ||\n 'Solicitud procesada.';\n\nif (!spaceName) {\n return {\n json: {\n ...data,\n\n google_chat_final_ready: false,\n google_chat_final_error: 'missing_space_name',\n\n final_space_name: null,\n final_thread_name: threadName,\n final_message_text: String(responseText),\n\n google_chat_final_debug: {\n data_google_chat_space_name: data.google_chat_space_name || null,\n audit_google_chat_space_name: data.audit_event?.google_chat_space_name || null,\n normalized_google_chat_space_name: normalizedGoogleChat.google_chat_space_name || null,\n normalized_keys: Object.keys(normalizedGoogleChat || {})\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n google_chat_final_ready: true,\n\n final_space_name: spaceName,\n final_thread_name: threadName,\n final_message_text: String(responseText),\n\n google_chat_final_debug: {\n resolved_from:\n data.google_chat_space_name\n ? 'data.google_chat_space_name'\n : data.audit_event?.google_chat_space_name\n ? 'audit_event.google_chat_space_name'\n : 'Code - Normalize Google Chat Input',\n\n final_space_name: spaceName,\n final_thread_name: threadName\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
21424,
|
||
-4368
|
||
],
|
||
"id": "b1f79026-4a0a-461a-b4ac-746734068837",
|
||
"name": "Code - Build Google Chat Final Message"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "620c7e6c-9848-49ed-8ecc-1d63e5e8a755",
|
||
"leftValue": "={{ $json.google_chat_final_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
21632,
|
||
-4368
|
||
],
|
||
"id": "fd22774c-7a8c-47b9-8cfa-e2c892c41893",
|
||
"name": "IF - Google Chat Final Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.final_space_name }}",
|
||
"messageUi": {
|
||
"text": "={{ $json.response_text }}"
|
||
},
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
22000,
|
||
-4416
|
||
],
|
||
"id": "3de50865-ba0d-4dc9-b16a-b465317e2c4e",
|
||
"name": "Chat - Enviar respuesta final",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "{\n \"text\": \"⏳ Procesando tu solicitud, dame un momento...\"\n}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.5,
|
||
"position": [
|
||
-20480,
|
||
-2880
|
||
],
|
||
"id": "a8fb64d3-ace3-4f5f-8f00-03f084efb70a",
|
||
"name": "Respond - Google Chat Processing"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nconst message = String(data.message || data.original_message || '').trim();\nconst normalized = normalize(message);\n\nconst confirmWords = [\n 'confirmar',\n 'confirmo',\n 'si',\n 'sí',\n 'ok',\n 'okay',\n 'dale',\n 'proceder',\n 'procede',\n 'ejecutar',\n 'ejecuta',\n 'aprobar',\n 'apruebo'\n].map(normalize);\n\nconst cancelWords = [\n 'cancelar',\n 'cancela',\n 'cancelado',\n 'no',\n 'detener',\n 'deten',\n 'no ejecutar',\n 'no ejecutes',\n 'anular',\n 'rechazar',\n 'rechazo'\n].map(normalize);\n\nconst isConfirmationReply =\n confirmWords.includes(normalized) ||\n normalized.startsWith('confirmar ') ||\n normalized.startsWith('confirmo ') ||\n normalized.startsWith('si ') ||\n normalized.startsWith('sí ') ||\n normalized.startsWith('ok ') ||\n normalized.startsWith('ejecutar ');\n\nconst isCancellationReply =\n cancelWords.includes(normalized) ||\n normalized.startsWith('cancelar ') ||\n normalized.startsWith('cancela ') ||\n normalized.startsWith('no ') ||\n normalized.startsWith('rechazar ');\n\nlet confirmationReplyType = null;\n\nif (isConfirmationReply) {\n confirmationReplyType = 'confirm';\n}\n\nif (isCancellationReply) {\n confirmationReplyType = 'cancel';\n}\n\nreturn {\n json: {\n ...data,\n\n is_confirmation_reply: Boolean(confirmationReplyType),\n confirmation_reply_type: confirmationReplyType,\n confirmation_reply_text: message\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20272,
|
||
-2880
|
||
],
|
||
"id": "5fb42bef-9c9b-4b09-8f26-a93eb7b9a060",
|
||
"name": "Code - Detect Confirmation Reply"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c9f980ce-1e9a-45e7-b545-b1ed6e2234e3",
|
||
"leftValue": "={{ $json.is_confirmation_reply }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-20064,
|
||
-2880
|
||
],
|
||
"id": "181f33b5-375d-482f-8119-0ef81d66aab2",
|
||
"name": "IF - Is Confirmation Reply?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?user_email=eq.{{ encodeURIComponent($json.user_email) }}&confirmation_status=eq.pending&expires_at=gt.{{ encodeURIComponent(new Date().toISOString()) }}&order=created_at.desc&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-19712,
|
||
-2992
|
||
],
|
||
"id": "9bad8293-fbd6-4669-ae66-e3712ac3c710",
|
||
"name": "Supabase - Get Pending Confirmation",
|
||
"alwaysOutputData": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const replyContext = $items('Code - Detect Confirmation Reply')[0].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.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.map(item => item.json).filter(Boolean);\n}\n\nconst rows = normalizeRows(items);\nconst pending = rows[0] || null;\n\nif (!pending) {\n return [{\n json: {\n ...replyContext,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No encontré una confirmación pendiente vigente para tu usuario. Vuelve a solicitar la acción si todavía deseas ejecutarla.',\n\n confirmation_lookup_result: 'not_found_or_expired',\n confirmation_id: null,\n\n audit_event: {\n request_id: replyContext.request_id,\n request_started_at: replyContext.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n mode: 'confirmation',\n intent: 'resolve_pending_confirmation',\n risk_level: 'medium',\n\n authorization_result: 'pending_confirmation_not_found',\n execution_result: 'pending_confirmation_not_found'\n }\n }\n }];\n}\n\nconst pendingPayload = pending.pending_payload || {};\nconst auditEvent = pending.audit_event || {};\n\nconst isConfirm = replyContext.confirmation_reply_type === 'confirm';\nconst isCancel = replyContext.confirmation_reply_type === 'cancel';\n\nif (isCancel) {\n return [{\n json: {\n ...replyContext,\n\n confirmation_id: pending.confirmation_id,\n pending_confirmation_row_id: pending.id,\n\n mode: pending.mode || pendingPayload.mode || 'action',\n intent: pending.intent || pendingPayload.intent || 'unknown',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `Solicitud cancelada.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n confirmation_resolution: 'cancelled',\n\n audit_event: {\n ...auditEvent,\n\n request_id: replyContext.request_id,\n request_started_at: replyContext.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n confirmation_id: pending.confirmation_id,\n confirmation_status: 'cancelled',\n\n authorization_result: 'confirmation_cancelled_by_user',\n execution_result: 'confirmation_cancelled_by_user'\n }\n }\n }];\n}\n\nif (isConfirm) {\n const restoredAction =\n pendingPayload.data?.action ||\n pendingPayload.action ||\n pending.action ||\n null;\n\n const restoredEmployeeId =\n pendingPayload.data?.employee_id ||\n pendingPayload.employee_id ||\n pending.target_employee_id ||\n pendingPayload.audit_event?.target_employee_id ||\n null;\n\n const restoredEmployeeName =\n pendingPayload.data?.employee_name ||\n pendingPayload.employee_name ||\n pending.target_employee_name ||\n pendingPayload.audit_event?.target_employee_name ||\n null;\n\n const restoredEmployeeEmail =\n pendingPayload.data?.employee_email ||\n pendingPayload.employee_email ||\n pending.target_employee_email ||\n pendingPayload.audit_event?.target_employee_email ||\n null;\n\n return [{\n json: {\n ...pendingPayload,\n\n request_id:\n replyContext.request_id ||\n pendingPayload.request_id,\n\n request_started_at:\n replyContext.request_started_at ||\n pendingPayload.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n google_chat_space_name: replyContext.google_chat_space_name,\n google_chat_thread_name: replyContext.google_chat_thread_name,\n google_chat_message_name: replyContext.google_chat_message_name,\n\n confirmation_id: pending.confirmation_id,\n pending_confirmation_row_id: pending.id,\n\n confirmation_status: 'confirmed',\n confirmation_resolution: 'confirmed',\n\n action_approved: true,\n confirmed_from_google_chat: true,\n\n can_continue: true,\n\n mode: pending.mode || pendingPayload.mode || 'accion',\n intent: pending.intent || pendingPayload.intent || 'update_employee_basic',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n action: restoredAction,\n\n employee_id: restoredEmployeeId,\n employee_name: restoredEmployeeName,\n employee_email: restoredEmployeeEmail,\n\n resolved_employee_id: restoredEmployeeId,\n resolved_employee_name: restoredEmployeeName,\n resolved_employee_email: restoredEmployeeEmail,\n\n requires_confirmation: true,\n\n data: {\n ...(pendingPayload.data || {}),\n\n employee_id: restoredEmployeeId,\n employee_name: restoredEmployeeName,\n employee_email: restoredEmployeeEmail,\n\n action: restoredAction,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true\n },\n\n audit_event: {\n ...(pendingPayload.audit_event || auditEvent || {}),\n\n request_id:\n replyContext.request_id ||\n pendingPayload.audit_event?.request_id ||\n pendingPayload.request_id,\n\n request_started_at:\n replyContext.request_started_at ||\n pendingPayload.audit_event?.request_started_at ||\n pendingPayload.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n mode: pending.mode || pendingPayload.mode || 'accion',\n intent: pending.intent || pendingPayload.intent || 'update_employee_basic',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n action: restoredAction,\n\n target_employee_id: restoredEmployeeId,\n target_employee_name: restoredEmployeeName,\n target_employee_email: restoredEmployeeEmail,\n\n confirmation_id: pending.confirmation_id,\n confirmation_status: 'confirmed',\n\n authorization_result: 'confirmation_confirmed_by_user'\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...replyContext,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No pude interpretar si deseas confirmar o cancelar. Responde CONFIRMAR o CANCELAR.',\n\n confirmation_lookup_result: 'unknown_reply_type'\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-19504,
|
||
-2992
|
||
],
|
||
"id": "38c4d2b8-9b0a-4982-972b-80d8e45fada0",
|
||
"name": "Code - Resolve Confirmation Reply"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "da3799e2-4b56-4d61-b899-bcbedfc78064",
|
||
"leftValue": "={{ $json.confirmation_resolution }}",
|
||
"rightValue": "cancelled",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-13584,
|
||
-3504
|
||
],
|
||
"id": "2b77cc64-771f-4758-b414-73e036f188be",
|
||
"name": "IF - Confirmation Cancelled?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?id=eq.{{ $json.pending_confirmation_row_id }}",
|
||
"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": "={{ { confirmation_status: \"cancelled\", cancelled_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12512,
|
||
-3456
|
||
],
|
||
"id": "8bf1e0b7-767c-464e-b3cd-57cdac1136aa",
|
||
"name": "Supabase - Mark Confirmation Cancelled"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction stripExistingConfirmationInstructions(text) {\n const raw = cleanText(text);\n\n if (!raw) {\n return '';\n }\n\n const lines = raw\n .split('\\n')\n .map(line => line.trimEnd());\n\n const filteredLines = lines.filter(line => {\n const normalized = line\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n\n if (normalized.includes('para continuar') && normalized.includes('confirmar')) return false;\n if (normalized.includes('para confirmar') && normalized.includes('confirmar')) return false;\n if (normalized.includes('para cancelar') && normalized.includes('cancelar')) return false;\n if (normalized.includes('esta confirmacion vence')) return false;\n if (normalized.includes('confirmacion vence')) return false;\n\n return true;\n });\n\n return filteredLines.join('\\n').trim();\n}\n\nconst isConfirmationResolution =\n data.confirmation_resolution === 'cancelled' ||\n data.confirmation_resolution === 'confirmed' ||\n data.mode === 'confirmation' ||\n data.intent === 'cancel_pending_confirmation' ||\n data.intent === 'resolve_pending_confirmation' ||\n data.status === 'confirmation_cancelled' ||\n data.status === 'confirmation_confirmed';\n\nconst isAlreadyConfirmedAction =\n data.confirmed_from_google_chat === true ||\n data.confirmed_action_execution === true ||\n data.action_approved === true ||\n data.confirmation_status === 'confirmed' ||\n data.data?.confirmation_status === 'confirmed' ||\n data.data?.action_approved === true ||\n data.data?.confirmed_from_google_chat === true ||\n data.data?.confirmed_action_execution === true ||\n data.audit_event?.confirmation_status === 'confirmed' ||\n data.audit_event?.action_approved === true ||\n data.audit_event?.confirmed_from_google_chat === true ||\n data.audit_event?.confirmed_action_execution === true;\n\nconst needsConfirmation =\n !isConfirmationResolution &&\n !isAlreadyConfirmedAction &&\n (\n data.response_type === 'confirmation_request' ||\n data.status === 'pending_confirmation' ||\n data.requires_confirmation === true ||\n data.data?.requires_confirmation === true ||\n data.audit_event?.requires_confirmation === true\n );\n\nconst isGoogleChat =\n data.channel === 'google_chat' ||\n data.audit_event?.channel === 'google_chat';\n\nconst userEmail =\n data.user_email ||\n data.audit_event?.user_email ||\n null;\n\nconst action =\n data.data?.action ||\n data.action ||\n data.audit_event?.action ||\n null;\n\nconst actionObject =\n action && typeof action === 'object'\n ? action\n : {};\n\nconst employeeId =\n data.data?.employee_id ||\n data.employee_id ||\n data.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n actionObject.employee_id ||\n actionObject.employeeId ||\n actionObject.target_employee_id ||\n null;\n\nconst employeeName =\n data.data?.employee_name ||\n data.employee_name ||\n data.resolved_employee_name ||\n data.audit_event?.target_employee_name ||\n actionObject.employee_name ||\n actionObject.employeeName ||\n actionObject.target_employee_name ||\n data.employee_query ||\n null;\n\nconst employeeEmail =\n data.data?.employee_email ||\n data.employee_email ||\n data.resolved_employee_email ||\n data.audit_event?.target_employee_email ||\n actionObject.employee_email ||\n actionObject.employeeEmail ||\n null;\n\nconst confirmationId =\n `conf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nconst expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();\n\nif (!needsConfirmation || !isGoogleChat || !userEmail) {\n return {\n json: {\n ...data,\n pending_confirmation_should_save: false,\n pending_confirmation_payload: null\n }\n };\n}\n\nconst rawBaseResponseText =\n data.response_text ||\n 'Esta acción requiere confirmación antes de ejecutarse.';\n\nconst baseResponseText =\n stripExistingConfirmationInstructions(rawBaseResponseText) ||\n 'Esta acción requiere confirmación antes de ejecutarse.';\n\nconst confirmationPrompt =\n `${baseResponseText}\\n\\n` +\n `Para confirmar responde: CONFIRMAR\\n` +\n `Para cancelar responde: CANCELAR\\n\\n` +\n `Esta confirmación vence en 15 minutos.`;\n\nconst pendingPayload = {\n ...data,\n\n response_text: undefined,\n response_type: undefined,\n\n pending_confirmation_should_save: undefined,\n pending_confirmation_payload: undefined\n};\n\nreturn {\n json: {\n ...data,\n\n response_text: confirmationPrompt,\n response_type: 'confirmation_request',\n\n pending_confirmation_should_save: true,\n\n pending_confirmation_payload: {\n confirmation_id: confirmationId,\n request_id: data.request_id || null,\n\n user_email: userEmail,\n user_name: data.user_name || data.audit_event?.user_name || null,\n\n channel: data.channel || data.audit_event?.channel || 'google_chat',\n environment: data.environment || data.audit_event?.environment || 'development',\n\n google_chat_space_name:\n data.google_chat_space_name ||\n data.audit_event?.google_chat_space_name ||\n null,\n\n google_chat_thread_name:\n data.google_chat_thread_name ||\n data.audit_event?.google_chat_thread_name ||\n null,\n\n google_chat_message_name:\n data.google_chat_message_name ||\n data.audit_event?.google_chat_message_name ||\n null,\n\n mode: data.mode || data.audit_event?.mode || null,\n intent: data.intent || data.audit_event?.intent || null,\n risk_level: data.risk_level || data.audit_event?.risk_level || null,\n\n action,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n confirmation_status: 'pending',\n confirmation_type: 'standard',\n\n original_message: data.original_message || null,\n confirmation_prompt: confirmationPrompt,\n\n pending_payload: pendingPayload,\n audit_event: data.audit_event || {},\n\n expires_at: expiresAt\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
19824,
|
||
-4256
|
||
],
|
||
"id": "a180ee6b-78f5-41d5-ba3e-f4374211a590",
|
||
"name": "Code - Prepare Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "77cce614-8fd1-406d-a80a-e2c86f533c30",
|
||
"leftValue": "={{ $json.pending_confirmation_should_save }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
20032,
|
||
-4256
|
||
],
|
||
"id": "c9b82f38-4065-457e-b511-ccf519e0a8f3",
|
||
"name": "IF - Should Save Pending Confirmation?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations",
|
||
"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.pending_confirmation_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
20704,
|
||
-4544
|
||
],
|
||
"id": "40077809-cb6c-4af8-b7ce-6c0cc80c83d4",
|
||
"name": "Supabase - Insert Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const original = $items('Code - Prepare Pending Confirmation')[0].json;\nconst inserted = $json;\n\nreturn {\n json: {\n ...original,\n\n pending_confirmation_inserted: true,\n pending_confirmation_insert_result: inserted,\n\n response_text:\n original.response_text ||\n original.pending_confirmation_payload?.confirmation_prompt ||\n 'Esta acción requiere confirmación.',\n\n response_type:\n original.response_type ||\n 'confirmation_request'\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
20912,
|
||
-4544
|
||
],
|
||
"id": "c49e1a5f-a732-48d2-ae3b-01f39f614e2f",
|
||
"name": "Code - Restore Response After Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const resolved = $items('Code - Resolve Confirmation Reply')[0].json;\nconst supabaseResult = $json;\n\nreturn {\n json: {\n ...resolved,\n\n status: 'confirmation_cancelled',\n can_continue: false,\n mode: 'confirmation',\n intent: 'cancel_pending_confirmation',\n response_type: 'chat_message',\n\n response_text:\n resolved.response_text ||\n 'Solicitud cancelada.\\n\\nNo se ejecutó ningún cambio en BambooHR.',\n\n confirmation_resolution: 'cancelled',\n confirmation_update_result: supabaseResult,\n\n // Importante para que Code - Prepare Pending Confirmation NO cree otra confirmación\n requires_confirmation: false,\n pending_confirmation_should_save: false,\n\n audit_event: {\n ...resolved.audit_event,\n\n mode: 'confirmation',\n intent: 'cancel_pending_confirmation',\n confirmation_status: 'cancelled',\n authorization_result: 'confirmation_cancelled_by_user',\n execution_result: 'confirmation_cancelled_by_user',\n\n requires_confirmation: false,\n response_type: 'chat_message',\n response_text:\n resolved.response_text ||\n 'Solicitud cancelada.\\n\\nNo se ejecutó ningún cambio en BambooHR.'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12112,
|
||
-3456
|
||
],
|
||
"id": "919396ee-523d-4db9-8a35-840ee4c7992c",
|
||
"name": "Code - Restore Cancelled Confirmation Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c3a30efa-572e-4e2b-bf4b-a76972cd1312",
|
||
"leftValue": "={{ $json.confirmation_resolution }}",
|
||
"rightValue": "confirmed",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-12912,
|
||
-2896
|
||
],
|
||
"id": "85e01b65-8296-486e-bbca-dc856e77dba8",
|
||
"name": "IF - Confirmation Confirmed?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?id=eq.{{ $json.pending_confirmation_row_id }}",
|
||
"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": "={{ { confirmation_status: \"confirmed\", confirmed_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12704,
|
||
-3056
|
||
],
|
||
"id": "6406f5d1-5c6f-4d40-961a-1903269d777b",
|
||
"name": "Supabase - Mark Confirmation Confirmed"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const resolved = $items('Code - Resolve Confirmation Reply')[0].json;\nconst supabaseResult = $json;\n\nconst action =\n resolved.data?.action ||\n resolved.action ||\n resolved.audit_event?.action ||\n null;\n\nconst employeeId =\n resolved.data?.employee_id ||\n resolved.employee_id ||\n resolved.resolved_employee_id ||\n resolved.audit_event?.target_employee_id ||\n null;\n\nconst employeeName =\n resolved.data?.employee_name ||\n resolved.employee_name ||\n resolved.resolved_employee_name ||\n resolved.audit_event?.target_employee_name ||\n null;\n\nconst employeeEmail =\n resolved.data?.employee_email ||\n resolved.employee_email ||\n resolved.resolved_employee_email ||\n resolved.audit_event?.target_employee_email ||\n null;\n\nreturn {\n json: {\n ...resolved,\n\n confirmation_update_result: supabaseResult,\n\n confirmation_status: 'confirmed',\n confirmation_resolution: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n can_continue: true,\n\n action,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n data: {\n ...(resolved.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true\n },\n\n /*\n Importante:\n No dejamos response_text aquí, porque si lo dejamos,\n puede llegar al final como respuesta definitiva aunque la acción no haya terminado.\n La acción real debe generar su propio mensaje final.\n */\n response_text: null,\n response_type: null,\n\n audit_event: {\n ...(resolved.audit_event || {}),\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n authorization_result: 'confirmation_confirmed_by_user',\n execution_result: 'confirmed_pending_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12496,
|
||
-3056
|
||
],
|
||
"id": "1d69b228-521c-45a6-bf25-86ae602881a2",
|
||
"name": "Code - Restore Confirmed Confirmation Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?user_email=eq.{{ encodeURIComponent($json.pending_confirmation_payload.user_email) }}&confirmation_status=eq.pending",
|
||
"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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ { confirmation_status: \"superseded\", superseded_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
20272,
|
||
-4496
|
||
],
|
||
"id": "f353cd63-3ebe-4c13-beed-220874b2a038",
|
||
"name": "Supabase - Supersede Previous Pending Confirmations"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const original = $items('Code - Prepare Pending Confirmation')[0].json;\nconst supersedeResult = $json;\n\nreturn {\n json: {\n ...original,\n supersede_previous_pending_result: supersedeResult\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
20496,
|
||
-4528
|
||
],
|
||
"id": "bd9c1254-c7b1-4147-9ef9-9ecd92f344af",
|
||
"name": "Code - Restore Pending Confirmation After Supersede"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst message =\n data.message ||\n data.original_message ||\n '';\n\nconst allowedIntents = [\n 'get_employee_field',\n 'get_employee_table',\n 'get_employee_files',\n 'get_employee_missing_fields',\n\n 'update_employee_basic',\n 'add_employee_table_row',\n 'prepare_create_employee',\n 'create_employee',\n 'prepare_terminate_employee',\n 'terminate_employee',\n\n 'headcount_by_country',\n 'headcount_by_department',\n 'headcount_by_country_department',\n 'employees_active_inactive',\n 'active_inactive_summary',\n 'list_saved_reports',\n 'run_saved_report',\n 'export_saved_report',\n\n 'unknown'\n];\n\nconst allowedFields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'employmentStatus',\n 'hireDate',\n 'compensation',\n 'jobInfo',\n 'employmentStatusTable',\n 'files'\n];\n\nconst prompt = `\nEres la capa de interpretación de lenguaje natural del GLM BambooHR Agent.\n\nTu ÚNICA tarea es convertir el mensaje del usuario en JSON estructurado.\nNO ejecutes acciones.\nNO inventes datos.\nNO decidas permisos.\nNO confirmes acciones.\nNO respondas en lenguaje natural.\nSOLO devuelve JSON válido.\n\nContexto técnico:\n- El sistema consulta y ejecuta acciones sobre BambooHR.\n- n8n orquesta.\n- Gemini interpreta intención, entidades, campos y filtros.\n- Código/Python valida.\n- Neo4j autoriza.\n- BambooHR ejecuta.\n- Supabase audita.\n- Google Chat comunica.\n\nMensaje original del usuario:\n\"\"\"${message}\"\"\"\n\nUsuario:\n${JSON.stringify({\n user_email: data.user_email || null,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null\n}, null, 2)}\n\nIntenciones permitidas:\n${allowedIntents.map(i => `- ${i}`).join('\\n')}\n\nCampos permitidos:\n${allowedFields.map(f => `- ${f}`).join('\\n')}\n\nModos permitidos:\n- query\n- action\n- report\n- confirmation\n- system\n- unknown\n\nNiveles de riesgo:\n- low: consultas normales o reportes no sensibles.\n- medium: cambios editables no destructivos, como extensión laboral, teléfono laboral o correo laboral.\n- high: creación de empleado, cambios sensibles, compensation, jobInfo o subida de archivos.\n- critical: desvinculación, terminación, baja o cambios destructivos.\n\nReglas generales obligatorias:\n\n1. Devuelve SIEMPRE JSON válido.\n2. No devuelvas markdown.\n3. No devuelvas texto fuera del JSON.\n4. No inventes employee_id si el usuario no lo dio.\n5. No inventes datos del empleado.\n6. No inventes valores de acciones.\n7. Si aparece un ID numérico de empleado, por ejemplo \"empleado 50802\":\n - employee_query: \"50802\"\n - employee_id: \"50802\"\n8. Si aparece un correo, úsalo como employee_query.\n9. Si aparece un nombre de persona, úsalo como employee_query.\n10. Si el usuario es amable, saluda o escribe con ruido adicional, ignora el ruido y conserva la intención real.\n\nReglas de reportes:\n\nREGLA PRIORITARIA PARA REPORTES GUARDADOS:\n\nSi el usuario pide cualquiera de estas cosas:\n- reportes guardados\n- reportes disponibles\n- lista de reportes\n- custom reports\n- reportes custom\n- saved reports\n- reportes configurados\n- reportes existentes en BambooHR\n- muéstrame los reportes guardados de BambooHR\n- lista los custom reports disponibles en BambooHR\n\nEntonces devuelve obligatoriamente:\n{\n \"mode\": \"report\",\n \"intent\": \"list_saved_reports\",\n \"confidence\": 1,\n \"risk_level\": \"low\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {},\n \"report_type\": \"list_saved_reports\",\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"El usuario solicita listar reportes guardados/custom reports disponibles en BambooHR.\"\n}\n\nIMPORTANTE:\n- No confundas \"reportes guardados\" con headcount.\n- No uses headcount_by_country.\n- No uses headcount_by_department.\n- No uses headcount_by_country_department.\n- No uses generate_report.\n\nREGLA PRIORITARIA PARA EJECUTAR REPORTES GUARDADOS:\n\nSi el usuario pide ejecutar, correr, abrir, consultar o generar un reporte guardado/custom report por ID, por ejemplo:\n- Ejecuta el reporte guardado ID 13\n- Corre el custom report 23\n- Genera el reporte ID 7\n- Ejecuta BambooHR report 10\n- Corre el reporte guardado 14\n\nEntonces devuelve obligatoriamente:\n{\n \"mode\": \"report\",\n \"intent\": \"run_saved_report\",\n \"confidence\": 1,\n \"risk_level\": \"high\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {\n \"report_id\": \"ID_NUMERICO\"\n },\n \"report_type\": \"run_saved_report\",\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"El usuario solicita ejecutar un reporte custom guardado de BambooHR por ID.\"\n}\n\nIMPORTANTE:\n- El ID debe venir del mensaje del usuario.\n- Si el usuario no indica ID, usa intent \"list_saved_reports\" o pide aclaración.\n- No inventes report_id.\n- No confundas ejecutar un reporte guardado con headcount.\n\nSi el usuario pide exportar, descargar, generar archivo, generar Excel, generar CSV, guardar en Sheet o sacar completo un reporte guardado:\n- mode: \"report\"\n- intent: \"export_saved_report\"\n- risk_level: \"low\"\n- requires_confirmation: false\n- Si menciona un ID, guarda ese ID en filters.report_id.\n- Si menciona un nombre de reporte, guarda ese nombre en filters.report_name.\n- Si menciona página, también guarda filters.page, aunque la exportación completa normalmente debe traer todas las páginas.\n\n\n11. Si el usuario pide headcount, cantidad de empleados, número de empleados, cuántos empleados hay o total de empleados:\n - mode: \"report\"\n - risk_level: \"low\"\n - requires_confirmation: false\n\n12. Si el usuario pide empleados por país, por ubicación, por location o por país/ubicación:\n - intent: \"headcount_by_country\"\n\n13. Si el usuario pide empleados por departamento, por área o por depto:\n - intent: \"headcount_by_department\"\n\n14. Si el usuario pide empleados por país y departamento al mismo tiempo:\n - intent: \"headcount_by_country_department\"\n\n15. Si el usuario pregunta \"cuántos empleados tenemos en BambooHR\", \"total de empleados\", \"número total de empleados\", \"cuántos empleados tenemos\" o similar, sin pedir desglose:\n - intent: \"headcount_by_country_department\"\n - report_type: \"headcount_total\"\n\n16. Si el usuario pregunta por un país específico, ejemplo:\n - \"Cuántos empleados tenemos en República Dominicana\"\n - \"Headcount de Guatemala\"\n - \"Número de empleados en Panamá\"\n entonces:\n - mode: \"report\"\n - intent: \"headcount_by_country_department\"\n - report_type: \"headcount_total\"\n - filters.country debe contener el país solicitado exactamente como lo escribió el usuario.\n\n17. Si el usuario pregunta por empleados activos e inactivos, activos vs inactivos, empleados dados de baja, empleados terminados, empleados activos o resumen de estatus:\n - mode: \"report\"\n - intent: \"employees_active_inactive\"\n - report_type: \"active_inactive_summary\"\n - risk_level: \"low\"\n - requires_confirmation: false\n\nReglas de consultas de empleado:\n\n18. Si el usuario pide información de un empleado:\n - mode: \"query\"\n - intent: \"get_employee_field\"\n - risk_level: \"low\"\n - requires_confirmation: false\n - employee_query debe contener nombre, correo o ID si aparece.\n - requested_fields debe incluir los campos solicitados.\n\n19. Si el usuario pide \"información\", \"datos\", \"perfil\", \"información básica\", \"datos básicos\" o algo general de un empleado:\n - requested_fields debe ser:\n [\"jobTitle\", \"department\", \"division\", \"location\", \"supervisor\", \"workEmail\", \"workPhone\", \"workPhoneExtension\", \"employmentStatus\", \"hireDate\"]\n\n20. Si el usuario pregunta por cargo, puesto, posición o job title:\n - requested_fields debe incluir \"jobTitle\"\n\n21. Si el usuario pregunta por departamento o área:\n - requested_fields debe incluir \"department\"\n\n22. Si el usuario pregunta por división:\n - requested_fields debe incluir \"division\"\n\n23. Si el usuario pregunta por ubicación, país, location o dónde trabaja:\n - requested_fields debe incluir \"location\"\n\n24. Si el usuario pregunta por supervisor, manager o jefe:\n - requested_fields debe incluir \"supervisor\"\n\n25. Si el usuario pregunta por correo laboral, email laboral o work email:\n - requested_fields debe incluir \"workEmail\"\n\n26. Si el usuario pregunta por teléfono laboral:\n - requested_fields debe incluir \"workPhone\"\n\n27. Si el usuario pregunta por extensión laboral:\n - requested_fields debe incluir \"workPhoneExtension\"\n\n28. Si el usuario pregunta por estatus laboral:\n - requested_fields debe incluir \"employmentStatus\"\n\n29. Si el usuario pregunta por fecha de ingreso:\n - requested_fields debe incluir \"hireDate\"\n\n30. Si el usuario pregunta por historial jobInfo, historial de cargo, historial de puesto, historial laboral o tabla jobInfo:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"jobInfo\"\n - requested_fields: [\"jobInfo\"]\n\n31. Si el usuario pregunta por historial employmentStatus, historial de estatus o tabla employmentStatus:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"employmentStatus\"\n - requested_fields: [\"employmentStatusTable\"]\n\n32. Si el usuario pregunta por compensation, salario, sueldo o compensación:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"compensation\"\n - requested_fields: [\"compensation\"]\n - risk_level: \"high\"\n\n33. Si el usuario pregunta por archivos o documentos del empleado:\n - mode: \"query\"\n - intent: \"get_employee_files\"\n - requested_fields: [\"files\"]\n\n34. Si el usuario pregunta por datos faltantes del empleado:\n - mode: \"query\"\n - intent: \"get_employee_missing_fields\"\n\nReglas de acciones:\n\n35. Si el usuario pide cambiar extensión laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workPhoneExtension\"\n - action.label: \"extensión laboral\"\n - action.value: nuevo valor solicitado\n\n36. Si el usuario pide cambiar teléfono laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workPhone\"\n - action.label: \"teléfono laboral\"\n - action.value: nuevo valor solicitado\n\n37. Si el usuario pide cambiar correo laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workEmail\"\n - action.label: \"correo laboral\"\n - action.value: nuevo valor solicitado\n\n38. Si el usuario pide agregar una fila jobInfo:\n - mode: \"action\"\n - intent: \"add_employee_table_row\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - table_name: \"jobInfo\"\n - action.type: \"add_row\"\n - action.table: \"jobInfo\"\n - action.payload debe incluir los datos mencionados: date, jobTitle, department, division, location, si aparecen.\n\n39. Si el usuario pide preparar creación de empleado nuevo:\n - mode: \"action\"\n - intent: \"prepare_create_employee\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - employee_query: null\n - employee_id: null\n - action.type: \"prepare_create_employee\"\n - action.payload debe incluir los datos mencionados.\n\n40. Si el usuario pide crear empleado real:\n - mode: \"action\"\n - intent: \"create_employee\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - employee_query: null\n - employee_id: null\n - action.type: \"create_employee\"\n - action.payload debe incluir los datos mencionados.\n\n41. Si el usuario pide desvincular, terminar, dar de baja o sacar a un empleado:\n - mode: \"action\"\n - risk_level: \"critical\"\n - requires_confirmation: true\n - Si faltan fecha, motivo o tipo, usa intent: \"prepare_terminate_employee\" y confidence menor a 0.8.\n - Si están fecha, motivo y tipo, usa intent: \"prepare_terminate_employee\".\n - Solo usa \"terminate_employee\" cuando el mensaje indique claramente ejecución final de desvinculación ya confirmada.\n\n42. Para desvinculación:\n - action.type: \"terminate_employee\"\n - action.payload.terminationDate debe contener la fecha de salida si aparece.\n - action.payload.terminationReason debe contener el motivo si aparece.\n - action.payload.terminationType debe contener el tipo si aparece.\n\nReglas de confirmación:\n\n43. Si el usuario responde solo \"CONFIRMAR\", \"confirmar\", \"sí confirmo\", \"ejecuta\" o similar:\n - mode: \"confirmation\"\n - intent: \"resolve_pending_confirmation\"\n - confidence: 1\n - risk_level: \"medium\"\n - requires_confirmation: false\n - action: {\"type\": \"confirm\"}\n\n44. Si el usuario responde solo \"CANCELAR\", \"cancelar\", \"no\", \"no ejecutar\" o similar:\n - mode: \"confirmation\"\n - intent: \"cancel_pending_confirmation\"\n - confidence: 1\n - risk_level: \"low\"\n - requires_confirmation: false\n - action: {\"type\": \"cancel\"}\n\nReglas de incertidumbre:\n\n45. Si no estás seguro:\n - intent: \"unknown\"\n - mode: \"unknown\"\n - confidence menor a 0.8\n - missing_information debe explicar qué falta.\n\n46. Si el usuario pide una acción pero falta el empleado:\n - confidence menor a 0.8\n - missing_information debe incluir \"empleado\"\n\n47. Si el usuario pide una acción pero falta el nuevo valor:\n - confidence menor a 0.8\n - missing_information debe incluir \"nuevo valor\"\n\n48. Si el usuario pide una consulta de empleado pero no menciona empleado:\n - confidence menor a 0.8\n - missing_information debe incluir \"empleado\"\n\nFormato obligatorio de respuesta:\n\nDevuelve exactamente este JSON:\n\n{\n \"mode\": \"query | action | report | confirmation | system | unknown\",\n \"intent\": \"una intención permitida\",\n \"confidence\": 0.0,\n \"risk_level\": \"low | medium | high | critical\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {},\n \"report_type\": null,\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"explicación breve, sin cadena de pensamiento\"\n}\n`;\n\nreturn {\n json: {\n ...data,\n\n gemini_enabled: true,\n gemini_prompt: prompt,\n\n gemini_allowed_intents: allowedIntents,\n gemini_allowed_fields: allowedFields\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-11312,
|
||
-2592
|
||
],
|
||
"id": "b354a006-560d-4784-a882-d4a8ffa9fc38",
|
||
"name": "Code - Build Gemini Intent Prompt"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-3.1-flash-lite",
|
||
"options": {
|
||
"temperature": 0
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-11232,
|
||
-2384
|
||
],
|
||
"id": "5314a9c0-b3fc-4e41-9a69-d1b396e084af",
|
||
"name": "Google Gemini Chat Model",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "6Jdcd9FONxG35YuJ",
|
||
"name": "Isaac - Gemini Api Gratis"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"promptType": "define",
|
||
"text": "={{ $json.gemini_prompt }}",
|
||
"options": {
|
||
"systemMessage": "Eres una capa de interpretación para BambooHR Agent. Tu única tarea es convertir el mensaje del usuario en JSON válido. No ejecutes acciones, no inventes datos, no decidas permisos, no confirmes operaciones y no respondas en lenguaje natural. Devuelve únicamente JSON válido.",
|
||
"maxIterations": 1
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||
"typeVersion": 3.1,
|
||
"position": [
|
||
-11088,
|
||
-2592
|
||
],
|
||
"id": "3bffe8fe-5617-4805-b10a-f53e50f2af1b",
|
||
"name": "AI Agent - Gemini Intent Interpreter"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const requestContext = $items('Code - Build Gemini Intent Prompt')[0].json;\n\nconst allowedIntents = [\n ...new Set([\n ...(requestContext.gemini_allowed_intents || []),\n\n // Intent oficial para reporte activos/inactivos.\n 'employees_active_inactive',\n\n // Alias viejo temporal por compatibilidad.\n 'active_inactive_summary',\n\n // Intent oficial para listar reportes guardados/custom reports.\n 'list_saved_reports',\n\n 'run_saved_report'\n ])\n];\n\nconst allowedFields =\n requestContext.gemini_allowed_fields ||\n [];\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction extractJsonFromText(text) {\n const raw = cleanText(text);\n\n if (!raw) {\n return null;\n }\n\n let cleaned = raw\n .replace(/^```json/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n const firstBrace = cleaned.indexOf('{');\n const lastBrace = cleaned.lastIndexOf('}');\n\n if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {\n cleaned = cleaned.slice(firstBrace, lastBrace + 1);\n }\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return null;\n }\n}\n\nfunction normalizeMode(value) {\n const mode = cleanText(value).toLowerCase();\n\n const allowed = [\n 'query',\n 'action',\n 'report',\n 'confirmation',\n 'system',\n 'unknown'\n ];\n\n return allowed.includes(mode) ? mode : 'unknown';\n}\n\nfunction normalizeRisk(value) {\n const risk = cleanText(value).toLowerCase();\n\n const allowed = [\n 'low',\n 'medium',\n 'high',\n 'critical'\n ];\n\n return allowed.includes(risk) ? risk : 'low';\n}\n\nfunction normalizeConfidence(value) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return 0;\n }\n\n if (number < 0) return 0;\n if (number > 1) return 1;\n\n return Number(number.toFixed(2));\n}\n\nfunction normalizeArray(value) {\n if (Array.isArray(value)) {\n return value\n .map(item => cleanText(item))\n .filter(Boolean);\n }\n\n if (typeof value === 'string' && value.trim()) {\n return [value.trim()];\n }\n\n return [];\n}\n\nfunction filterAllowedFields(fields) {\n return normalizeArray(fields)\n .filter(field => allowedFields.includes(field));\n}\n\nconst rawGeminiOutput =\n $json.output ||\n $json.text ||\n $json.response ||\n $json.content ||\n $json.message ||\n '';\n\nconst parsed = extractJsonFromText(rawGeminiOutput);\n\nif (!parsed || typeof parsed !== 'object') {\n return {\n json: {\n ...requestContext,\n\n gemini_interpretation_valid: false,\n gemini_interpretation_error: 'invalid_json',\n gemini_raw_output: rawGeminiOutput,\n\n // Permitimos fallback al parser determinístico existente.\n use_deterministic_parser_fallback: true\n }\n };\n}\n\nlet parsedIntent = cleanText(parsed.intent);\n\n// Compatibilidad temporal:\n// Si Gemini o algún prompt viejo devuelve active_inactive_summary,\n// lo convertimos al intent oficial nuevo.\nif (parsedIntent === 'active_inactive_summary') {\n parsedIntent = 'employees_active_inactive';\n parsed.report_type = parsed.report_type || 'active_inactive_summary';\n}\n\n// Compatibilidad por si Gemini devuelve variantes comunes para reportes guardados.\nconst normalizedParsedIntentLower = parsedIntent.toLowerCase();\n\nif (\n normalizedParsedIntentLower === 'saved_reports' ||\n normalizedParsedIntentLower === 'list_reports' ||\n normalizedParsedIntentLower === 'list_custom_reports' ||\n normalizedParsedIntentLower === 'custom_reports' ||\n normalizedParsedIntentLower === 'bamboohr_saved_reports'\n) {\n parsedIntent = 'list_saved_reports';\n parsed.report_type = parsed.report_type || 'list_saved_reports';\n}\n\nif (\n normalizedParsedIntentLower === 'run_report' ||\n normalizedParsedIntentLower === 'execute_report' ||\n normalizedParsedIntentLower === 'execute_saved_report' ||\n normalizedParsedIntentLower === 'run_saved_report' ||\n normalizedParsedIntentLower === 'get_saved_report' ||\n normalizedParsedIntentLower === 'bamboohr_report'\n) {\n parsedIntent = 'run_saved_report';\n parsed.report_type = parsed.report_type || 'run_saved_report';\n}\n\nconst normalizedIntent = allowedIntents.includes(parsedIntent)\n ? parsedIntent\n : 'unknown';\n\nconst normalized = {\n mode: normalizeMode(parsed.mode),\n intent: normalizedIntent,\n confidence: normalizeConfidence(parsed.confidence),\n risk_level: normalizeRisk(parsed.risk_level),\n requires_confirmation: parsed.requires_confirmation === true,\n\n employee_query:\n parsed.employee_query !== undefined && parsed.employee_query !== null\n ? cleanText(parsed.employee_query)\n : null,\n\n employee_id:\n parsed.employee_id !== undefined && parsed.employee_id !== null\n ? cleanText(parsed.employee_id)\n : null,\n\n requested_fields: filterAllowedFields(parsed.requested_fields),\n\n table_name:\n parsed.table_name !== undefined && parsed.table_name !== null\n ? cleanText(parsed.table_name)\n : null,\n\n filters:\n parsed.filters && typeof parsed.filters === 'object' && !Array.isArray(parsed.filters)\n ? parsed.filters\n : {},\n\n report_type:\n parsed.report_type !== undefined && parsed.report_type !== null\n ? cleanText(parsed.report_type)\n : null,\n\n action:\n parsed.action && typeof parsed.action === 'object' && !Array.isArray(parsed.action)\n ? parsed.action\n : null,\n\n missing_information: normalizeArray(parsed.missing_information),\n\n reasoning_summary:\n parsed.reasoning_summary !== undefined && parsed.reasoning_summary !== null\n ? cleanText(parsed.reasoning_summary)\n : null\n};\n\nconst validationIssues = [];\n\nif (normalized.mode === 'unknown') {\n validationIssues.push('mode_unknown');\n}\n\nif (normalized.intent === 'unknown') {\n validationIssues.push('intent_unknown');\n}\n\nif (normalized.confidence < 0.8) {\n validationIssues.push('low_confidence');\n}\n\nif (\n normalized.mode === 'action' &&\n !normalized.action\n) {\n validationIssues.push('missing_action_object');\n}\n\n// Validación especial:\n// list_saved_reports es un reporte de solo lectura.\n// No necesita employee_query, action ni filtros obligatorios.\nif (normalized.intent === 'list_saved_reports') {\n normalized.mode = 'report';\n normalized.risk_level = 'low';\n normalized.requires_confirmation = false;\n normalized.employee_query = null;\n normalized.employee_id = null;\n normalized.report_type = normalized.report_type || 'list_saved_reports';\n}\n\nconst isValid =\n validationIssues.length === 0;\n\nreturn {\n json: {\n ...requestContext,\n\n gemini_interpretation_valid: isValid,\n gemini_interpretation_issues: validationIssues,\n gemini_interpretation: normalized,\n gemini_raw_output: rawGeminiOutput,\n\n use_deterministic_parser_fallback: !isValid,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n gemini_enabled: true,\n gemini_interpretation_valid: isValid,\n gemini_interpretation_issues: validationIssues,\n gemini_interpreted_mode: normalized.mode,\n gemini_interpreted_intent: normalized.intent,\n gemini_confidence: normalized.confidence,\n gemini_risk_level: normalized.risk_level\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10736,
|
||
-2592
|
||
],
|
||
"id": "bab585e3-0cca-40b6-96ef-5e031a6f9759",
|
||
"name": "Code - Normalize Gemini Intent"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://glm.bamboohr.com/api/v1/employees",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendQuery": true,
|
||
"queryParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "fields",
|
||
"value": "displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate,employeeNumber"
|
||
},
|
||
{
|
||
"name": "page[limit]",
|
||
"value": "2500"
|
||
}
|
||
]
|
||
},
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Name",
|
||
"value": "Accept"
|
||
},
|
||
{
|
||
"name": "Value",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-688,
|
||
-6160
|
||
],
|
||
"id": "9008719a-7dd9-4791-a6a2-567580c6b0af",
|
||
"name": "HTTP - List Employees For Resolver",
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const requestContext = $items('Code - Check Direct Employee ID Fallback')[0].json;\nconst response = $json || {};\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!.,;:()[\\]{}\"'`´]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction compact(value) {\n return normalize(value).replace(/\\s+/g, '');\n}\n\nfunction getEmployeesFromResponse(body) {\n if (!body) return [];\n\n if (Array.isArray(body)) return body;\n\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employees)) return body.employees;\n if (Array.isArray(body.rows)) return body.rows;\n\n return [];\n}\n\nfunction getEmployeeId(employee) {\n return (\n employee.employeeId ||\n employee.id ||\n employee.employee_id ||\n null\n );\n}\n\nfunction getEmployeeName(employee) {\n const displayName = employee.displayName || '';\n const firstName = employee.firstName || '';\n const lastName = employee.lastName || '';\n const preferredName = employee.preferredName || '';\n\n return (\n displayName ||\n [firstName, lastName].filter(Boolean).join(' ') ||\n preferredName ||\n 'Empleado sin nombre'\n );\n}\n\nfunction getEmployeeEmail(employee) {\n return (\n employee.workEmail ||\n employee.email ||\n employee.employeeEmail ||\n null\n );\n}\n\nfunction buildSearchText(employee) {\n const values = [\n getEmployeeId(employee),\n employee.employeeNumber,\n employee.displayName,\n employee.firstName,\n employee.lastName,\n employee.preferredName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n getEmployeeEmail(employee),\n employee.jobTitle,\n employee.department,\n employee.division,\n employee.location\n ];\n\n return normalize(values.filter(Boolean).join(' '));\n}\n\nfunction scoreEmployee(employee, query) {\n const normalizedQuery = normalize(query);\n const compactQuery = compact(query);\n\n const employeeId = String(getEmployeeId(employee) || '').trim();\n const employeeNumber = String(employee.employeeNumber || '').trim();\n\n const displayName = normalize(employee.displayName);\n const fullName = normalize(\n [employee.firstName, employee.lastName].filter(Boolean).join(' ')\n );\n const preferredFullName = normalize(\n [employee.preferredName || employee.firstName, employee.lastName]\n .filter(Boolean)\n .join(' ')\n );\n\n const email = normalize(getEmployeeEmail(employee));\n const searchText = buildSearchText(employee);\n const compactDisplayName = compact(employee.displayName);\n const compactFullName = compact(fullName);\n const compactSearchText = compact(searchText);\n\n let score = 0;\n const reasons = [];\n\n if (!normalizedQuery) {\n return { score: 0, reasons: [] };\n }\n\n if (employeeId && normalizedQuery === normalize(employeeId)) {\n score += 1000;\n reasons.push('employee_id_exact');\n }\n\n if (employeeNumber && normalizedQuery === normalize(employeeNumber)) {\n score += 900;\n reasons.push('employee_number_exact');\n }\n\n if (email && normalizedQuery === email) {\n score += 850;\n reasons.push('email_exact');\n }\n\n if (displayName && normalizedQuery === displayName) {\n score += 800;\n reasons.push('display_name_exact');\n }\n\n if (fullName && normalizedQuery === fullName) {\n score += 750;\n reasons.push('full_name_exact');\n }\n\n if (preferredFullName && normalizedQuery === preferredFullName) {\n score += 720;\n reasons.push('preferred_full_name_exact');\n }\n\n if (compactQuery && compactDisplayName && compactQuery === compactDisplayName) {\n score += 700;\n reasons.push('display_name_compact_exact');\n }\n\n if (compactQuery && compactFullName && compactQuery === compactFullName) {\n score += 650;\n reasons.push('full_name_compact_exact');\n }\n\n if (searchText.includes(normalizedQuery)) {\n score += 450;\n reasons.push('search_text_contains_query');\n }\n\n if (compactQuery && compactSearchText.includes(compactQuery)) {\n score += 420;\n reasons.push('compact_search_text_contains_query');\n }\n\n const queryWords = normalizedQuery\n .split(' ')\n .map(word => word.trim())\n .filter(word => word.length >= 2);\n\n if (queryWords.length > 0) {\n const matchedWords = queryWords.filter(word => searchText.includes(word));\n const ratio = matchedWords.length / queryWords.length;\n\n if (ratio === 1) {\n score += 350;\n reasons.push('all_query_words_match');\n } else if (ratio >= 0.75) {\n score += 220;\n reasons.push('most_query_words_match');\n } else if (ratio >= 0.5) {\n score += 100;\n reasons.push('half_query_words_match');\n }\n }\n\n return { score, reasons };\n}\n\nconst employeeQuery =\n requestContext.employee_query ||\n requestContext.employee_id ||\n requestContext.data?.employee_id ||\n requestContext.data?.employee_name ||\n requestContext.employee_name ||\n null;\n\nconst employees = getEmployeesFromResponse(response);\n\nif (!employeeQuery) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_query_missing',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: null,\n\n response_text:\n 'Necesito que indiques el empleado para poder continuar.',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n authorization_result: 'blocked_missing_employee_query',\n execution_result: 'blocked_missing_employee_query'\n }\n }\n };\n}\n\nconst scoredMatches = employees\n .map(employee => {\n const result = scoreEmployee(employee, employeeQuery);\n\n return {\n employee,\n score: result.score,\n reasons: result.reasons\n };\n })\n .filter(match => match.score >= 350)\n .sort((a, b) => b.score - a.score);\n\nconst topScore = scoredMatches[0]?.score || 0;\n\nconst strongMatches = scoredMatches.filter(match => {\n if (topScore >= 700) {\n return match.score >= topScore - 50;\n }\n\n return match.score >= topScore;\n});\n\nif (scoredMatches.length === 0) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_not_found',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n response_text:\n `No encontré ningún empleado que coincida con \"${employeeQuery}\".`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n authorization_result: 'employee_not_found',\n execution_result: 'employee_not_found'\n }\n }\n };\n}\n\nif (strongMatches.length > 1) {\n const preview = strongMatches.slice(0, 8).map((match, index) => {\n const employee = match.employee;\n\n return (\n `${index + 1}. ${getEmployeeName(employee)}` +\n ` — ID ${getEmployeeId(employee) || 'sin ID'}` +\n `${getEmployeeEmail(employee) ? ` — ${getEmployeeEmail(employee)}` : ''}` +\n `${employee.location ? ` — ${employee.location}` : ''}`\n );\n });\n\n return {\n json: {\n ...requestContext,\n\n status: 'multiple_employee_matches',\n can_continue: false,\n response_type: 'clarification_request',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n multiple_matches: true,\n matches: strongMatches.slice(0, 8).map(match => ({\n id: String(getEmployeeId(match.employee) || ''),\n displayName: getEmployeeName(match.employee),\n preferredName: match.employee.preferredName || null,\n workEmail: getEmployeeEmail(match.employee),\n jobTitle: match.employee.jobTitle || null,\n department: match.employee.department || null,\n division: match.employee.division || null,\n location: match.employee.location || null,\n score: match.score,\n reasons: match.reasons\n })),\n\n response_text:\n `Encontré varios empleados que coinciden con \"${employeeQuery}\". ` +\n `Por favor especifica mejor el nombre, correo o ID:\\n\\n` +\n preview.join('\\n'),\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n authorization_result: 'multiple_employee_matches',\n execution_result: 'multiple_employee_matches'\n }\n }\n };\n}\n\nconst selected = scoredMatches[0].employee;\n\nconst selectedId = String(getEmployeeId(selected) || '').trim();\nconst selectedName = getEmployeeName(selected);\nconst selectedEmail = getEmployeeEmail(selected);\n\nif (!selectedId) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_found_without_internal_id',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n\n response_text:\n `Encontré el empleado \"${selectedName}\", pero no pude obtener su ID interno de BambooHR.`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n target_employee_name: selectedName,\n target_employee_email: selectedEmail,\n\n authorization_result: 'employee_found_without_internal_id',\n execution_result: 'employee_found_without_internal_id'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...requestContext,\n\n status: 'employee_resolved',\n can_continue: true,\n response_type: 'chat_message',\n\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n employee_query: employeeQuery,\n\n employee_id: selectedId,\n employee_name: selectedName,\n employee_email: selectedEmail,\n\n resolved_employee_id: selectedId,\n resolved_employee_name: selectedName,\n resolved_employee_email: selectedEmail,\n\n employee_match: {\n id: selectedId,\n displayName: selectedName,\n preferredName: selected.preferredName || null,\n workEmail: selectedEmail,\n jobTitle: selected.jobTitle || null,\n department: selected.department || null,\n division: selected.division || null,\n location: selected.location || null,\n status: selected.status || null,\n hireDate: selected.hireDate || null,\n employeeNumber: selected.employeeNumber || null,\n score: scoredMatches[0].score,\n reasons: scoredMatches[0].reasons\n },\n\n data: {\n ...(requestContext.data || {}),\n\n employee_id: selectedId,\n employee_name: selectedName,\n employee_email: selectedEmail\n },\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n target_employee_id: selectedId,\n target_employee_name: selectedName,\n target_employee_email: selectedEmail,\n\n authorization_result:\n requestContext.audit_event?.authorization_result ||\n requestContext.authorization_result ||\n 'employee_resolved',\n\n execution_result: 'employee_resolved'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
112,
|
||
-6192
|
||
],
|
||
"id": "5d695bb8-10c4-4944-9739-f9c78cf37bd2",
|
||
"name": "Code - Match Employee From List Employees"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "{\n \"title\": \"BambooHR Agent - Active Inactive Employees\",\n \"fields\": [\n \"firstName\",\n \"lastName\",\n \"displayName\",\n \"workEmail\",\n \"status\",\n \"location\",\n \"department\",\n \"division\",\n \"jobTitle\",\n \"hireDate\",\n \"terminationDate\"\n ]\n}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4656,
|
||
-5872
|
||
],
|
||
"id": "72d6f391-cfe1-469d-b5f4-c872df7f02e6",
|
||
"name": "HTTP - Custom Report Active Inactive",
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "employees_active_inactive",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "2a556b28-9ea2-47a3-b2d9-a9a7b152a1c8"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "active_inactive"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "dbb97647-8851-4e96-83b6-3451b1ae0c14",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "run_saved_report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "run_saved_report"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a26808f2-31d1-4124-a4dc-bf8735d1fcc1",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "headcount_by_country",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "headcount"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "edf1db2c-02b0-41fb-8dbc-8ae798cbdd3d",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "headcount_by_department",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "headcount_department"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "4871b0ad-637b-46f0-b4d8-850cd9b35652",
|
||
"leftValue": "={{ ['generate_report', 'headcount_by_country_department'].includes($json.intent) }}",
|
||
"rightValue": "reporte",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "general_report"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "816852dd-9010-4d91-8550-a87cd7dd3b8f",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "list_saved_reports",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "saved_reports"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "cf8a0043-59d8-49b5-8328-936a30476a66",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "export_saved_report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "export_saved_report"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-5408,
|
||
-5296
|
||
],
|
||
"id": "656b8daa-03fa-4229-98ac-342ab64e464f",
|
||
"name": "Switch - Report Type"
|
||
},
|
||
{
|
||
"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 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(\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n ''\n );\n\n return allowedCountries.includes(employeeCountry);\n}\n\nfunction getReportEmployees(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 getEmployeeName(employee) {\n return (\n employee.displayName ||\n [employee.firstName, employee.lastName].filter(Boolean).join(' ') ||\n employee.name ||\n `Empleado ${employee.id || employee.employeeId || ''}`.trim()\n );\n}\n\nfunction getStatus(employee) {\n const status =\n employee.status ||\n employee.employeeStatus ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus ||\n '';\n\n const normalized = normalize(status);\n\n if (normalized === 'active' || normalized === 'activo') return 'active';\n if (normalized === 'inactive' || normalized === 'inactivo') return 'inactive';\n\n if (\n normalized.includes('terminado') ||\n normalized.includes('terminated') ||\n normalized.includes('desvinculado')\n ) {\n return 'inactive';\n }\n\n if (!normalized) return 'unknown';\n\n return normalized;\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]) => ({ name, total }))\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 = getReportEmployees(items);\n\nconst scopedEmployees = allEmployees.filter(employee =>\n employeeIsAllowedByCountry(employee, permission)\n);\n\nconst activeEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'active');\nconst inactiveEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'inactive');\nconst unknownStatusEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'unknown');\n\nconst statusCounts = countBy(scopedEmployees, employee => {\n const status = getStatus(employee);\n\n if (status === 'active') return 'Activos';\n if (status === 'inactive') return 'Inactivos';\n if (status === 'unknown') return 'Sin estatus';\n\n return status;\n});\n\nconst countryCounts = countBy(scopedEmployees, employee =>\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n 'Sin país/ubicación'\n);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nconst scopeText = hasAllCountries\n ? 'Alcance: todos los países.'\n : `Alcance: ${allowedCountriesRaw.join(', ') || 'Sin países permitidos'}.`;\n\nconst responseText =\n `Empleados activos e inactivos\\n\\n` +\n `Total empleados incluidos: ${scopedEmployees.length}\\n` +\n `Activos: ${activeEmployees.length}\\n` +\n `Inactivos: ${inactiveEmployees.length}\\n` +\n `Sin estatus legible: ${unknownStatusEmployees.length}\\n` +\n `${scopeText}\\n\\n` +\n `Por estatus:\\n` +\n `${formatTable(statusCounts, 'estatus')}\\n\\n` +\n `Por país / ubicación:\\n` +\n `${formatTable(countryCounts, 'país / ubicación')}`;\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'report_generated',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: 'employees_active_inactive',\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_active_employees: activeEmployees.length,\n report_inactive_employees: inactiveEmployees.length,\n report_unknown_status_employees: unknownStatusEmployees.length,\n\n report_status_counts: statusCounts,\n report_country_counts: countryCounts,\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: 'employees_active_inactive',\n report_total_employees_in_scope: scopedEmployees.length,\n report_active_employees: activeEmployees.length,\n report_inactive_employees: inactiveEmployees.length,\n report_unknown_status_employees: unknownStatusEmployees.length,\n report_status_counts: statusCounts,\n report_country_counts: countryCounts,\n\n bamboohr_endpoint: '/api/v1/reports/custom?format=JSON&onlyCurrent=false'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4048,
|
||
-5856
|
||
],
|
||
"id": "627482cd-b39e-4738-bbff-910b188d6e97",
|
||
"name": "Code - Format Active Inactive Report"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://glm.bamboohr.com/api/v1/custom-reports",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4736,
|
||
-4896
|
||
],
|
||
"id": "a660dc6e-e76c-4140-a3bf-8762bbccba3f",
|
||
"name": "HTTP - List BambooHR Saved Reports",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Authorization Gate')[0].json;\n\nfunction normalizeItems(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.reports)) return body.reports;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.results)) return body.results;\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 getReportId(report) {\n return (\n report.id ||\n report.reportId ||\n report.report_id ||\n report.customReportId ||\n null\n );\n}\n\nfunction getReportName(report) {\n return (\n report.name ||\n report.title ||\n report.reportName ||\n report.report_name ||\n 'Reporte sin nombre'\n );\n}\n\nconst reports = normalizeItems(items)\n .map(report => ({\n id: getReportId(report),\n name: getReportName(report),\n raw: report\n }))\n .filter(report => report.id || report.name)\n .sort((a, b) => String(a.name).localeCompare(String(b.name)));\n\nconst maxToShow = 30;\nconst visibleReports = reports.slice(0, maxToShow);\n\nlet responseText = '';\n\nif (reports.length === 0) {\n responseText =\n `No encontré reportes custom guardados disponibles en BambooHR.\\n\\n` +\n `Esto no necesariamente es un error. Puede significar que la credencial/API Key no tiene acceso a reportes guardados o que no existen custom reports compartidos con ese usuario.`;\n} else {\n responseText =\n `Reportes custom guardados disponibles en BambooHR\\n\\n` +\n `Total encontrados: ${reports.length}\\n\\n` +\n visibleReports\n .map((report, index) => `${index + 1}. ${report.name} — ID: ${report.id || 'No disponible'}`)\n .join('\\n');\n\n if (reports.length > maxToShow) {\n responseText +=\n `\\n\\nMostrando los primeros ${maxToShow}. Hay ${reports.length - maxToShow} adicionales.`;\n }\n\n responseText +=\n `\\n\\nPara ejecutar uno después, el usuario podrá pedir algo como:\\n` +\n `Ejecuta el reporte guardado ID 123`;\n}\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'saved_reports_listed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: 'reporte',\n intent: 'list_saved_reports',\n risk_level: 'low',\n\n saved_reports_count: reports.length,\n saved_reports_preview: visibleReports.map(report => ({\n id: report.id,\n name: report.name\n })),\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: 'reporte',\n intent: 'list_saved_reports',\n risk_level: 'low',\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 'Listado de reportes guardados autorizado.',\n\n execution_result: 'saved_reports_listed',\n\n saved_reports_count: reports.length,\n saved_reports_preview: visibleReports.map(report => ({\n id: report.id,\n name: report.name\n })),\n\n bamboohr_endpoint: '/api/v1/custom-reports'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4528,
|
||
-4896
|
||
],
|
||
"id": "eb18f9b3-fc6b-413f-927d-314ec37a6d8b",
|
||
"name": "Code - Format Saved Reports List"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports/{{ $json.saved_report_id }}?page={{ $json.saved_report_page }}&page_size={{ $json.saved_report_page_size }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-3184,
|
||
-5616
|
||
],
|
||
"id": "498f5291-2f08-4b70-a8ec-2422b901eace",
|
||
"name": "HTTP - Run BambooHR Saved Report",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function getFirstJsonFromNode(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n\n if (\n Array.isArray(nodeItems) &&\n nodeItems.length > 0 &&\n nodeItems[0] &&\n nodeItems[0].json\n ) {\n return nodeItems[0].json;\n }\n\n return null;\n } catch (error) {\n return null;\n }\n}\n\nfunction hasReportIdContext(context) {\n if (!context || typeof context !== 'object') {\n return false;\n }\n\n return Boolean(\n context.saved_report_id ||\n context.report_id ||\n context.filters?.report_id ||\n context.data?.filters?.report_id ||\n context.audit_event?.saved_report_id\n );\n}\n\nconst resolvedByNameContext =\n getFirstJsonFromNode('Code - Resolve Saved Report By Name');\n\nconst normalizedSavedReportContext =\n getFirstJsonFromNode('Code - Normalize Saved Report Request');\n\nconst authorizationContext =\n getFirstJsonFromNode('Code - Authorization Gate');\n\nconst requestContext =\n [\n resolvedByNameContext,\n normalizedSavedReportContext,\n authorizationContext\n ].find(hasReportIdContext) ||\n resolvedByNameContext ||\n normalizedSavedReportContext ||\n authorizationContext ||\n {};\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 normalizeText(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction cleanValue(value) {\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch (error) {\n return 'No disponible';\n }\n }\n\n return String(value).trim() || 'No disponible';\n}\n\nfunction normalizeReportBody(items) {\n if (!items || items.length === 0) {\n return {\n data: [],\n pagination: null,\n raw: null\n };\n }\n\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) {\n return {\n data: [],\n pagination: null,\n raw: body || null\n };\n }\n\n if (Array.isArray(body)) {\n return {\n data: body,\n pagination: null,\n raw: body\n };\n }\n\n return {\n data: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || null,\n raw: body\n };\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre',\n firstName: 'Nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction isSensitiveKey(key) {\n const normalizedKey = normalizeText(key);\n\n const sensitiveKeywords = [\n 'ssn',\n 'social',\n 'cedula',\n 'dni',\n 'passport',\n 'pasaporte',\n 'bank',\n 'banco',\n 'cuenta',\n 'account',\n 'routing',\n 'salary',\n 'salario',\n 'sueldo',\n 'pay',\n 'rate',\n 'compensation',\n 'compensacion',\n 'bonus',\n 'bono',\n 'birth',\n 'nacimiento',\n 'address',\n 'direccion',\n 'dirección'\n ];\n\n return sensitiveKeywords.some(keyword =>\n normalizedKey.includes(keyword)\n );\n}\n\nfunction maskSensitiveValue(key, value) {\n if (!isSensitiveKey(key)) {\n return cleanValue(value);\n }\n\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n return '[DATO SENSIBLE OCULTO]';\n}\n\nfunction pickFirst(row, keys) {\n for (const key of keys) {\n if (\n row &&\n row[key] !== undefined &&\n row[key] !== null &&\n String(row[key]).trim() !== ''\n ) {\n return row[key];\n }\n }\n\n return null;\n}\n\nfunction buildRowTitle(row) {\n const name =\n pickFirst(row, [\n 'displayName',\n 'lastNameFirstName'\n ]);\n\n if (name) {\n return cleanValue(name);\n }\n\n const firstName = pickFirst(row, ['firstName']);\n const lastName = pickFirst(row, ['lastName']);\n\n if (firstName || lastName) {\n return `${cleanValue(firstName)} ${cleanValue(lastName)}`.trim();\n }\n\n const employeeId =\n pickFirst(row, [\n 'employeeId',\n 'id',\n 'employeeNumber'\n ]);\n\n if (employeeId) {\n return `Empleado ${cleanValue(employeeId)}`;\n }\n\n return 'Registro sin nombre';\n}\n\nfunction getBestFieldsForPreview(row) {\n const preferredKeys = [\n 'jobInformationJobTitle',\n 'jobTitle',\n\n 'jobInformationDepartment',\n 'department',\n\n 'jobInformationDivision',\n 'division',\n\n 'country',\n 'location',\n 'jobInformationLocation',\n\n 'jobInformationReportsTo',\n 'supervisor',\n\n 'workEmail',\n 'email',\n\n 'hireDate',\n 'status',\n 'employmentStatus'\n ];\n\n const fields = [];\n\n const used = new Set([\n 'displayName',\n 'lastNameFirstName',\n 'firstName',\n 'lastName'\n ]);\n\n for (const key of preferredKeys) {\n if (\n row[key] !== undefined &&\n row[key] !== null &&\n String(row[key]).trim() !== '' &&\n !used.has(key)\n ) {\n fields.push({\n key,\n label: getFriendlyColumnName(key),\n value: maskSensitiveValue(key, row[key])\n });\n\n used.add(key);\n }\n\n if (fields.length >= 5) {\n break;\n }\n }\n\n if (fields.length < 5) {\n for (const [key, value] of Object.entries(row || {})) {\n if (used.has(key)) {\n continue;\n }\n\n if (value === undefined || value === null || String(value).trim() === '') {\n continue;\n }\n\n fields.push({\n key,\n label: getFriendlyColumnName(key),\n value: maskSensitiveValue(key, value)\n });\n\n used.add(key);\n\n if (fields.length >= 5) {\n break;\n }\n }\n }\n\n return fields;\n}\n\nfunction formatPreviewRow(row, index) {\n const title = buildRowTitle(row);\n const fields = getBestFieldsForPreview(row);\n\n if (!fields.length) {\n return `${index + 1}. ${title}\\n Sin campos disponibles para vista previa.`;\n }\n\n const fieldLines = fields\n .map(field => ` ${field.label}: ${field.value}`)\n .join('\\n');\n\n return `${index + 1}. ${title}\\n${fieldLines}`;\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nfunction formatColumnsForChat(columns) {\n if (!columns.length) {\n return 'No disponibles';\n }\n\n const friendlyColumns = columns.map(getFriendlyColumnName);\n\n if (friendlyColumns.length <= 12) {\n return friendlyColumns.join(', ');\n }\n\n return (\n friendlyColumns.slice(0, 12).join(', ') +\n `, ... ${friendlyColumns.length - 12} columnas más`\n );\n}\n\nfunction firstPresent(values) {\n for (const value of values || []) {\n if (value === undefined || value === null) {\n continue;\n }\n\n const text = String(value).trim();\n\n if (text !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction extractRequestedPageRaw(context) {\n const directValue = firstPresent([\n context.saved_report_page_raw,\n context.requested_page_raw,\n context.report_page_raw,\n context.page_raw,\n\n context.filters?.page_raw,\n context.filters?.requested_page_raw,\n context.filters?.saved_report_page_raw,\n\n context.data?.saved_report_page_raw,\n context.data?.requested_page_raw,\n context.data?.filters?.page_raw,\n context.data?.filters?.requested_page_raw,\n\n context.action?.page_raw,\n context.action?.payload?.page_raw,\n context.action?.payload?.requested_page_raw,\n\n context.audit_event?.saved_report_page_raw,\n context.audit_event?.requested_page_raw\n ]);\n\n if (directValue !== null) {\n return String(directValue).trim();\n }\n\n const message = String(\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n context.audit_event?.message ||\n ''\n ).trim();\n\n if (!message) {\n return null;\n }\n\n const pageMatch =\n message.match(/\\bp[aá]gina\\s+([a-zA-Z0-9_-]+)/i) ||\n message.match(/\\bpage\\s+([a-zA-Z0-9_-]+)/i);\n\n if (pageMatch) {\n return String(pageMatch[1] || '').trim();\n }\n\n return null;\n}\n\nfunction parseRequestedPage(value) {\n if (value === undefined || value === null || String(value).trim() === '') {\n return {\n was_requested: false,\n raw: null,\n valid: false,\n value: null,\n reason: null\n };\n }\n\n const raw = String(value).trim();\n\n if (!/^\\d+$/.test(raw)) {\n return {\n was_requested: true,\n raw,\n valid: false,\n value: null,\n reason: 'not_numeric'\n };\n }\n\n const number = Number(raw);\n\n if (!Number.isFinite(number) || number < 1) {\n return {\n was_requested: true,\n raw,\n valid: false,\n value: null,\n reason: 'less_than_one'\n };\n }\n\n return {\n was_requested: true,\n raw,\n valid: true,\n value: number,\n reason: null\n };\n}\n\nfunction parsePositiveInteger(value, fallback) {\n const number = Number(value);\n\n if (!Number.isFinite(number) || number < 1) {\n return fallback;\n }\n\n return Math.floor(number);\n}\n\nfunction getRequestedPageSize(context) {\n return parsePositiveInteger(\n firstPresent([\n context.saved_report_page_size,\n context.report_page_size,\n context.page_size,\n\n context.filters?.page_size,\n context.filters?.pageSize,\n\n context.data?.saved_report_page_size,\n context.data?.filters?.page_size,\n context.data?.filters?.pageSize,\n\n context.action?.payload?.page_size,\n context.action?.payload?.pageSize\n ]),\n 50\n );\n}\n\nfunction buildPageAdjustmentNote({\n requestedPageInfo,\n pageNumber,\n totalPages\n}) {\n if (!requestedPageInfo.was_requested) {\n return null;\n }\n\n if (!requestedPageInfo.valid) {\n if (requestedPageInfo.reason === 'less_than_one') {\n return `Nota de página: pediste la página ${requestedPageInfo.raw}, pero las páginas empiezan en 1. Se usó la página ${pageNumber}.`;\n }\n\n return `Nota de página: \"${requestedPageInfo.raw}\" no es una página válida. Se usó la página ${pageNumber}.`;\n }\n\n if (\n totalPages &&\n requestedPageInfo.value > totalPages\n ) {\n return `Nota de página: pediste la página ${requestedPageInfo.value}, pero este reporte solo tiene ${totalPages} página(s). Se devolvió la página ${pageNumber}.`;\n }\n\n if (requestedPageInfo.value !== pageNumber) {\n return `Nota de página: pediste la página ${requestedPageInfo.value}, pero BambooHR devolvió la página ${pageNumber}.`;\n }\n\n return null;\n}\n\nconst reportId =\n requestContext.filters?.report_id ||\n requestContext.report_id ||\n requestContext.data?.filters?.report_id ||\n null;\n\nconst requestedPageRaw = extractRequestedPageRaw(requestContext);\nconst requestedPageInfo = parseRequestedPage(requestedPageRaw);\n\nconst normalizedBody = normalizeReportBody(items);\nconst rows = normalizedBody.data;\n\nconst pagination = normalizedBody.pagination || {};\n\nconst totalRows =\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n rows.length;\n\nconst requestedPageSize = getRequestedPageSize(requestContext);\n\nconst pageSize = parsePositiveInteger(\n firstPresent([\n pagination.page_size,\n pagination.pageSize,\n requestContext.saved_report_page_size,\n requestContext.report_page_size,\n requestContext.filters?.page_size,\n requestContext.filters?.pageSize,\n requestedPageSize\n ]),\n 50\n);\n\nconst pageNumber = parsePositiveInteger(\n firstPresent([\n pagination.page,\n pagination.current_page,\n pagination.currentPage,\n requestContext.saved_report_page,\n requestContext.report_page,\n requestContext.filters?.page,\n requestContext.filters?.pageNumber\n ]),\n 1\n);\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst pageAdjustmentNote = buildPageAdjustmentNote({\n requestedPageInfo,\n pageNumber,\n totalPages\n});\n\nconst previewRows = rows.slice(0, 5);\nconst columns = collectColumns(rows);\n\nconst navigationCurrentPage =\n Math.min(\n Math.max(Number(pageNumber || 1), 1),\n Number(totalPages || 1)\n );\n\nconst navigationTotalPages =\n Number(totalPages || 1);\n\nconst navigationHasPreviousPage =\n navigationCurrentPage > 1;\n\nconst navigationHasNextPage =\n navigationCurrentPage < navigationTotalPages;\n\nconst navigationPreviousPage =\n navigationHasPreviousPage\n ? navigationCurrentPage - 1\n : null;\n\nconst navigationNextPage =\n navigationHasNextPage\n ? navigationCurrentPage + 1\n : null;\n\nconst navigationLines = [];\n\nif (navigationHasPreviousPage) {\n navigationLines.push(\n `Página anterior: Ejecuta el reporte guardado ID ${reportId} página ${navigationPreviousPage}`\n );\n}\n\nif (navigationHasNextPage) {\n navigationLines.push(\n `Página siguiente: Ejecuta el reporte guardado ID ${reportId} página ${navigationNextPage}`\n );\n}\n\nif (navigationTotalPages > 1) {\n navigationLines.push(\n `También puedes pedir una página específica. Ejemplo: Ejecuta el reporte guardado ID ${reportId} página ${navigationCurrentPage}`\n );\n}\n\nconst navigationText =\n navigationLines.length > 0\n ? `\\n\\nNavegación:\\n${navigationLines.join('\\n')}`\n : '';\n\n\nlet responseText = '';\n\nif (!reportId) {\n responseText =\n `No pude ejecutar el reporte porque no encontré el ID del reporte solicitado.`;\n} else if (rows.length === 0) {\n responseText =\n `Reporte guardado ID ${reportId} ejecutado en BambooHR.\\n\\n` +\n `No encontré filas en la página ${pageNumber} del resultado.` +\n `${pageAdjustmentNote ? `\\n\\n${pageAdjustmentNote}` : ''}`;\n} else {\n responseText =\n `Reporte guardado ID ${reportId} ejecutado en BambooHR\\n\\n` +\n `Filas recibidas en esta página: ${rows.length}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Página: ${pageNumber} de ${totalPages}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Columnas detectadas: ${columns.length}\\n` +\n `${formatColumnsForChat(columns)}` +\n `${pageAdjustmentNote ? `\\n\\n${pageAdjustmentNote}` : ''}\\n\\n` +\n `Vista previa de las primeras ${previewRows.length} filas:\\n\\n` +\n `${previewRows.map(formatPreviewRow).join('\\n\\n')}\\n\\n` +\n `Nota: si el reporte contiene campos sensibles, algunos valores se muestran ocultos en el chat.` +\nnavigationText;\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: 'run_saved_report',\n saved_report_id: reportId,\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page: pageNumber,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n saved_report_requested_page_raw: requestedPageRaw,\n saved_report_requested_page_valid: requestedPageInfo.valid,\n saved_report_requested_page:\n requestedPageInfo.valid === true\n ? requestedPageInfo.value\n : null,\n saved_report_page_adjustment_note: pageAdjustmentNote,\n\n saved_report_columns: columns,\n saved_report_preview_rows: previewRows,\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 execution_result: 'saved_report_executed',\n\n report_type: 'run_saved_report',\n saved_report_id: reportId,\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page: pageNumber,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n saved_report_requested_page_raw: requestedPageRaw,\n saved_report_requested_page_valid: requestedPageInfo.valid,\n saved_report_requested_page:\n requestedPageInfo.valid === true\n ? requestedPageInfo.value\n : null,\n saved_report_page_adjustment_note: pageAdjustmentNote,\n saved_report_columns_count: columns.length,\n\n bamboohr_endpoint: `/api/v1/custom-reports/${reportId}`\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2752,
|
||
-5616
|
||
],
|
||
"id": "2e70a6b4-2994-428e-92cd-e68225e38ae9",
|
||
"name": "Code - Format Saved Report Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst originalMessage =\n data.message ||\n data.original_message ||\n data.raw_text_detected ||\n data.chat_message_text ||\n '';\n\nfunction normalizeText(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanText(value) {\n return String(value || '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractReportId(message) {\n const raw = String(message || '');\n\n const patterns = [\n /\\bid\\s*#?\\s*(\\d+)\\b/i,\n /\\breporte\\s+guardado\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\breporte\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\bcustom\\s+report\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\breport\\s+id\\s*#?\\s*(\\d+)\\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]).trim();\n }\n }\n\n return null;\n}\n\nfunction extractRequestedPage(message) {\n const raw = String(message || '');\n\n const pageMatch = raw.match(/\\bp[aá]gina\\s+([a-zA-Z0-9]+)\\b/i);\n\n if (!pageMatch || !pageMatch[1]) {\n return {\n requested_page_raw: null,\n page: 1,\n page_note: null\n };\n }\n\n const requestedRaw = String(pageMatch[1]).trim();\n const parsed = Number(requestedRaw);\n\n if (!Number.isFinite(parsed)) {\n return {\n requested_page_raw: requestedRaw,\n page: 1,\n page_note: `Nota de página: \"${requestedRaw}\" no es una página válida. Se usó la página 1.`\n };\n }\n\n const integerPage = Math.floor(parsed);\n\n if (integerPage < 1) {\n return {\n requested_page_raw: requestedRaw,\n page: 1,\n page_note: `Nota de página: pediste la página ${requestedRaw}, pero las páginas empiezan en 1. Se usó la página 1.`\n };\n }\n\n return {\n requested_page_raw: requestedRaw,\n page: integerPage,\n page_note: null\n };\n}\n\nfunction extractPageSize(message) {\n const raw = String(message || '');\n\n const pageSizeMatch =\n raw.match(/\\bpage[_\\s-]*size\\s+(\\d+)\\b/i) ||\n raw.match(/\\btama[nñ]o\\s+de\\s+p[aá]gina\\s+(\\d+)\\b/i) ||\n raw.match(/\\b(\\d+)\\s+filas\\s+por\\s+p[aá]gina\\b/i);\n\n if (!pageSizeMatch || !pageSizeMatch[1]) {\n return 50;\n }\n\n const parsed = Number(pageSizeMatch[1]);\n\n if (!Number.isFinite(parsed) || parsed < 1) {\n return 50;\n }\n\n return Math.min(Math.floor(parsed), 1000);\n}\n\nfunction extractReportNameQuery(message, reportId) {\n let text = cleanText(message);\n\n // Quitamos página y tamaño de página para que no contaminen el nombre.\n text = text\n .replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ')\n .replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ')\n .replace(/\\bpage[_\\s-]*size\\s+\\d+\\b/gi, ' ')\n .replace(/\\btama[nñ]o\\s+de\\s+p[aá]gina\\s+\\d+\\b/gi, ' ')\n .replace(/\\b\\d+\\s+filas\\s+por\\s+p[aá]gina\\b/gi, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Si vino ID directo, no necesitamos resolver por nombre.\n if (reportId) {\n return null;\n }\n\n function cleanReportNameCandidate(value) {\n let candidate = cleanText(value);\n\n candidate = candidate\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos ID accidental si quedó pegado.\n candidate = candidate\n .replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ')\n .replace(/\\b#\\s*\\d+\\b/gi, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos artículos sueltos al inicio.\n candidate = candidate\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!candidate) {\n return null;\n }\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte',\n 'custom report',\n 'custom reports',\n 'saved report',\n 'saved reports'\n ];\n\n const normalizedCandidate = normalizeText(candidate);\n\n if (blocked.includes(normalizedCandidate)) {\n return null;\n }\n\n return candidate;\n }\n\n // Caso más importante:\n // \"Exporta el reporte guardado Reporte de Activos\"\n // Debe devolver \"Reporte de Activos\", no \"de Activos\".\n const explicitSavedReportNamePatterns = [\n /\\b(?:el\\s+)?reporte\\s+guardado\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i,\n /\\bsaved\\s+report\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i,\n /\\bcustom\\s+report\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i\n ];\n\n for (const pattern of explicitSavedReportNamePatterns) {\n const match = text.match(pattern);\n\n if (match && match[1]) {\n const candidate = cleanReportNameCandidate(match[1]);\n\n if (candidate) {\n return candidate;\n }\n }\n }\n\n // Fallback para frases tipo:\n // \"Exporta el reporte de activos\"\n // \"Ejecuta reporte de activos\"\n // \"Exporta activos\"\n text = text\n .replace(/\\bejecuta\\b/gi, ' ')\n .replace(/\\bejecutar\\b/gi, ' ')\n .replace(/\\bcorre\\b/gi, ' ')\n .replace(/\\bcorrer\\b/gi, ' ')\n .replace(/\\bgenera\\b/gi, ' ')\n .replace(/\\bgenerar\\b/gi, ' ')\n .replace(/\\babre\\b/gi, ' ')\n .replace(/\\babrir\\b/gi, ' ')\n .replace(/\\bconsulta\\b/gi, ' ')\n .replace(/\\bconsultar\\b/gi, ' ')\n .replace(/\\bmuestrame\\b/gi, ' ')\n .replace(/\\bmu[eé]strame\\b/gi, ' ')\n .replace(/\\bmostrar\\b/gi, ' ')\n .replace(/\\bdame\\b/gi, ' ')\n .replace(/\\bexporta\\b/gi, ' ')\n .replace(/\\bexportar\\b/gi, ' ')\n .replace(/\\bdescarga\\b/gi, ' ')\n .replace(/\\bdescargar\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO eliminamos \"reporte de activos\",\n // porque \"Reporte de Activos\" puede ser el nombre real.\n text = text\n .replace(/\\bel\\s+reporte\\s+guardado\\s+id\\s*#?\\s*\\d+/gi, ' ')\n .replace(/\\breporte\\s+guardado\\s+id\\s*#?\\s*\\d+/gi, ' ')\n .replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ')\n .replace(/\\breporte\\s+guardado\\b/gi, ' ')\n .replace(/\\breportes\\s+guardados\\b/gi, ' ')\n .replace(/\\bcustom\\s+reports?\\b/gi, ' ')\n .replace(/\\bsaved\\s+reports?\\b/gi, ' ')\n .replace(/\\bde\\s+bamboohr\\b/gi, ' ')\n .replace(/\\ben\\s+bamboohr\\b/gi, ' ')\n .replace(/\\bbamboohr\\b/gi, ' ');\n\n return cleanReportNameCandidate(text);\n}\n\nconst reportId = extractReportId(originalMessage);\nconst pageInfo = extractRequestedPage(originalMessage);\nconst pageSize = extractPageSize(originalMessage);\nconst reportNameQuery = extractReportNameQuery(originalMessage, reportId);\n\nconst filters = {\n ...(data.filters || {})\n};\n\nconst dataFilters = {\n ...(data.data?.filters || {})\n};\n\nif (reportId) {\n filters.report_id = String(reportId);\n dataFilters.report_id = String(reportId);\n}\n\nif (reportNameQuery) {\n filters.report_name = reportNameQuery;\n filters.report_name_query = reportNameQuery;\n\n dataFilters.report_name = reportNameQuery;\n dataFilters.report_name_query = reportNameQuery;\n}\n\nconst status = reportId\n ? 'saved_report_request_normalized_with_id'\n : reportNameQuery\n ? 'saved_report_request_normalized_without_id'\n : 'missing_saved_report_id_or_name';\n\nreturn {\n json: {\n ...data,\n\n mode: data.mode || 'reporte',\n intent: data.intent || 'run_saved_report',\n report_type: data.report_type || 'run_saved_report',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n\n saved_report_name_query: reportNameQuery,\n\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize,\n\n filters,\n\n data: {\n ...(data.data || {}),\n filters: dataFilters,\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n saved_report_name_query: reportNameQuery,\n\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize\n },\n\n status,\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n mode: data.mode || 'reporte',\n intent: data.intent || 'run_saved_report',\n report_type: data.report_type || 'run_saved_report',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n\n saved_report_name_query: reportNameQuery,\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize,\n\n execution_result: status\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4800,
|
||
-5552
|
||
],
|
||
"id": "d9b766a8-f44a-4ddc-9f8f-b72af6f9fe3f",
|
||
"name": "Code - Normalize Saved Report Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "3bfa41c6-1909-40e2-a89e-2daeee56069b",
|
||
"leftValue": "={{ !!$json.saved_report_id }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-4560,
|
||
-5584
|
||
],
|
||
"id": "db5df19c-8f74-47c2-aa1f-fe0f93eec4d0",
|
||
"name": "IF - Saved Report Has ID?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4352,
|
||
-5488
|
||
],
|
||
"id": "f22b6901-1c8a-4c96-9e6a-9abe5bf87779",
|
||
"name": "HTTP - List Saved Reports For Run",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Normalize Saved Report Request')[0].json;\n\nfunction normalizeText(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanText(value) {\n return String(value || '')\n .trim()\n .replace(/\\s+/g, ' ');\n}\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 normalizeReportsResponse(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.reports)) return body.reports;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.rows)) return body.rows;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction extractReportName(report) {\n return (\n report.name ||\n report.title ||\n report.reportName ||\n report.displayName ||\n ''\n );\n}\n\nfunction extractReportId(report) {\n return (\n report.id ||\n report.reportId ||\n report.report_id ||\n null\n );\n}\n\nfunction extractRequestedReportName(context) {\n const directName =\n context.saved_report_name_query ||\n context.report_name_query ||\n context.saved_report_name ||\n context.report_name ||\n context.filters?.report_name ||\n context.filters?.report_name_query ||\n context.data?.saved_report_name_query ||\n context.data?.filters?.report_name ||\n context.action?.payload?.report_name ||\n context.action?.payload?.reportName ||\n null;\n\n if (directName) {\n return cleanText(directName);\n }\n\n const message = String(\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n context.audit_event?.message ||\n ''\n ).trim();\n\n if (!message) {\n return null;\n }\n\n let cleaned = message;\n\n cleaned = cleaned.replace(/ejecuta(r)?/gi, ' ');\n cleaned = cleaned.replace(/corre(r)?/gi, ' ');\n cleaned = cleaned.replace(/mu[eé]strame/gi, ' ');\n cleaned = cleaned.replace(/dame/gi, ' ');\n cleaned = cleaned.replace(/el reporte/gi, ' ');\n cleaned = cleaned.replace(/reporte guardado/gi, ' ');\n cleaned = cleaned.replace(/reporte/gi, ' ');\n cleaned = cleaned.replace(/guardado/gi, ' ');\n cleaned = cleaned.replace(/\\bID\\s*\\d+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\s+/g, ' ').trim();\n\n return cleaned || null;\n}\n\nfunction scoreReportMatch(report, query) {\n const reportName = extractReportName(report);\n const normalizedReportName = normalizeText(reportName);\n const normalizedQuery = normalizeText(query);\n\n if (!normalizedReportName || !normalizedQuery) {\n return 0;\n }\n\n if (normalizedReportName === normalizedQuery) {\n return 100;\n }\n\n if (normalizedReportName.includes(normalizedQuery)) {\n return 90;\n }\n\n if (normalizedQuery.includes(normalizedReportName)) {\n return 85;\n }\n\n const queryWords = normalizedQuery\n .split(' ')\n .map(word => word.trim())\n .filter(Boolean);\n\n const reportWords = normalizedReportName\n .split(' ')\n .map(word => word.trim())\n .filter(Boolean);\n\n if (!queryWords.length || !reportWords.length) {\n return 0;\n }\n\n let matchedWords = 0;\n\n for (const queryWord of queryWords) {\n if (\n reportWords.some(reportWord =>\n reportWord === queryWord ||\n reportWord.includes(queryWord) ||\n queryWord.includes(reportWord)\n )\n ) {\n matchedWords += 1;\n }\n }\n\n const ratio = matchedWords / queryWords.length;\n\n return Math.round(ratio * 80);\n}\n\nconst reports = normalizeReportsResponse(items);\n\nconst requestedName = extractRequestedReportName(requestContext);\n\nconst scoredReports = reports\n .map(report => {\n const id = extractReportId(report);\n const name = extractReportName(report);\n const score = scoreReportMatch(report, requestedName);\n\n return {\n id,\n name,\n score,\n raw: report\n };\n })\n .filter(report => report.id && report.name)\n .sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return String(a.name).localeCompare(String(b.name));\n });\n\nconst bestMatch = scoredReports[0] || null;\n\nconst exactMatches = scoredReports.filter(report =>\n normalizeText(report.name) === normalizeText(requestedName)\n);\n\nconst strongMatches = scoredReports.filter(report => report.score >= 70);\n\nconst topScore = bestMatch ? bestMatch.score : 0;\n\nconst secondBestMatch = scoredReports[1] || null;\nconst secondScore = secondBestMatch ? secondBestMatch.score : 0;\n\n// Regla de resolución:\n// 1. Si hay coincidencia exacta única, se ejecuta.\n// 2. Si no hay exacta, pero hay un solo match fuerte, se ejecuta.\n// 3. Si el mejor score es mucho mayor que el segundo, se ejecuta.\n// 4. Si hay empate o ambigüedad real, se bloquea y muestra candidatos.\nconst resolved =\n !!bestMatch &&\n (\n exactMatches.length === 1 ||\n (\n bestMatch.score >= 70 &&\n strongMatches.length === 1\n ) ||\n (\n bestMatch.score >= 90 &&\n bestMatch.score - secondScore >= 15\n )\n );\n\nif (!requestedName) {\n return [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: false,\n saved_report_name_resolution_status: 'missing_report_name',\n saved_report_name_query: null,\n saved_report_candidates: scoredReports.slice(0, 10),\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `No encontré el ID ni el nombre del reporte que deseas ejecutar.\\n\\n` +\n `Puedes escribir algo como:\\n` +\n `Ejecuta el reporte guardado ID 13\\n` +\n `o\\n` +\n `Ejecuta el reporte guardado Reporte de Activos`\n }\n }\n ];\n}\n\nif (!resolved) {\n const candidatesText = scoredReports\n .slice(0, 10)\n .map((report, index) => {\n return `${index + 1}. ${report.name} — ID: ${report.id}`;\n })\n .join('\\n');\n\n const reason =\n exactMatches.length > 1\n ? 'multiple_exact_matches'\n : strongMatches.length > 1\n ? 'multiple_possible_matches'\n : 'no_strong_match';\n\n return [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: false,\n saved_report_name_resolution_status: reason,\n saved_report_name_query: requestedName,\n saved_report_candidates: scoredReports.slice(0, 10),\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `No pude identificar con suficiente seguridad el reporte \"${requestedName}\".\\n\\n` +\n `Reportes más parecidos:\\n` +\n `${candidatesText || 'No hay reportes disponibles.'}\\n\\n` +\n `Para evitar ejecutar el reporte incorrecto, usa el ID exacto. Ejemplo:\\n` +\n `Ejecuta el reporte guardado ID 13`\n }\n }\n ];\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: true,\n saved_report_name_resolution_status: 'resolved',\n saved_report_name_query: requestedName,\n\n saved_report_id: String(bestMatch.id),\n report_id: String(bestMatch.id),\n\n saved_report_name: bestMatch.name,\n saved_report_match_score: bestMatch.score,\n\n saved_report_second_best_match: secondBestMatch\n ? {\n id: String(secondBestMatch.id),\n name: secondBestMatch.name,\n score: secondBestMatch.score\n }\n : null,\n\n saved_report_exact_match_used: exactMatches.length === 1,\n\n filters: {\n ...(requestContext.filters || {}),\n report_id: String(bestMatch.id),\n report_name: bestMatch.name\n },\n\n data: {\n ...(requestContext.data || {}),\n filters: {\n ...(requestContext.data?.filters || {}),\n report_id: String(bestMatch.id),\n report_name: bestMatch.name\n }\n },\n\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n saved_report_name_resolved: true,\n saved_report_name_query: requestedName,\n saved_report_id: String(bestMatch.id),\n saved_report_name: bestMatch.name,\n saved_report_match_score: bestMatch.score,\n\n saved_report_second_best_match: secondBestMatch\n ? {\n id: String(secondBestMatch.id),\n name: secondBestMatch.name,\n score: secondBestMatch.score\n }\n : null,\n\n saved_report_exact_match_used: exactMatches.length === 1,\n\n execution_result: 'saved_report_name_resolved'\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4144,
|
||
-5488
|
||
],
|
||
"id": "682cdda5-e171-4cf6-9950-f7e3899b2b60",
|
||
"name": "Code - Resolve Saved Report By Name"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a0f337d3-a790-4bc0-83a5-0549b9259f29",
|
||
"leftValue": "={{ $json.saved_report_name_resolved }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-3920,
|
||
-5552
|
||
],
|
||
"id": "56f0134c-7e20-4b60-9d52-54a0d42646cc",
|
||
"name": "IF - Saved Report Name Resolved?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nreturn {\n json: {\n ...data,\n\n status: 'saved_report_name_not_resolved',\n can_continue: false,\n response_type: 'chat_message',\n\n response_text:\n data.response_text ||\n `No pude identificar el reporte guardado solicitado. Usa el ID exacto del reporte para evitar errores.`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n execution_result: 'saved_report_name_not_resolved',\n authorization_result:\n data.audit_event?.authorization_result ||\n data.authorization_result ||\n 'authorized_report'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3488,
|
||
-5360
|
||
],
|
||
"id": "3ebb6aed-5106-437b-996f-23c94d04a7d4",
|
||
"name": "Code - Format Saved Report Name Not Found"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "67358279-0a9b-4bd5-a2a9-ef2169cdc58a",
|
||
"leftValue": "={{ $json.intent === 'export_saved_report' || $json.report_type === 'export_saved_report' || $json.saved_report_export_requested === true }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-3568,
|
||
-5744
|
||
],
|
||
"id": "b8272f1d-0012-402c-9724-be29c67ff9db",
|
||
"name": "IF - Saved Report Wants Export?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst reportId =\n data.saved_report_id ||\n data.report_id ||\n data.filters?.report_id ||\n data.data?.filters?.report_id ||\n null;\n\nconst reportName =\n data.saved_report_name ||\n data.filters?.report_name ||\n data.data?.filters?.report_name ||\n null;\n\nif (!reportId) {\n return {\n json: {\n ...data,\n\n can_continue: false,\n status: 'missing_saved_report_id_for_export',\n response_type: 'chat_message',\n response_text:\n 'No pude exportar el reporte porque no encontré el ID del reporte solicitado.',\n\n audit_event: {\n ...(data.audit_event || {}),\n execution_result: 'missing_saved_report_id_for_export'\n }\n }\n };\n}\n\nconst pageSize = 1000;\n\nreturn {\n json: {\n ...data,\n\n saved_report_export_requested: true,\n saved_report_export_mode: 'single_request_large_page',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_page: 1,\n saved_report_page_size: pageSize,\n\n export_file_type: 'google_sheet',\n export_status: 'prepared',\n\n can_continue: true,\n status: 'saved_report_export_request_built',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n saved_report_export_requested: true,\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_page_size: pageSize,\n\n execution_result: 'saved_report_export_request_built'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3360,
|
||
-5840
|
||
],
|
||
"id": "72202324-7dda-4b3a-ab4c-b5d41e4b7d55",
|
||
"name": "Code - Build Saved Report Export Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports/{{ $json.saved_report_id }}?format=json&page={{ $json.saved_report_page }}&page_size={{ $json.saved_report_page_size }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-3104,
|
||
-5840
|
||
],
|
||
"id": "dde2fac7-d71c-4fcb-bc46-ff8d7d80a191",
|
||
"name": "HTTP - Run BambooHR Saved Report Export",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Build Saved Report Export Request')[0].json;\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 normalizeReportBody(items) {\n if (!items || items.length === 0) {\n return {\n rows: [],\n pagination: null\n };\n }\n\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: null\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: null\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || null\n };\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nconst normalized = normalizeReportBody(items);\nconst rows = normalized.rows;\nconst pagination = normalized.pagination || {};\n\nconst totalRows =\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n rows.length;\n\nconst pageSize =\n pagination.page_size ||\n pagination.pageSize ||\n requestContext.saved_report_page_size ||\n rows.length;\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst columns = collectColumns(rows);\n\nconst isCompleteInOneRequest =\n rows.length >= totalRows || totalPages <= 1;\n\nconst responseText =\n `Exportación preparada para reporte guardado ID ${requestContext.saved_report_id}\\n\\n` +\n `Reporte: ${requestContext.saved_report_name || 'Nombre no disponible'}\\n` +\n `Filas recibidas en esta primera consulta: ${rows.length}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Columnas detectadas: ${columns.length}\\n` +\n `Páginas estimadas: ${totalPages}\\n\\n` +\n (\n isCompleteInOneRequest\n ? `Estado: este reporte cabe completo en una sola consulta. Próximo paso: generar Google Sheet.`\n : `Estado: este reporte tiene varias páginas. Próximo paso: crear paginación completa antes de generar Google Sheet.`\n );\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_export_preview_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_export_rows_first_page: rows,\n saved_report_export_columns: columns,\n saved_report_export_rows_received: rows.length,\n saved_report_export_total_rows: totalRows,\n saved_report_export_page_size: pageSize,\n saved_report_export_total_pages: totalPages,\n saved_report_export_complete_in_one_request: isCompleteInOneRequest,\n\n response_text: responseText,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_export_preview_ready',\n\n saved_report_export_rows_received: rows.length,\n saved_report_export_total_rows: totalRows,\n saved_report_export_columns_count: columns.length,\n saved_report_export_total_pages: totalPages,\n saved_report_export_complete_in_one_request: isCompleteInOneRequest,\n\n bamboohr_endpoint: `/api/v1/custom-reports/${requestContext.saved_report_id}`\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2096,
|
||
-6112
|
||
],
|
||
"id": "22d28c82-863a-48c0-b9be-d09d23531248",
|
||
"name": "Code - Format Saved Report Export Preview"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\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 normalizeRowsFromCurrentInput(data) {\n if (!data || isEmptyObject(data)) {\n return [];\n }\n\n if (Array.isArray(data.saved_report_export_rows)) {\n return data.saved_report_export_rows;\n }\n\n if (Array.isArray(data.data)) {\n return data.data;\n }\n\n if (Array.isArray(data.employees)) {\n return data.employees;\n }\n\n if (Array.isArray(data.rows)) {\n return data.rows;\n }\n\n return [];\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre',\n firstName: 'Nombre',\n middleName: 'Segundo nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n originalHireDate: 'Fecha de ingreso original',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral',\n employmentStatusDate: 'Fecha estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction buildSpreadsheetTitle(reportId, reportName) {\n const now = new Date();\n\n const timestamp = now\n .toISOString()\n .replace('T', ' ')\n .slice(0, 16)\n .replace(/:/g, '-');\n\n const safeName = cleanText(reportName || `Reporte ${reportId || 'sin-id'}`)\n .replace(/[\\\\/:*?\"<>|#%{}[\\]~]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 80);\n\n return `BambooHR - ${safeName} - ${timestamp}`;\n}\n\nconst reportId =\n requestContext.saved_report_id ||\n requestContext.report_id ||\n requestContext.filters?.report_id ||\n requestContext.data?.filters?.report_id ||\n 'sin-id';\n\nconst reportName =\n requestContext.saved_report_name ||\n requestContext.filters?.report_name ||\n requestContext.data?.filters?.report_name ||\n requestContext.saved_report_name_query ||\n `Reporte ${reportId}`;\n\nconst rows = normalizeRowsFromCurrentInput(requestContext);\n\nconst columns =\n Array.isArray(requestContext.saved_report_export_columns) &&\n requestContext.saved_report_export_columns.length > 0\n ? requestContext.saved_report_export_columns\n : collectColumns(rows);\n\nconst columnLabels = columns.map(getFriendlyColumnName);\n\nconst totalRows =\n Number(\n requestContext.saved_report_total_records ||\n requestContext.saved_report_export_total_records_expected ||\n rows.length\n ) || rows.length;\n\nconst pageSize =\n Number(\n requestContext.saved_report_page_size ||\n requestContext.saved_report_export_page_size ||\n rows.length\n ) || rows.length;\n\nconst estimatedPages =\n Number(\n requestContext.saved_report_total_pages ||\n requestContext.saved_report_estimated_pages ||\n requestContext.saved_report_export_total_pages_expected ||\n (\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1\n )\n ) || 1;\n\nconst spreadsheetTitle = buildSpreadsheetTitle(reportId, reportName);\n\nif (!rows.length) {\n return [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_google_sheet_blocked_no_rows',\n can_continue: false,\n response_type: 'error_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_rows: [],\n saved_report_export_columns: columns,\n saved_report_export_column_labels: columnLabels,\n\n response_text:\n `No puedo crear el Google Sheet porque el reporte no devolvió filas.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId}`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_google_sheet_blocked_no_rows',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_rows_received: 0,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages\n }\n }\n }\n ];\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_google_sheet_prepared',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_rows: rows,\n saved_report_export_columns: columns,\n saved_report_export_column_labels: columnLabels,\n\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages,\n\n google_sheet_title: spreadsheetTitle,\n google_sheet_first_sheet_name: 'Reporte',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_google_sheet_prepared',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages,\n\n google_sheet_title: spreadsheetTitle\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1984,
|
||
-5840
|
||
],
|
||
"id": "17f16acb-4f10-4439-a661-744b680fb30e",
|
||
"name": "Code - Prepare Saved Report Google Sheet"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "spreadsheet",
|
||
"title": "={{ $json.google_sheet_title }}",
|
||
"sheetsUi": {
|
||
"sheetValues": [
|
||
{
|
||
"title": "={{ $json.google_sheet_first_sheet_name }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleSheets",
|
||
"typeVersion": 4.7,
|
||
"position": [
|
||
-1776,
|
||
-5840
|
||
],
|
||
"id": "5266bde5-5482-4ac0-8a54-67eb155a1b70",
|
||
"name": "Google Sheets - Create Saved Report Spreadsheet",
|
||
"credentials": {
|
||
"googleSheetsOAuth2Api": {
|
||
"id": "K0hDZh3a85MpOHCs",
|
||
"name": "Google Sheets account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\n\nfunction cleanValue(value) {\n if (value === null || value === undefined) {\n return '';\n }\n\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch (error) {\n return '';\n }\n }\n\n return String(value);\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre completo',\n firstName: 'Nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n originalHireDate: 'Fecha de ingreso original',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral',\n employmentStatusDate: 'Fecha estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction makeUniqueColumnLabels(columns) {\n const seen = new Map();\n\n return columns.map(column => {\n const baseLabel = getFriendlyColumnName(column);\n const count = seen.get(baseLabel) || 0;\n\n seen.set(baseLabel, count + 1);\n\n if (count === 0) {\n return baseLabel;\n }\n\n return `${baseLabel} (${column})`;\n });\n}\n\nconst rows = Array.isArray(exportContext.saved_report_export_rows)\n ? exportContext.saved_report_export_rows\n : [];\n\nconst columns = Array.isArray(exportContext.saved_report_export_columns)\n ? exportContext.saved_report_export_columns\n : [];\n\nconst labels = makeUniqueColumnLabels(columns);\n\nif (!rows.length) {\n return [\n {\n json: {\n Sin_datos: 'No hay filas para exportar'\n }\n }\n ];\n}\n\nreturn rows.map(row => {\n const output = {};\n\n columns.forEach((column, index) => {\n const label = labels[index] || column;\n output[label] = cleanValue(row[column]);\n });\n\n return {\n json: output\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1568,
|
||
-5840
|
||
],
|
||
"id": "2276e6f0-09da-4d27-95c8-43d3301c02c3",
|
||
"name": "Code - Prepare Saved Report Sheet Values"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\nconst createSheetResponse = $items('Google Sheets - Create Saved Report Spreadsheet')[0].json;\n\nfunction getSpreadsheetId(response) {\n return (\n response.spreadsheetId ||\n response.id ||\n response.spreadsheet_id ||\n response.data?.spreadsheetId ||\n response.data?.id ||\n null\n );\n}\n\nfunction getSpreadsheetUrl(response, spreadsheetId) {\n return (\n response.spreadsheetUrl ||\n response.webViewLink ||\n response.url ||\n response.data?.spreadsheetUrl ||\n response.data?.webViewLink ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n )\n );\n}\n\nconst spreadsheetId = getSpreadsheetId(createSheetResponse);\nconst spreadsheetUrl = getSpreadsheetUrl(createSheetResponse, spreadsheetId);\n\nconst reportId =\n exportContext.saved_report_id ||\n exportContext.report_id ||\n exportContext.filters?.report_id ||\n 'No disponible';\n\nconst reportName =\n exportContext.saved_report_name ||\n exportContext.filters?.report_name ||\n exportContext.saved_report_name_query ||\n 'Nombre no disponible';\n\nconst rowsExported =\n exportContext.saved_report_rows_received ||\n exportContext.saved_report_export_rows?.length ||\n 0;\n\nconst columnsExported =\n exportContext.saved_report_export_columns?.length ||\n 0;\n\nlet responseText = '';\n\nif (!spreadsheetUrl) {\n responseText =\n `El reporte guardado ID ${reportId} fue consultado, pero no pude obtener el enlace del Google Sheet generado.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `Filas exportadas: ${rowsExported}\\n` +\n `Columnas exportadas: ${columnsExported}\\n\\n` +\n `Revisa el nodo de creación del Google Sheet antes de repetir la exportación.`;\n} else {\n responseText =\n `Exportación completada en Google Sheets.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId}\\n` +\n `Filas exportadas: ${rowsExported}\\n` +\n `Columnas exportadas: ${columnsExported}\\n\\n` +\n `Enlace:\\n${spreadsheetUrl}`;\n}\n\nreturn [\n {\n json: {\n ...exportContext,\n\n status: spreadsheetUrl\n ? 'saved_report_google_sheet_created'\n : 'saved_report_google_sheet_created_without_url',\n\n can_continue: Boolean(spreadsheetUrl),\n response_type: spreadsheetUrl ? 'chat_message' : 'error_message',\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n\n response_text: responseText,\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n\n execution_result: spreadsheetUrl\n ? 'saved_report_google_sheet_created'\n : 'saved_report_google_sheet_created_without_url',\n\n google_spreadsheet_id: spreadsheetId || null,\n google_spreadsheet_url: spreadsheetUrl || null,\n rows_exported: rowsExported,\n columns_exported: columnsExported\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-176,
|
||
-5840
|
||
],
|
||
"id": "62ed6e35-a61a-4001-a2d4-4517638d941f",
|
||
"name": "Code - Format Saved Report Google Sheet Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "appendOrUpdate",
|
||
"documentId": {
|
||
"__rl": true,
|
||
"value": "={{ $items(\"Google Sheets - Create Saved Report Spreadsheet\")[0].json.spreadsheetId || $items(\"Google Sheets - Create Saved Report Spreadsheet\")[0].json.id }}",
|
||
"mode": "id"
|
||
},
|
||
"sheetName": {
|
||
"__rl": true,
|
||
"value": "=Reporte",
|
||
"mode": "name"
|
||
},
|
||
"columns": {
|
||
"mappingMode": "autoMapInputData",
|
||
"value": {},
|
||
"matchingColumns": [],
|
||
"schema": [],
|
||
"attemptToConvertTypes": false,
|
||
"convertFieldsToString": false
|
||
},
|
||
"options": {
|
||
"cellFormat": "USER_ENTERED",
|
||
"locationDefine": {
|
||
"values": {}
|
||
},
|
||
"handlingExtraData": "insertInNewColumn"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleSheets",
|
||
"typeVersion": 4.7,
|
||
"position": [
|
||
-1360,
|
||
-5840
|
||
],
|
||
"id": "71c5ac93-fcc0-4e5b-9d6d-029065dc4a27",
|
||
"name": "Google Sheets - Write Saved Report Rows",
|
||
"credentials": {
|
||
"googleSheetsOAuth2Api": {
|
||
"id": "K0hDZh3a85MpOHCs",
|
||
"name": "Google Sheets account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Build Saved Report Export Request')[0].json;\nconst firstResponse = items[0]?.json || {};\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 normalizeReportBody(body) {\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: {}\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: {}\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || {}\n };\n}\n\nfunction parsePositiveInteger(value, fallback) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return fallback;\n }\n\n const integer = Math.floor(number);\n\n if (integer < 1) {\n return fallback;\n }\n\n return integer;\n}\n\nconst normalized = normalizeReportBody(firstResponse);\n\nconst firstRows = normalized.rows;\nconst pagination = normalized.pagination || {};\n\nconst reportId =\n exportContext.saved_report_id ||\n exportContext.report_id ||\n exportContext.filters?.report_id ||\n exportContext.data?.filters?.report_id ||\n null;\n\nconst reportName =\n exportContext.saved_report_name ||\n exportContext.filters?.report_name ||\n exportContext.data?.filters?.report_name ||\n exportContext.saved_report_name_query ||\n null;\n\nconst pageSize =\n parsePositiveInteger(\n exportContext.saved_report_export_page_size ||\n exportContext.saved_report_page_size ||\n pagination.page_size ||\n pagination.pageSize ||\n firstRows.length ||\n 1000,\n 1000\n );\n\nconst totalRows =\n parsePositiveInteger(\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n exportContext.saved_report_total_records ||\n firstRows.length,\n firstRows.length\n );\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst maxPagesAllowed = 20;\n\nif (!reportId) {\n return [\n {\n json: {\n ...exportContext,\n\n status: 'saved_report_export_blocked_missing_report_id',\n can_continue: false,\n response_type: 'error_message',\n\n response_text:\n 'No puedo exportar el reporte porque no encontré el ID del reporte solicitado.',\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n execution_result: 'saved_report_export_blocked_missing_report_id'\n }\n }\n }\n ];\n}\n\nif (totalPages > maxPagesAllowed) {\n return [\n {\n json: {\n ...exportContext,\n\n status: 'saved_report_export_blocked_too_many_pages',\n can_continue: false,\n response_type: 'error_message',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n\n response_text:\n `El reporte guardado ID ${reportId} tiene demasiadas páginas para exportarlo automáticamente en esta versión.\\n\\n` +\n `Reporte: ${reportName || 'Nombre no disponible'}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Páginas estimadas: ${totalPages}\\n\\n` +\n `Límite actual seguro: ${maxPagesAllowed} páginas.`,\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n execution_result: 'saved_report_export_blocked_too_many_pages'\n }\n }\n }\n ];\n}\n\nconst pageRequests = [];\n\nfor (let page = 1; page <= totalPages; page++) {\n pageRequests.push({\n json: {\n ...exportContext,\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_total_records_expected: totalRows,\n saved_report_export_total_pages_expected: totalPages,\n saved_report_export_page_size: pageSize,\n\n saved_report_export_page: page,\n saved_report_export_is_first_page: page === 1,\n\n saved_report_export_url:\n `https://glm.bamboohr.com/api/v1/custom-reports/${reportId}` +\n `?format=json&page=${page}&page_size=${pageSize}`,\n\n status: 'saved_report_export_page_request_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_export_total_records_expected: totalRows,\n saved_report_export_total_pages_expected: totalPages,\n saved_report_export_page_size: pageSize,\n saved_report_export_page: page,\n\n execution_result: 'saved_report_export_page_request_ready'\n }\n }\n });\n}\n\nreturn pageRequests;"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2768,
|
||
-5824
|
||
],
|
||
"id": "e139f31f-c159-4403-8a25-2c9e7bb039b1",
|
||
"name": "Code - Build Saved Report Page Requests"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.saved_report_export_url }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-2560,
|
||
-5824
|
||
],
|
||
"id": "25ac7417-6c02-404e-8847-f7fd0edfab9f",
|
||
"name": "HTTP - Run BambooHR Saved Report Export Page",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const firstPageContext = $items('Code - Build Saved Report Page Requests')[0].json;\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 normalizeReportBody(body) {\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: {}\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: {}\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || {}\n };\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nconst allRows = [];\nconst pageSummaries = [];\n\nfor (let index = 0; index < items.length; index++) {\n const body = items[index].json || {};\n const normalized = normalizeReportBody(body);\n\n const context =\n $items('Code - Build Saved Report Page Requests')[index]?.json ||\n {};\n\n const page =\n context.saved_report_export_page ||\n normalized.pagination.page ||\n normalized.pagination.current_page ||\n index + 1;\n\n pageSummaries.push({\n page,\n rows_received: normalized.rows.length\n });\n\n for (const row of normalized.rows) {\n allRows.push(row);\n }\n}\n\nconst columns = collectColumns(allRows);\n\nconst reportId =\n firstPageContext.saved_report_id ||\n firstPageContext.report_id ||\n firstPageContext.filters?.report_id ||\n null;\n\nconst reportName =\n firstPageContext.saved_report_name ||\n firstPageContext.filters?.report_name ||\n firstPageContext.saved_report_name_query ||\n 'Nombre no disponible';\n\nconst expectedTotal =\n firstPageContext.saved_report_export_total_records_expected ||\n allRows.length;\n\nconst expectedPages =\n firstPageContext.saved_report_export_total_pages_expected ||\n items.length;\n\nconst pageSize =\n firstPageContext.saved_report_export_page_size ||\n 1000;\n\nconst responseText =\n `Exportación completa preparada para Google Sheets.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId || 'No disponible'}\\n` +\n `Filas consolidadas: ${allRows.length}\\n` +\n `Total reportado por BambooHR: ${expectedTotal}\\n` +\n `Páginas consultadas: ${items.length} de ${expectedPages}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Columnas detectadas: ${columns.length}\\n\\n` +\n `Siguiente paso: crear Google Sheet y escribir las filas.`;\n\nreturn [\n {\n json: {\n ...firstPageContext,\n\n status: 'saved_report_export_pages_combined',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n saved_report_name: reportName,\n\n saved_report_rows_received: allRows.length,\n saved_report_total_records: expectedTotal,\n saved_report_total_pages: expectedPages,\n saved_report_page_size: pageSize,\n\n saved_report_export_rows: allRows,\n saved_report_export_columns: columns,\n saved_report_export_page_summaries: pageSummaries,\n\n response_text: responseText,\n\n audit_event: {\n ...(firstPageContext.audit_event || {}),\n\n saved_report_id: reportId ? String(reportId) : null,\n saved_report_name: reportName,\n\n saved_report_rows_received: allRows.length,\n saved_report_total_records: expectedTotal,\n saved_report_total_pages: expectedPages,\n saved_report_page_size: pageSize,\n saved_report_columns_count: columns.length,\n saved_report_export_page_summaries: pageSummaries,\n\n execution_result: 'saved_report_export_pages_combined'\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2272,
|
||
-5840
|
||
],
|
||
"id": "239e2bf3-f528-41db-981d-e1400c0a4e9e",
|
||
"name": "Code - Combine Saved Report Export Pages"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const prepareContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\nconst createSpreadsheetResponse = $items('Google Sheets - Create Saved Report Spreadsheet')[0].json;\nconst writeRowsResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction firstPresent(values, fallback = null) {\n for (const value of values) {\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n\n return fallback;\n}\n\nfunction extractSpreadsheetId() {\n return firstPresent([\n prepareContext.google_spreadsheet_id,\n prepareContext.spreadsheetId,\n prepareContext.spreadsheet_id,\n\n createSpreadsheetResponse.spreadsheetId,\n createSpreadsheetResponse.spreadsheet_id,\n createSpreadsheetResponse.id,\n\n writeRowsResponse.spreadsheetId,\n writeRowsResponse.spreadsheet_id,\n writeRowsResponse.id\n ]);\n}\n\nfunction extractSpreadsheetUrl(spreadsheetId) {\n return firstPresent([\n prepareContext.google_spreadsheet_url,\n prepareContext.spreadsheetUrl,\n prepareContext.spreadsheet_url,\n\n createSpreadsheetResponse.spreadsheetUrl,\n createSpreadsheetResponse.spreadsheet_url,\n createSpreadsheetResponse.url,\n\n writeRowsResponse.spreadsheetUrl,\n writeRowsResponse.spreadsheet_url,\n writeRowsResponse.url\n ], spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n}\n\nfunction extractSheetId() {\n const sheets =\n createSpreadsheetResponse.sheets ||\n writeRowsResponse.sheets ||\n [];\n\n if (\n Array.isArray(sheets) &&\n sheets.length > 0 &&\n sheets[0].properties &&\n sheets[0].properties.sheetId !== undefined\n ) {\n return Number(sheets[0].properties.sheetId);\n }\n\n // En un spreadsheet nuevo, normalmente la primera hoja tiene sheetId 0.\n return 0;\n}\n\nconst spreadsheetId = extractSpreadsheetId();\nconst spreadsheetUrl = extractSpreadsheetUrl(spreadsheetId);\nconst sheetId = extractSheetId();\n\nconst rows =\n Array.isArray(prepareContext.saved_report_export_rows)\n ? prepareContext.saved_report_export_rows\n : [];\n\nconst columns =\n Array.isArray(prepareContext.saved_report_export_columns)\n ? prepareContext.saved_report_export_columns\n : [];\n\nconst rowCount = Math.max(rows.length + 1, 1);\nconst columnCount = Math.max(columns.length, 1);\n\nconst reportId =\n prepareContext.saved_report_id ||\n prepareContext.report_id ||\n prepareContext.filters?.report_id ||\n 'sin-id';\n\nconst reportName =\n prepareContext.saved_report_name ||\n prepareContext.filters?.report_name ||\n prepareContext.saved_report_name_query ||\n `Reporte guardado ID ${reportId}`;\n\nif (!spreadsheetId) {\n return [\n {\n json: {\n ...prepareContext,\n\n status: 'saved_report_sheet_format_skipped_missing_spreadsheet_id',\n can_continue: true,\n response_type: 'chat_message',\n\n google_sheet_format_ready: false,\n google_sheet_format_skipped_reason: 'missing_spreadsheet_id',\n\n google_spreadsheet_id: null,\n google_spreadsheet_url: spreadsheetUrl,\n\n google_sheets_write_response: writeRowsResponse,\n\n response_text:\n `Exportación completada en Google Sheets, pero no pude preparar el formato porque no encontré el spreadsheetId.`,\n\n audit_event: {\n ...(prepareContext.audit_event || {}),\n\n execution_result: 'saved_report_sheet_created_format_skipped_missing_spreadsheet_id',\n google_sheet_format_ready: false,\n google_sheet_format_skipped_reason: 'missing_spreadsheet_id'\n }\n }\n }\n ];\n}\n\nconst requests = [\n {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: 1\n }\n },\n fields: 'gridProperties.frozenRowCount'\n }\n },\n {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n bold: true\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP'\n }\n },\n fields: 'userEnteredFormat(textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)'\n }\n },\n {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: rowCount,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n }\n },\n {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: columnCount\n }\n }\n }\n];\n\nreturn [\n {\n json: {\n ...prepareContext,\n\n status: 'saved_report_sheet_format_request_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n google_sheet_id: sheetId,\n\n google_sheet_format_ready: true,\n google_sheet_format_row_count: rowCount,\n google_sheet_format_column_count: columnCount,\n\n google_sheets_write_response: writeRowsResponse,\n\n google_sheets_batch_update_body: {\n requests\n },\n\n audit_event: {\n ...(prepareContext.audit_event || {}),\n\n execution_result: 'saved_report_sheet_format_request_ready',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n google_sheet_id: sheetId,\n\n google_sheet_format_ready: true,\n google_sheet_format_row_count: rowCount,\n google_sheet_format_column_count: columnCount\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1104,
|
||
-5840
|
||
],
|
||
"id": "6bd791b1-1ba2-4c22-ad67-dd06089cb67e",
|
||
"name": "Code - Build Saved Report Sheet Format Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.google_spreadsheet_id }}:batchUpdate",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.google_sheets_batch_update_body }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-736,
|
||
-5840
|
||
],
|
||
"id": "87440c58-db60-423e-8f1c-832ea43d27fc",
|
||
"name": "HTTP - Format Saved Report Google Sheet",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
},
|
||
"googleOAuth2Api": {
|
||
"id": "zDheCLMV1u5K3EW1",
|
||
"name": "Google account - Isaac"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction normalizeForPdfUpload(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanPdfText(value) {\n return String(value || '')\n .replace(/[¿?¡!.,;:]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractPdfUploadEmployeeQuery(messageText) {\n const raw = cleanPdfText(messageText);\n\n const patterns = [\n /\\b(?:a|para|de|del)\\s+(?:empleado|colaborador|persona)?\\s*([a-záéíóúñü\\s]+?)(?=\\s+(?:como|tipo|documento|archivo|pdf|carta|contrato|cedula|cédula|licencia|certificacion|certificación|con|en)|$)/i,\n /\\b(?:empleado|colaborador|persona)\\s+([a-záéíóúñü\\s]+?)(?=\\s+(?:como|tipo|documento|archivo|pdf|carta|contrato|cedula|cédula|licencia|certificacion|certificación|con|en)|$)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n const candidate = cleanPdfText(match[1]);\n\n if (\n candidate &&\n !['este', 'esta', 'archivo', 'documento', 'pdf'].includes(normalizeForPdfUpload(candidate))\n ) {\n return candidate;\n }\n }\n }\n\n return null;\n}\n\nfunction extractPdfDocumentType(messageText) {\n const normalized = normalizeForPdfUpload(messageText);\n\n const documentTypes = [\n {\n keys: ['carta de oferta', 'oferta laboral', 'offer letter'],\n value: 'Carta de Oferta'\n },\n {\n keys: ['contrato', 'contrato laboral', 'employment agreement'],\n value: 'Contrato'\n },\n {\n keys: ['cedula', 'cédula', 'identificacion', 'identificación', 'id personal'],\n value: 'Cédula'\n },\n {\n keys: ['pasaporte', 'passport'],\n value: 'Pasaporte'\n },\n {\n keys: ['licencia', 'licencia medica', 'licencia médica'],\n value: 'Licencia'\n },\n {\n keys: ['certificacion', 'certificación', 'certificado'],\n value: 'Certificación'\n },\n {\n keys: ['hoja de vida', 'cv', 'curriculum', 'currículo'],\n value: 'CV'\n },\n {\n keys: ['amonestacion', 'amonestación', 'disciplinario'],\n value: 'Amonestación'\n },\n {\n keys: ['renuncia'],\n value: 'Renuncia'\n },\n {\n keys: ['desvinculacion', 'desvinculación', 'terminacion', 'terminación'],\n value: 'Desvinculación'\n }\n ];\n\n for (const item of documentTypes) {\n if (item.keys.some(key => normalized.includes(normalizeForPdfUpload(key)))) {\n return item.value;\n }\n }\n\n return null;\n}\n\nfunction extractPdfUploadCountry(messageText) {\n const normalized = normalizeForPdfUpload(messageText);\n\n const countries = [\n 'Republica Dominicana',\n 'República Dominicana',\n 'Guatemala',\n 'Honduras',\n 'Colombia',\n 'Costa Rica',\n 'Panamá',\n 'Panama',\n 'Jamaica',\n 'Trinidad',\n 'El Salvador',\n 'Nicaragua',\n 'Puerto Rico'\n ];\n\n for (const country of countries) {\n if (normalized.includes(normalizeForPdfUpload(country))) {\n return country;\n }\n }\n\n return null;\n}\n\nconst pdfUploadAttachments = Array.isArray(input.google_chat_pdf_attachments)\n ? input.google_chat_pdf_attachments\n : [];\n\nconst hasPdfUploadAttachment = pdfUploadAttachments.length > 0;\n\nif (hasPdfUploadAttachment) {\n const originalPdfMessage =\n input.original_message ||\n input.message ||\n '';\n\n const pdfEmployeeQuery = extractPdfUploadEmployeeQuery(originalPdfMessage);\n const pdfDocumentType = extractPdfDocumentType(originalPdfMessage);\n const pdfCountry = extractPdfUploadCountry(originalPdfMessage);\n\n const firstPdf = pdfUploadAttachments[0];\n\n const missingRequired = [];\n\n if (!pdfEmployeeQuery) {\n missingRequired.push('empleado');\n }\n\n if (!pdfDocumentType) {\n missingRequired.push('tipo de documento');\n }\n\n const pdfReady = missingRequired.length === 0;\n\n return {\n json: {\n ...input,\n\n mode: 'accion',\n intent: 'upload_employee_file',\n risk_level: 'high',\n\n confidence: pdfReady ? 0.95 : 0.7,\n requires_confirmation: true,\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n requested_fields: [],\n fields: [],\n\n table_name: null,\n filters: {\n has_pdf_attachment: true,\n document_type: pdfDocumentType,\n country: pdfCountry\n },\n\n action: {\n type: 'upload_employee_file',\n label: 'subir PDF al perfil del empleado',\n payload: {\n document_type: pdfDocumentType,\n country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n google_chat_attachment_resource_name: firstPdf.attachment_resource_name,\n google_drive_file_id: firstPdf.drive_file_id,\n\n attachment_source: firstPdf.source,\n attachment_index: firstPdf.index\n }\n },\n\n can_continue: pdfReady,\n status: pdfReady ? 'ready' : 'needs_clarification',\n\n response_type: pdfReady ? 'chat_message' : 'error_message',\n\n response_text: pdfReady\n ? null\n : (\n `Recibí el PDF, pero faltan datos para subirlo correctamente a BambooHR.\\n\\n` +\n `Falta: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo:\\n` +\n `Sube esta carta de oferta al empleado Bruce Wayne.`\n ),\n\n parser_source: 'deterministic_pdf_upload_priority',\n\n data: {\n ...(input.data || {}),\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n requested_fields: [],\n fields: [],\n\n filters: {\n ...(input.data?.filters || {}),\n has_pdf_attachment: true,\n document_type: pdfDocumentType,\n country: pdfCountry\n },\n\n action: {\n type: 'upload_employee_file',\n label: 'subir PDF al perfil del empleado',\n payload: {\n document_type: pdfDocumentType,\n country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n google_chat_attachment_resource_name: firstPdf.attachment_resource_name,\n google_drive_file_id: firstPdf.drive_file_id,\n\n attachment_source: firstPdf.source,\n attachment_index: firstPdf.index\n }\n },\n\n requires_confirmation: true,\n confidence: pdfReady ? 0.95 : 0.7,\n status: pdfReady ? 'ready' : 'needs_clarification'\n },\n\n audit_event: {\n ...(input.audit_event || {}),\n\n original_message: originalPdfMessage,\n\n parser_source: 'deterministic_pdf_upload_priority',\n\n mode: 'accion',\n intent: 'upload_employee_file',\n risk_level: 'high',\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n document_type: pdfDocumentType,\n document_country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n has_pdf_attachment: true,\n\n authorization_result: 'pending_authorization',\n execution_result: pdfReady\n ? 'interpreted_pdf_upload_request'\n : 'pdf_upload_needs_clarification'\n }\n }\n };\n}\n\n/*\n Este nodo convierte la interpretación validada de Gemini\n en el formato oficial que usa el resto del workflow.\n\n Gemini NO ejecuta.\n Gemini interpreta.\n\n Este nodo toma:\n - gemini_interpretation\n - use_deterministic_parser_fallback\n\n Y produce:\n - mode\n - intent\n - confidence\n - risk_level\n - requires_confirmation\n - employee_query\n - employee_id\n - requested_fields\n - table_name\n - filters\n - report_type\n - action\n*/\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction normalizeMode(value) {\n const normalized = normalize(value);\n\n const map = {\n query: 'consulta',\n consulta: 'consulta',\n\n action: 'accion',\n accion: 'accion',\n acción: 'accion',\n\n report: 'reporte',\n reporte: 'reporte',\n\n confirmation: 'confirmation',\n confirmacion: 'confirmation',\n confirmación: 'confirmation',\n\n system: 'system',\n sistema: 'system'\n };\n\n return map[normalized] || value || 'unknown';\n}\n\nfunction normalizeIntent(value) {\n const intent = cleanText(value);\n\n const aliases = {\n active_inactive_summary: 'employees_active_inactive',\n employees_active_inactive: 'employees_active_inactive',\n\n list_saved_reports: 'list_saved_reports',\n saved_reports: 'list_saved_reports',\n\n run_saved_report: 'run_saved_report',\n execute_saved_report: 'run_saved_report',\n\n export_saved_report: 'export_saved_report',\n saved_report_export: 'export_saved_report'\n };\n\n return aliases[intent] || intent || 'unknown';\n}\n\nfunction normalizeRisk(value) {\n const normalized = normalize(value);\n\n if (['low', 'medium', 'high', 'critical'].includes(normalized)) {\n return normalized;\n }\n\n if (normalized === 'bajo') return 'low';\n if (normalized === 'medio') return 'medium';\n if (normalized === 'alto') return 'high';\n if (normalized === 'critico' || normalized === 'crítico') return 'critical';\n\n return 'low';\n}\n\nfunction normalizeConfidence(value) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) return 0;\n if (number < 0) return 0;\n if (number > 1) return 1;\n\n return Number(number.toFixed(2));\n}\n\nfunction normalizeArray(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean);\n }\n\n if (typeof value === 'string' && value.trim()) {\n return [value.trim()];\n }\n\n return [];\n}\n\nfunction normalizeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nconst gemini =\n data.gemini_interpretation &&\n typeof data.gemini_interpretation === 'object'\n ? data.gemini_interpretation\n : null;\n\nconst shouldFallback =\n data.use_deterministic_parser_fallback === true ||\n data.gemini_interpretation_valid !== true ||\n !gemini;\n\nif (shouldFallback) {\n return {\n json: {\n ...data,\n\n ai_interpretation_source: 'deterministic_fallback',\n ai_interpretation_applied: false,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n ai_interpretation_source: 'deterministic_fallback',\n ai_interpretation_applied: false,\n gemini_interpretation_valid: data.gemini_interpretation_valid === true,\n gemini_interpretation_issues: data.gemini_interpretation_issues || []\n }\n }\n };\n}\n\nconst mode = normalizeMode(gemini.mode);\nconst intent = normalizeIntent(gemini.intent);\nconst confidence = normalizeConfidence(gemini.confidence);\nconst riskLevel = normalizeRisk(gemini.risk_level);\n\nconst employeeQuery =\n gemini.employee_query !== undefined && gemini.employee_query !== null\n ? cleanText(gemini.employee_query)\n : data.employee_query || null;\n\nconst employeeId =\n gemini.employee_id !== undefined && gemini.employee_id !== null\n ? cleanText(gemini.employee_id)\n : data.employee_id || null;\n\nconst requestedFields = normalizeArray(gemini.requested_fields);\n\nconst tableName =\n gemini.table_name !== undefined && gemini.table_name !== null\n ? cleanText(gemini.table_name)\n : data.table_name || null;\n\nconst filters = {\n ...(data.filters || {}),\n ...normalizeObject(gemini.filters)\n};\n\nconst reportType =\n gemini.report_type !== undefined && gemini.report_type !== null\n ? cleanText(gemini.report_type)\n : data.report_type || intent;\n\nconst action =\n gemini.action && typeof gemini.action === 'object' && !Array.isArray(gemini.action)\n ? gemini.action\n : data.action || null;\n\nconst missingInformation = normalizeArray(gemini.missing_information);\n\nreturn {\n json: {\n ...data,\n\n mode,\n intent,\n confidence,\n risk_level: riskLevel,\n requires_confirmation: gemini.requires_confirmation === true,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n\n requested_fields: requestedFields,\n table_name: tableName,\n\n filters,\n report_type: reportType,\n\n action,\n\n missing_information: missingInformation,\n reasoning_summary: gemini.reasoning_summary || null,\n\n ai_interpretation_source: 'gemini',\n ai_interpretation_applied: true,\n\n data: {\n ...(data.data || {}),\n\n mode,\n intent,\n confidence,\n risk_level: riskLevel,\n requires_confirmation: gemini.requires_confirmation === true,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n\n requested_fields: requestedFields,\n table_name: tableName,\n\n filters,\n report_type: reportType,\n\n action,\n missing_information: missingInformation\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n ai_interpretation_source: 'gemini',\n ai_interpretation_applied: true,\n\n gemini_interpreted_mode: mode,\n gemini_interpreted_intent: intent,\n gemini_confidence: confidence,\n gemini_risk_level: riskLevel,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n requested_fields: requestedFields,\n table_name: tableName,\n filters,\n report_type: reportType,\n action,\n\n missing_information: missingInformation,\n reasoning_summary: gemini.reasoning_summary || null\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10112,
|
||
-2304
|
||
],
|
||
"id": "71cc5ff5-97ae-462a-9c2e-4d7052b39b4e",
|
||
"name": "Code - Apply Gemini Interpretation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n GATE DE APROBACIÓN SENSIBLE DE MÁXIMO\n\n Objetivo:\n - Detectar acciones sensibles.\n - Si el solicitante NO es Máximo, crear aprobación pendiente.\n - Si el solicitante ES Máximo, NO mandarle aprobación a sí mismo.\n - Aunque Máximo no requiera aprobación de Máximo, la acción real todavía debe pasar\n por la confirmación normal estricta si corresponde.\n*/\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\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.map(item => String(item));\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.map(item => String(item));\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 getActionField(context) {\n return (\n context.action?.field ||\n context.data?.action?.field ||\n context.action?.payload?.field ||\n context.data?.action?.payload?.field ||\n context.payload?.field ||\n context.data?.payload?.field ||\n null\n );\n}\n\nfunction getActionType(context) {\n return (\n context.action?.type ||\n context.data?.action?.type ||\n context.action_type ||\n context.audit_event?.action_type ||\n context.intent ||\n null\n );\n}\n\nfunction getMessage(context) {\n return normalize(\n context.original_message ||\n context.message ||\n context.user_message ||\n context.audit_event?.original_message ||\n context.data?.original_message ||\n context.data?.message ||\n ''\n );\n}\n\nfunction messageContainsSensitiveTerms(context) {\n const message = getMessage(context);\n\n const terms = [\n 'salario',\n 'sueldo',\n 'compensacion',\n 'compensación',\n 'compensation',\n 'salary',\n 'pay rate',\n 'pago',\n 'aumento',\n 'incremento salarial',\n 'bono',\n 'bonus',\n\n 'desvincular',\n 'desvinculacion',\n 'desvinculación',\n 'terminar',\n 'dar de baja',\n 'termination',\n 'terminate',\n 'terminated',\n\n 'subir documento',\n 'subir archivo',\n 'documento sensible',\n 'archivo sensible',\n\n 'cuenta bancaria',\n 'banco',\n 'cedula',\n 'cédula',\n 'dni',\n 'pasaporte',\n 'passport'\n ];\n\n return terms.some(term =>\n message.includes(normalize(term))\n );\n}\n\nfunction fieldIsSensitive(field) {\n const normalizedField = normalize(field);\n\n if (!normalizedField) return false;\n\n const sensitiveFields = [\n 'compensation',\n 'salary',\n 'salario',\n 'sueldo',\n 'pay',\n 'payrate',\n 'pay rate',\n 'rate',\n 'bonus',\n 'bono',\n\n 'termination',\n 'terminate',\n 'employmentstatus',\n 'employment status',\n\n 'bank',\n 'banco',\n 'cuenta',\n 'account',\n\n 'ssn',\n 'cedula',\n 'cédula',\n 'dni',\n 'passport',\n 'pasaporte'\n ];\n\n return sensitiveFields.some(item =>\n normalizedField.includes(normalize(item))\n );\n}\n\nfunction requestedFieldsContainSensitiveFields(context) {\n const fields = [\n ...parseArray(context.requested_fields),\n ...parseArray(context.data?.requested_fields),\n ...parseArray(context.action?.requested_fields),\n ...parseArray(context.data?.action?.requested_fields)\n ];\n\n return fields.some(field => fieldIsSensitive(field));\n}\n\nfunction payloadContainsSensitiveKeys(context) {\n const payloads = [\n context.payload,\n context.data?.payload,\n context.action?.payload,\n context.data?.action?.payload\n ].filter(value =>\n value &&\n typeof value === 'object' &&\n !Array.isArray(value)\n );\n\n for (const payload of payloads) {\n for (const key of Object.keys(payload)) {\n if (fieldIsSensitive(key)) {\n return true;\n }\n }\n }\n\n return false;\n}\n\nfunction intentRequiresApproval(context) {\n const intent = normalize(context.intent);\n\n const approvalIntents = [\n 'terminate_employee',\n 'prepare_terminate_employee',\n 'update_compensation',\n 'update_salary',\n 'upload_employee_file',\n 'delete_employee_file'\n ];\n\n return approvalIntents.includes(intent);\n}\n\nfunction actionTypeRequiresApproval(context) {\n const actionType = normalize(getActionType(context));\n\n if (!actionType) return false;\n\n const approvalActions = [\n 'update_salary',\n 'update_compensation',\n 'compensation',\n 'salary',\n 'terminate_employee',\n 'termination',\n 'upload_employee_file',\n 'delete_employee_file'\n ];\n\n return approvalActions.some(action =>\n actionType.includes(normalize(action))\n );\n}\n\nfunction riskRequiresApproval(context) {\n const risk = normalize(context.risk_level);\n\n return (\n risk === 'high' ||\n risk === 'critical'\n );\n}\n\nfunction isRequesterMaximo(context) {\n const email = normalize(\n context.user_email ||\n context.audit_event?.user_email ||\n context.data?.user_email\n );\n\n const maximoEmails = [\n 'mgomez@gomezleemarketing.com',\n 'maximo@gomezleemarketing.com'\n ];\n\n return maximoEmails.includes(email);\n}\n\nfunction wasAlreadyApprovedByMaximo(context) {\n return (\n context.approved_by_maximo === true ||\n context.maximo_approval_status === 'approved' ||\n context.approval_status === 'approved' ||\n context.audit_event?.approval_status === 'approved'\n );\n}\n\nconst actionField = getActionField(data);\nconst actionType = getActionType(data);\n\nconst requesterIsMaximo = isRequesterMaximo(data);\nconst alreadyApprovedByMaximo = wasAlreadyApprovedByMaximo(data);\n\nconst requiresApproval =\n data.requires_maximo_approval === true ||\n intentRequiresApproval(data) ||\n actionTypeRequiresApproval(data) ||\n fieldIsSensitive(actionField) ||\n requestedFieldsContainSensitiveFields(data) ||\n payloadContainsSensitiveKeys(data) ||\n riskRequiresApproval(data) ||\n messageContainsSensitiveTerms(data);\n\n/*\n Regla clave:\n - Si Isaac u otro usuario pide algo sensible: crear aprobación de Máximo.\n - Si Máximo pide algo sensible: NO crear aprobación para él mismo.\n - Si ya vino aprobado por Máximo: no volver a crear aprobación.\n - Si es una acción confirmada desde Google Chat: no volver a crear aprobación.\n*/\nconst shouldCreateMaximoApproval =\n requiresApproval === true &&\n requesterIsMaximo !== true &&\n alreadyApprovedByMaximo !== true &&\n data.confirmed_from_google_chat !== true &&\n data.final_execution_approved !== true;\n\nconst maximoApprovalStatus = shouldCreateMaximoApproval\n ? 'pending_approval_required'\n : requesterIsMaximo && requiresApproval\n ? 'not_required_requester_is_maximo'\n : alreadyApprovedByMaximo\n ? 'not_required_already_approved_by_maximo'\n : 'not_required';\n\nreturn {\n json: {\n ...data,\n\n requires_maximo_approval: requiresApproval,\n should_create_maximo_approval: shouldCreateMaximoApproval,\n\n requester_is_maximo: requesterIsMaximo,\n already_approved_by_maximo: alreadyApprovedByMaximo,\n\n sensitive_approval_action_type: actionType,\n sensitive_approval_action_field: actionField,\n\n maximo_approval_status: maximoApprovalStatus,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n requires_maximo_approval: requiresApproval,\n should_create_maximo_approval: shouldCreateMaximoApproval,\n\n requester_is_maximo: requesterIsMaximo,\n already_approved_by_maximo: alreadyApprovedByMaximo,\n\n sensitive_approval_action_type: actionType,\n sensitive_approval_action_field: actionField,\n\n maximo_approval_status: maximoApprovalStatus\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8704,
|
||
-2624
|
||
],
|
||
"id": "f76ccb84-0963-4fa3-9f3b-1b1966ef1fd5",
|
||
"name": "Code - Sensitive Approval Gate"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b78c2b9a-a183-46f5-9e7f-4fe5f6193003",
|
||
"leftValue": "={{ $json.should_create_maximo_approval }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-8496,
|
||
-2624
|
||
],
|
||
"id": "6649f03d-f59e-478a-afce-ab713886959d",
|
||
"name": "IF - Needs Maximo Approval?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n Este nodo prepara una solicitud pendiente de aprobación para Máximo.\n\n Importante:\n - NO ejecuta BambooHR.\n - NO modifica salario.\n - NO desvincula.\n - NO sube archivos.\n - Solo construye el registro que se guardará en Supabase.\n*/\n\nconst MAXIMO_EMAIL = 'mgomez@gomezleemarketing.com';\nconst MAXIMO_NAME = 'Máximo Gómez';\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getFirst(values, fallback = null) {\n for (const value of values) {\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return fallback;\n}\n\nfunction safeJsonObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction createApprovalId() {\n const now = new Date();\n\n const stamp = now\n .toISOString()\n .replace(/[-:]/g, '')\n .replace('T', '')\n .slice(0, 14);\n\n const random = Math.random()\n .toString(36)\n .slice(2, 8)\n .toUpperCase();\n\n return `APR-${stamp}-${random}`;\n}\n\nfunction addHours(date, hours) {\n return new Date(date.getTime() + hours * 60 * 60 * 1000);\n}\n\nfunction getActionType(context) {\n return getFirst([\n context.sensitive_approval_action_type,\n context.action?.type,\n context.data?.action?.type,\n context.action_type,\n context.intent\n ], 'sensitive_action');\n}\n\nfunction getTargetEmployeeId(context) {\n return getFirst([\n context.employee_id,\n context.resolved_employee_id,\n context.data?.employee_id,\n context.data?.resolved_employee_id,\n context.audit_event?.target_employee_id,\n context.action?.payload?.employee_id,\n context.action?.payload?.employeeId,\n context.data?.action?.payload?.employee_id,\n context.data?.action?.payload?.employeeId\n ]);\n}\n\nfunction getTargetEmployeeName(context) {\n return getFirst([\n context.employee_name,\n context.resolved_employee_name,\n context.data?.employee_name,\n context.data?.resolved_employee_name,\n context.audit_event?.target_employee_name,\n context.employee_query\n ]);\n}\n\nfunction getTargetEmployeeEmail(context) {\n return getFirst([\n context.employee_email,\n context.resolved_employee_email,\n context.data?.employee_email,\n context.data?.resolved_employee_email,\n context.audit_event?.target_employee_email\n ]);\n}\n\nfunction getOriginalMessage(context) {\n return getFirst([\n context.original_message,\n context.message,\n context.user_message,\n context.audit_event?.original_message,\n context.data?.original_message,\n context.data?.message\n ], '');\n}\n\nfunction getGoogleChatSpaceName(context) {\n return getFirst([\n context.google_chat_space_name,\n context.space_name,\n context.space?.name,\n context.data?.google_chat_space_name,\n context.data?.space_name,\n context.audit_event?.google_chat_space_name\n ]);\n}\n\nfunction getGoogleChatThreadName(context) {\n return getFirst([\n context.google_chat_thread_name,\n context.thread_name,\n context.thread?.name,\n context.message?.thread?.name,\n context.data?.google_chat_thread_name,\n context.data?.thread_name,\n context.audit_event?.google_chat_thread_name\n ]);\n}\n\nfunction getGoogleChatMessageName(context) {\n return getFirst([\n context.google_chat_message_name,\n context.message_name,\n context.message?.name,\n context.data?.google_chat_message_name,\n context.data?.message_name,\n context.audit_event?.google_chat_message_name\n ]);\n}\n\nfunction buildHumanActionName(actionType, intent) {\n const normalizedAction = normalize(actionType);\n const normalizedIntent = normalize(intent);\n\n if (\n normalizedAction.includes('salary') ||\n normalizedAction.includes('compensation') ||\n normalizedAction.includes('salario') ||\n normalizedIntent.includes('salary') ||\n normalizedIntent.includes('compensation')\n ) {\n return 'Cambio o consulta sensible de salario / compensación';\n }\n\n if (\n normalizedAction.includes('terminate') ||\n normalizedAction.includes('termination') ||\n normalizedIntent.includes('terminate') ||\n normalizedIntent.includes('desvinc')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n normalizedAction.includes('file') ||\n normalizedAction.includes('document') ||\n normalizedIntent.includes('file') ||\n normalizedIntent.includes('document')\n ) {\n return 'Carga o modificación de archivo de empleado';\n }\n\n return actionType || intent || 'Acción sensible';\n}\n\nconst now = new Date();\nconst expiresAt = addHours(now, 6);\n\nconst approvalId = createApprovalId();\n\nconst requestId =\n data.request_id ||\n data.audit_event?.request_id ||\n `request_${approvalId}`;\n\nconst requestedByEmail =\n getFirst([\n data.user_email,\n data.requested_by_email,\n data.audit_event?.user_email\n ], 'usuario_no_disponible');\n\nconst requestedByName =\n getFirst([\n data.user_name,\n data.requested_by_name,\n data.audit_event?.user_name\n ], requestedByEmail);\n\nconst mode = data.mode || data.data?.mode || 'accion';\nconst intent = data.intent || data.data?.intent || 'unknown';\nconst actionType = getActionType(data);\nconst riskLevel = data.risk_level || data.data?.risk_level || 'high';\n\nconst targetEmployeeId = getTargetEmployeeId(data);\nconst targetEmployeeName = getTargetEmployeeName(data);\nconst targetEmployeeEmail = getTargetEmployeeEmail(data);\n\nconst originalMessage = getOriginalMessage(data);\n\nconst proposedAction =\n data.action ||\n data.data?.action ||\n {\n type: actionType,\n intent\n };\n\nconst proposedPayload =\n proposedAction.payload ||\n data.data?.action?.payload ||\n data.payload ||\n {};\n\nconst humanActionName = buildHumanActionName(actionType, intent);\n\nconst approvalTitle = `${humanActionName}`;\n\nconst approvalSummary =\n `Solicitud sensible pendiente de aprobación.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Solicitante: ${requestedByName} <${requestedByEmail}>\\n` +\n `Aprobador: ${MAXIMO_NAME} <${MAXIMO_EMAIL}>\\n` +\n `Acción: ${humanActionName}\\n` +\n `Intent: ${intent}\\n` +\n `Riesgo: ${riskLevel}\\n` +\n `Empleado: ${targetEmployeeName || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Expira: ${expiresAt.toISOString()}\\n\\n` +\n `Mensaje original:\\n${originalMessage || 'No disponible'}`;\n\nconst approvalReason =\n `La solicitud requiere aprobación de Máximo porque fue clasificada como sensible o de alto riesgo.`;\n\nconst pendingPayload = {\n approval_id: approvalId,\n request_id: requestId,\n\n original_context: data,\n\n proposed_action: proposedAction,\n proposed_payload: proposedPayload,\n\n execution_allowed_after_approval: true,\n expires_at: expiresAt.toISOString()\n};\n\nconst row = {\n approval_id: approvalId,\n request_id: requestId,\n\n requested_by_email: requestedByEmail,\n requested_by_name: requestedByName,\n\n approver_email: MAXIMO_EMAIL,\n approver_name: MAXIMO_NAME,\n\n channel: data.channel || data.audit_event?.channel || 'google_chat',\n environment: data.environment || data.audit_event?.environment || 'development',\n\n google_chat_space_name: getGoogleChatSpaceName(data),\n google_chat_thread_name: getGoogleChatThreadName(data),\n google_chat_message_name: getGoogleChatMessageName(data),\n\n mode,\n intent,\n action_type: actionType,\n risk_level: riskLevel,\n\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n target_employee_email: targetEmployeeEmail,\n\n approval_title: approvalTitle,\n approval_summary: approvalSummary,\n approval_reason: approvalReason,\n\n original_message: originalMessage,\n\n proposed_action: proposedAction,\n proposed_payload: proposedPayload,\n pending_payload: pendingPayload,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n maximo_approval_required: true,\n execution_result: 'pending_maximo_approval_created'\n },\n\n approval_status: 'pending',\n approval_type: 'sensitive_action',\n\n expires_at: expiresAt.toISOString()\n};\n\nconst responseText =\n `Solicitud pendiente de aprobación de Máximo.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Acción: ${humanActionName}\\n` +\n `Empleado: ${targetEmployeeName || data.employee_query || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Solicitante: ${requestedByName}\\n` +\n `Expira en: 6 horas\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nconst maximoMessage =\n `Solicitud sensible pendiente de aprobación\\n\\n` +\n `ID: ${approvalId}\\n` +\n `Solicitante: ${requestedByName} <${requestedByEmail}>\\n` +\n `Acción: ${humanActionName}\\n` +\n `Empleado: ${targetEmployeeName || data.employee_query || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Riesgo: ${riskLevel}\\n` +\n `Expira: ${expiresAt.toLocaleString('es-DO')}\\n\\n` +\n `Mensaje original:\\n${originalMessage || 'No disponible'}\\n\\n` +\n `Para aprobar responde:\\nAPROBAR ${approvalId}\\n\\n` +\n `Para rechazar responde:\\nRECHAZAR ${approvalId}`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'approval_pending_message',\n response_text: responseText,\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt.toISOString(),\n\n maximo_approver_email: MAXIMO_EMAIL,\n maximo_approver_name: MAXIMO_NAME,\n\n supabase_pending_approval_row: row,\n\n maximo_approval_message: maximoMessage,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n maximo_approval_required: true,\n execution_result: 'pending_maximo_approval_prepared'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8240,
|
||
-2816
|
||
],
|
||
"id": "32ecee10-ec1a-447b-af5f-28f2b3ffbd08",
|
||
"name": "Code - Build Maximo Approval Record"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals",
|
||
"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.supabase_pending_approval_row }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-8032,
|
||
-2816
|
||
],
|
||
"id": "5e9e8c59-fea4-4ab4-9265-99250cdbc7ce",
|
||
"name": "HTTP - Insert Pending Maximo Approval",
|
||
"retryOnFail": false
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buildContext = $items('Code - Build Maximo Approval Record')[0].json;\nconst insertResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getActionName(context, approval) {\n const originalMessage = normalize(\n approval?.original_message ||\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n ''\n );\n\n const actionType = normalize(\n approval?.action_type ||\n context.action_type ||\n context.action?.type ||\n context.data?.action?.type ||\n context.intent ||\n ''\n );\n\n const approvalTitle = normalize(\n approval?.approval_title ||\n context.approval_title ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context.proposed_action ||\n context.action ||\n context.data?.action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n\n const proposedField = normalize(\n proposedAction?.field ||\n proposedAction?.payload?.field ||\n ''\n );\n\n const proposedPayload = proposedAction?.payload || {};\n\n const joinedText = [\n originalMessage,\n actionType,\n approvalTitle,\n proposedTable,\n proposedField,\n normalize(JSON.stringify(proposedPayload))\n ].join(' ');\n\n if (\n joinedText.includes('salario') ||\n joinedText.includes('sueldo') ||\n joinedText.includes('compensacion') ||\n joinedText.includes('compensation') ||\n joinedText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n joinedText.includes('desvincular') ||\n joinedText.includes('desvinculacion') ||\n joinedText.includes('terminar') ||\n joinedText.includes('termination') ||\n joinedText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n joinedText.includes('archivo') ||\n joinedText.includes('documento') ||\n joinedText.includes('file') ||\n joinedText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (\n actionType.includes('add_row') ||\n actionType.includes('add_table_row') ||\n proposedAction?.type === 'add_row' ||\n proposedAction?.type === 'add_table_row'\n ) {\n return 'Actualización de historial del empleado';\n }\n\n if (\n actionType.includes('update_field') ||\n actionType.includes('update_employee') ||\n proposedAction?.type === 'update_field'\n ) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst insertedApproval = getFirstRow(insertResponse);\n\nconst approvalId =\n insertedApproval?.approval_id ||\n buildContext.approval_id ||\n 'No disponible';\n\nconst expiresAt =\n insertedApproval?.expires_at ||\n buildContext.approval_expires_at ||\n buildContext.expires_at ||\n null;\n\nconst employeeName =\n insertedApproval?.target_employee_name ||\n insertedApproval?.employee_name ||\n buildContext.target_employee_name ||\n buildContext.employee_name ||\n buildContext.resolved_employee_name ||\n 'empleado indicado';\n\nconst actionName = getActionName(buildContext, insertedApproval);\n\nconst readableExpiresAt = formatDateForDR(expiresAt);\n\nconst responseText =\n `Tu solicitud fue enviada a Máximo para aprobación.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Expira: ${readableExpiresAt}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR todavía.\\n\\n` +\n `Te avisaré automáticamente cuando Máximo apruebe o rechace la solicitud.`;\n\nreturn {\n json: {\n ...buildContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n maximo_approval_status: 'pending_approval_created',\n approval_pending_message_clean: true,\n\n data: {\n ...(buildContext.data || {}),\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n maximo_approval_status: 'pending_approval_created'\n },\n\n audit_event: {\n ...(buildContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n maximo_approval_status: 'pending_approval_created',\n approval_pending_message_clean: true,\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n execution_result: 'pending_maximo_approval'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7536,
|
||
-2816
|
||
],
|
||
"id": "9e478fe8-1785-4b92-9329-34fbe14784d5",
|
||
"name": "Code - Format Maximo Approval Pending Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,role,is_active,can_receive_approvals,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent('mgomez@gomezleemarketing.com') + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-7712,
|
||
-3104
|
||
],
|
||
"id": "1d3d89fc-ab65-443e-9056-63208876b673",
|
||
"name": "HTTP - Get Maximo Chat Contact"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buildContext = $items('Code - Build Maximo Approval Record')[0].json;\nconst insertResponse = $items('HTTP - Insert Pending Maximo Approval')[0].json;\nconst contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction escapeHtml(value) {\n return cleanText(value)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanRequestType(context, insertedApproval) {\n const rawAction =\n insertedApproval?.action_type ||\n insertedApproval?.approval_title ||\n context.supabase_pending_approval_row?.action_type ||\n context.supabase_pending_approval_row?.approval_title ||\n context.sensitive_approval_action_type ||\n context.action?.type ||\n context.intent ||\n '';\n\n const originalMessage =\n insertedApproval?.original_message ||\n context.supabase_pending_approval_row?.original_message ||\n context.original_message ||\n context.message ||\n '';\n\n const combined = normalize(`${rawAction} ${originalMessage}`);\n\n if (\n combined.includes('salario') ||\n combined.includes('sueldo') ||\n combined.includes('compensacion') ||\n combined.includes('compensation') ||\n combined.includes('salary')\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n combined.includes('desvincular') ||\n combined.includes('terminar') ||\n combined.includes('termination') ||\n combined.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n combined.includes('archivo') ||\n combined.includes('documento') ||\n combined.includes('file') ||\n combined.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n return 'Acción sensible';\n}\n\nconst insertedApproval = getFirstRow(insertResponse);\nconst maximoContact = getFirstRow(contactResponse);\n\nconst approvalId =\n insertedApproval?.approval_id ||\n buildContext.approval_id;\n\nconst expiresAt =\n insertedApproval?.expires_at ||\n buildContext.approval_expires_at;\n\nconst readableExpiresAt = formatDateForDR(expiresAt);\n\nconst requestType = getHumanRequestType(buildContext, insertedApproval);\n\nconst requesterName =\n insertedApproval?.requested_by_name ||\n buildContext.supabase_pending_approval_row?.requested_by_name ||\n buildContext.user_name ||\n 'Usuario no disponible';\n\nconst requesterEmail =\n insertedApproval?.requested_by_email ||\n buildContext.supabase_pending_approval_row?.requested_by_email ||\n buildContext.user_email ||\n 'Correo no disponible';\n\nconst originalMessage =\n insertedApproval?.original_message ||\n buildContext.supabase_pending_approval_row?.original_message ||\n buildContext.original_message ||\n buildContext.message ||\n 'No disponible';\n\nconst maximoSpaceName =\n maximoContact?.google_chat_space_name ||\n null;\n\nconst canReceiveApprovals =\n maximoContact?.can_receive_approvals === true &&\n cleanText(maximoSpaceName) !== '';\n\nconst cardId =\n `maximo_approval_${String(approvalId || 'sin_id').replace(/[^a-zA-Z0-9_]/g, '_')}`;\n\nconst googleChatCardBody = {\n text: 'Solicitud sensible pendiente de aprobación.',\n cardsV2: [\n {\n cardId,\n card: {\n header: {\n title: 'Solicitud sensible pendiente de aprobación',\n subtitle: 'GLM BambooHR Agent'\n },\n sections: [\n {\n widgets: [\n {\n decoratedText: {\n topLabel: 'Tipo de solicitud',\n text: escapeHtml(requestType),\n wrapText: true\n }\n },\n {\n decoratedText: {\n topLabel: 'Solicitante',\n text: `${escapeHtml(requesterName)} <${escapeHtml(requesterEmail)}>`,\n wrapText: true\n }\n },\n {\n decoratedText: {\n topLabel: 'Expira',\n text: escapeHtml(readableExpiresAt),\n wrapText: true\n }\n },\n {\n textParagraph: {\n text:\n `<b>Mensaje original</b><br>` +\n `${escapeHtml(originalMessage).replace(/\\n/g, '<br>')}`\n }\n },\n {\n textParagraph: {\n text:\n `<i>No se ejecutará ningún cambio en BambooHR hasta que apruebes.</i><br>` +\n `<i>Esta solicitud vence automáticamente en 6 horas.</i>`\n }\n },\n {\n buttonList: {\n buttons: [\n {\n text: '✅ Aprobar solicitud',\n onClick: {\n action: {\n function: 'maximo_approval_decision',\n parameters: [\n {\n key: 'approval_id',\n value: approvalId\n },\n {\n key: 'decision',\n value: 'approve'\n }\n ]\n }\n }\n },\n {\n text: '❌ Rechazar solicitud',\n onClick: {\n action: {\n function: 'maximo_approval_decision',\n parameters: [\n {\n key: 'approval_id',\n value: approvalId\n },\n {\n key: 'decision',\n value: 'reject'\n }\n ]\n }\n }\n }\n ]\n }\n }\n ]\n }\n ]\n }\n }\n ]\n};\n\nreturn {\n json: {\n ...buildContext,\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n request_type_human: requestType,\n\n maximo_chat_contact_found: !!maximoContact,\n maximo_chat_notification_ready: canReceiveApprovals,\n maximo_chat_space_name: maximoSpaceName,\n\n maximo_chat_notification_text:\n `Solicitud sensible pendiente de aprobación.\\n\\n` +\n `Máximo, revisa la tarjeta para aprobar o rechazar.`,\n\n google_chat_message_body: googleChatCardBody,\n\n audit_event: {\n ...(buildContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n request_type_human: requestType,\n\n maximo_chat_contact_found: !!maximoContact,\n maximo_chat_notification_ready: canReceiveApprovals,\n maximo_chat_space_name: maximoSpaceName,\n maximo_notification_status: canReceiveApprovals\n ? 'ready_to_send_card'\n : 'missing_maximo_chat_space'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7504,
|
||
-3104
|
||
],
|
||
"id": "5606a01e-32b2-4947-9c98-6654fe456347",
|
||
"name": "Code - Build Maximo Chat Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "5169a929-574d-4604-931c-43f7f4dfbcd8",
|
||
"leftValue": "={{ $json.maximo_chat_notification_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-7296,
|
||
-3104
|
||
],
|
||
"id": "d4ce2dbb-fa57-478c-bc57-c67e0e98ce15",
|
||
"name": "IF - Maximo Chat Space Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getOriginalMessage(context) {\n return cleanText(\n context.original_message ||\n context.message ||\n context.text ||\n context.user_message ||\n context.data?.original_message ||\n context.data?.message ||\n context.body?.message?.text ||\n context.body?.message?.argumentText ||\n ''\n );\n}\n\nfunction extractApprovalId(text) {\n const match = String(text || '').match(/\\bAPR-\\d{14}-[A-Z0-9]{6}\\b/i);\n\n return match ? match[0].toUpperCase() : null;\n}\n\nconst originalMessage = getOriginalMessage(data);\nconst normalizedMessage = normalize(originalMessage);\n\nconst approvalId = extractApprovalId(originalMessage);\n\nconst isApproveCommand =\n normalizedMessage.startsWith('aprobar') ||\n normalizedMessage.startsWith('approve') ||\n normalizedMessage.startsWith('aprobado');\n\nconst isRejectCommand =\n normalizedMessage.startsWith('rechazar') ||\n normalizedMessage.startsWith('reject') ||\n normalizedMessage.startsWith('rechazado') ||\n normalizedMessage.startsWith('denegar') ||\n normalizedMessage.startsWith('deny');\n\nconst isApprovalCommand =\n isApproveCommand ||\n isRejectCommand;\n\nlet approvalCommand = null;\n\nif (isApproveCommand) {\n approvalCommand = 'approve';\n}\n\nif (isRejectCommand) {\n approvalCommand = 'reject';\n}\n\nif (isApprovalCommand && !approvalId) {\n return {\n json: {\n ...data,\n\n is_approval_command: true,\n approval_command_valid: false,\n approval_command: approvalCommand,\n approval_id: null,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `Detecté que quieres ${approvalCommand === 'approve' ? 'aprobar' : 'rechazar'} una solicitud, pero falta el ID de aprobación.\\n\\n` +\n `Usa este formato:\\n` +\n `${approvalCommand === 'approve' ? 'APROBAR' : 'RECHAZAR'} APR-YYYYMMDDHHMMSS-XXXXXX`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n is_approval_command: true,\n approval_command_valid: false,\n approval_command: approvalCommand,\n approval_id: null,\n execution_result: 'approval_command_missing_approval_id'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n is_approval_command: isApprovalCommand,\n approval_command_valid: isApprovalCommand ? true : null,\n approval_command: approvalCommand,\n approval_id: approvalId,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n is_approval_command: isApprovalCommand,\n approval_command_valid: isApprovalCommand ? true : null,\n approval_command: approvalCommand,\n approval_id: approvalId\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12000,
|
||
-2576
|
||
],
|
||
"id": "b035b321-5407-4e17-88d7-31b5a0868092",
|
||
"name": "Code - Detect Approval Command"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c45ec1e8-a19c-4327-80c2-85d5653f86f2",
|
||
"leftValue": "={{ $json.is_approval_command }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-11792,
|
||
-2576
|
||
],
|
||
"id": "6d150e82-0d1d-498b-8ee0-bbdfe06785d2",
|
||
"name": "IF - Is Approval Command?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c53dac63-614f-4a9f-8d17-8e861ebfa40a",
|
||
"leftValue": "={{ $json.approval_command_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-11536,
|
||
-2736
|
||
],
|
||
"id": "0f227f13-e900-4d06-b1b5-23357b94bf6b",
|
||
"name": "IF - Approval Command Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.{{ $json.approval_id }}&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-11200,
|
||
-2912
|
||
],
|
||
"id": "57fb0092-0cac-4c32-83fa-3355a655f8f7",
|
||
"name": "HTTP - Get Pending Approval By ID",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const commandContext = $items('Code - Detect Approval Command')[0].json;\nconst response = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst approval = getFirstRow(response);\n\nconst approvalId = commandContext.approval_id;\nconst decision = commandContext.approval_command;\n\nconst userEmail =\n commandContext.user_email ||\n commandContext.audit_event?.user_email ||\n commandContext.data?.user_email ||\n null;\n\nconst userName =\n commandContext.user_name ||\n commandContext.audit_event?.user_name ||\n commandContext.data?.user_name ||\n userEmail ||\n 'Usuario no disponible';\n\nif (!approval) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_not_found',\n\n response_type: 'chat_message',\n response_text:\n `No encontré una solicitud pendiente con el ID:\\n${approvalId}\\n\\n` +\n `Verifica el ID e inténtalo de nuevo.`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n execution_result: 'approval_not_found'\n }\n }\n };\n}\n\nconst expectedApproverEmail = normalize(approval.approver_email);\nconst actualUserEmail = normalize(userEmail);\n\nif (expectedApproverEmail && expectedApproverEmail !== actualUserEmail) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approver_mismatch',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `No tienes permiso para decidir esta aprobación.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Aprobador esperado: ${approval.approver_name || approval.approver_email}\\n` +\n `Usuario actual: ${userName} <${userEmail || 'correo no disponible'}>`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n expected_approver_email: approval.approver_email,\n actual_user_email: userEmail,\n execution_result: 'approval_decision_blocked_approver_mismatch'\n }\n }\n };\n}\n\nif (approval.approval_status !== 'pending') {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_not_pending',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `Esta solicitud ya no está pendiente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado actual: ${approval.approval_status}`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n approval_status: approval.approval_status,\n execution_result: 'approval_decision_blocked_not_pending'\n }\n }\n };\n}\n\nconst now = new Date();\nconst expiresAt = new Date(approval.expires_at);\n\nif (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_expired',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `Esta solicitud de aprobación ya venció.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Venció: ${formatDateForDR(approval.expires_at)}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n approval_status: approval.approval_status,\n expires_at: approval.expires_at,\n execution_result: 'approval_decision_blocked_expired'\n }\n }\n };\n}\n\nconst nextStatus = decision === 'approve'\n ? 'approved'\n : 'rejected';\n\nconst decisionAt = new Date().toISOString();\n\nconst updatePayload = decision === 'approve'\n ? {\n approval_status: 'approved',\n approved_at: decisionAt,\n approved_by_email: userEmail,\n approved_by_name: userName,\n execution_result: 'approved_waiting_execution'\n }\n : {\n approval_status: 'rejected',\n rejected_at: decisionAt,\n rejected_by_email: userEmail,\n rejected_by_name: userName,\n rejection_reason: 'Rechazado desde Google Chat',\n execution_result: 'rejected_by_approver'\n };\n\nreturn {\n json: {\n ...commandContext,\n\n can_continue: true,\n\n approval_decision_valid: true,\n approval_decision: decision,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n\n approval_record: approval,\n approval_update_payload: updatePayload,\n\n response_type: 'chat_message',\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: true,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n execution_result: `approval_${nextStatus}_validated`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10992,
|
||
-2912
|
||
],
|
||
"id": "039fb415-91a4-48ea-ba57-c623c2083535",
|
||
"name": "Code - Validate Approval Decision"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "62a5b47d-12f1-42ee-a393-ca12ef9db2fb",
|
||
"leftValue": "={{ $json.approval_decision_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-10784,
|
||
-2912
|
||
],
|
||
"id": "d811c9f8-a840-4732-8d75-11f429b9336e",
|
||
"name": "IF - Approval Decision Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.{{ $json.approval_id }}&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Prefer",
|
||
"value": "return=representation"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.approval_update_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-10400,
|
||
-3008
|
||
],
|
||
"id": "2b4e5e24-6bc0-4454-82bd-6e14d156a273",
|
||
"name": "HTTP - Update Approval Decision",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const decisionContext = $items('Code - Validate Approval Decision')[0].json;\nconst updateResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst updatedApproval = getFirstRow(updateResponse) || decisionContext.approval_record;\n\nconst approvalId =\n updatedApproval?.approval_id ||\n decisionContext.approval_id;\n\nconst status =\n updatedApproval?.approval_status ||\n decisionContext.approval_next_status;\n\nconst actionName =\n updatedApproval?.approval_title ||\n decisionContext.approval_record?.approval_title ||\n 'Acción sensible';\n\nconst requesterName =\n updatedApproval?.requested_by_name ||\n decisionContext.approval_record?.requested_by_name ||\n 'Solicitante no disponible';\n\nconst employeeName =\n updatedApproval?.target_employee_name ||\n decisionContext.approval_record?.target_employee_name ||\n 'No resuelto todavía';\n\nconst employeeId =\n updatedApproval?.target_employee_id ||\n decisionContext.approval_record?.target_employee_id ||\n null;\n\nconst approvedAtReadable = formatDateForDR(\n updatedApproval?.approved_at ||\n updatedApproval?.rejected_at ||\n decisionContext.approval_decision_at\n);\n\nlet responseText = '';\n\nif (status === 'approved') {\n responseText =\n `Solicitud aprobada correctamente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado: approved\\n` +\n `Acción: ${actionName}\\n` +\n `Empleado: ${employeeName}${employeeId ? ` (ID ${employeeId})` : ''}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Aprobada: ${approvedAtReadable}\\n\\n` +\n `Importante: en este momento la solicitud quedó aprobada, pero todavía no hemos conectado la ejecución automática del cambio sensible.`;\n} else {\n responseText =\n `Solicitud rechazada correctamente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado: rejected\\n` +\n `Acción: ${actionName}\\n` +\n `Empleado: ${employeeName}${employeeId ? ` (ID ${employeeId})` : ''}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Rechazada: ${approvedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approval_id: approvalId,\n approval_status: status,\n approval_updated: true,\n approval_update_response: updateResponse,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: status,\n approval_updated: true,\n execution_result: status === 'approved'\n ? 'approval_approved_waiting_execution'\n : 'approval_rejected_no_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10192,
|
||
-3008
|
||
],
|
||
"id": "6523a2b2-b3d0-4616-9c21-8637fdf76ff8",
|
||
"name": "Code - Format Approval Decision Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.maximo_chat_space_name }}",
|
||
"jsonParameters": true,
|
||
"messageJson": "={{ $json.google_chat_message_body }}",
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-6912,
|
||
-3200
|
||
],
|
||
"id": "35146802-016a-4d1d-a402-4e4511f1f7d3",
|
||
"name": "Chat - Enviar notificación aprobación Máximo",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\nconst body = input.body || input;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parametersToObject(value) {\n if (!value) return {};\n\n if (Array.isArray(value)) {\n const obj = {};\n\n for (const item of value) {\n const key = item.key || item.name;\n const val =\n item.value ||\n item.stringValue ||\n item.textValue ||\n item.intValue ||\n item.boolValue;\n\n if (key) {\n obj[key] = val;\n }\n }\n\n return obj;\n }\n\n if (typeof value === 'object') {\n return value;\n }\n\n return {};\n}\n\nfunction getActionParameters(eventBody) {\n return {\n ...parametersToObject(eventBody.common?.parameters),\n ...parametersToObject(eventBody.action?.parameters),\n ...parametersToObject(eventBody.action?.actionParameters),\n ...parametersToObject(eventBody.common?.formInputs),\n ...parametersToObject(eventBody.action?.formInputs)\n };\n}\n\nfunction extractApprovalId(text) {\n const match = String(text || '').match(/\\bAPR-\\d{14}-[A-Z0-9]{6}\\b/i);\n return match ? match[0].toUpperCase() : null;\n}\n\nfunction normalizeDecision(value) {\n const normalized = normalize(value);\n\n if (\n normalized === 'approve' ||\n normalized === 'approved' ||\n normalized === 'aprobar' ||\n normalized === 'aprobado'\n ) {\n return 'approve';\n }\n\n if (\n normalized === 'reject' ||\n normalized === 'rejected' ||\n normalized === 'rechazar' ||\n normalized === 'rechazado' ||\n normalized === 'denegar'\n ) {\n return 'reject';\n }\n\n return null;\n}\n\nconst eventType =\n body.type ||\n input.type ||\n null;\n\nconst actionParameters = getActionParameters(body);\n\nconst invokedFunction =\n body.common?.invokedFunction ||\n body.action?.function ||\n body.action?.actionMethodName ||\n body.action?.actionMethod ||\n null;\n\nlet approvalId =\n actionParameters.approval_id ||\n actionParameters.approvalId ||\n null;\n\nlet approvalDecision =\n normalizeDecision(\n actionParameters.decision ||\n actionParameters.approval_decision ||\n null\n );\n\nconst messageText =\n cleanText(\n body.message?.text ||\n body.message?.argumentText ||\n body.message?.formattedText ||\n ''\n );\n\nconst normalizedMessage = normalize(messageText);\n\nconst isCardClicked =\n eventType === 'CARD_CLICKED';\n\nconst isApprovalFunction =\n invokedFunction === 'maximo_approval_decision' ||\n cleanText(actionParameters.approval_id) !== '';\n\n// Fallback por texto manual: APROBAR APR-...\nconst textApprovalId = extractApprovalId(messageText);\n\nconst isApproveTextCommand =\n normalizedMessage.startsWith('aprobar') ||\n normalizedMessage.startsWith('approve') ||\n normalizedMessage.startsWith('aprobado');\n\nconst isRejectTextCommand =\n normalizedMessage.startsWith('rechazar') ||\n normalizedMessage.startsWith('reject') ||\n normalizedMessage.startsWith('rechazado') ||\n normalizedMessage.startsWith('denegar');\n\nlet isApprovalButtonEvent =\n isCardClicked &&\n isApprovalFunction;\n\nif (!isApprovalButtonEvent && (isApproveTextCommand || isRejectTextCommand)) {\n isApprovalButtonEvent = true;\n approvalId = textApprovalId;\n approvalDecision = isApproveTextCommand ? 'approve' : 'reject';\n}\n\nconst userEmail =\n body.user?.email ||\n body.message?.sender?.email ||\n null;\n\nconst userName =\n body.user?.displayName ||\n body.message?.sender?.displayName ||\n userEmail ||\n 'Usuario no disponible';\n\nconst finalSpaceName =\n body.space?.name ||\n body.message?.space?.name ||\n null;\n\nconst finalThreadName =\n body.thread?.name ||\n body.message?.thread?.name ||\n null;\n\nconst requestId =\n `approval_click_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nif (!isApprovalButtonEvent) {\n return {\n json: {\n ...input,\n\n is_approval_button_event: false,\n approval_decision_event_valid: null,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters\n }\n };\n}\n\nif (!approvalId || !approvalDecision) {\n return {\n json: {\n ...input,\n\n request_id: requestId,\n\n is_approval_button_event: true,\n approval_decision_event_valid: false,\n\n approval_id: approvalId || null,\n approval_decision: approvalDecision || null,\n\n user_email: userEmail,\n user_name: userName,\n\n final_space_name: finalSpaceName,\n final_thread_name: finalThreadName,\n\n response_type: 'chat_message',\n response_text:\n `No pude procesar la decisión de aprobación.\\n\\n` +\n `Falta el ID de aprobación o la decisión del botón.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters\n }\n };\n}\n\nreturn {\n json: {\n ...input,\n\n request_id: requestId,\n\n is_approval_button_event: true,\n approval_decision_event_valid: true,\n\n approval_id: approvalId,\n approval_decision: approvalDecision,\n\n user_email: userEmail,\n user_name: userName,\n\n final_space_name: finalSpaceName,\n final_thread_name: finalThreadName,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters,\n\n response_type: 'approval_decision'\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-21600,
|
||
-2880
|
||
],
|
||
"id": "ef9025d5-315d-421c-b5d8-abf4ce17dfae",
|
||
"name": "Code - Detect Approval Button Event"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "cf3bc951-d0a1-4fd7-b2bb-5f5265dd8b2d",
|
||
"leftValue": "={{ $json.is_approval_button_event }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-21392,
|
||
-2880
|
||
],
|
||
"id": "0fc91f75-294a-4e67-af1d-41fbbf90b081",
|
||
"name": "IF - Is Approval Button Event?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d14b8a65-7635-4be2-b935-2dfd290ae6ea",
|
||
"leftValue": "={{ $json.approval_decision_event_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-21072,
|
||
-3040
|
||
],
|
||
"id": "df4922ce-f5fb-4f15-b241-7bfd99eaca8b",
|
||
"name": "IF - Approval Button Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.' + encodeURIComponent($json.approval_id) + '&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-20720,
|
||
-3152
|
||
],
|
||
"id": "cbe220e6-26b5-462e-a1d9-b61daffbfc3f",
|
||
"name": "HTTP - Get Approval By Button ID"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buttonContext = $items('Code - Detect Approval Button Event')[0].json;\nconst response = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst approval = getFirstRow(response);\n\nconst approvalId = buttonContext.approval_id;\nconst decision = buttonContext.approval_decision;\n\nconst userEmail = buttonContext.user_email || null;\nconst userName = buttonContext.user_name || userEmail || 'Usuario no disponible';\n\nfunction blocked(message, result, extra = {}) {\n return {\n json: {\n ...buttonContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: result,\n\n approval_record: approval || null,\n\n response_type: 'chat_message',\n response_text: message,\n\n audit_event: {\n ...(buttonContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n execution_result: result,\n ...extra\n }\n }\n };\n}\n\nif (!approval) {\n return blocked(\n `No encontré una solicitud pendiente con este ID.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_not_found'\n );\n}\n\nconst expectedApproverEmail = normalize(approval.approver_email);\nconst actualUserEmail = normalize(userEmail);\n\nif (expectedApproverEmail && expectedApproverEmail !== actualUserEmail) {\n return blocked(\n `No tienes permiso para decidir esta aprobación.\\n\\n` +\n `Aprobador esperado: ${approval.approver_name || approval.approver_email}\\n` +\n `Usuario actual: ${userName} <${userEmail || 'correo no disponible'}>\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_decision_blocked_approver_mismatch',\n {\n expected_approver_email: approval.approver_email,\n actual_user_email: userEmail\n }\n );\n}\n\nif (approval.approval_status !== 'pending') {\n return blocked(\n `Esta solicitud ya no está pendiente.\\n\\n` +\n `Estado actual: ${approval.approval_status}\\n\\n` +\n `No se ejecutó ningún cambio adicional en BambooHR.`,\n 'approval_decision_blocked_not_pending',\n {\n approval_status: approval.approval_status\n }\n );\n}\n\nconst now = new Date();\nconst expiresAt = new Date(approval.expires_at);\n\nif (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {\n return blocked(\n `Esta solicitud de aprobación ya venció.\\n\\n` +\n `Venció: ${formatDateForDR(approval.expires_at)}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_decision_blocked_expired',\n {\n expires_at: approval.expires_at\n }\n );\n}\n\nconst nextStatus = decision === 'approve'\n ? 'approved'\n : 'rejected';\n\nconst decisionAt = new Date().toISOString();\n\nconst updatePayload = decision === 'approve'\n ? {\n approval_status: 'approved',\n approved_at: decisionAt,\n approved_by_email: userEmail,\n approved_by_name: userName,\n execution_result: 'approved_waiting_execution'\n }\n : {\n approval_status: 'rejected',\n rejected_at: decisionAt,\n rejected_by_email: userEmail,\n rejected_by_name: userName,\n rejection_reason: 'Rechazado desde botón de Google Chat',\n execution_result: 'rejected_by_approver'\n };\n\nreturn {\n json: {\n ...buttonContext,\n\n can_continue: true,\n\n approval_decision_valid: true,\n approval_decision: decision,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n\n approval_record: approval,\n approval_update_payload: updatePayload,\n\n response_type: 'chat_message',\n\n audit_event: {\n ...(buttonContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: true,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n execution_result: `approval_${nextStatus}_validated`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20368,
|
||
-3152
|
||
],
|
||
"id": "54bd1ef8-dad0-4383-876b-566c4e523f1a",
|
||
"name": "Code - Validate Approval Button Decision"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "279fa4a4-9253-485c-8f3f-c020ac2a8413",
|
||
"leftValue": "={{ $json.approval_decision_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-20160,
|
||
-3184
|
||
],
|
||
"id": "f3317ffe-917c-4029-9684-3d0a1b2e09e9",
|
||
"name": "IF - Approval Button Decision Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Prefer",
|
||
"value": "return=representation"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.approval_update_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-19776,
|
||
-3808
|
||
],
|
||
"id": "a0fea6e6-2885-4b0e-83aa-7250582b9472",
|
||
"name": "HTTP - Update Approval From Button"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const decisionContext = $items('Code - Validate Approval Button Decision')[0].json;\nconst updateResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanActionName(approval, context) {\n const rawText = normalize(\n approval?.approval_title ||\n approval?.action_type ||\n approval?.original_message ||\n context?.approval_record?.approval_title ||\n context?.approval_record?.action_type ||\n context?.approval_record?.original_message ||\n context?.original_message ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context?.approval_record?.proposed_action ||\n context?.proposed_action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n const proposedType = normalize(proposedAction?.type);\n\n if (\n rawText.includes('salario') ||\n rawText.includes('sueldo') ||\n rawText.includes('compensacion') ||\n rawText.includes('compensation') ||\n rawText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n rawText.includes('desvincular') ||\n rawText.includes('desvinculacion') ||\n rawText.includes('terminar') ||\n rawText.includes('termination') ||\n rawText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n rawText.includes('archivo') ||\n rawText.includes('documento') ||\n rawText.includes('file') ||\n rawText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (proposedType === 'add_row' || rawText.includes('add_row')) {\n return 'Actualización de historial del empleado';\n }\n\n if (proposedType === 'update_field' || rawText.includes('update_field')) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst updatedApproval = getFirstRow(updateResponse);\n\nif (!updatedApproval || !updatedApproval.approval_status) {\n const responseText =\n `Recibí la decisión, pero no pude confirmar la actualización en Supabase.\\n\\n` +\n `ID de aprobación: ${decisionContext.approval_id || 'No disponible'}\\n` +\n `Decisión recibida: ${decisionContext.approval_decision || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n\\n` +\n `Revisa el nodo HTTP - Update Approval From Button.`;\n\n return {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n webhook_response_body: {\n text: responseText\n },\n\n approval_updated: false,\n approval_update_response: updateResponse,\n\n final_space_name: decisionContext.final_space_name,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: decisionContext.approval_id || null,\n approval_updated: false,\n execution_result: 'approval_update_failed_no_returned_row'\n }\n }\n };\n}\n\nconst status = updatedApproval.approval_status;\n\nconst actionName = getHumanActionName(updatedApproval, decisionContext);\n\nconst requesterName =\n updatedApproval.requested_by_name ||\n decisionContext.approval_record?.requested_by_name ||\n 'Solicitante no disponible';\n\nconst decidedAtReadable = formatDateForDR(\n updatedApproval.approved_at ||\n updatedApproval.rejected_at ||\n decisionContext.approval_decision_at\n);\n\nlet responseText = '';\n\nif (status === 'approved') {\n responseText =\n `Solicitud aprobada correctamente.\\n\\n` +\n `Acción: ${actionName}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Aprobada: ${decidedAtReadable}\\n\\n` +\n `La solicitud quedó aprobada y pendiente de ejecución automática.`;\n} else if (status === 'rejected') {\n responseText =\n `Solicitud rechazada correctamente.\\n\\n` +\n `Acción: ${actionName}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Rechazada: ${decidedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n responseText =\n `La solicitud fue procesada, pero quedó con estado inesperado.\\n\\n` +\n `Estado actual: ${status}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n webhook_response_body: {\n text: responseText\n },\n\n approval_id: updatedApproval.approval_id,\n approval_status: status,\n approval_updated: true,\n approval_update_response: updateResponse,\n\n final_space_name: decisionContext.final_space_name,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: updatedApproval.approval_id,\n approval_status: status,\n approval_updated: true,\n execution_result: status === 'approved'\n ? 'approval_approved_waiting_execution'\n : 'approval_rejected_no_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-19504,
|
||
-3696
|
||
],
|
||
"id": "c7902820-7b11-4117-bbb4-392198bb2dc5",
|
||
"name": "Code - Format Approval Button Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $json.webhook_response_body || { text: $json.response_text || 'Solicitud procesada.' } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.5,
|
||
"position": [
|
||
-17344,
|
||
-6016
|
||
],
|
||
"id": "595e726e-ed27-4a86-aa34-abe3e364df96",
|
||
"name": "Respond - Approval Button Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanActionName(approval, context) {\n const rawText = normalize(\n approval?.approval_title ||\n approval?.action_type ||\n approval?.original_message ||\n context?.approval_record?.approval_title ||\n context?.approval_record?.action_type ||\n context?.approval_record?.original_message ||\n context?.original_message ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context?.approval_record?.proposed_action ||\n context?.proposed_action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n const proposedType = normalize(proposedAction?.type);\n\n if (\n rawText.includes('salario') ||\n rawText.includes('sueldo') ||\n rawText.includes('compensacion') ||\n rawText.includes('compensation') ||\n rawText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n rawText.includes('desvincular') ||\n rawText.includes('desvinculacion') ||\n rawText.includes('terminar') ||\n rawText.includes('termination') ||\n rawText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n rawText.includes('archivo') ||\n rawText.includes('documento') ||\n rawText.includes('file') ||\n rawText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (proposedType === 'add_row' || rawText.includes('add_row')) {\n return 'Actualización de historial del empleado';\n }\n\n if (proposedType === 'update_field' || rawText.includes('update_field')) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst updatedApproval =\n getFirstRow(data.approval_update_response) ||\n data.approval_record ||\n data;\n\nconst status =\n data.approval_status ||\n updatedApproval?.approval_status ||\n null;\n\nconst requesterEmail =\n updatedApproval?.requested_by_email ||\n data.approval_record?.requested_by_email ||\n data.requested_by_email ||\n null;\n\nconst requesterName =\n updatedApproval?.requested_by_name ||\n data.approval_record?.requested_by_name ||\n data.requested_by_name ||\n requesterEmail ||\n 'Solicitante';\n\nconst deciderEmail =\n updatedApproval?.approved_by_email ||\n updatedApproval?.rejected_by_email ||\n data.user_email ||\n null;\n\nconst deciderName =\n updatedApproval?.approved_by_name ||\n updatedApproval?.rejected_by_name ||\n data.user_name ||\n 'Máximo';\n\nconst employeeName =\n updatedApproval?.target_employee_name ||\n updatedApproval?.employee_name ||\n data.approval_record?.target_employee_name ||\n data.approval_record?.employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n 'empleado indicado';\n\nconst decisionAt =\n updatedApproval?.approved_at ||\n updatedApproval?.rejected_at ||\n data.approval_decision_at ||\n null;\n\nconst readableDecisionAt = formatDateForDR(decisionAt);\n\nconst actionName = getHumanActionName(updatedApproval, data);\n\nconst originalMessage =\n updatedApproval?.original_message ||\n data.approval_record?.original_message ||\n data.original_message ||\n null;\n\nconst approvalId =\n updatedApproval?.approval_id ||\n data.approval_id ||\n null;\n\nconst shouldNotifyRequester =\n !!requesterEmail &&\n ['approved', 'rejected'].includes(status) &&\n normalize(requesterEmail) !== normalize(deciderEmail);\n\nlet requesterNotificationText = '';\n\nif (status === 'approved') {\n requesterNotificationText =\n `✅ Tu solicitud fue aprobada por Máximo.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Aprobada: ${readableDecisionAt}\\n\\n` +\n `Estado: pendiente de ejecución automática.\\n\\n` +\n `No necesitas escribirle a Máximo por WhatsApp. El agente continuará con el proceso.`;\n} else if (status === 'rejected') {\n requesterNotificationText =\n `❌ Tu solicitud fue rechazada por Máximo.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Rechazada: ${readableDecisionAt}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n requesterNotificationText =\n `La solicitud fue procesada, pero quedó con un estado inesperado.\\n\\n` +\n `Estado actual: ${status || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...data,\n\n approval_id: approvalId,\n approval_status: status,\n\n approval_requester_email: requesterEmail,\n approval_requester_name: requesterName,\n\n approval_decider_email: deciderEmail,\n approval_decider_name: deciderName,\n\n approval_employee_name: employeeName,\n approval_action_name: actionName,\n approval_original_message: originalMessage,\n\n should_notify_requester: shouldNotifyRequester,\n requester_notification_text: requesterNotificationText,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: status,\n\n approval_requester_email: requesterEmail,\n approval_requester_name: requesterName,\n\n approval_decider_email: deciderEmail,\n approval_decider_name: deciderName,\n\n should_notify_requester: shouldNotifyRequester,\n requester_notification_prepared: true\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17600,
|
||
-6224
|
||
],
|
||
"id": "47e6d5c4-4354-447f-ae2c-7cde06ff2008",
|
||
"name": "Code - Prepare Requester Approval Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e0b38e3b-0a84-4011-87c5-9985d09af151",
|
||
"leftValue": "={{ $json.should_notify_requester }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-17392,
|
||
-6224
|
||
],
|
||
"id": "6b72019d-6b22-4ce1-806b-6fab4ba68b0f",
|
||
"name": "IF - Should Notify Requester?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,role,is_active,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent($json.approval_requester_email) + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17072,
|
||
-6352
|
||
],
|
||
"id": "9105ea28-b2a3-42d8-bdc6-b11ed7418e7a",
|
||
"name": "HTTP - Get Requester Chat Contact",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const notificationContext = $items('Code - Prepare Requester Approval Notification')[0].json;\nconst contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nconst requesterContact = getFirstRow(contactResponse);\n\nconst requesterChatSpaceName =\n requesterContact?.google_chat_space_name ||\n null;\n\nconst requesterChatAvailable =\n cleanText(requesterChatSpaceName) !== '';\n\nreturn {\n json: {\n ...notificationContext,\n\n requester_chat_contact_found: !!requesterContact,\n requester_chat_space_name: requesterChatSpaceName,\n requester_chat_available: requesterChatAvailable,\n\n requester_chat_notification_text:\n notificationContext.requester_notification_text,\n\n audit_event: {\n ...(notificationContext.audit_event || {}),\n\n requester_chat_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n requester_chat_space_name: requesterChatSpaceName\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16864,
|
||
-6352
|
||
],
|
||
"id": "55eadf4c-a42b-40c6-827e-8f641f5378c5",
|
||
"name": "Code - Build Requester Decision Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "4e80ca2e-d496-4ca1-92bf-66c86407cf04",
|
||
"leftValue": "={{ $json.requester_chat_available }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16656,
|
||
-6352
|
||
],
|
||
"id": "73e8dd34-d8e6-4318-90f9-0d056d53e962",
|
||
"name": "IF - Requester Chat Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.final_space_name || $json.requester_google_chat_space_name }}",
|
||
"messageUi": {
|
||
"text": "={{ $json.requester_chat_notification_text || $json.response_text || 'La solicitud fue actualizada.' }}"
|
||
},
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-11056,
|
||
-4656
|
||
],
|
||
"id": "c976153e-dd92-4eef-905e-4c96d5385c8f",
|
||
"name": "Chat - Notify Requester Approval Decision",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "602a9028-459d-49fe-99d4-918a98bfe282",
|
||
"leftValue": "={{ $json.approval_status }}",
|
||
"rightValue": "=approved",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-19184,
|
||
-3392
|
||
],
|
||
"id": "2ac456fb-fb23-47ec-9bca-8152ebc4ec12",
|
||
"name": "IF - Maximo Approved?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction safeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction firstNonEmpty(...values) {\n for (const value of values) {\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction getHumanActionName(route, actionField, actionTable, originalMessage) {\n const joinedText = normalize([\n route,\n actionField,\n actionTable,\n originalMessage\n ].join(' '));\n\n if (\n route === 'compensation_update' ||\n joinedText.includes('compensation') ||\n joinedText.includes('salario') ||\n joinedText.includes('sueldo') ||\n joinedText.includes('compensacion') ||\n joinedText.includes('salary')\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n route === 'terminate_employee' ||\n joinedText.includes('desvincular') ||\n joinedText.includes('desvinculacion') ||\n joinedText.includes('terminar') ||\n joinedText.includes('termination') ||\n joinedText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n route === 'upload_employee_file' ||\n joinedText.includes('archivo') ||\n joinedText.includes('documento') ||\n joinedText.includes('file')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (route === 'add_table_row') {\n return 'Actualización de historial del empleado';\n }\n\n if (route === 'employee_basic_update') {\n return 'Actualización de dato básico del empleado';\n }\n\n return 'Acción sensible aprobada';\n}\n\nfunction getApprovedActionRoute(actionType, actionField, actionTable, actionPayload) {\n const normalizedType = normalize(actionType);\n const normalizedField = normalize(actionField);\n const normalizedTable = normalize(actionTable);\n const payloadText = normalize(JSON.stringify(actionPayload || {}));\n\n const basicEmployeeFields = [\n 'workemail',\n 'workphone',\n 'workphoneextension',\n 'jobtitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'hiredate',\n 'employmentstatus'\n ];\n\n if (\n normalizedField === 'compensation' ||\n normalizedTable === 'compensation' ||\n payloadText.includes('compensation') ||\n payloadText.includes('salary') ||\n payloadText.includes('salario') ||\n payloadText.includes('sueldo')\n ) {\n return 'compensation_update';\n }\n\n if (\n normalizedType === 'terminate_employee' ||\n normalizedType === 'termination' ||\n normalizedType.includes('terminate')\n ) {\n return 'terminate_employee';\n }\n\n if (\n normalizedType === 'upload_employee_file' ||\n normalizedType === 'upload_file' ||\n normalizedType.includes('file')\n ) {\n return 'upload_employee_file';\n }\n\n if (\n normalizedType === 'add_row' ||\n normalizedType === 'add_table_row'\n ) {\n return 'add_table_row';\n }\n\n if (\n normalizedType === 'update_field' &&\n basicEmployeeFields.includes(normalizedField)\n ) {\n return 'employee_basic_update';\n }\n\n return 'unsupported_action';\n}\n\nconst updateResponse =\n getFirstRow(data.approval_update_response) ||\n {};\n\nconst approvalRecord =\n safeObject(data.approval_record);\n\nconst updatedApproval = {\n ...approvalRecord,\n ...safeObject(updateResponse)\n};\n\nconst pendingPayload =\n safeObject(updatedApproval.pending_payload) ||\n safeObject(approvalRecord.pending_payload) ||\n {};\n\nconst originalContext =\n safeObject(pendingPayload.original_context);\n\nconst originalData =\n safeObject(originalContext.data);\n\nconst proposedAction =\n safeObject(updatedApproval.proposed_action) ||\n safeObject(approvalRecord.proposed_action) ||\n safeObject(pendingPayload.proposed_action) ||\n safeObject(originalData.action) ||\n safeObject(originalContext.action) ||\n safeObject(data.action);\n\nconst proposedPayload =\n safeObject(proposedAction.payload) ||\n safeObject(updatedApproval.proposed_payload) ||\n safeObject(approvalRecord.proposed_payload) ||\n safeObject(pendingPayload.proposed_payload);\n\nconst actionType =\n firstNonEmpty(\n proposedAction.type,\n updatedApproval.action_type,\n approvalRecord.action_type,\n originalData.action?.type,\n originalContext.action?.type,\n data.action_type\n );\n\nconst actionField =\n firstNonEmpty(\n proposedAction.field,\n proposedPayload.field,\n originalData.action?.field,\n originalContext.action?.field\n );\n\nconst actionLabel =\n firstNonEmpty(\n proposedAction.label,\n proposedPayload.label,\n originalData.action?.label,\n originalContext.action?.label,\n actionField\n );\n\nconst actionValue =\n firstNonEmpty(\n proposedAction.value,\n proposedPayload.value,\n originalData.action?.value,\n originalContext.action?.value\n );\n\nconst actionTable =\n firstNonEmpty(\n proposedAction.table,\n proposedPayload.table,\n updatedApproval.target_table,\n approvalRecord.target_table,\n originalData.table_name,\n originalContext.table_name,\n normalize(actionField) === 'compensation' ? 'compensation' : null\n );\n\nconst approvalId =\n firstNonEmpty(\n updatedApproval.approval_id,\n approvalRecord.approval_id,\n data.approval_id\n );\n\nconst originalMessage =\n firstNonEmpty(\n updatedApproval.original_message,\n approvalRecord.original_message,\n originalContext.original_message,\n originalContext.message,\n originalData.original_message,\n data.original_message\n );\n\nconst employeeQuery =\n firstNonEmpty(\n updatedApproval.employee_query,\n approvalRecord.employee_query,\n originalData.employee_query,\n originalContext.employee_query,\n updatedApproval.target_employee_name,\n approvalRecord.target_employee_name,\n data.employee_query\n );\n\nconst targetEmployeeId =\n firstNonEmpty(\n updatedApproval.target_employee_id,\n approvalRecord.target_employee_id,\n originalData.employee_id,\n originalContext.employee_id,\n data.target_employee_id,\n data.employee_id\n );\n\nconst targetEmployeeName =\n firstNonEmpty(\n updatedApproval.target_employee_name,\n approvalRecord.target_employee_name,\n originalData.employee_name,\n originalContext.employee_name,\n data.target_employee_name,\n data.employee_name,\n employeeQuery,\n 'empleado indicado'\n );\n\nconst requesterEmail =\n firstNonEmpty(\n updatedApproval.requested_by_email,\n approvalRecord.requested_by_email,\n data.approval_requester_email,\n data.requester_email\n );\n\nconst requesterName =\n firstNonEmpty(\n updatedApproval.requested_by_name,\n approvalRecord.requested_by_name,\n data.approval_requester_name,\n data.requester_name,\n requesterEmail,\n 'Solicitante'\n );\n\nconst approvedByEmail =\n firstNonEmpty(\n updatedApproval.approved_by_email,\n data.approved_by_email,\n data.user_email\n );\n\nconst approvedByName =\n firstNonEmpty(\n updatedApproval.approved_by_name,\n data.approved_by_name,\n data.user_name,\n 'Máximo'\n );\n\nconst approvedActionPayload = {\n type: actionType,\n field: actionField,\n label: actionLabel,\n value: actionValue,\n table: actionTable,\n payload: proposedPayload\n};\n\nconst approvedActionRoute =\n getApprovedActionRoute(\n actionType,\n actionField,\n actionTable,\n approvedActionPayload\n );\n\nconst approvedActionSupported =\n approvedActionRoute !== 'unsupported_action';\n\nconst requiresEmployeeResolution =\n !targetEmployeeId && !!employeeQuery;\n\nconst approvedActionName =\n getHumanActionName(\n approvedActionRoute,\n actionField,\n actionTable,\n originalMessage\n );\n\nreturn {\n json: {\n ...data,\n\n can_continue: true,\n\n approved_by_maximo: true,\n maximo_approval_status: 'approved',\n approval_status: 'approved',\n approval_id: approvalId,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_payload: approvedActionPayload,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution,\n\n mode: originalData.mode || originalContext.mode || updatedApproval.mode || 'accion',\n intent: originalData.intent || originalContext.intent || updatedApproval.intent || data.intent,\n\n employee_query: employeeQuery,\n employee_id: targetEmployeeId,\n\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n\n requester_email: requesterEmail,\n requester_name: requesterName,\n\n approved_by_email: approvedByEmail,\n approved_by_name: approvedByName,\n\n original_message: originalMessage,\n\n action: approvedActionPayload,\n\n data: {\n ...(data.data || {}),\n\n mode: originalData.mode || originalContext.mode || updatedApproval.mode || 'accion',\n intent: originalData.intent || originalContext.intent || updatedApproval.intent || data.intent,\n\n employee_query: employeeQuery,\n employee_id: targetEmployeeId,\n\n action: approvedActionPayload,\n\n approved_by_maximo: true,\n maximo_approval_status: 'approved',\n approval_status: 'approved',\n approval_id: approvalId,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_payload: approvedActionPayload,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution\n },\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'approved',\n\n approved_by_maximo: true,\n approved_by_email: approvedByEmail,\n approved_by_name: approvedByName,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution,\n\n employee_query: employeeQuery,\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n\n requester_email: requesterEmail,\n requester_name: requesterName,\n\n execution_result: approvedActionSupported\n ? 'approved_action_ready_for_execution'\n : 'approved_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-18752,
|
||
-4144
|
||
],
|
||
"id": "f747051d-5a97-4d7a-af4e-1537e2c4bcfd",
|
||
"name": "Code - Build Approved Action Execution Context"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "71b60975-102e-4bae-8049-62a532798e46",
|
||
"leftValue": "={{ $json.approved_action_supported }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-18544,
|
||
-4144
|
||
],
|
||
"id": "72e7a2f2-8b69-42bb-9960-79e1105f56e2",
|
||
"name": "IF - Approved Action Supported?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst responseText =\n `La solicitud fue aprobada por Máximo, pero esta acción todavía no tiene ejecución automática habilitada.\\n\\n` +\n `Solicitud: ${data.approved_action_name || 'Acción sensible'}\\n` +\n `Empleado: ${data.target_employee_name || 'empleado indicado'}\\n\\n` +\n `Estado: aprobada y pendiente de implementación del ejecutor.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approved_action_execution_status: 'not_supported_yet',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approved_action_execution_status: 'not_supported_yet',\n execution_result: 'approved_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17504,
|
||
-3392
|
||
],
|
||
"id": "c4e46ef8-6615-450d-ae80-b54a2aca6474",
|
||
"name": "Code - Format Approved Action Not Supported"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "compensation_update",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "0d7efa9e-9e75-4de0-8c46-52fa3a0a3e90"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "compensation_update"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d36c496c-77a3-465c-8de6-dd4bf1ba4071",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "employee_basic_update",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "employee_basic_update"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "ca3e23fb-90b5-4d37-a0df-12dbd0365074",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "add_table_row",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "add_table_row"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8738b2f2-e591-4d6e-995e-76b6931c710f",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "terminate_employee",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "terminate_employee"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "1c5beffb-8a31-4ac6-bb80-86ceb3871c57",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "upload_employee_file",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "upload_employee_file"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d71bfb28-2508-4b38-831c-cf87d496e33f",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "unsupported_action",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "unsupported_action"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-18320,
|
||
-4384
|
||
],
|
||
"id": "1e7fd9d2-99d2-4ecc-8850-602b18b286f2",
|
||
"name": "Switch - Approved Action Route"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseAmount(value) {\n const raw = cleanText(value);\n\n if (!raw) {\n return {\n raw,\n normalized: null,\n is_valid: false,\n reason: 'missing_amount'\n };\n }\n\n const numericText = raw\n .replace(/[^\\d.,-]/g, '')\n .replace(/,/g, '');\n\n const amount = Number(numericText);\n\n if (!Number.isFinite(amount)) {\n return {\n raw,\n normalized: null,\n is_valid: false,\n reason: 'invalid_number'\n };\n }\n\n if (amount <= 0) {\n return {\n raw,\n normalized: amount,\n is_valid: false,\n reason: 'amount_must_be_greater_than_zero'\n };\n }\n\n return {\n raw,\n normalized: amount,\n is_valid: true,\n reason: 'valid'\n };\n}\n\nconst approvedPayload =\n data.approved_action_payload || {};\n\nconst actionValue =\n data.approved_action_value ||\n approvedPayload.value ||\n approvedPayload.payload?.value ||\n approvedPayload.payload?.salary ||\n approvedPayload.payload?.amount ||\n null;\n\nconst amountInfo = parseAmount(actionValue);\n\nconst employeeQuery =\n data.target_employee_id ||\n data.employee_id ||\n data.employee_query ||\n data.target_employee_name ||\n null;\n\nconst canContinuePreflight =\n data.approved_by_maximo === true &&\n data.approval_status === 'approved' &&\n data.approved_action_route === 'compensation_update' &&\n !!employeeQuery;\n\nreturn {\n json: {\n ...data,\n\n compensation_preflight_ready: canContinuePreflight,\n compensation_dry_run: true,\n compensation_execution_enabled: false,\n\n compensation_amount_raw: amountInfo.raw,\n compensation_amount: amountInfo.normalized,\n compensation_amount_valid: amountInfo.is_valid,\n compensation_amount_validation_reason: amountInfo.reason,\n\n employee_query: employeeQuery,\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n compensation_preflight_ready: canContinuePreflight,\n compensation_dry_run: true,\n compensation_execution_enabled: false,\n\n compensation_amount_raw: amountInfo.raw,\n compensation_amount: amountInfo.normalized,\n compensation_amount_valid: amountInfo.is_valid,\n compensation_amount_validation_reason: amountInfo.reason,\n\n execution_result: canContinuePreflight\n ? 'approved_compensation_preflight_ready'\n : 'approved_compensation_preflight_blocked'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17264,
|
||
-5664
|
||
],
|
||
"id": "b944e184-6c54-4fb8-8a41-577024fbec32",
|
||
"name": "Code - Prepare Approved Compensation Preflight"
|
||
},
|
||
{
|
||
"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": [
|
||
-16912,
|
||
-5728
|
||
],
|
||
"id": "bba62412-ebc9-41d5-b10e-aa8dedf25ae1",
|
||
"name": "HTTP - Approved Get Employee Directory",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Prepare Approved Compensation Preflight')[0].json;\nconst directoryResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9@.\\s-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction getEmployeesFromDirectory(value) {\n if (Array.isArray(value?.employees)) {\n return value.employees;\n }\n\n if (Array.isArray(value?.data?.employees)) {\n return value.data.employees;\n }\n\n if (Array.isArray(value)) {\n return value;\n }\n\n return [];\n}\n\nfunction getEmployeeName(employee) {\n return cleanText(\n employee.displayName ||\n employee.fullName1 ||\n employee.fullName2 ||\n employee.preferredName ||\n [\n employee.firstName,\n employee.middleName,\n employee.lastName\n ].filter(Boolean).join(' ')\n );\n}\n\nfunction getEmployeeSearchText(employee) {\n return normalize([\n employee.id,\n employee.employeeNumber,\n employee.workEmail,\n employee.email,\n employee.firstName,\n employee.middleName,\n employee.lastName,\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.preferredName,\n getEmployeeName(employee)\n ].filter(Boolean).join(' '));\n}\n\nfunction scoreEmployee(employee, query) {\n const normalizedQuery = normalize(query);\n const searchText = getEmployeeSearchText(employee);\n\n const employeeId = normalize(employee.id);\n const employeeNumber = normalize(employee.employeeNumber);\n const workEmail = normalize(employee.workEmail || employee.email);\n const displayName = normalize(getEmployeeName(employee));\n\n if (!normalizedQuery) return 0;\n\n if (employeeId && normalizedQuery === employeeId) return 100;\n if (employeeNumber && normalizedQuery === employeeNumber) return 95;\n if (workEmail && normalizedQuery === workEmail) return 95;\n if (displayName && normalizedQuery === displayName) return 90;\n\n const queryParts = normalizedQuery\n .split(' ')\n .filter(part => part.length >= 2);\n\n const allPartsMatch =\n queryParts.length > 0 &&\n queryParts.every(part => searchText.includes(part));\n\n if (allPartsMatch) return 80;\n\n if (searchText.includes(normalizedQuery)) return 70;\n\n return 0;\n}\n\nconst employees = getEmployeesFromDirectory(directoryResponse);\n\nconst employeeQuery =\n context.employee_query ||\n context.target_employee_name ||\n context.target_employee_id ||\n null;\n\nconst scoredMatches = employees\n .map(employee => ({\n employee,\n score: scoreEmployee(employee, employeeQuery)\n }))\n .filter(item => item.score > 0)\n .sort((a, b) => b.score - a.score);\n\nconst bestScore = scoredMatches[0]?.score || 0;\n\nconst topMatches = scoredMatches\n .filter(item => item.score === bestScore)\n .map(item => item.employee);\n\nconst resolvedEmployee =\n topMatches.length === 1\n ? topMatches[0]\n : null;\n\nconst resolvedEmployeeId =\n resolvedEmployee?.id ||\n context.target_employee_id ||\n context.employee_id ||\n null;\n\nconst resolvedEmployeeName =\n resolvedEmployee\n ? getEmployeeName(resolvedEmployee)\n : null;\n\nlet resolutionStatus = 'not_found';\nlet canContinue = false;\nlet responseText = '';\n\nif (!employees.length) {\n resolutionStatus = 'directory_empty_or_unavailable';\n responseText =\n `No pude consultar el directorio de BambooHR para resolver el empleado.\\n\\n` +\n `Empleado buscado: ${employeeQuery || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else if (!employeeQuery) {\n resolutionStatus = 'missing_employee_query';\n responseText =\n `No tengo el empleado necesario para ejecutar la acción aprobada.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else if (resolvedEmployee) {\n resolutionStatus = 'resolved_unique_employee';\n canContinue = true;\n} else if (topMatches.length > 1) {\n resolutionStatus = 'ambiguous_employee';\n\n const matchList = topMatches\n .slice(0, 10)\n .map(employee => {\n const name = getEmployeeName(employee);\n const id = employee.id || 'sin ID';\n const email = employee.workEmail || employee.email || 'sin correo';\n return `- ${name} — ID ${id} — ${email}`;\n })\n .join('\\n');\n\n responseText =\n `Encontré más de un empleado posible para \"${employeeQuery}\".\\n\\n` +\n `${matchList}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n resolutionStatus = 'not_found';\n responseText =\n `No encontré un empleado único para \"${employeeQuery}\".\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...context,\n\n employee_directory_count: employees.length,\n\n approved_employee_resolution_status: resolutionStatus,\n approved_employee_resolved: canContinue,\n\n resolved_employee_id: resolvedEmployeeId,\n resolved_employee_name: resolvedEmployeeName,\n resolved_employee_email:\n resolvedEmployee?.workEmail ||\n resolvedEmployee?.email ||\n null,\n\n target_employee_id: resolvedEmployeeId,\n target_employee_name:\n resolvedEmployeeName ||\n context.target_employee_name ||\n context.employee_query,\n\n bamboohr_directory_match: resolvedEmployee,\n bamboohr_directory_matches_count: topMatches.length,\n\n can_continue: canContinue,\n response_type: canContinue ? 'internal_execution' : 'chat_message',\n response_text: responseText || null,\n\n audit_event: {\n ...(context.audit_event || {}),\n\n approved_employee_resolution_status: resolutionStatus,\n approved_employee_resolved: canContinue,\n\n resolved_employee_id: resolvedEmployeeId,\n resolved_employee_name: resolvedEmployeeName,\n resolved_employee_email:\n resolvedEmployee?.workEmail ||\n resolvedEmployee?.email ||\n null,\n\n execution_result: canContinue\n ? 'approved_employee_resolved'\n : `approved_employee_resolution_${resolutionStatus}`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16704,
|
||
-5728
|
||
],
|
||
"id": "535885c1-77a3-4ea0-a9f7-ef8ca8dabd9a",
|
||
"name": "Code - Resolve Approved Employee From Directory"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "38cd8264-7eb1-4929-8c74-df7a4a873cbe",
|
||
"leftValue": "={{ $json.approved_employee_resolved }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16496,
|
||
-5728
|
||
],
|
||
"id": "187eee8e-23c5-42db-a998-43a596e68ce1",
|
||
"name": "IF - Approved Employee Resolved?"
|
||
},
|
||
{
|
||
"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": [
|
||
-14048,
|
||
-4144
|
||
],
|
||
"id": "5c1b1674-58f1-46a1-a4a0-71797f0f1e16",
|
||
"name": "HTTP - Approved Get Current Compensation",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Resolve Approved Employee From Directory')[0].json;\nconst compensationResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction safeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getRows(value) {\n if (Array.isArray(value)) return value;\n if (Array.isArray(value?.data)) return value.data;\n if (Array.isArray(value?.rows)) return value.rows;\n if (Array.isArray(value?.employees)) return value.employees;\n return [];\n}\n\nfunction getLatestRow(rows) {\n if (!Array.isArray(rows) || rows.length === 0) {\n return null;\n }\n\n const sorted = [...rows].sort((a, b) => {\n const dateA = new Date(a.date || a.effectiveDate || a.startDate || 0).getTime();\n const dateB = new Date(b.date || b.effectiveDate || b.startDate || 0).getTime();\n return dateB - dateA;\n });\n\n return sorted[0] || rows[0];\n}\n\nfunction extractText(value) {\n if (value === null || value === undefined) return null;\n\n if (typeof value === 'object') {\n return (\n value.value ||\n value.name ||\n value.label ||\n value.id ||\n value.amount ||\n null\n );\n }\n\n return cleanText(value);\n}\n\nfunction extractAmount(value) {\n if (value === null || value === undefined) {\n return null;\n }\n\n if (typeof value === 'object') {\n return extractAmount(\n value.value ??\n value.amount ??\n value.rate ??\n value.salary\n );\n }\n\n const raw = cleanText(value);\n\n if (!raw) return null;\n\n const numberText = raw\n .replace(/[^\\d.,-]/g, '')\n .replace(/,/g, '');\n\n const number = Number(numberText);\n\n return Number.isFinite(number) ? number : null;\n}\n\nfunction formatDateISO(value) {\n const raw = cleanText(value);\n\n if (!raw) return null;\n\n const date = new Date(raw);\n\n if (Number.isNaN(date.getTime())) {\n return null;\n }\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction pickFirst(...values) {\n for (const value of values) {\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction findDateInMessage(message) {\n const text = cleanText(message);\n\n const isoMatch = text.match(/\\b(20\\d{2})-(\\d{2})-(\\d{2})\\b/);\n if (isoMatch) return isoMatch[0];\n\n const latinDate = text.match(/\\b(\\d{1,2})[\\/-](\\d{1,2})[\\/-](20\\d{2})\\b/);\n if (latinDate) {\n const day = latinDate[1].padStart(2, '0');\n const month = latinDate[2].padStart(2, '0');\n const year = latinDate[3];\n return `${year}-${month}-${day}`;\n }\n\n return null;\n}\n\nfunction getApprovedEffectiveDate(data) {\n const payload = safeObject(data.approved_action_payload);\n const nested = safeObject(payload.payload);\n\n const explicitDate = pickFirst(\n payload.effectiveDate,\n payload.effective_date,\n payload.date,\n payload.startDate,\n nested.effectiveDate,\n nested.effective_date,\n nested.date,\n nested.startDate,\n data.effectiveDate,\n data.effective_date,\n data.startDate\n );\n\n const formattedExplicitDate = formatDateISO(explicitDate);\n if (formattedExplicitDate) return formattedExplicitDate;\n\n const messageDate = findDateInMessage(data.original_message || '');\n if (messageDate) return messageDate;\n\n return null;\n}\n\nfunction getReason(data) {\n const payload = safeObject(data.approved_action_payload);\n const nested = safeObject(payload.payload);\n\n return cleanText(\n pickFirst(\n payload.reason,\n payload.comment,\n payload.comments,\n nested.reason,\n nested.comment,\n nested.comments,\n data.reason,\n data.original_message,\n 'Aprobado por Máximo vía GLM BambooHR Agent'\n )\n );\n}\n\nconst rows = getRows(compensationResponse);\nconst latestRow = getLatestRow(rows);\n\nconst approvedPayload = safeObject(context.approved_action_payload);\nconst approvedNestedPayload = safeObject(approvedPayload.payload);\n\nconst approvedAmount = extractAmount(\n pickFirst(\n context.approved_action_value,\n approvedPayload.value,\n approvedPayload.amount,\n approvedPayload.salary,\n approvedNestedPayload.value,\n approvedNestedPayload.amount,\n approvedNestedPayload.salary\n )\n);\n\nconst effectiveDate = getApprovedEffectiveDate(context);\n\nconst currentRate = extractAmount(\n pickFirst(\n latestRow?.rate,\n latestRow?.payRate,\n latestRow?.amount,\n latestRow?.salary,\n latestRow?.compensation\n )\n);\n\nconst currentPaySchedule = extractText(\n pickFirst(\n latestRow?.paySchedule,\n latestRow?.payScheduleId,\n latestRow?.pay_schedule,\n latestRow?.pay_schedule_id\n )\n);\n\nconst currentPayType = extractText(\n pickFirst(\n latestRow?.payType,\n latestRow?.payTypeId,\n latestRow?.pay_type,\n latestRow?.pay_type_id\n )\n);\n\nconst currentCurrency = extractText(\n pickFirst(\n latestRow?.currency,\n latestRow?.currencyCode,\n latestRow?.currency_code\n )\n);\n\nconst reason = getReason(context);\n\nconst blockers = [];\n\nif (context.approved_by_maximo !== true) {\n blockers.push('La acción no viene marcada como aprobada por Máximo.');\n}\n\nif (context.approval_status !== 'approved') {\n blockers.push('La aprobación no está en estado approved.');\n}\n\nif (context.approved_action_route !== 'compensation_update') {\n blockers.push('La ruta aprobada no es compensation_update.');\n}\n\nif (!context.resolved_employee_id) {\n blockers.push('No se resolvió el ID del empleado.');\n}\n\nif (!approvedAmount || approvedAmount <= 0) {\n blockers.push('El monto aprobado no es válido o es menor/igual a cero.');\n}\n\nif (!effectiveDate) {\n blockers.push('Falta fecha efectiva del cambio salarial.');\n}\n\nif (!latestRow) {\n blockers.push('No se encontró una fila actual de compensation para copiar estructura.');\n}\n\nif (!currentPaySchedule) {\n blockers.push('No se detectó paySchedule/calendario actual.');\n}\n\nif (!currentPayType) {\n blockers.push('No se detectó payType/tipo de pago actual.');\n}\n\nconst bamboohrBody = {\n date: effectiveDate,\n rate: String(approvedAmount),\n paySchedule: currentPaySchedule,\n payType: currentPayType,\n reason\n};\n\nif (currentCurrency) {\n bamboohrBody.currency = currentCurrency;\n}\n\nconst readyToExecute = blockers.length === 0;\n\nconst bamboohrUrl =\n `https://glm.bamboohr.com/api/v1/employees/${context.resolved_employee_id}/tables/compensation`;\n\nreturn {\n json: {\n ...context,\n\n compensation_rows_count: rows.length,\n compensation_latest_row: latestRow,\n\n compensation_current_rate: currentRate,\n compensation_current_pay_schedule: currentPaySchedule,\n compensation_current_pay_type: currentPayType,\n compensation_current_currency: currentCurrency,\n\n compensation_new_rate: approvedAmount,\n compensation_effective_date: effectiveDate,\n compensation_reason: reason,\n\n compensation_execution_ready: readyToExecute,\n compensation_execution_blockers: blockers,\n\n bamboohr_execute_method: 'POST',\n bamboohr_execute_url: bamboohrUrl,\n bamboohr_execute_body: bamboohrBody,\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n compensation_rows_count: rows.length,\n\n compensation_current_rate: currentRate,\n compensation_current_pay_schedule: currentPaySchedule,\n compensation_current_pay_type: currentPayType,\n compensation_current_currency: currentCurrency,\n\n compensation_new_rate: approvedAmount,\n compensation_effective_date: effectiveDate,\n\n compensation_execution_ready: readyToExecute,\n compensation_execution_blockers: blockers,\n\n bamboohr_endpoint: bamboohrUrl,\n execution_result: readyToExecute\n ? 'approved_compensation_ready_to_execute'\n : 'approved_compensation_blocked_before_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13840,
|
||
-4144
|
||
],
|
||
"id": "384ba03f-82bf-4b54-b649-4846e0ed27ca",
|
||
"name": "Code - Build Approved Compensation Write Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8ca09828-0392-4fc8-af7b-a8eed8ab7cce",
|
||
"leftValue": "={{ $json.compensation_execution_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-13632,
|
||
-4144
|
||
],
|
||
"id": "c1793f86-dbc6-4927-abb6-62025398087c",
|
||
"name": "IF - Approved Compensation Ready To Execute?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.compensation_execution_blockers)\n ? data.compensation_execution_blockers\n : [];\n\nconst responseText =\n `⚠️ Máximo aprobó tu solicitud, pero el agente no pudo ejecutarla automáticamente todavía.\\n\\n` +\n `Solicitud: ${data.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${data.resolved_employee_name || data.target_employee_name || 'No disponible'}\\n` +\n `Monto solicitado: ${data.compensation_new_rate || data.approved_action_value || 'No disponible'}\\n` +\n `Fecha efectiva: ${data.compensation_effective_date || 'No disponible'}\\n\\n` +\n `Motivo:\\n` +\n `${blockers.length ? blockers.map(item => `- ${item}`).join('\\n') : '- Faltan datos para ejecutar con seguridad.'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'approved_but_blocked_before_bamboohr_write',\n\n approval_execution_patch_body: {\n execution_result: 'approved_but_blocked_before_bamboohr_write',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'approved_but_blocked_before_bamboohr_write',\n execution_result: 'approved_but_blocked_before_bamboohr_write'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13328,
|
||
-4032
|
||
],
|
||
"id": "72721d77-a1a3-4e93-8dd7-6768ddae99c0",
|
||
"name": "Code - Format Compensation Execution Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13072,
|
||
-3920
|
||
],
|
||
"id": "5712e9ec-27dd-4ab5-aa58-6e48e65ea91a",
|
||
"name": "HTTP - Mark Approval Execution Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "={{ $json.bamboohr_execute_method }}",
|
||
"url": "={{ $json.bamboohr_execute_url }}",
|
||
"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_execute_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13424,
|
||
-4240
|
||
],
|
||
"id": "1974427f-a8db-4cd7-a289-4d190158e1f7",
|
||
"name": "HTTP - Execute Approved Compensation Update",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Build Approved Compensation Write Request')[0].json;\nconst bamboohrResponse = $json;\n\nfunction hasError(value) {\n const text = JSON.stringify(value || {}).toLowerCase();\n\n return (\n value?.error ||\n value?.errors ||\n value?.message?.toLowerCase?.().includes('error') ||\n text.includes('unauthorized') ||\n text.includes('forbidden') ||\n text.includes('bad request') ||\n text.includes('not found')\n );\n}\n\nconst executionFailed = hasError(bamboohrResponse);\n\nconst responseText = executionFailed\n ? (\n `❌ Máximo aprobó tu solicitud, pero BambooHR devolvió un error al intentar ejecutarla.\\n\\n` +\n `Solicitud: ${context.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n` +\n `Monto solicitado: ${context.compensation_new_rate || 'No disponible'}\\n` +\n `Fecha efectiva: ${context.compensation_effective_date || 'No disponible'}\\n\\n` +\n `No se pudo confirmar el cambio en BambooHR. IT debe revisar la ejecución.`\n )\n : (\n `✅ Solicitud aprobada y ejecutada correctamente en BambooHR.\\n\\n` +\n `Solicitud: ${context.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n` +\n `Monto anterior detectado: ${context.compensation_current_rate || 'No disponible'}\\n` +\n `Monto nuevo: ${context.compensation_new_rate || 'No disponible'}\\n` +\n `Fecha efectiva: ${context.compensation_effective_date || 'No disponible'}\\n\\n` +\n `El cambio quedó registrado en BambooHR.`\n );\n\nconst executionResult = executionFailed\n ? 'bamboohr_execution_failed'\n : 'executed_successfully';\n\nreturn {\n json: {\n ...context,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n bamboohr_execution_response: bamboohrResponse,\n bamboohr_execution_failed: executionFailed,\n\n final_execution_status: executionFailed ? 'failed' : 'executed',\n final_execution_result: executionResult,\n\n approval_execution_patch_body: {\n execution_result: executionResult,\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_execution_failed: executionFailed,\n final_execution_status: executionFailed ? 'failed' : 'executed',\n final_execution_result: executionResult,\n execution_result: executionResult\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13216,
|
||
-4240
|
||
],
|
||
"id": "dd1cb3da-c560-4fc7-ad68-f8d5cd078fbd",
|
||
"name": "Code - Format Compensation Execution Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13040,
|
||
-4256
|
||
],
|
||
"id": "fabd1fe2-2dd2-4d5d-b567-f4af69afbef1",
|
||
"name": "HTTP - Mark Approval Executed",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction getContextFromNode(nodeName) {\n try {\n const items = $items(nodeName);\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction buildFallbackText(context) {\n const status = cleanText(context.final_execution_status || context.final_execution_result);\n\n if (status === 'executed' || status === 'executed_successfully') {\n return (\n `✅ Tu solicitud fue aprobada y ejecutada correctamente.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `El cambio quedó registrado en BambooHR.`\n );\n }\n\n if (status === 'blocked') {\n return (\n `⚠️ Máximo aprobó tu solicitud, pero el agente no pudo ejecutarla automáticamente.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`\n );\n }\n\n if (status === 'failed') {\n return (\n `❌ Máximo aprobó tu solicitud, pero BambooHR devolvió un error durante la ejecución.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `IT debe revisar la ejecución.`\n );\n }\n\n return 'La solicitud fue actualizada.';\n}\n\n/*\n Este nodo puede venir de dos ramas:\n 1. Ejecución real completada:\n Code - Format Compensation Execution Result\n\n 2. Ejecución bloqueada por seguridad:\n Code - Format Compensation Execution Blocked\n*/\nconst executionContext =\n getContextFromNode('Code - Format PDF Upload Result') ||\n getContextFromNode('Code - Format BambooHR PDF Upload Precheck Blocked') ||\n getContextFromNode('Code - Format PDF Download Blocked') ||\n getContextFromNode('Code - Format PDF Upload Blocked') ||\n getContextFromNode('Code - Format Compensation Execution Result') ||\n getContextFromNode('Code - Format Compensation Execution Blocked') ||\n getContextFromNode('Code - Format Approved Action Not Supported') ||\n {};\n\nconst requesterContact = getFirstRow(contactResponse);\n\nconst requesterSpaceName =\n requesterContact?.google_chat_space_name ||\n executionContext.requester_google_chat_space_name ||\n executionContext.google_chat_space_name ||\n null;\n\nconst requesterThreadName =\n requesterContact?.google_chat_thread_name ||\n executionContext.requester_google_chat_thread_name ||\n executionContext.google_chat_thread_name ||\n null;\n\nconst notificationText =\n cleanText(executionContext.response_text) ||\n cleanText(executionContext.requester_chat_notification_text) ||\n buildFallbackText(executionContext);\n\nconst requesterChatAvailable = !!requesterSpaceName;\n\nreturn {\n json: {\n ...executionContext,\n\n requester_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n\n requester_user_email:\n requesterContact?.user_email ||\n executionContext.requested_by_email ||\n executionContext.user_email ||\n executionContext.audit_event?.user_email ||\n null,\n\n requester_user_name:\n requesterContact?.user_name ||\n executionContext.requested_by_name ||\n executionContext.user_name ||\n executionContext.audit_event?.user_name ||\n null,\n\n requester_google_chat_space_name: requesterSpaceName,\n requester_google_chat_thread_name: requesterThreadName,\n\n final_space_name: requesterSpaceName,\n final_thread_name: requesterThreadName,\n\n requester_chat_notification_text: notificationText,\n response_text: notificationText,\n\n chat_message_body: {\n text: notificationText\n },\n\n audit_event: {\n ...(executionContext.audit_event || {}),\n\n requester_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n requester_google_chat_space_name: requesterSpaceName,\n requester_google_chat_thread_name: requesterThreadName,\n\n requester_final_notification_prepared: requesterChatAvailable\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12592,
|
||
-4288
|
||
],
|
||
"id": "4ae2afb1-e4d7-4cc1-bfba-df1d4351a09c",
|
||
"name": "Code - Build Requester Final Execution Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "6455a1ea-92ac-47fc-aa4c-61256ceb6a47",
|
||
"leftValue": "={{ $json.requester_chat_available }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-12384,
|
||
-4288
|
||
],
|
||
"id": "0f7323f7-9d63-4dcb-971f-06cf54b0e513",
|
||
"name": "IF - Requester Final Chat Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://TU_SUPABASE_URL/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,is_active,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent($json.requested_by_email || $json.user_email || $json.audit_event?.user_email || '') + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12832,
|
||
-4256
|
||
],
|
||
"id": "3db19bb6-7ac7-420e-9dd2-46d31f900af5",
|
||
"name": "HTTP - Get Requester Final Chat Contact",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getFriendlyActionName(route, context) {\n const text = [\n route,\n context.approved_action_name,\n context.action_type,\n context.intent,\n context.original_message\n ].map(cleanText).join(' ').toLowerCase();\n\n if (text.includes('salary') || text.includes('salario') || text.includes('compensation')) {\n return 'Cambio de salario / compensación';\n }\n\n if (text.includes('terminate') || text.includes('termination') || text.includes('desvincular')) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (text.includes('file') || text.includes('archivo') || text.includes('documento')) {\n return 'Carga o modificación de documento';\n }\n\n if (route === 'employee_basic_update') {\n return 'Actualización de dato básico del empleado';\n }\n\n if (route === 'add_table_row') {\n return 'Actualización de historial del empleado';\n }\n\n return 'Acción aprobada pendiente de implementación';\n}\n\nconst route =\n cleanText(data.approved_action_route) ||\n cleanText(data.action_type) ||\n cleanText(data.intent) ||\n 'unsupported_action';\n\nconst actionName = getFriendlyActionName(route, data);\n\nconst employeeName =\n data.resolved_employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n 'No disponible';\n\nconst approvedAtReadable = formatDateForDR(\n data.approved_at ||\n data.updated_at ||\n new Date().toISOString()\n);\n\nconst responseText =\n `⚠️ Máximo aprobó tu solicitud, pero esta acción todavía no tiene ejecución automática activa en el agente.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Aprobada: ${approvedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n\\n` +\n `IT debe completar el ejecutor automático para esta acción antes de usarla en producción.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approved_action_route: route,\n final_execution_status: 'not_supported',\n final_execution_result: 'approved_but_action_not_supported_yet',\n\n approval_execution_patch_body: {\n execution_result: 'approved_but_action_not_supported_yet',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approved_action_route: route,\n final_execution_status: 'not_supported',\n final_execution_result: 'approved_but_action_not_supported_yet',\n execution_result: 'approved_but_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17744,
|
||
-3744
|
||
],
|
||
"id": "f1b3bb9b-c32f-46bf-a961-cb4386f18b11",
|
||
"name": "Code - Format Approved Action Not Supported1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17520,
|
||
-3744
|
||
],
|
||
"id": "a4838fbd-ed49-4e7f-a7ef-42ff663cfff4",
|
||
"name": "HTTP - Mark Approved Action Not Supported",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction sanitizeFilePart(value) {\n return cleanText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s-]/g, '')\n .replace(/\\s+/g, '_')\n .replace(/_+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 80);\n}\n\nfunction formatDateYYYYMMDD(value) {\n const date = value ? new Date(value) : new Date();\n\n if (Number.isNaN(date.getTime())) {\n return new Date().toISOString().slice(0, 10).replace(/-/g, '');\n }\n\n return date.toISOString().slice(0, 10).replace(/-/g, '');\n}\n\nconst action =\n data.approved_action ||\n data.action ||\n data.data?.action ||\n {};\n\nconst payload =\n action.payload ||\n data.approved_action_payload ||\n data.data?.action?.payload ||\n {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.target_employee_id ||\n data.employee_id ||\n data.data?.employee_id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n data.data?.employee_name ||\n data.employee_query ||\n 'Empleado';\n\nconst country =\n payload.country ||\n data.filters?.country ||\n data.audit_event?.document_country ||\n 'GLM';\n\nconst documentType =\n payload.document_type ||\n data.filters?.document_type ||\n data.audit_event?.document_type ||\n 'Documento';\n\nconst originalFileName =\n payload.original_file_name ||\n data.filters?.original_file_name ||\n data.audit_event?.original_file_name ||\n 'documento.pdf';\n\nconst originalContentType =\n payload.original_content_type ||\n data.filters?.original_content_type ||\n data.audit_event?.original_content_type ||\n 'application/pdf';\n\nconst attachmentResourceName =\n payload.google_chat_attachment_resource_name ||\n data.audit_event?.google_chat_attachment_resource_name ||\n null;\n\nconst driveFileId =\n payload.google_drive_file_id ||\n data.audit_event?.google_drive_file_id ||\n null;\n\nconst attachmentSource =\n payload.attachment_source ||\n data.filters?.attachment_source ||\n data.audit_event?.attachment_source ||\n 'unknown';\n\nconst datePart = formatDateYYYYMMDD(new Date().toISOString());\n\nconst finalFileName =\n [\n 'GLM',\n sanitizeFilePart(country),\n sanitizeFilePart(documentType),\n sanitizeFilePart(employeeName),\n datePart\n ]\n .filter(Boolean)\n .join('_') + '.pdf';\n\nconst blockers = [];\n\nif (!employeeId) {\n blockers.push('No se encontró employee_id resuelto para subir el archivo.');\n}\n\nif (!attachmentResourceName && !driveFileId) {\n blockers.push('No se encontró referencia descargable del PDF.');\n}\n\nif (!originalContentType.toLowerCase().includes('pdf') && !originalFileName.toLowerCase().endsWith('.pdf')) {\n blockers.push('El archivo adjunto no parece ser PDF.');\n}\n\nconst canContinue =\n blockers.length === 0;\n\nreturn {\n json: {\n ...data,\n\n pdf_upload_ready: canContinue,\n pdf_upload_blockers: blockers,\n\n pdf_employee_id: employeeId,\n pdf_employee_name: employeeName,\n\n pdf_document_type: documentType,\n pdf_country: country,\n\n pdf_original_file_name: originalFileName,\n pdf_original_content_type: originalContentType,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource,\n\n pdf_final_file_name: finalFileName,\n\n final_execution_status: canContinue ? 'ready_to_download_pdf' : 'blocked',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_upload_ready: canContinue,\n pdf_upload_blockers: blockers,\n\n pdf_employee_id: employeeId,\n pdf_employee_name: employeeName,\n\n pdf_document_type: documentType,\n pdf_country: country,\n\n pdf_original_file_name: originalFileName,\n pdf_original_content_type: originalContentType,\n pdf_final_file_name: finalFileName,\n\n execution_result: canContinue\n ? 'pdf_upload_ready_to_download'\n : 'pdf_upload_blocked_before_download'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-18016,
|
||
-4064
|
||
],
|
||
"id": "5b534071-4084-4b7c-9176-e36a2caf2b75",
|
||
"name": "Code - Prepare Approved PDF Upload"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e4b3f896-c42c-4420-9272-d8054aac986d",
|
||
"leftValue": "={{ $json.pdf_upload_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-17792,
|
||
-4096
|
||
],
|
||
"id": "60b17828-2c0e-4f9c-8f10-093d9b1f704d",
|
||
"name": "IF - Approved PDF Upload Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_upload_blockers)\n ? data.pdf_upload_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo ejecutarla automáticamente.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nNo se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_blocked_before_download',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_upload_blocked_before_download',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_blocked_before_download',\n execution_result: 'pdf_upload_blocked_before_download'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17408,
|
||
-4000
|
||
],
|
||
"id": "5bc08e8d-5ad0-4bc0-b4a7-27dc3e947ca5",
|
||
"name": "Code - Format PDF Upload Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17200,
|
||
-4000
|
||
],
|
||
"id": "06c7229e-a917-4aa7-92b2-4bf0fdf24ca8",
|
||
"name": "HTTP - Mark PDF Upload Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://chat.googleapis.com/v1/media/' + String($json.pdf_attachment_resource_name || '').replace(/^\\/+/, '') + '?alt=media' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleApi",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17056,
|
||
-4944
|
||
],
|
||
"id": "980a99a3-7c3e-442f-b856-dc5d793f4af6",
|
||
"name": "HTTP - Download Approved PDF From Google Chat",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "eHseMeH39kRcXgOF",
|
||
"name": "Google account 2"
|
||
},
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
},
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const currentJson = $json || {};\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nconst context =\n currentJson.pdf_employee_id\n ? currentJson\n : (\n getNodeJson('Code - Decide Approved PDF Download Source') ||\n getNodeJson('Code - Prepare Approved PDF Upload') ||\n currentJson\n );\n\nconst binary = $binary || {};\nconst fileBinary = binary.data || null;\n\nconst blockers = [];\n\nif (!fileBinary) {\n blockers.push('No se descargó el binario del PDF desde Google Chat o Google Drive.');\n}\n\nconst mimeType =\n fileBinary?.mimeType ||\n context.pdf_original_content_type ||\n 'application/pdf';\n\nconst fileName =\n context.pdf_final_file_name ||\n fileBinary?.fileName ||\n 'documento.pdf';\n\nif (!String(mimeType).toLowerCase().includes('pdf')) {\n blockers.push(`El archivo descargado no parece PDF. MIME detectado: ${mimeType}`);\n}\n\nconst fileSize = Number(fileBinary?.fileSize || fileBinary?.fileSizeBytes || 0);\n\nif (fileSize > 0 && fileSize > 20 * 1024 * 1024) {\n blockers.push('El PDF supera 20 MB, límite soportado para subida de archivo en BambooHR.');\n}\n\nconst ready = blockers.length === 0;\n\nreturn {\n json: {\n ...context,\n\n pdf_download_ready: ready,\n pdf_download_blockers: blockers,\n\n pdf_downloaded_mime_type: mimeType,\n pdf_downloaded_file_name: fileName,\n pdf_downloaded_file_size_bytes: fileSize || null,\n\n final_execution_status: ready ? 'pdf_downloaded' : 'blocked',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n pdf_download_ready: ready,\n pdf_download_blockers: blockers,\n pdf_downloaded_mime_type: mimeType,\n pdf_downloaded_file_name: fileName,\n pdf_downloaded_file_size_bytes: fileSize || null,\n\n execution_result: ready\n ? 'pdf_downloaded_from_google_attachment'\n : 'pdf_download_blocked'\n }\n },\n binary: {\n data: fileBinary\n ? {\n ...fileBinary,\n fileName,\n mimeType\n }\n : undefined\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16800,
|
||
-4944
|
||
],
|
||
"id": "18f3510c-55a1-4ea6-bd1c-17d31ec3f4df",
|
||
"name": "Code - Validate Downloaded PDF Binary"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e395ce23-29c1-4126-8f02-2fbf1668df31",
|
||
"leftValue": "={{ $json.pdf_download_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16640,
|
||
-4944
|
||
],
|
||
"id": "d9d5f9ea-8fa4-4cf8-a93d-7afb1fa23871",
|
||
"name": "IF - Downloaded PDF Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_download_blockers)\n ? data.pdf_download_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo descargar el archivo desde Google Chat.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nNo se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_blocked',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_download_blocked',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_blocked',\n execution_result: 'pdf_download_blocked'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16432,
|
||
-4848
|
||
],
|
||
"id": "dda5f754-63b3-45d9-be12-9e767dee108e",
|
||
"name": "Code - Format PDF Download Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-16224,
|
||
-4848
|
||
],
|
||
"id": "fccb68ee-e3e7-416b-a140-4a30f10d3c66",
|
||
"name": "HTTP - Mark PDF Download Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction mapDocumentCategory(documentType) {\n const normalized = cleanText(documentType)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n\n if (normalized.includes('contrato')) return 'Contracts';\n if (normalized.includes('carta de oferta')) return 'Offer Letters';\n if (normalized.includes('cedula')) return 'Personal Documents';\n if (normalized.includes('pasaporte')) return 'Personal Documents';\n if (normalized.includes('licencia')) return 'Personal Documents';\n if (normalized.includes('certificacion')) return 'Certifications';\n if (normalized.includes('cv')) return 'Resumes and Applications';\n if (normalized.includes('renuncia')) return 'Termination Documents';\n if (normalized.includes('desvinculacion')) return 'Termination Documents';\n if (normalized.includes('amonestacion')) return 'Disciplinary Documents';\n if (normalized.includes('evaluacion')) return 'Performance Documents';\n\n return 'Employee Documents';\n}\n\nconst employeeId =\n data.pdf_employee_id ||\n data.resolved_employee_id ||\n data.target_employee_id ||\n data.employee_id ||\n null;\n\nconst finalFileName =\n data.pdf_final_file_name ||\n data.pdf_downloaded_file_name ||\n 'documento.pdf';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst category =\n mapDocumentCategory(documentType);\n\nconst shareWithEmployee = false;\n\nconst blockers = [];\n\nif (!employeeId) {\n blockers.push('No se encontró employee_id para subir el archivo a BambooHR.');\n}\n\nif (!$binary?.data) {\n blockers.push('No existe binary.data para enviar a BambooHR.');\n}\n\nconst ready = blockers.length === 0;\n\nreturn {\n json: {\n ...data,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n bamboohr_employee_file_upload_url:\n employeeId\n ? `https://glm.bamboohr.com/api/v1/employees/${employeeId}/files`\n : null,\n\n bamboohr_file_category: category,\n bamboohr_file_name: finalFileName,\n bamboohr_file_share: shareWithEmployee ? 'yes' : 'no',\n\n final_execution_status: ready ? 'ready_to_upload_to_bamboohr' : 'blocked',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n bamboohr_file_category: category,\n bamboohr_file_name: finalFileName,\n bamboohr_file_share: shareWithEmployee ? 'yes' : 'no',\n\n execution_result: ready\n ? 'pdf_ready_to_upload_to_bamboohr'\n : 'pdf_upload_to_bamboohr_blocked_before_request'\n }\n },\n binary: {\n data: {\n ...$binary.data,\n fileName: finalFileName,\n mimeType: 'application/pdf'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16480,
|
||
-5136
|
||
],
|
||
"id": "6d9a58cd-c46d-4609-a284-c16421d047ec",
|
||
"name": "Code - Prepare BambooHR PDF Upload Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "6a928b50-bacd-4dc9-ac38-6d01811e7316",
|
||
"leftValue": "={{ $json.bamboohr_pdf_upload_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-15712,
|
||
-4336
|
||
],
|
||
"id": "223e685e-1544-4e6e-a874-5862b723ec12",
|
||
"name": "IF - BambooHR PDF Upload Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.bamboohr_pdf_upload_blockers)\n ? data.bamboohr_pdf_upload_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst fileName =\n data.bamboohr_file_name ||\n data.pdf_final_file_name ||\n 'documento.pdf';\n\nconst desiredCategory =\n data.bamboohr_file_category_desired_label ||\n data.bamboohr_file_category ||\n 'No disponible';\n\nconst detectedCategories = Array.isArray(data.bamboohr_file_categories_uploadable)\n ? data.bamboohr_file_categories_uploadable\n : [];\n\nconst categoriesText =\n detectedCategories.length > 0\n ? detectedCategories\n .slice(0, 10)\n .map(category => `- ${category.id}: ${category.name}`)\n .join('\\n')\n : '- No se detectaron categorías disponibles para subir archivos.';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo preparar la solicitud final hacia BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n` +\n `Archivo: ${fileName}\\n` +\n `Categoría buscada: ${desiredCategory}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nCategorías detectadas en BambooHR:\\n` +\n `${categoriesText}\\n\\n` +\n `No se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n execution_result: 'pdf_upload_to_bamboohr_blocked_before_request'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-15504,
|
||
-4240
|
||
],
|
||
"id": "ea50aaa2-1c16-4775-9fe4-9da45f2faaa1",
|
||
"name": "Code - Format BambooHR PDF Upload Precheck Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15296,
|
||
-4240
|
||
],
|
||
"id": "57e82484-beb9-4050-9f5a-3b457fe95b36",
|
||
"name": "HTTP - Mark BambooHR PDF Upload Precheck Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ $json.bamboohr_employee_file_upload_url }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "multipart-form-data",
|
||
"bodyParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "category",
|
||
"value": "={{ $json.bamboohr_file_category_id }}"
|
||
},
|
||
{
|
||
"name": "fileName",
|
||
"value": "={{ $json.bamboohr_file_name }}"
|
||
},
|
||
{
|
||
"name": "share",
|
||
"value": "={{ $json.bamboohr_file_share }}"
|
||
},
|
||
{
|
||
"parameterType": "formBinaryData",
|
||
"name": "file",
|
||
"inputDataFieldName": "data"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15488,
|
||
-4496
|
||
],
|
||
"id": "8ee9bb94-9a80-417a-9036-54284f4cf578",
|
||
"name": "HTTP - Upload PDF To BambooHR",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const httpResult = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction safeStringify(value) {\n try {\n if (value === undefined || value === null) return '';\n if (typeof value === 'string') return value;\n return JSON.stringify(value);\n } catch (error) {\n return String(value || '');\n }\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nconst context =\n getNodeJson('Code - Prepare BambooHR PDF Upload Request') ||\n {};\n\nconst statusCode =\n Number(\n httpResult.statusCode ||\n httpResult.status ||\n httpResult.response?.statusCode ||\n httpResult.error?.statusCode ||\n 0\n );\n\nconst rawErrorMessage =\n httpResult.error?.message ||\n httpResult.error?.description ||\n httpResult.message ||\n httpResult.description ||\n '';\n\nconst responseTextRaw = safeStringify(httpResult);\n\nconst hasExplicitError =\n !!rawErrorMessage ||\n responseTextRaw.toLowerCase().includes('error') ||\n responseTextRaw.toLowerCase().includes('bad request') ||\n responseTextRaw.toLowerCase().includes('unauthorized') ||\n responseTextRaw.toLowerCase().includes('forbidden');\n\nconst hasHttpFailure =\n statusCode >= 400;\n\nconst uploadSucceeded =\n !hasExplicitError &&\n !hasHttpFailure;\n\nconst employeeName =\n context.pdf_employee_name ||\n context.resolved_employee_name ||\n context.target_employee_name ||\n 'No disponible';\n\nconst employeeId =\n context.pdf_employee_id ||\n context.resolved_employee_id ||\n context.target_employee_id ||\n 'No disponible';\n\nconst documentType =\n context.pdf_document_type ||\n 'Documento';\n\nconst fileName =\n context.bamboohr_file_name ||\n context.pdf_final_file_name ||\n 'documento.pdf';\n\nconst category =\n context.bamboohr_file_category_name ||\n context.bamboohr_file_category ||\n context.bamboohr_file_category_id ||\n 'No disponible';\n\nconst categoryId =\n context.bamboohr_file_category_id ||\n 'No disponible';\n\nconst resultMessage =\n uploadSucceeded\n ? (\n `✅ Máximo aprobó la subida del PDF y el archivo fue cargado correctamente en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Employee ID: ${employeeId}\\n` +\n `Documento: ${documentType}\\n` +\n `Categoría BambooHR: ${category}\\n` +\n `Category ID: ${categoryId}\\n` +\n `Archivo: ${fileName}`\n )\n : (\n `❌ Máximo aprobó la subida del PDF, pero BambooHR devolvió un error durante la carga.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Employee ID: ${employeeId}\\n` +\n `Documento: ${documentType}\\n` +\n `Categoría BambooHR: ${category}\\n` +\n `Category ID: ${categoryId}\\n` +\n `Archivo: ${fileName}\\n\\n` +\n `No puedo confirmar que el archivo haya sido subido.\\n\\n` +\n `Detalle técnico:\\n` +\n `${cleanText(rawErrorMessage) || responseTextRaw.slice(0, 1000) || 'Error no especificado'}`\n );\n\nconst executionResult =\n uploadSucceeded\n ? 'pdf_uploaded_to_bamboohr'\n : 'pdf_upload_to_bamboohr_failed';\n\nreturn {\n json: {\n ...context,\n\n bamboohr_upload_http_result: httpResult,\n bamboohr_upload_status_code: statusCode || null,\n\n bamboohr_pdf_upload_succeeded: uploadSucceeded,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: resultMessage,\n\n final_execution_status: uploadSucceeded ? 'executed' : 'failed',\n final_execution_result: executionResult,\n\n approval_execution_patch_body: {\n execution_result: executionResult,\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_upload_status_code: statusCode || null,\n bamboohr_pdf_upload_succeeded: uploadSucceeded,\n\n final_execution_status: uploadSucceeded ? 'executed' : 'failed',\n final_execution_result: executionResult,\n execution_result: executionResult\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-15280,
|
||
-4496
|
||
],
|
||
"id": "3e3a4d95-812b-4704-98af-58a7a0d5e45d",
|
||
"name": "Code - Format PDF Upload Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15072,
|
||
-4496
|
||
],
|
||
"id": "e1e4f580-b6bd-45b8-9557-ec3375deb38b",
|
||
"name": "HTTP - Mark PDF Upload Finished",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction preview(value, maxLength = 1500) {\n try {\n if (value === undefined || value === null) return null;\n\n const text =\n typeof value === 'string'\n ? value\n : JSON.stringify(value);\n\n return text.slice(0, maxLength);\n } catch (error) {\n return String(value || '').slice(0, maxLength);\n }\n}\n\nconst uploadStatus =\n data.bamboohr_pdf_upload_succeeded === true\n ? 'success'\n : 'failed';\n\nconst metadataLogBody = {\n request_id: cleanText(data.request_id) || null,\n approval_id: cleanText(data.approval_id) || null,\n\n requester_email:\n cleanText(data.requested_by_email) ||\n cleanText(data.user_email) ||\n cleanText(data.audit_event?.user_email) ||\n null,\n\n requester_name:\n cleanText(data.requested_by_name) ||\n cleanText(data.user_name) ||\n cleanText(data.audit_event?.user_name) ||\n null,\n\n approver_email:\n cleanText(data.approved_by_email) ||\n cleanText(data.approver_email) ||\n cleanText(data.audit_event?.approved_by_email) ||\n null,\n\n approver_name:\n cleanText(data.approved_by_name) ||\n cleanText(data.approver_name) ||\n cleanText(data.audit_event?.approved_by_name) ||\n null,\n\n target_employee_id:\n cleanText(data.pdf_employee_id) ||\n cleanText(data.resolved_employee_id) ||\n cleanText(data.target_employee_id) ||\n null,\n\n target_employee_name:\n cleanText(data.pdf_employee_name) ||\n cleanText(data.resolved_employee_name) ||\n cleanText(data.target_employee_name) ||\n null,\n\n document_type:\n cleanText(data.pdf_document_type) ||\n cleanText(data.audit_event?.document_type) ||\n null,\n\n document_country:\n cleanText(data.pdf_country) ||\n cleanText(data.audit_event?.document_country) ||\n null,\n\n original_file_name:\n cleanText(data.pdf_original_file_name) ||\n cleanText(data.audit_event?.original_file_name) ||\n null,\n\n final_file_name:\n cleanText(data.bamboohr_file_name) ||\n cleanText(data.pdf_final_file_name) ||\n null,\n\n bamboohr_file_category:\n [\n cleanText(data.bamboohr_file_category_id),\n cleanText(data.bamboohr_file_category_name)\n ].filter(Boolean).join(' - ') ||\n cleanText(data.bamboohr_file_category) ||\n null,\n\n attachment_source:\n cleanText(data.pdf_attachment_source) ||\n cleanText(data.audit_event?.attachment_source) ||\n null,\n\n google_chat_attachment_resource_name:\n cleanText(data.pdf_attachment_resource_name) ||\n cleanText(data.audit_event?.google_chat_attachment_resource_name) ||\n null,\n\n google_drive_file_id:\n cleanText(data.pdf_drive_file_id) ||\n cleanText(data.audit_event?.google_drive_file_id) ||\n null,\n\n upload_status: uploadStatus,\n\n execution_result:\n cleanText(data.final_execution_result) ||\n cleanText(data.audit_event?.execution_result) ||\n null,\n\n bamboohr_status_code:\n data.bamboohr_upload_status_code !== undefined &&\n data.bamboohr_upload_status_code !== null\n ? String(data.bamboohr_upload_status_code)\n : null,\n\n bamboohr_response_preview:\n preview(data.bamboohr_upload_http_result)\n};\n\nreturn {\n json: {\n ...data,\n pdf_upload_metadata_log_body: metadataLogBody\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-14944,
|
||
-4688
|
||
],
|
||
"id": "6e63b7d9-182d-4ed9-96b9-ec1e3ea287e3",
|
||
"name": "Code - Build PDF Upload Metadata Log"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_file_upload_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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.pdf_upload_metadata_log_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-14736,
|
||
-4688
|
||
],
|
||
"id": "e4b522ab-a8cd-4b70-a0c2-c3dc34f61193",
|
||
"name": "Supabase - Log PDF Upload Metadata",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction preview(value, maxLength = 1500) {\n try {\n if (value === undefined || value === null) return null;\n\n const text =\n typeof value === 'string'\n ? value\n : JSON.stringify(value);\n\n return text.slice(0, maxLength);\n } catch (error) {\n return String(value || '').slice(0, maxLength);\n }\n}\n\nconst blockers =\n data.pdf_upload_blockers ||\n data.pdf_download_blockers ||\n data.bamboohr_pdf_upload_blockers ||\n [];\n\nconst blockerText = Array.isArray(blockers)\n ? blockers.join(' | ')\n : cleanText(blockers);\n\nconst metadataLogBody = {\n request_id: cleanText(data.request_id) || null,\n approval_id: cleanText(data.approval_id) || null,\n\n requester_email:\n cleanText(data.requested_by_email) ||\n cleanText(data.user_email) ||\n cleanText(data.audit_event?.user_email) ||\n null,\n\n requester_name:\n cleanText(data.requested_by_name) ||\n cleanText(data.user_name) ||\n cleanText(data.audit_event?.user_name) ||\n null,\n\n approver_email:\n cleanText(data.approved_by_email) ||\n cleanText(data.approver_email) ||\n cleanText(data.audit_event?.approved_by_email) ||\n null,\n\n approver_name:\n cleanText(data.approved_by_name) ||\n cleanText(data.approver_name) ||\n cleanText(data.audit_event?.approved_by_name) ||\n null,\n\n target_employee_id:\n cleanText(data.pdf_employee_id) ||\n cleanText(data.resolved_employee_id) ||\n cleanText(data.target_employee_id) ||\n null,\n\n target_employee_name:\n cleanText(data.pdf_employee_name) ||\n cleanText(data.resolved_employee_name) ||\n cleanText(data.target_employee_name) ||\n null,\n\n document_type:\n cleanText(data.pdf_document_type) ||\n cleanText(data.audit_event?.document_type) ||\n null,\n\n document_country:\n cleanText(data.pdf_country) ||\n cleanText(data.audit_event?.document_country) ||\n null,\n\n original_file_name:\n cleanText(data.pdf_original_file_name) ||\n cleanText(data.audit_event?.original_file_name) ||\n null,\n\n final_file_name:\n cleanText(data.bamboohr_file_name) ||\n cleanText(data.pdf_final_file_name) ||\n null,\n\n bamboohr_file_category:\n [\n cleanText(data.bamboohr_file_category_id),\n cleanText(data.bamboohr_file_category_name)\n ].filter(Boolean).join(' - ') ||\n cleanText(data.bamboohr_file_category) ||\n null,\n\n attachment_source:\n cleanText(data.pdf_attachment_source) ||\n cleanText(data.audit_event?.attachment_source) ||\n null,\n\n google_chat_attachment_resource_name:\n cleanText(data.pdf_attachment_resource_name) ||\n cleanText(data.audit_event?.google_chat_attachment_resource_name) ||\n null,\n\n google_drive_file_id:\n cleanText(data.pdf_drive_file_id) ||\n cleanText(data.audit_event?.google_drive_file_id) ||\n null,\n\n upload_status: 'blocked',\n\n execution_result:\n cleanText(data.final_execution_result) ||\n cleanText(data.audit_event?.execution_result) ||\n 'pdf_upload_blocked',\n\n bamboohr_status_code: null,\n\n bamboohr_response_preview:\n preview({\n blockers: blockerText,\n response_text: data.response_text || null\n })\n};\n\nreturn {\n json: {\n ...data,\n pdf_upload_metadata_log_body: metadataLogBody\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-14112,
|
||
-4528
|
||
],
|
||
"id": "948083aa-269e-4f2d-9f4d-76e4e6edfd01",
|
||
"name": "Code - Build PDF Blocked Metadata Log"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_file_upload_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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.pdf_upload_metadata_log_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13904,
|
||
-4528
|
||
],
|
||
"id": "ddfdb8cb-5f64-41a0-8ecc-099213f8c51f",
|
||
"name": "Supabase - Log PDF Blocked Metadata",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://glm.bamboohr.com/api/v1/employees/' + encodeURIComponent($json.pdf_employee_id) + '/files/view/' }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-16272,
|
||
-5136
|
||
],
|
||
"id": "12327d4c-bfc0-4658-96ec-1be2ccb34ad0",
|
||
"name": "HTTP - List BambooHR Employee File Categories",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const httpResult = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction asArray(value) {\n if (Array.isArray(value)) return value;\n if (value && typeof value === 'object') return [value];\n return [];\n}\n\nfunction findCategoriesDeep(value, depth = 0) {\n if (!value || depth > 5) return [];\n\n if (Array.isArray(value)) {\n let results = [];\n\n for (const item of value) {\n results = results.concat(findCategoriesDeep(item, depth + 1));\n }\n\n return results;\n }\n\n if (typeof value !== 'object') return [];\n\n let found = [];\n\n const possibleArrays = [\n value.categories,\n value.employeeFileCategories,\n value.employee_file_categories,\n value.fileCategories,\n value.file_categories,\n value.files,\n value.data\n ];\n\n for (const possible of possibleArrays) {\n if (Array.isArray(possible)) {\n found = found.concat(possible);\n }\n }\n\n for (const item of Object.values(value)) {\n if (item && typeof item === 'object') {\n found = found.concat(findCategoriesDeep(item, depth + 1));\n }\n }\n\n return found;\n}\n\nfunction normalizeCategory(item) {\n if (!item || typeof item !== 'object') return null;\n\n const id =\n item.id ||\n item.categoryId ||\n item.category_id ||\n item.fileCategoryId ||\n item.file_category_id ||\n item.sectionId ||\n item.section_id ||\n null;\n\n const name =\n item.name ||\n item.category ||\n item.categoryName ||\n item.category_name ||\n item.displayName ||\n item.display_name ||\n item.sectionName ||\n item.section_name ||\n null;\n\n if (!id || !name) return null;\n\n const canUploadRaw =\n item.canUpload ??\n item.can_upload ??\n item.canUploadFiles ??\n item.can_upload_files ??\n item.canAddFiles ??\n item.can_add_files ??\n item.permissions?.canUpload ??\n item.permissions?.can_upload ??\n null;\n\n const canUpload =\n canUploadRaw === null ||\n canUploadRaw === undefined ||\n canUploadRaw === true ||\n canUploadRaw === 'true' ||\n canUploadRaw === 'yes' ||\n canUploadRaw === 1;\n\n return {\n id: String(id),\n name: cleanText(name),\n normalized_name: normalize(name),\n can_upload: canUpload,\n raw: item\n };\n}\n\nfunction uniqueCategories(categories) {\n const map = new Map();\n\n for (const category of categories) {\n const normalized = normalizeCategory(category);\n\n if (!normalized) continue;\n\n const key = `${normalized.id}_${normalized.normalized_name}`;\n\n if (!map.has(key)) {\n map.set(key, normalized);\n }\n }\n\n return Array.from(map.values());\n}\n\nfunction getDesiredKeywords(documentType, desiredLabel) {\n const text = normalize(`${documentType} ${desiredLabel}`);\n\n if (text.includes('oferta') || text.includes('offer')) {\n return ['oferta', 'offer', 'carta', 'letter'];\n }\n\n if (text.includes('contrato') || text.includes('contract')) {\n return ['contrato', 'contract', 'agreement'];\n }\n\n if (\n text.includes('cedula') ||\n text.includes('pasaporte') ||\n text.includes('licencia') ||\n text.includes('personal')\n ) {\n return ['personal', 'documento', 'documentos', 'identificacion', 'identification'];\n }\n\n if (text.includes('certificacion') || text.includes('certificado')) {\n return ['certificacion', 'certification', 'certificado'];\n }\n\n if (text.includes('cv') || text.includes('resume') || text.includes('hoja de vida')) {\n return ['cv', 'resume', 'curriculum', 'application', 'aplicacion'];\n }\n\n if (\n text.includes('renuncia') ||\n text.includes('desvinculacion') ||\n text.includes('terminacion') ||\n text.includes('termination')\n ) {\n return ['termination', 'terminacion', 'desvinculacion', 'renuncia', 'salida'];\n }\n\n if (text.includes('amonestacion') || text.includes('disciplinario')) {\n return ['disciplinario', 'disciplinary', 'amonestacion'];\n }\n\n if (text.includes('evaluacion') || text.includes('performance')) {\n return ['performance', 'evaluacion', 'evaluaciones'];\n }\n\n return ['employee', 'empleado', 'document', 'documento', 'general', 'otros', 'other'];\n}\n\nfunction scoreCategory(category, keywords) {\n let score = 0;\n\n if (!category.can_upload) {\n score -= 100;\n }\n\n const name = category.normalized_name;\n\n for (const keyword of keywords) {\n const normalizedKeyword = normalize(keyword);\n\n if (!normalizedKeyword) continue;\n\n if (name === normalizedKeyword) {\n score += 100;\n } else if (name.includes(normalizedKeyword)) {\n score += 25;\n }\n }\n\n const genericWords = ['employee', 'empleado', 'document', 'documento', 'documents', 'documentos', 'general', 'otros', 'other'];\n\n for (const word of genericWords) {\n if (name.includes(normalize(word))) {\n score += 5;\n }\n }\n\n return score;\n}\n\nconst context =\n getNodeJson('Code - Prepare BambooHR PDF Upload Request') ||\n {};\n\nconst rawCategories = uniqueCategories(findCategoriesDeep(httpResult));\n\nconst uploadableCategories = rawCategories.filter(category => category.can_upload);\n\nconst desiredLabel =\n context.bamboohr_file_category ||\n context.bamboohr_file_category_name ||\n context.pdf_document_type ||\n 'Employee Documents';\n\nconst documentType =\n context.pdf_document_type ||\n desiredLabel ||\n 'Documento';\n\nconst keywords = getDesiredKeywords(documentType, desiredLabel);\n\nconst scored = uploadableCategories\n .map(category => ({\n ...category,\n score: scoreCategory(category, keywords)\n }))\n .sort((a, b) => b.score - a.score);\n\nconst bestCategory =\n scored.find(category => category.score > 0) ||\n uploadableCategories.find(category =>\n category.normalized_name.includes('employee') ||\n category.normalized_name.includes('empleado') ||\n category.normalized_name.includes('document') ||\n category.normalized_name.includes('documento') ||\n category.normalized_name.includes('general')\n ) ||\n null;\n\nconst blockers = Array.isArray(context.bamboohr_pdf_upload_blockers)\n ? [...context.bamboohr_pdf_upload_blockers]\n : [];\n\nif (!bestCategory) {\n blockers.push(\n 'No se pudo resolver un ID de categoría válido para archivos del empleado en BambooHR.'\n );\n}\n\nconst ready =\n context.bamboohr_pdf_upload_ready === true &&\n !!bestCategory &&\n blockers.length === 0;\n\nreturn {\n json: {\n ...context,\n\n bamboohr_file_categories_response: httpResult,\n bamboohr_file_categories_detected: rawCategories,\n bamboohr_file_categories_uploadable: uploadableCategories,\n\n bamboohr_file_category_desired_label: desiredLabel,\n\n bamboohr_file_category_id: bestCategory?.id || null,\n bamboohr_file_category_name: bestCategory?.name || null,\n\n bamboohr_file_category_match_score: bestCategory?.score || null,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n final_execution_status: ready\n ? 'ready_to_upload_to_bamboohr'\n : 'blocked',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_file_category_desired_label: desiredLabel,\n bamboohr_file_category_id: bestCategory?.id || null,\n bamboohr_file_category_name: bestCategory?.name || null,\n bamboohr_file_category_match_score: bestCategory?.score || null,\n\n bamboohr_file_categories_count: rawCategories.length,\n bamboohr_file_uploadable_categories_count: uploadableCategories.length,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n execution_result: ready\n ? 'pdf_ready_to_upload_to_bamboohr'\n : 'pdf_upload_to_bamboohr_blocked_category_not_resolved'\n }\n },\n binary: {\n data: {\n ...$binary.data,\n fileName: context.bamboohr_file_name || context.pdf_final_file_name || 'documento.pdf',\n mimeType: 'application/pdf'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16064,
|
||
-5136
|
||
],
|
||
"id": "1b404504-66a1-4151-a2ed-fa616ae0bf63",
|
||
"name": "Code - Resolve BambooHR PDF Category ID"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst attachmentResourceName =\n data.pdf_attachment_resource_name ||\n data.pdf_google_chat_attachment_resource_name ||\n data.audit_event?.google_chat_attachment_resource_name ||\n null;\n\nconst driveFileId =\n data.pdf_drive_file_id ||\n data.google_drive_file_id ||\n data.audit_event?.google_drive_file_id ||\n null;\n\nconst attachmentSource =\n data.pdf_attachment_source ||\n data.attachment_source ||\n data.audit_event?.attachment_source ||\n 'unknown';\n\nlet route = 'unsupported_source';\nconst blockers = [];\n\nif (attachmentResourceName) {\n route = 'google_chat_media';\n} else if (driveFileId) {\n route = 'google_drive_file';\n} else {\n blockers.push('El PDF no tiene attachmentResourceName de Google Chat ni driveFileId de Google Drive.');\n}\n\nreturn {\n json: {\n ...data,\n\n pdf_download_source_route: route,\n pdf_download_source_blockers: blockers,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_download_source_route: route,\n pdf_download_source_blockers: blockers,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource\n }\n },\n binary: $binary\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17552,
|
||
-4320
|
||
],
|
||
"id": "f5bebc99-90b7-411f-8ef6-94544373506f",
|
||
"name": "Code - Decide Approved PDF Download Source"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "google_chat_media",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "ca967a0f-d67b-4799-b2ca-9f81ebebcd97"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "google_chat_media"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c0f75af3-de22-4161-b383-c38301495faf",
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "google_drive_file",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "google_drive_file"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b2875375-4d5c-4060-8965-d0d341f94636",
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "unsupported_source",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "unsupported_source"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-17360,
|
||
-4448
|
||
],
|
||
"id": "33ee8521-e9fb-4095-9a32-11f7fbd9bc0d",
|
||
"name": "Switch - Approved PDF Download Source"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://www.googleapis.com/drive/v3/files/' + encodeURIComponent($json.pdf_drive_file_id) + '?alt=media' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/pdf"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17104,
|
||
-4592
|
||
],
|
||
"id": "ff2a8f74-1b3f-4296-ba14-feba62b13330",
|
||
"name": "HTTP - Download Approved PDF From Google Drive",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "eHseMeH39kRcXgOF",
|
||
"name": "Google account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_download_source_blockers)\n ? data.pdf_download_source_blockers\n : ['No se pudo determinar la fuente descargable del PDF.'];\n\nreturn {\n json: {\n ...data,\n\n pdf_download_ready: false,\n pdf_download_blockers: blockers,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_source_not_supported',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_download_ready: false,\n pdf_download_blockers: blockers,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_source_not_supported',\n execution_result: 'pdf_download_source_not_supported'\n }\n },\n binary: $binary\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17072,
|
||
-4384
|
||
],
|
||
"id": "42b9d5b3-55b3-438f-a158-505d3fbe9917",
|
||
"name": "Code - Prepare PDF Download Source Blocker"
|
||
}
|
||
],
|
||
"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 - Apply Gemini Interpretation",
|
||
"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": "Switch - Report Type",
|
||
"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 - Detect Approval Command",
|
||
"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": "Code - Prepare Pending Confirmation",
|
||
"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": "HTTP - List Employees For Resolver",
|
||
"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 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 - 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": "Code - Sensitive Approval Gate",
|
||
"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 - Detect Approval Button Event",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Google Chat Input": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respond - Google Chat Processing",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Google Chat Channel?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Google Chat Final Message",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Google Chat Final Message": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Google Chat Final Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Google Chat Final Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Enviar respuesta final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Respond - Google Chat Processing": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Detect Confirmation Reply",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Confirmation Reply": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Confirmation Reply?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Confirmation Reply?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Get Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Request Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Get Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Confirmation Reply",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Confirmation Reply": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Confirmation Cancelled?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Confirmation Cancelled?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Mark Confirmation Cancelled",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - Confirmation Confirmed?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Mark Confirmation Cancelled": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Cancelled Confirmation Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Should Save Pending Confirmation?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Should Save Pending Confirmation?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Supersede Previous Pending Confirmations",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - Is Google Chat Channel?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Insert Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Response After Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Response After Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Google Chat Channel?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Cancelled Confirmation Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Confirmation Confirmed?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Mark Confirmation Confirmed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Mark Confirmation Confirmed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Confirmed Confirmation Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Confirmed Confirmation Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Request Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Supersede Previous Pending Confirmations": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Pending Confirmation After Supersede",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Pending Confirmation After Supersede": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Insert Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Gemini Intent Prompt": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "AI Agent - Gemini Intent Interpreter",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Gemini Chat Model": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "AI Agent - Gemini Intent Interpreter",
|
||
"type": "ai_languageModel",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"AI Agent - Gemini Intent Interpreter": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Normalize Gemini Intent",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Gemini Intent": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Intent Parser",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List Employees For Resolver": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Match Employee From List Employees",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Match Employee From List Employees": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Employee Scope Gate",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Custom Report Active Inactive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Active Inactive Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Report Type": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Custom Report Active Inactive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Saved Report Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - List BambooHR Saved Reports",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Saved Report Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Active Inactive Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List BambooHR Saved Reports": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Reports List",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Reports List": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Saved Report Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Has ID?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Has ID?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Wants Export?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - List Saved Reports For Run",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List Saved Reports For Run": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Saved Report By Name",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Saved Report By Name": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Name Resolved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Name Resolved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Wants Export?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Name Not Found",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Name Not Found": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Wants Export?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Export Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Export Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report Export",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report Export": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Page Requests",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Export Preview": {
|
||
"main": [
|
||
[]
|
||
]
|
||
},
|
||
"Code - Prepare Saved Report Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Google Sheets - Create Saved Report Spreadsheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Sheets - Create Saved Report Spreadsheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Saved Report Sheet Values",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Saved Report Sheet Values": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Google Sheets - Write Saved Report Rows",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Google Sheet Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Sheets - Write Saved Report Rows": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Sheet Format Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Page Requests": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report Export Page",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report Export Page": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Combine Saved Report Export Pages",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Combine Saved Report Export Pages": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Saved Report Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Sheet Format Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Format Saved Report Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Format Saved Report Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Google Sheet Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Apply Gemini Interpretation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Intent",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Sensitive Approval Gate": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Needs Maximo Approval?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Needs Maximo Approval?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Maximo Approval Record",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - User Authorized?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Maximo Approval Record": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Insert Pending Maximo Approval",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Insert Pending Maximo Approval": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Maximo Approval Pending Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "HTTP - Get Maximo Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Maximo Approval Pending Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Maximo Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Maximo Chat Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Maximo Chat Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Maximo Chat Space Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Maximo Chat Space Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Enviar notificación aprobación Máximo",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Approval Command": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Approval Command?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Approval Command?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Command Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Build Gemini Intent Prompt",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Command Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Pending Approval By ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Pending Approval By ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Approval Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Decision Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Decision Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Update Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Update Approval Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Approval Decision Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approval Decision Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Approval Button Event": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Approval Button Event?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Approval Button Event?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Button Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Google Chat Input",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Button Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Approval By Button ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Approval By Button ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Approval Button Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Approval Button Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Button Decision Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Button Decision Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Update Approval From Button",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Update Approval From Button": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Approval Button Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approval Button Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Prepare Requester Approval Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "IF - Maximo Approved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Requester Approval Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Should Notify Requester?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Should Notify Requester?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Requester Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Requester Decision Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Requester Decision Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Requester Chat Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Requester Chat Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Maximo Approved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Approved Action Execution Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Approved Action Execution Context": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Action Supported?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Action Supported?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Switch - Approved Action Route",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Approved Action Not Supported",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approved Action Not Supported": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Approved Action Route": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Approved Compensation Preflight",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[],
|
||
[],
|
||
[],
|
||
[
|
||
{
|
||
"node": "Code - Prepare Approved PDF Upload",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Approved Action Not Supported1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Approved Compensation Preflight": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Approved Get Employee Directory",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Approved Get Employee Directory": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Approved Employee From Directory",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Approved Employee From Directory": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Employee Resolved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Employee Resolved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Approved Get Current Compensation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Approved Get Current Compensation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Approved Compensation Write Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Approved Compensation Write Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Compensation Ready To Execute?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Compensation Ready To Execute?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Execute Approved Compensation Update",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Compensation Execution Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Compensation Execution Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approval Execution Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Execute Approved Compensation Update": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Compensation Execution Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Compensation Execution Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approval Executed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approval Executed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approval Execution Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Requester Final Execution Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Requester Final Chat Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Requester Final Chat Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Requester Final Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Requester Final Execution Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approved Action Not Supported1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approved Action Not Supported",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approved Action Not Supported": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Approved PDF Upload": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved PDF Upload Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved PDF Upload Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Decide Approved PDF Download Source",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Upload Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Upload Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Upload Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Upload Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Download Approved PDF From Google Chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Downloaded PDF Binary",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Downloaded PDF Binary": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Downloaded PDF Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Downloaded PDF Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare BambooHR PDF Upload Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Download Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Download Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare BambooHR PDF Upload Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - List BambooHR Employee File Categories",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - BambooHR PDF Upload Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Upload PDF To BambooHR",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format BambooHR PDF Upload Precheck Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format BambooHR PDF Upload Precheck Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark BambooHR PDF Upload Precheck Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark BambooHR PDF Upload Precheck Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Upload PDF To BambooHR": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Upload Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Upload Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Upload Finished",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Upload Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build PDF Upload Metadata Log": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Log PDF Upload Metadata",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Log PDF Upload Metadata": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build PDF Blocked Metadata Log": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Log PDF Blocked Metadata",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List BambooHR Employee File Categories": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve BambooHR PDF Category ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve BambooHR PDF Category ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - BambooHR PDF Upload Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Upload Finished": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Decide Approved PDF Download Source": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Switch - Approved PDF Download Source",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Approved PDF Download Source": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Download Approved PDF From Google Chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Download Approved PDF From Google Drive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Prepare PDF Download Source Blocker",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Download Approved PDF From Google Drive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Downloaded PDF Binary",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare PDF Download Source Blocker": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1",
|
||
"binaryMode": "separate",
|
||
"availableInMCP": false
|
||
},
|
||
"staticData": null,
|
||
"meta": {
|
||
"templateCredsSetupCompleted": true
|
||
},
|
||
"versionId": "bdb63908-b79f-4b9e-957a-fe3f12332942",
|
||
"activeVersionId": "bdb63908-b79f-4b9e-957a-fe3f12332942",
|
||
"versionCounter": 2879,
|
||
"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 <iaracena@gomezleemarketing.com>",
|
||
"type": "personal",
|
||
"icon": null,
|
||
"description": null,
|
||
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
||
}
|
||
}
|
||
],
|
||
"tags": [],
|
||
"activeVersion": {
|
||
"updatedAt": "2026-07-02T22:04:39.000Z",
|
||
"createdAt": "2026-07-02T22:03:50.016Z",
|
||
"versionId": "bdb63908-b79f-4b9e-957a-fe3f12332942",
|
||
"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": [
|
||
-4128,
|
||
-7072
|
||
],
|
||
"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": [
|
||
-20688,
|
||
-2400
|
||
],
|
||
"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": [
|
||
-20480,
|
||
-2400
|
||
],
|
||
"id": "ca88cf95-285f-4389-84c9-458198f82f94",
|
||
"name": "Set - Incoming Message"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\n\nfunction normalizeGeminiText(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeGeminiFieldName(value) {\n const raw = String(value || '').trim();\n\n if (!raw) return null;\n\n const normalized = normalizeGeminiText(raw);\n\n const fieldMap = {\n // Cargo / puesto\n cargo: 'jobTitle',\n puesto: 'jobTitle',\n posicion: 'jobTitle',\n posición: 'jobTitle',\n titulo: 'jobTitle',\n 'titulo laboral': 'jobTitle',\n 'título laboral': 'jobTitle',\n job: 'jobTitle',\n jobtitle: 'jobTitle',\n 'job title': 'jobTitle',\n jobTitle: 'jobTitle',\n\n // Departamento\n departamento: 'department',\n area: 'department',\n área: 'department',\n department: 'department',\n\n // División\n division: 'division',\n división: 'division',\n unidad: 'division',\n bu: 'division',\n\n // Ubicación / país\n ubicacion: 'location',\n ubicación: 'location',\n pais: 'location',\n país: 'location',\n localidad: 'location',\n location: 'location',\n\n // Supervisor\n supervisor: 'supervisor',\n jefe: 'supervisor',\n manager: 'supervisor',\n lider: 'supervisor',\n líder: 'supervisor',\n\n // Correo laboral\n correo: 'workEmail',\n email: 'workEmail',\n 'correo laboral': 'workEmail',\n 'email laboral': 'workEmail',\n 'correo de trabajo': 'workEmail',\n 'email de trabajo': 'workEmail',\n workemail: 'workEmail',\n workEmail: 'workEmail',\n 'work email': 'workEmail',\n\n // Teléfono laboral\n telefono: 'workPhone',\n teléfono: 'workPhone',\n 'telefono laboral': 'workPhone',\n 'teléfono laboral': 'workPhone',\n 'telefono de trabajo': 'workPhone',\n 'teléfono de trabajo': 'workPhone',\n workphone: 'workPhone',\n workPhone: 'workPhone',\n 'work phone': 'workPhone',\n\n // Extensión laboral\n extension: 'workPhoneExtension',\n extensión: 'workPhoneExtension',\n 'extension laboral': 'workPhoneExtension',\n 'extensión laboral': 'workPhoneExtension',\n 'extension de trabajo': 'workPhoneExtension',\n 'extensión de trabajo': 'workPhoneExtension',\n workphoneextension: 'workPhoneExtension',\n workPhoneExtension: 'workPhoneExtension',\n 'work phone extension': 'workPhoneExtension',\n\n // Estatus laboral\n estatus: 'employmentStatus',\n estado: 'employmentStatus',\n 'estatus laboral': 'employmentStatus',\n 'estado laboral': 'employmentStatus',\n employmentstatus: 'employmentStatus',\n employmentStatus: 'employmentStatus',\n 'employment status': 'employmentStatus',\n\n // Fecha de ingreso\n ingreso: 'hireDate',\n 'fecha ingreso': 'hireDate',\n 'fecha de ingreso': 'hireDate',\n 'fecha contratacion': 'hireDate',\n 'fecha contratación': 'hireDate',\n 'fecha de contratacion': 'hireDate',\n 'fecha de contratación': 'hireDate',\n hiredate: 'hireDate',\n hireDate: 'hireDate',\n 'hire date': 'hireDate',\n\n // Nombre\n nombre: 'displayName',\n 'nombre completo': 'displayName',\n displayname: 'displayName',\n displayName: 'displayName',\n 'display name': 'displayName',\n\n // ID / número empleado\n id: 'id',\n employeeid: 'id',\n employeeId: 'id',\n 'employee id': 'id',\n\n numeroempleado: 'employeeNumber',\n 'numero empleado': 'employeeNumber',\n 'número empleado': 'employeeNumber',\n 'numero de empleado': 'employeeNumber',\n 'número de empleado': 'employeeNumber',\n employeenumber: 'employeeNumber',\n employeeNumber: 'employeeNumber',\n 'employee number': 'employeeNumber'\n };\n\n if (fieldMap[raw]) return fieldMap[raw];\n if (fieldMap[normalized]) return fieldMap[normalized];\n\n const canonicalFields = [\n 'id',\n 'displayName',\n 'firstName',\n 'lastName',\n 'preferredName',\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'supervisorEmail',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'employmentStatus',\n 'status',\n 'hireDate',\n 'employeeNumber'\n ];\n\n const canonicalMatch = canonicalFields.find(field => field.toLowerCase() === normalized.toLowerCase());\n\n return canonicalMatch || raw;\n}\n\nfunction normalizeGeminiRequestedFields(value) {\n let rawFields = [];\n\n if (Array.isArray(value)) {\n rawFields = value;\n } else if (typeof value === 'string' && value.trim()) {\n rawFields = value\n .split(/,| y | e | and /i)\n .map(item => item.trim())\n .filter(Boolean);\n }\n\n const normalizedFields = [];\n\n for (const field of rawFields) {\n const normalizedField = normalizeGeminiFieldName(field);\n\n if (!normalizedField) continue;\n\n if (!normalizedFields.includes(normalizedField)) {\n normalizedFields.push(normalizedField);\n }\n }\n\n return normalizedFields;\n}\n\nfunction normalizeGeminiActionObject(action) {\n if (!action || typeof action !== 'object') {\n return action || null;\n }\n\n const normalizedAction = {\n ...action\n };\n\n if (normalizedAction.field) {\n normalizedAction.field = normalizeGeminiFieldName(normalizedAction.field);\n }\n\n if (normalizedAction.payload && typeof normalizedAction.payload === 'object') {\n const normalizedPayload = {};\n\n for (const [key, value] of Object.entries(normalizedAction.payload)) {\n const normalizedKey = normalizeGeminiFieldName(key) || key;\n normalizedPayload[normalizedKey] = value;\n }\n\n normalizedAction.payload = normalizedPayload;\n }\n\n return normalizedAction;\n}\n\n\n// ----------------------------------------------------\n// PRIORIDAD ABSOLUTA: REPORTES GUARDADOS / CUSTOM REPORTS\n// ----------------------------------------------------\n// Este bloque evita que Gemini clasifique mal frases como:\n// \"Ejecuta el reporte guardado reporte inventado xyz\"\n// como list_saved_reports.\n// Si el usuario usa verbo de ejecución + reporte, forzamos run_saved_report.\n\nfunction extractForcedSavedReportPage(messageText) {\n const raw = String(messageText || '').trim();\n\n const pageMatch =\n raw.match(/\\bp[aá]gina\\s+([a-zA-Z0-9_-]+)\\b/i) ||\n raw.match(/\\bpage\\s+([a-zA-Z0-9_-]+)\\b/i);\n\n if (!pageMatch || !pageMatch[1]) {\n return {\n page: 1,\n requested_page_raw: null\n };\n }\n\n const rawPage = String(pageMatch[1]).trim();\n const pageNumber = Number(rawPage);\n\n if (!Number.isFinite(pageNumber) || pageNumber < 1) {\n return {\n page: 1,\n requested_page_raw: rawPage\n };\n }\n\n return {\n page: Math.floor(pageNumber),\n requested_page_raw: rawPage\n };\n}\n\nfunction extractForcedSavedReportId(messageText) {\n const raw = String(messageText || '').trim();\n\n // Caso fuerte: \"ID 13\", \"id #13\"\n const explicitIdMatch = raw.match(/\\bID\\s*#?\\s*([0-9]{1,10})\\b/i);\n\n if (explicitIdMatch && explicitIdMatch[1]) {\n return explicitIdMatch[1].trim();\n }\n\n // Caso aceptado: \"reporte guardado 13\"\n // Evitamos confundir \"página 2\" como ID.\n const reportIdMatch = raw.match(/\\breporte\\s+guardado\\s+([0-9]{1,10})(?!\\s*(?:p[aá]gina|page))\\b/i);\n\n if (reportIdMatch && reportIdMatch[1]) {\n return reportIdMatch[1].trim();\n }\n\n return null;\n}\n\nfunction extractForcedSavedReportNameQuery(messageText, reportId) {\n if (reportId) {\n return null;\n }\n\n let text = String(messageText || '').trim();\n\n if (!text) return null;\n\n text = text\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos página.\n text = text.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n text = text.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n\n // Quitamos verbos de ejecución/consulta.\n text = text.replace(/\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar|mu[eé]strame|muestrame|dame)\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO quitamos la palabra \"reporte\"\n // si forma parte del nombre real, como \"Reporte de Activos\".\n text = text.replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breportes\\s+guardados\\b/gi, ' ');\n text = text.replace(/\\bcustom reports?\\b/gi, ' ');\n text = text.replace(/\\breportes?\\s+custom\\b/gi, ' ');\n text = text.replace(/\\bsaved reports?\\b/gi, ' ');\n text = text.replace(/\\bde\\s+bamboohr\\b/gi, ' ');\n text = text.replace(/\\ben\\s+bamboohr\\b/gi, ' ');\n text = text.replace(/\\bbamboohr\\b/gi, ' ');\n\n // Quitamos ID si aparece por seguridad.\n text = text.replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ');\n text = text.replace(/\\b#\\s*\\d+\\b/gi, ' ');\n\n text = text\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!text) return null;\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte'\n ];\n\n const normalized = normalizeGeminiText(text);\n\n if (blocked.includes(normalized)) {\n return null;\n }\n\n return text;\n}\n\nconst forcedSavedReportOriginalMessage =\n input.message ||\n input.original_message ||\n '';\n\nconst forcedSavedReportNormalizedMessage =\n normalizeGeminiText(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasExecutionVerb =\n /\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar)\\b/i\n .test(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasListVerb =\n /\\b(lista|listar|listame|lístame|muestra|muestrame|muéstrame|dame|ver)\\b/i\n .test(forcedSavedReportOriginalMessage);\n\nconst forcedSavedReportHasReportTerm =\n forcedSavedReportNormalizedMessage.includes('reporte guardado') ||\n forcedSavedReportNormalizedMessage.includes('reportes guardados') ||\n forcedSavedReportNormalizedMessage.includes('custom report') ||\n forcedSavedReportNormalizedMessage.includes('custom reports') ||\n forcedSavedReportNormalizedMessage.includes('saved report') ||\n forcedSavedReportNormalizedMessage.includes('saved reports') ||\n forcedSavedReportNormalizedMessage.includes('reporte ');\n\nconst forcedSavedReportIsPureListRequest =\n !forcedSavedReportHasExecutionVerb &&\n forcedSavedReportHasListVerb &&\n (\n forcedSavedReportNormalizedMessage.includes('reportes guardados') ||\n forcedSavedReportNormalizedMessage.includes('reportes disponibles') ||\n forcedSavedReportNormalizedMessage.includes('lista de reportes') ||\n forcedSavedReportNormalizedMessage.includes('listar reportes') ||\n forcedSavedReportNormalizedMessage.includes('custom reports') ||\n forcedSavedReportNormalizedMessage.includes('reportes custom') ||\n forcedSavedReportNormalizedMessage.includes('saved reports') ||\n forcedSavedReportNormalizedMessage.includes('reportes configurados') ||\n forcedSavedReportNormalizedMessage.includes('reportes existentes')\n );\n\nconst forcedSavedReportIsRunRequest =\n forcedSavedReportHasExecutionVerb &&\n forcedSavedReportHasReportTerm;\n\nif (forcedSavedReportIsRunRequest || forcedSavedReportIsPureListRequest) {\n const forcedReportId = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportId(forcedSavedReportOriginalMessage)\n : null;\n\n const forcedPageInfo = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportPage(forcedSavedReportOriginalMessage)\n : {\n page: 1,\n requested_page_raw: null\n };\n\n const forcedReportNameQuery = forcedSavedReportIsRunRequest\n ? extractForcedSavedReportNameQuery(forcedSavedReportOriginalMessage, forcedReportId)\n : null;\n\n const forcedIntent = forcedSavedReportIsRunRequest\n ? 'run_saved_report'\n : 'list_saved_reports';\n\n const forcedRiskLevel = forcedSavedReportIsRunRequest\n ? 'high'\n : 'low';\n\n const forcedFilters = forcedSavedReportIsRunRequest\n ? {\n ...(forcedReportId ? { report_id: forcedReportId } : {}),\n ...(forcedReportNameQuery ? { report_name_query: forcedReportNameQuery } : {}),\n saved_report_page: forcedPageInfo.page,\n requested_page_raw: forcedPageInfo.requested_page_raw\n }\n : {};\n\n const forcedStatus =\n forcedSavedReportIsPureListRequest ||\n forcedReportId ||\n forcedReportNameQuery\n ? 'ready'\n : 'needs_clarification';\n\n const forcedCanContinue =\n forcedStatus === 'ready';\n\n const forcedResponseText = forcedCanContinue\n ? null\n : 'Entiendo que deseas ejecutar un reporte guardado, pero necesito que indiques el ID o el nombre del reporte. Ejemplo: \"Ejecuta el reporte guardado ID 13\" o \"Ejecuta el reporte guardado Reporte de Activos\".';\n\n return {\n json: {\n ...input,\n\n mode: 'reporte',\n intent: forcedIntent,\n report_type: forcedIntent,\n risk_level: forcedRiskLevel,\n\n confidence: forcedCanContinue ? 1 : 0.75,\n requires_confirmation: false,\n\n employee_query: null,\n employee_id: null,\n requested_fields: [],\n fields: [],\n\n filters: forcedFilters,\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw,\n\n can_continue: forcedCanContinue,\n status: forcedStatus,\n response_text: forcedResponseText,\n response_type: forcedCanContinue ? 'chat_message' : 'error_message',\n\n parser_source: 'deterministic_saved_report_priority',\n\n data: {\n ...(input.data || {}),\n\n report_type: forcedIntent,\n\n filters: {\n ...(input.data?.filters || {}),\n ...forcedFilters\n },\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw\n },\n\n audit_event: {\n ...(input.audit_event || {}),\n\n original_message: forcedSavedReportOriginalMessage,\n\n parser_source: 'deterministic_saved_report_priority',\n\n mode: 'reporte',\n intent: forcedIntent,\n report_type: forcedIntent,\n risk_level: forcedRiskLevel,\n\n saved_report_id: forcedReportId,\n report_id: forcedReportId,\n saved_report_name_query: forcedReportNameQuery,\n saved_report_page: forcedPageInfo.page,\n saved_report_requested_page_raw: forcedPageInfo.requested_page_raw,\n\n authorization_result: 'pending_authorization',\n execution_result: 'interpreted_by_saved_report_priority_parser'\n }\n }\n };\n}\n\n\nconst geminiInput = $json;\n\nif (\n geminiInput.gemini_interpretation_valid === true &&\n geminiInput.gemini_interpretation &&\n geminiInput.use_deterministic_parser_fallback !== true\n) {\n const g = geminiInput.gemini_interpretation;\n\n const modeMap = {\n query: 'consulta',\n consulta: 'consulta',\n consultar: 'consulta',\n\n action: 'accion',\n accion: 'accion',\n acción: 'accion',\n modificar: 'accion',\n actualizar: 'accion',\n editar: 'accion',\n crear: 'accion',\n desvincular: 'accion',\n\n report: 'reporte',\n reporte: 'reporte',\n reportes: 'reporte',\n\n confirmation: 'confirmation',\n confirmacion: 'confirmation',\n confirmación: 'confirmation',\n confirmar: 'confirmation',\n cancelar: 'confirmation',\n\n system: 'system',\n unknown: 'unknown'\n };\n\n const normalizedMode =\n modeMap[String(g.mode || '').trim().toLowerCase()] ||\n 'unknown';\n\n const employeeQuery =\n g.employee_query ||\n g.employee_id ||\n null;\n\n const normalizedRequestedFields = normalizeGeminiRequestedFields(\n g.requested_fields ||\n g.fields ||\n []\n );\n\n const normalizedAction = normalizeGeminiActionObject(g.action || null);\n\n return {\n json: {\n ...geminiInput,\n\n mode: normalizedMode,\n intent: g.intent,\n confidence: g.confidence,\n risk_level: g.risk_level,\n\n requires_confirmation: g.requires_confirmation,\n\n employee_query: employeeQuery,\n employee_id: g.employee_id || null,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n table_name: g.table_name || null,\n filters: g.filters || {},\n report_type: g.report_type || null,\n\n action: normalizedAction,\n\n can_continue: true,\n status: 'interpreted_by_gemini',\n\n response_text: null,\n response_type: 'chat_message',\n\n parser_source: 'gemini',\n\n data: {\n ...(geminiInput.data || {}),\n\n employee_id: g.employee_id || null,\n employee_query: employeeQuery,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n table_name: g.table_name || null,\n filters: g.filters || {},\n report_type: g.report_type || null,\n\n action: normalizedAction,\n\n requires_confirmation: g.requires_confirmation,\n confidence: g.confidence,\n status: 'interpreted_by_gemini'\n },\n\n audit_event: {\n ...(geminiInput.audit_event || {}),\n\n original_message:\n geminiInput.original_message ||\n geminiInput.message ||\n null,\n\n parser_source: 'gemini',\n\n gemini_interpretation_valid: true,\n gemini_interpreted_mode: normalizedMode,\n gemini_interpreted_intent: g.intent,\n gemini_confidence: g.confidence,\n gemini_risk_level: g.risk_level,\n\n gemini_original_requested_fields:\n g.requested_fields ||\n g.fields ||\n [],\n\n gemini_normalized_requested_fields: normalizedRequestedFields,\n\n mode: normalizedMode,\n intent: g.intent,\n risk_level: g.risk_level,\n\n employee_query: employeeQuery,\n employee_id: g.employee_id || null,\n\n requested_fields: normalizedRequestedFields,\n fields: normalizedRequestedFields,\n\n action: normalizedAction,\n\n authorization_result: 'pending_authorization',\n execution_result: 'interpreted_by_gemini'\n }\n }\n };\n}\n\nif (\n $json.confirmed_from_google_chat === true &&\n $json.confirmation_status === 'confirmed' &&\n (\n $json.data?.action ||\n $json.action\n )\n) {\n const action =\n normalizeGeminiActionObject(\n $json.data?.action ||\n $json.action\n );\n\n const employeeId =\n $json.data?.employee_id ||\n $json.employee_id ||\n $json.resolved_employee_id ||\n $json.audit_event?.target_employee_id ||\n null;\n\n const employeeName =\n $json.data?.employee_name ||\n $json.employee_name ||\n $json.resolved_employee_name ||\n $json.audit_event?.target_employee_name ||\n null;\n\n const employeeEmail =\n $json.data?.employee_email ||\n $json.employee_email ||\n $json.resolved_employee_email ||\n $json.audit_event?.target_employee_email ||\n null;\n\n return {\n json: {\n ...$json,\n\n mode: $json.mode || 'accion',\n intent: $json.intent || 'update_employee_basic',\n risk_level: $json.risk_level || 'medium',\n\n can_continue: true,\n confidence: 1,\n status: 'ready',\n response_text: null,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n action,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n\n data: {\n ...($json.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true,\n confidence: 1,\n status: 'ready'\n },\n\n audit_event: {\n ...($json.audit_event || {}),\n\n mode: $json.mode || 'accion',\n intent: $json.intent || 'update_employee_basic',\n risk_level: $json.risk_level || 'medium',\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n\n authorization_result: 'confirmation_confirmed_by_user',\n execution_result: 'confirmed_pending_execution'\n }\n }\n };\n}\n\nif (input.skip_core === true || input.can_continue === false) {\n return {\n json: {\n ...input,\n mode: input.mode || 'system',\n intent: input.intent || 'system_event',\n risk_level: input.risk_level || 'low',\n confidence: 1,\n status: 'ready',\n can_continue: false,\n response_text:\n input.response_text ||\n 'Evento recibido, pero no requiere procesamiento del core BambooHR.'\n }\n };\n}\n\n\nconst originalMessage =\n input.message ||\n input.original_message ||\n '';\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/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 / REPORTES GUARDADOS\n// -------------------------\nconst asksSavedReports =\n message.includes('reportes guardados') ||\n message.includes('reportes disponibles') ||\n message.includes('reporte disponible') ||\n message.includes('lista de reportes') ||\n message.includes('listar reportes') ||\n message.includes('listame los reportes') ||\n message.includes('lístame los reportes') ||\n message.includes('muestrame los reportes') ||\n message.includes('muéstrame los reportes') ||\n message.includes('custom reports disponibles') ||\n message.includes('reportes custom disponibles') ||\n message.includes('saved reports') ||\n message.includes('reportes configurados') ||\n message.includes('reportes existentes');\n\nconst savedReportIdMatch = originalMessage.match(/\\b(?:id|ID)?\\s*#?\\s*([0-9]{1,10})\\b/);\n\nconst asksRunSavedReport =\n (\n message.includes('ejecuta') ||\n message.includes('ejecutar') ||\n message.includes('corre') ||\n message.includes('correr') ||\n message.includes('genera') ||\n message.includes('generar') ||\n message.includes('abre') ||\n message.includes('abrir') ||\n message.includes('consulta') ||\n message.includes('consultar')\n ) &&\n (\n message.includes('reporte guardado') ||\n message.includes('reportes guardados') ||\n message.includes('custom report') ||\n message.includes('custom reports') ||\n message.includes('saved report') ||\n message.includes('saved reports') ||\n message.includes('reporte id') ||\n message.includes('report id') ||\n\n // IMPORTANTE:\n // Esto permite frases naturales como:\n // \"Ejecuta el reporte de activos página 1\"\n // \"Ejecuta el reporte correos\"\n // \"Corre el reporte Reporte de Activos\"\n message.includes('reporte ')\n );\n\nfunction extractSavedReportNameQuery(messageText) {\n let text = String(messageText || '').trim();\n\n if (!text) return null;\n\n text = text\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos verbos comunes.\n text = text.replace(/\\b(ejecuta|ejecutar|corre|correr|genera|generar|abre|abrir|consulta|consultar|mu[eé]strame|muestrame|dame)\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO quitamos \"reporte\" si forma parte del nombre real.\n text = text.replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breporte\\s+guardado\\b/gi, ' ');\n text = text.replace(/\\breportes\\s+guardados\\b/gi, ' ');\n text = text.replace(/\\bcustom reports?\\b/gi, ' ');\n text = text.replace(/\\bsaved reports?\\b/gi, ' ');\n\n // Quitamos ID si aparece.\n text = text.replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ');\n text = text.replace(/\\b#\\s*\\d+\\b/gi, ' ');\n\n // Quitamos página.\n text = text.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n text = text.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n\n // Limpieza de artículos sueltos al inicio.\n text = text\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!text) return null;\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte'\n ];\n\n const normalized = normalize(text);\n\n if (blocked.includes(normalized)) {\n return null;\n }\n\n return text;\n}\n\nconst asksActiveInactive =\n (\n message.includes('activos') &&\n message.includes('inactivos')\n ) ||\n message.includes('activo e inactivo') ||\n message.includes('activos e inactivos') ||\n message.includes('empleados activos e inactivos') ||\n message.includes('colaboradores activos e inactivos');\n\nconst asksHeadcount =\n message.includes('headcount') ||\n message.includes('cantidad de empleados') ||\n message.includes('numero de empleados') ||\n message.includes('número de empleados') ||\n message.includes('cuantos empleados') ||\n message.includes('cuántos empleados') ||\n message.includes('total empleados') ||\n message.includes('total de empleados') ||\n message.includes('empleados por pais') ||\n message.includes('empleados por país') ||\n message.includes('empleados por ubicacion') ||\n message.includes('empleados por ubicación') ||\n message.includes('empleados por departamento') ||\n message.includes('headcount por pais') ||\n message.includes('headcount por país') ||\n message.includes('headcount por departamento');\n\nif (asksRunSavedReport) {\n const reportId =\n savedReportIdMatch && savedReportIdMatch[1]\n ? savedReportIdMatch[1]\n : null;\n\n const reportNameQuery = reportId\n ? null\n : extractSavedReportNameQuery(originalMessage);\n\n mode = 'reporte';\n intent = 'run_saved_report';\n riskLevel = 'high';\n requiresConfirmation = false;\n\n confidence =\n reportId || reportNameQuery\n ? 0.95\n : 0.75;\n\n status =\n reportId || reportNameQuery\n ? 'ready'\n : 'needs_clarification';\n\n responseText =\n reportId || reportNameQuery\n ? null\n : 'Entiendo que deseas ejecutar un reporte guardado, pero necesito que indiques el ID o el nombre del reporte. Ejemplo: \"Ejecuta el reporte guardado ID 13\" o \"Ejecuta el reporte Reporte de Activos\".';\n\n fields = [];\n\n filters = {\n ...(reportId ? { report_id: reportId } : {}),\n ...(reportNameQuery ? { report_name_query: reportNameQuery } : {})\n };\n\n tableName = null;\n action = null;\n}\n\nelse if (asksSavedReports) {\n // Reportes guardados SIEMPRE primero.\n // Esto evita que \"Muéstrame los reportes guardados\" caiga como headcount general.\n mode = 'reporte';\n intent = 'list_saved_reports';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 1;\n status = 'ready';\n responseText = null;\n\n fields = [];\n filters = {};\n tableName = null;\n action = null;\n}\n \n// Activos / inactivos.\nelse if (asksActiveInactive) {\n mode = 'reporte';\n intent = 'employees_active_inactive';\n riskLevel = 'low';\n requiresConfirmation = false;\n confidence = 0.95;\n status = 'ready';\n responseText = null;\n\n fields = [];\n filters = {\n include_inactive: true\n };\n\n tableName = null;\n action = null;\n}\n\n// Headcount.\n// OJO: aquí NO usamos message.includes('reporte') solo,\n// porque eso rompía reportes guardados/custom reports.\nelse if (asksHeadcount) {\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 fields = [];\n tableName = null;\n action = null;\n\n if (\n message.includes('pais') ||\n message.includes('país') ||\n message.includes('ubicacion') ||\n message.includes('ubicación')\n ) {\n intent = 'headcount_by_country';\n }\n\n if (\n message.includes('departamento') ||\n message.includes('area') ||\n message.includes('área') ||\n message.includes('depto')\n ) {\n intent = 'headcount_by_department';\n }\n\n if (\n (\n message.includes('pais') ||\n message.includes('país') ||\n message.includes('ubicacion') ||\n message.includes('ubicación')\n ) &&\n (\n message.includes('departamento') ||\n message.includes('area') ||\n message.includes('área') ||\n message.includes('depto')\n )\n ) {\n intent = 'headcount_by_country_department';\n }\n\n filters = {\n status: 'Active'\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": [
|
||
-10320,
|
||
-2304
|
||
],
|
||
"id": "16424bed-7d3c-4d1d-8da7-8b8c1437a9f6",
|
||
"name": "Code - Intent Parser"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n PASS-THROUGH DE ACCIÓN CONFIRMADA DESDE GOOGLE CHAT\n\n Este bloque es clave:\n Cuando el usuario responde CONFIRMAR, el mensaje real que llega al parser es \"CONFIRMAR\",\n pero la acción original ya viene restaurada desde la tabla de confirmaciones pendientes.\n\n Por eso NO debemos volver a validar confidence, employee_query ni reinterpretar \"CONFIRMAR\".\n Debemos dejar pasar la acción restaurada.\n*/\nif (\n data.confirmed_from_google_chat === true &&\n data.confirmation_status === 'confirmed' &&\n (\n data.data?.action ||\n data.action\n )\n) {\n const action =\n data.data?.action ||\n data.action;\n\n const employeeId =\n data.data?.employee_id ||\n data.employee_id ||\n data.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n null;\n\n const employeeName =\n data.data?.employee_name ||\n data.employee_name ||\n data.resolved_employee_name ||\n data.audit_event?.target_employee_name ||\n null;\n\n const employeeEmail =\n data.data?.employee_email ||\n data.employee_email ||\n data.resolved_employee_email ||\n data.audit_event?.target_employee_email ||\n null;\n\n return {\n json: {\n ...data,\n\n can_continue: true,\n confidence: data.confidence || 1,\n status: 'ready',\n\n response_text: null,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n action,\n\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n data: {\n ...(data.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n confidence: data.confidence || 1,\n status: 'ready'\n },\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\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n authorization_result:\n data.audit_event?.authorization_result ||\n 'confirmation_confirmed_by_user',\n\n execution_result: 'validated_confirmed_action'\n }\n }\n };\n}\n\nif (data.skip_core === true || data.can_continue === false) {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n data.response_text ||\n 'Evento recibido, pero no requiere procesamiento del core BambooHR.'\n }\n };\n}\n\nif (!data.mode || data.mode === 'unknown') {\n return {\n json: {\n ...data,\n can_continue: false,\n response_text:\n '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:\n '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": [
|
||
-9808,
|
||
-2288
|
||
],
|
||
"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": [
|
||
-5664,
|
||
-6208
|
||
],
|
||
"id": "1c7b9cd5-b635-4012-9b7e-e5086d2ca66c",
|
||
"name": "Switch - Mode Router"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"fieldToSplitOut": "employees",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.splitOut",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-3888,
|
||
-7072
|
||
],
|
||
"id": "88fcede6-4eea-4b65-b320-1a86d1da10ed",
|
||
"name": "Split Out - Employees"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const intent = $node[\"Code - Intent Parser\"].json;\n\nconst queryRaw =\n intent.employee_query ||\n intent.employee_id ||\n intent.resolved_employee_id ||\n intent.data?.employee_id ||\n intent.audit_event?.target_employee_id ||\n '';\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\nif (!query) {\n return [{\n json: {\n ...intent,\n\n employee_found: false,\n employee_query: null,\n multiple_matches: false,\n\n status: 'needs_clarification',\n can_continue: false,\n response_type: 'clarification_request',\n\n response_text:\n 'Necesito que indiques el empleado para poder continuar. Puedes usar nombre, correo o ID BambooHR.',\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n authorization_result: 'employee_query_missing',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\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 ...intent,\n\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 audit_event: {\n ...(intent.audit_event || {}),\n\n employee_query: queryRaw,\n authorization_result: 'employee_not_found',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\n\nif (matches.length > 1) {\n return [{\n json: {\n ...intent,\n\n employee_found: false,\n employee_query: queryRaw,\n multiple_matches: true,\n\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\n response_text:\n `Encontré varios empleados que coinciden con \"${queryRaw}\". Por favor especifica mejor el nombre, correo o ID BambooHR.`,\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n employee_query: queryRaw,\n matches_count: matches.length,\n authorization_result: 'multiple_employee_matches',\n execution_result: 'needs_clarification'\n }\n }\n }];\n}\n\nconst employee = matches[0].json;\n\nreturn [{\n json: {\n ...intent,\n ...employee,\n\n employee_found: true,\n employee_query: queryRaw,\n\n resolved_employee_id: String(employee.id),\n resolved_employee_name: employee.displayName,\n resolved_employee_email: employee.workEmail || null,\n\n status: 'employee_resolved',\n can_continue: true,\n response_text: null,\n\n audit_event: {\n ...(intent.audit_event || {}),\n\n target_employee_id: String(employee.id),\n target_employee_name: employee.displayName,\n target_employee_email: employee.workEmail || null,\n\n authorization_result: 'employee_resolved',\n execution_result: 'employee_resolved'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3680,
|
||
-7072
|
||
],
|
||
"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": [
|
||
8240,
|
||
-13984
|
||
],
|
||
"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 =\n $items('Code - Intent Parser')?.[0]?.json ||\n {};\n\nconst employee = $json || {};\n\nconst requestedFields =\n intent.requested_fields ||\n intent.fields ||\n [];\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 employeeId =\n employee.id ||\n intent.employee_id ||\n intent.resolved_employee_id ||\n null;\n\nconst employeeName =\n employee.displayName ||\n intent.employee_name ||\n intent.resolved_employee_name ||\n `Empleado ${employeeId || 'no identificado'}`;\n\nlet finalRequestedFields = requestedFields;\n\nif (!Array.isArray(finalRequestedFields)) {\n finalRequestedFields = [];\n}\n\nfinalRequestedFields = finalRequestedFields.filter(Boolean);\n\nif (finalRequestedFields.length === 0) {\n finalRequestedFields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'workEmail'\n ];\n}\n\nconst lines = finalRequestedFields.map(field => {\n const label = labels[field] || field;\n const value = formatFieldValue(field, employee[field]);\n\n return `- ${label}: ${value}`;\n});\n\nconst responseText =\n `Consulta completada para ${employeeName}:\\n\\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: employeeId,\n employee_name: employeeName,\n requested_fields: finalRequestedFields,\n\n response_text: responseText,\n response_type: 'chat_message',\n can_continue: true,\n status: 'success',\n\n audit_event: {\n ...(intent.audit_event || {}),\n\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 requested_fields: finalRequestedFields,\n\n authorization_result:\n intent.audit_event?.authorization_result ||\n 'authorized',\n\n execution_result: 'success',\n\n bamboohr_endpoint: `/api/v1/employees/${employeeId}`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
8496,
|
||
-13984
|
||
],
|
||
"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": [
|
||
19488,
|
||
-4256
|
||
],
|
||
"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": [
|
||
-9600,
|
||
-2288
|
||
],
|
||
"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": [
|
||
-3120,
|
||
-7072
|
||
],
|
||
"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: input.original_message || message,\n\n confirmation_status: input.confirmation_status || null,\n\n action_approved:\n input.action_approved === true ||\n String(input.action_approved || '').toLowerCase() === 'true',\n\n confirmed_from_google_chat:\n input.confirmed_from_google_chat === true ||\n String(input.confirmed_from_google_chat || '').toLowerCase() === 'true',\n\n final_execution_approved:\n input.final_execution_approved === true ||\n String(input.final_execution_approved || '').toLowerCase() === 'true',\n\n final_execution_token: input.final_execution_token || null,\n\n google_chat_event_type: input.google_chat_event_type || null,\n google_chat_space_name: input.google_chat_space_name || null,\n google_chat_space_display_name: input.google_chat_space_display_name || null,\n google_chat_message_name: input.google_chat_message_name || null,\n google_chat_thread_name: input.google_chat_thread_name || null,\n\n is_confirmation_reply: input.is_confirmation_reply || false,\n confirmation_reply_type: input.confirmation_reply_type || null,\n confirmation_reply_text: input.confirmation_reply_text || null,\n confirmation_id: input.confirmation_id || null,\n pending_confirmation_row_id: input.pending_confirmation_row_id || null,\n confirmation_resolution: input.confirmation_resolution || 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": [
|
||
-12240,
|
||
-2592
|
||
],
|
||
"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": [
|
||
5968,
|
||
-12768
|
||
],
|
||
"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": [
|
||
7840,
|
||
-13184
|
||
],
|
||
"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": [
|
||
8048,
|
||
-13184
|
||
],
|
||
"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": [
|
||
7776,
|
||
-12944
|
||
],
|
||
"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": [
|
||
7984,
|
||
-12944
|
||
],
|
||
"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": [
|
||
7712,
|
||
-12528
|
||
],
|
||
"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": [
|
||
8032,
|
||
-12496
|
||
],
|
||
"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": [
|
||
6528,
|
||
-12544
|
||
],
|
||
"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": [
|
||
7728,
|
||
-12240
|
||
],
|
||
"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": [
|
||
7936,
|
||
-12240
|
||
],
|
||
"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": [
|
||
7248,
|
||
-11984
|
||
],
|
||
"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": [
|
||
7456,
|
||
-11984
|
||
],
|
||
"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": [
|
||
8016,
|
||
-12000
|
||
],
|
||
"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": [
|
||
8224,
|
||
-12000
|
||
],
|
||
"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": [
|
||
1408,
|
||
-8704
|
||
],
|
||
"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": [
|
||
3840,
|
||
-8416
|
||
],
|
||
"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": [
|
||
4144,
|
||
-8416
|
||
],
|
||
"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": [
|
||
6080,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6288,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6496,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6704,
|
||
-11200
|
||
],
|
||
"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": [
|
||
6912,
|
||
-11200
|
||
],
|
||
"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": [
|
||
4416,
|
||
-9040
|
||
],
|
||
"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": [
|
||
5792,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6960,
|
||
-10240
|
||
],
|
||
"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": [
|
||
7168,
|
||
-10240
|
||
],
|
||
"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": [
|
||
7376,
|
||
-10240
|
||
],
|
||
"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": [
|
||
6000,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6208,
|
||
-10224
|
||
],
|
||
"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": [
|
||
6416,
|
||
-10224
|
||
],
|
||
"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": [
|
||
-4784,
|
||
-6112
|
||
],
|
||
"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": [
|
||
-4464,
|
||
-6112
|
||
],
|
||
"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": [
|
||
224,
|
||
-3072
|
||
],
|
||
"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": [
|
||
624,
|
||
-3088
|
||
],
|
||
"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": [
|
||
6224,
|
||
-8544
|
||
],
|
||
"id": "b37a3f61-7d9e-42a2-beb2-760e0ffba872",
|
||
"name": "Code - Format Prepare Termination Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n if (nodeItems && nodeItems.length > 0) {\n return nodeItems[0].json || {};\n }\n } catch (error) {\n return {};\n }\n\n return {};\n}\n\nconst intent = getNodeJson('Code - Intent Parser');\n\nconst action =\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n data.audit_event?.action ||\n intent.audit_event?.action ||\n null;\n\nconst actionObject =\n action && typeof action === 'object'\n ? action\n : {};\n\nconst rawQuery =\n data.employee_query ||\n data.data?.employee_query ||\n intent.employee_query ||\n intent.data?.employee_query ||\n '';\n\nconst possibleDirectId =\n data.employee_id ||\n data.data?.employee_id ||\n data.resolved_employee_id ||\n data.data?.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n intent.employee_id ||\n intent.data?.employee_id ||\n intent.resolved_employee_id ||\n intent.data?.resolved_employee_id ||\n intent.audit_event?.target_employee_id ||\n actionObject.employee_id ||\n actionObject.employeeId ||\n actionObject.target_employee_id ||\n null;\n\nconst originalMessage =\n data.original_message ||\n data.message ||\n intent.original_message ||\n intent.message ||\n '';\n\nfunction extractEmployeeId(value) {\n const text = String(value || '').trim();\n\n if (!text) {\n return null;\n }\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 return null;\n}\n\nconst directEmployeeId =\n extractEmployeeId(possibleDirectId) ||\n extractEmployeeId(rawQuery) ||\n extractEmployeeId(originalMessage);\n\nconst mergedAuditEvent = {\n ...(intent.audit_event || {}),\n ...(data.audit_event || {})\n};\n\nif (!directEmployeeId) {\n return {\n json: {\n ...intent,\n ...data,\n\n employee_found: false,\n direct_employee_id_fallback_available: false,\n direct_employee_id: null,\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 ...mergedAuditEvent,\n\n request_id: data.request_id || intent.request_id || mergedAuditEvent.request_id || null,\n request_started_at: data.request_started_at || intent.request_started_at || mergedAuditEvent.request_started_at || null,\n\n mode: data.mode || intent.mode || mergedAuditEvent.mode || null,\n intent: data.intent || intent.intent || mergedAuditEvent.intent || null,\n risk_level: data.risk_level || intent.risk_level || mergedAuditEvent.risk_level || null,\n\n user_email: data.user_email || intent.user_email || mergedAuditEvent.user_email || null,\n user_name: data.user_name || intent.user_name || mergedAuditEvent.user_name || null,\n channel: data.channel || intent.channel || mergedAuditEvent.channel || null,\n environment: data.environment || intent.environment || mergedAuditEvent.environment || null,\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 // Muy importante para que los nodos posteriores no busquen con query vacía\n employee_query: rawQuery || String(directEmployeeId),\n\n status: 'pending_direct_employee_lookup',\n can_continue: true,\n response_type: 'chat_message',\n\n response_text: null,\n\n action:\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n null,\n\n action_approved:\n data.action_approved === true ||\n data.data?.action_approved === true ||\n intent.action_approved === true ||\n intent.data?.action_approved === true,\n\n confirmed_from_google_chat:\n data.confirmed_from_google_chat === true ||\n data.data?.confirmed_from_google_chat === true ||\n intent.confirmed_from_google_chat === true ||\n intent.data?.confirmed_from_google_chat === true,\n\n confirmed_action_execution:\n data.confirmed_action_execution === true ||\n data.data?.confirmed_action_execution === true ||\n intent.confirmed_action_execution === true ||\n intent.data?.confirmed_action_execution === true,\n\n confirmation_status:\n data.confirmation_status ||\n data.data?.confirmation_status ||\n intent.confirmation_status ||\n intent.data?.confirmation_status ||\n null,\n\n audit_event: {\n ...mergedAuditEvent,\n\n request_id: data.request_id || intent.request_id || mergedAuditEvent.request_id || null,\n request_started_at: data.request_started_at || intent.request_started_at || mergedAuditEvent.request_started_at || null,\n\n mode: data.mode || intent.mode || mergedAuditEvent.mode || null,\n intent: data.intent || intent.intent || mergedAuditEvent.intent || null,\n risk_level: data.risk_level || intent.risk_level || mergedAuditEvent.risk_level || null,\n\n user_email: data.user_email || intent.user_email || mergedAuditEvent.user_email || null,\n user_name: data.user_name || intent.user_name || mergedAuditEvent.user_name || null,\n channel: data.channel || intent.channel || mergedAuditEvent.channel || null,\n environment: data.environment || intent.environment || mergedAuditEvent.environment || null,\n\n employee_query: rawQuery || String(directEmployeeId),\n direct_employee_id: String(directEmployeeId),\n\n target_employee_id:\n data.employee_id ||\n data.data?.employee_id ||\n data.resolved_employee_id ||\n mergedAuditEvent.target_employee_id ||\n String(directEmployeeId),\n\n target_employee_name:\n data.employee_name ||\n data.data?.employee_name ||\n data.resolved_employee_name ||\n mergedAuditEvent.target_employee_name ||\n null,\n\n target_employee_email:\n data.employee_email ||\n data.data?.employee_email ||\n data.resolved_employee_email ||\n mergedAuditEvent.target_employee_email ||\n null,\n\n action:\n data.data?.action ||\n data.action ||\n intent.data?.action ||\n intent.action ||\n mergedAuditEvent.action ||\n null,\n\n confirmation_status:\n data.confirmation_status ||\n data.data?.confirmation_status ||\n intent.confirmation_status ||\n intent.data?.confirmation_status ||\n mergedAuditEvent.confirmation_status ||\n null,\n\n action_approved:\n data.action_approved === true ||\n data.data?.action_approved === true ||\n intent.action_approved === true ||\n intent.data?.action_approved === true ||\n mergedAuditEvent.action_approved === true,\n\n confirmed_from_google_chat:\n data.confirmed_from_google_chat === true ||\n data.data?.confirmed_from_google_chat === true ||\n intent.confirmed_from_google_chat === true ||\n intent.data?.confirmed_from_google_chat === true ||\n mergedAuditEvent.confirmed_from_google_chat === true,\n\n confirmed_action_execution:\n data.confirmed_action_execution === true ||\n data.data?.confirmed_action_execution === true ||\n intent.confirmed_action_execution === true ||\n intent.data?.confirmed_action_execution === true ||\n mergedAuditEvent.confirmed_action_execution === true,\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": [
|
||
-1920,
|
||
-6352
|
||
],
|
||
"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": [
|
||
-1152,
|
||
-6176
|
||
],
|
||
"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": [
|
||
64,
|
||
-7152
|
||
],
|
||
"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": [
|
||
384,
|
||
-7152
|
||
],
|
||
"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": [
|
||
6080,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6288,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6496,
|
||
-6464
|
||
],
|
||
"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": [
|
||
6704,
|
||
-6464
|
||
],
|
||
"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": [
|
||
7056,
|
||
-6336
|
||
],
|
||
"id": "d10bbd31-7e50-4d4b-9d1c-63ed04b16529",
|
||
"name": "Code - Format Termination Blocked 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": [
|
||
7088,
|
||
-6560
|
||
],
|
||
"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": [
|
||
7600,
|
||
-6560
|
||
],
|
||
"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": [
|
||
8240,
|
||
-6544
|
||
],
|
||
"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": [
|
||
8576,
|
||
-6432
|
||
],
|
||
"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 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": [
|
||
8736,
|
||
-6704
|
||
],
|
||
"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_<employee_id>'}`);\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": [
|
||
9664,
|
||
-6640
|
||
],
|
||
"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": [
|
||
10192,
|
||
-6592
|
||
],
|
||
"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": [
|
||
10704,
|
||
-6720
|
||
],
|
||
"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": [
|
||
11040,
|
||
-6784
|
||
],
|
||
"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": [
|
||
11296,
|
||
-6784
|
||
],
|
||
"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": [
|
||
11776,
|
||
-6784
|
||
],
|
||
"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": [
|
||
12016,
|
||
-6784
|
||
],
|
||
"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": [
|
||
12256,
|
||
-6768
|
||
],
|
||
"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": [
|
||
12464,
|
||
-6768
|
||
],
|
||
"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": [
|
||
-9216,
|
||
-2384
|
||
],
|
||
"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": [
|
||
-8912,
|
||
-2624
|
||
],
|
||
"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": [
|
||
-6832,
|
||
-2768
|
||
],
|
||
"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": [
|
||
848,
|
||
-8560
|
||
],
|
||
"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": [
|
||
1056,
|
||
-8560
|
||
],
|
||
"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": [
|
||
-4592,
|
||
-5200
|
||
],
|
||
"id": "272ad4e3-8d68-49a4-b744-b2d430c09a49",
|
||
"name": "HTTP - Employees Directory Report",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "/**\n * Code - Format Headcount Report\n *\n * Responsabilidad:\n * - Recibir empleados desde BambooHR Directory.\n * - Aplicar permisos por país.\n * - Aplicar filtro solicitado por el usuario, si existe.\n * - Formatear reportes de headcount por país, departamento o total.\n */\n\nconst 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 .replace(/[¿?¡!.,;:]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction parseArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value\n .flatMap(item => parseArray(item))\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (typeof value === 'object') {\n return Object.values(value)\n .flatMap(item => parseArray(item))\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 .flatMap(item => parseArray(item))\n .map(item => String(item || '').trim())\n .filter(Boolean);\n }\n\n if (parsed && typeof parsed === 'object') {\n return Object.values(parsed)\n .flatMap(item => parseArray(item))\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 [String(value).trim()].filter(Boolean);\n}\n\nfunction normalizeCountry(value) {\n const normalized = normalize(value);\n\n const aliases = {\n // República Dominicana\n 'rd': 'republica dominicana',\n 'rep dom': 'republica dominicana',\n 'rep dom.': 'republica dominicana',\n 'rep. dom': 'republica dominicana',\n 'rep. dom.': 'republica dominicana',\n 'republica dominicana': 'republica dominicana',\n 'rep dominicana': 'republica dominicana',\n 'dominicana': 'republica dominicana',\n 'dominican republic': 'republica dominicana',\n\n // Panamá\n 'panama': 'panama',\n 'panamá': 'panama',\n\n // Guatemala\n 'guatemala': 'guatemala',\n 'gt': 'guatemala',\n\n // Colombia\n 'colombia': 'colombia',\n 'co': 'colombia',\n\n // Nicaragua\n 'nicaragua': 'nicaragua',\n\n // Honduras\n 'honduras': 'honduras',\n\n // Costa Rica\n 'costa rica': 'costa rica',\n 'cr': 'costa rica',\n\n // Trinidad and Tobago\n 'trinidad': 'trinidad and tobago',\n 'trinidad y tobago': 'trinidad and tobago',\n 'trinidad and tobago': 'trinidad and tobago',\n 'tt': 'trinidad and tobago',\n 't&t': 'trinidad and tobago',\n\n // El Salvador\n 'el salvador': 'el salvador',\n 'salvador': 'el salvador',\n\n // Puerto Rico\n 'puerto rico': 'puerto rico',\n 'pr': 'puerto rico',\n\n // Jamaica\n 'jamaica': 'jamaica',\n 'jm': 'jamaica',\n\n // Venezuela\n 'venezuela': 'venezuela',\n\n // Barbados\n 'barbados': 'barbados',\n\n // Mexico\n 'mexico': 'mexico',\n 'méxico': 'mexico',\n\n // Ecuador\n 'ecuador': 'ecuador',\n\n // Guyana\n 'guyana': 'guyana',\n\n // Peru\n 'peru': 'peru',\n 'perú': 'peru',\n\n // Saint Thomas\n 'saint thomas': 'saint thomas',\n 'st thomas': 'saint thomas',\n 'st. thomas': 'saint thomas',\n\n // St. Croix\n 'saint croix': 'st. croix',\n 'st croix': 'st. croix',\n 'st. croix': 'st. croix'\n };\n\n return aliases[normalized] || normalized;\n}\n\nfunction displayCountryName(value) {\n const country = normalizeCountry(value);\n\n const displayNames = {\n 'republica dominicana': 'República Dominicana',\n 'panama': 'Panamá',\n 'guatemala': 'Guatemala',\n 'colombia': 'Colombia',\n 'nicaragua': 'Nicaragua',\n 'honduras': 'Honduras',\n 'costa rica': 'Costa Rica',\n 'trinidad and tobago': 'Trinidad and Tobago',\n 'el salvador': 'El Salvador',\n 'puerto rico': 'Puerto Rico',\n 'jamaica': 'Jamaica',\n 'venezuela': 'Venezuela',\n 'barbados': 'Barbados',\n 'mexico': 'Mexico',\n 'ecuador': 'Ecuador',\n 'guyana': 'Guyana',\n 'peru': 'Peru',\n 'saint thomas': 'Saint Thomas',\n 'st. croix': 'St. Croix',\n 'sin pais ubicacion': 'Sin país/ubicación',\n 'sin pais/ubicacion': 'Sin país/ubicación'\n };\n\n return displayNames[country] || String(value || 'Sin país/ubicación').trim();\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, options = {}) {\n const counts = new Map();\n\n for (const employee of employees) {\n const rawKey = String(getKeyFn(employee) || 'Sin dato').trim() || 'Sin dato';\n const normalizedKey = options.normalizeCountryKey\n ? normalizeCountry(rawKey)\n : rawKey;\n\n const displayKey = options.normalizeCountryKey\n ? displayCountryName(rawKey)\n : rawKey;\n\n if (!counts.has(normalizedKey)) {\n counts.set(normalizedKey, {\n name: displayKey,\n total: 0\n });\n }\n\n counts.get(normalizedKey).total += 1;\n }\n\n return [...counts.values()]\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\nfunction getReportFilters(context) {\n return (\n context.filters ||\n context.data?.filters ||\n context.audit_event?.filters ||\n {}\n );\n}\n\nfunction extractRequestedCountriesFromFilters(filters) {\n if (!filters) return [];\n\n const possibleValues = [\n filters.country,\n filters.countries,\n filters.pais,\n filters.país,\n filters.paises,\n filters.países,\n filters.location,\n filters.locations,\n filters.ubicacion,\n filters.ubicación,\n filters.employee_country,\n filters.employee_location,\n filters.country_name,\n filters.countryName,\n filters.location_name,\n filters.locationName\n ];\n\n return parseArray(possibleValues)\n .map(normalizeCountry)\n .filter(Boolean);\n}\n\nfunction inferCountriesFromMessage(message) {\n const normalizedMessage = normalize(message);\n\n const knownCountryPatterns = [\n { pattern: 'republica dominicana', country: 'republica dominicana' },\n { pattern: 'dominican republic', country: 'republica dominicana' },\n { pattern: 'rep dominicana', country: 'republica dominicana' },\n { pattern: 'rep dom', country: 'republica dominicana' },\n { pattern: 'dominicana', country: 'republica dominicana' },\n { pattern: ' rd ', country: 'republica dominicana' },\n\n { pattern: 'panama', country: 'panama' },\n { pattern: 'guatemala', country: 'guatemala' },\n { pattern: 'colombia', country: 'colombia' },\n { pattern: 'nicaragua', country: 'nicaragua' },\n { pattern: 'honduras', country: 'honduras' },\n { pattern: 'costa rica', country: 'costa rica' },\n { pattern: 'trinidad and tobago', country: 'trinidad and tobago' },\n { pattern: 'trinidad y tobago', country: 'trinidad and tobago' },\n { pattern: 'el salvador', country: 'el salvador' },\n { pattern: 'puerto rico', country: 'puerto rico' },\n { pattern: 'jamaica', country: 'jamaica' },\n { pattern: 'venezuela', country: 'venezuela' },\n { pattern: 'barbados', country: 'barbados' },\n { pattern: 'mexico', country: 'mexico' },\n { pattern: 'ecuador', country: 'ecuador' },\n { pattern: 'guyana', country: 'guyana' },\n { pattern: 'peru', country: 'peru' },\n { pattern: 'saint thomas', country: 'saint thomas' },\n { pattern: 'st thomas', country: 'saint thomas' },\n { pattern: 'saint croix', country: 'st. croix' },\n { pattern: 'st croix', country: 'st. croix' }\n ];\n\n const paddedMessage = ` ${normalizedMessage} `;\n\n const countries = [];\n\n for (const item of knownCountryPatterns) {\n if (paddedMessage.includes(` ${item.pattern} `) || normalizedMessage.includes(item.pattern)) {\n countries.push(item.country);\n }\n }\n\n return [...new Set(countries)];\n}\n\nfunction filterEmployeesByRequestedCountries(employees, requestedCountries) {\n if (!requestedCountries || requestedCountries.length === 0) {\n return employees;\n }\n\n const normalizedRequestedCountries = requestedCountries.map(normalizeCountry);\n\n return employees.filter(employee => {\n const employeeCountry = normalizeCountry(getEmployeeCountry(employee));\n return normalizedRequestedCountries.includes(employeeCountry);\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 filters = getReportFilters(requestContext);\n\nconst requestedCountriesFromFilters =\n extractRequestedCountriesFromFilters(filters);\n\nconst requestedCountriesFromMessage =\n inferCountriesFromMessage(\n requestContext.original_message ||\n requestContext.message ||\n requestContext.audit_event?.original_message ||\n ''\n );\n\nconst requestedCountries = [\n ...new Set([\n ...requestedCountriesFromFilters,\n ...requestedCountriesFromMessage\n ])\n];\n\nconst filteredEmployees = filterEmployeesByRequestedCountries(\n scopedEmployees,\n requestedCountries\n);\n\nconst originalUserMessage = String(\n requestContext.original_message ||\n requestContext.message ||\n requestContext.audit_event?.original_message ||\n ''\n);\n\nconst normalizedOriginalUserMessage = normalize(originalUserMessage);\n\nconst asksHowMany =\n normalizedOriginalUserMessage.includes('cuantos') ||\n normalizedOriginalUserMessage.includes('cuantas') ||\n normalizedOriginalUserMessage.includes('cantidad') ||\n normalizedOriginalUserMessage.includes('total') ||\n normalizedOriginalUserMessage.includes('headcount');\n\nconst asksByCountry =\n normalizedOriginalUserMessage.includes('por pais') ||\n normalizedOriginalUserMessage.includes('por ubicacion') ||\n normalizedOriginalUserMessage.includes('por location');\n\nconst asksByDepartment =\n normalizedOriginalUserMessage.includes('por departamento') ||\n normalizedOriginalUserMessage.includes('por area') ||\n normalizedOriginalUserMessage.includes('por depto');\n\nconst earlyCountryFilterRaw =\n requestContext.filters?.country ||\n requestContext.filters?.pais ||\n requestContext.filters?.location ||\n requestContext.country_filter ||\n requestContext.requested_country ||\n requestContext.report_country ||\n null;\n\nconst earlyHasCountryFilter =\n Array.isArray(earlyCountryFilterRaw)\n ? earlyCountryFilterRaw.length > 0\n : String(earlyCountryFilterRaw || '').trim() !== '';\n\nlet reportIntent =\n requestContext.report_type ||\n requestContext.intent ||\n 'generate_report';\n\n// Si el usuario pregunta “cuántos empleados tenemos en X país”,\n// respondemos solo el total de ese país, no el desglose por departamentos.\nif (\n earlyHasCountryFilter &&\n asksHowMany &&\n !asksByDepartment &&\n !asksByCountry\n) {\n reportIntent = 'headcount_total';\n}\n\n// Si el usuario pregunta “cuántos empleados tenemos en BambooHR”,\n// respondemos solo el total general, no todo el ranking.\nif (\n !earlyHasCountryFilter &&\n asksHowMany &&\n !asksByDepartment &&\n !asksByCountry &&\n (\n normalizedOriginalUserMessage.includes('bamboohr') ||\n normalizedOriginalUserMessage.includes('tenemos')\n )\n) {\n reportIntent = 'headcount_total';\n}\n\n// Si explícitamente pide “por país”, respetamos desglose por país.\nif (asksByCountry) {\n reportIntent = 'headcount_by_country';\n}\n\n// Si explícitamente pide “por departamento”, respetamos desglose por departamento.\nif (asksByDepartment) {\n reportIntent = 'headcount_by_department';\n}\n\nconst countryCounts = countBy(filteredEmployees, getEmployeeCountry, {\n normalizeCountryKey: true\n});\n\nconst departmentCounts = countBy(filteredEmployees, getEmployeeDepartment);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nconst hasCountryFilter = requestedCountries.length > 0;\nconst requestedCountryLabel = hasCountryFilter\n ? requestedCountries.map(displayCountryName).join(', ')\n : null;\n\nlet reportTitle = 'Reporte de Headcount';\nlet reportBody = '';\n\nif (\n reportIntent === 'headcount_total' ||\n reportIntent === 'employee_count_total' ||\n reportIntent === 'total_headcount'\n) {\n reportTitle = hasCountryFilter\n ? `Headcount total - ${requestedCountryLabel}`\n : 'Headcount total';\n\n reportBody = hasCountryFilter\n ? `${requestedCountryLabel}: ${filteredEmployees.length}`\n : `Total empleados activos/directorio: ${filteredEmployees.length}`;\n} else if (reportIntent === 'headcount_by_country') {\n reportTitle = hasCountryFilter\n ? `Headcount por país / ubicación - ${requestedCountryLabel}`\n : 'Headcount por país / ubicación';\n\n reportBody = formatTable(countryCounts, 'país / ubicación');\n} else if (reportIntent === 'headcount_by_department') {\n reportTitle = hasCountryFilter\n ? `Headcount por departamento - ${requestedCountryLabel}`\n : 'Headcount por departamento';\n\n reportBody = formatTable(departmentCounts, 'departamento');\n} else {\n reportTitle = hasCountryFilter\n ? `Reporte general de Headcount - ${requestedCountryLabel}`\n : '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 filterText = hasCountryFilter\n ? `Filtro solicitado: ${requestedCountryLabel}.`\n : null;\n\nconst responseText =\n `${reportTitle}\\n\\n` +\n `Total empleados incluidos: ${filteredEmployees.length}\\n` +\n `Total empleados activos/directorio leídos: ${activeEmployees.length}\\n` +\n `${scopeText}` +\n `${filterText ? `\\n${filterText}` : ''}\\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_filters: filters,\n report_requested_countries: requestedCountries,\n report_requested_country_label: requestedCountryLabel,\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_employees_after_filters: filteredEmployees.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_filters: filters,\n report_requested_countries: requestedCountries,\n report_requested_country_label: requestedCountryLabel,\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_total_employees_after_filters: filteredEmployees.length,\n report_total_active_employees_read: activeEmployees.length,\n\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": [
|
||
-4336,
|
||
-5200
|
||
],
|
||
"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": [
|
||
-22128,
|
||
-2880
|
||
],
|
||
"id": "f689f4e1-63a4-436c-a15f-9859a9b8a0fd",
|
||
"name": "Webhook - Google Chat Incoming",
|
||
"webhookId": "9a013eb7-1d67-4abf-b98b-22bb1d65b323",
|
||
"alwaysOutputData": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\n\nfunction safeJsonParse(value) {\n if (typeof value !== 'string') return value;\n\n const trimmed = value.trim();\n\n if (!trimmed) return value;\n\n try {\n return JSON.parse(trimmed);\n } catch (error) {\n return value;\n }\n}\n\nfunction getNested(obj, path, fallback = null) {\n try {\n const value = path\n .split('.')\n .reduce((current, key) => {\n if (current === undefined || current === null) return undefined;\n return current[key];\n }, obj);\n\n return value === undefined || value === null ? fallback : value;\n } catch (error) {\n return fallback;\n }\n}\n\nfunction firstNonEmpty(values) {\n for (const value of values) {\n if (value === undefined || value === null) continue;\n\n if (Array.isArray(value) && value.length > 0) {\n return value;\n }\n\n if (\n typeof value === 'object' &&\n !Array.isArray(value) &&\n Object.keys(value).length > 0\n ) {\n return value;\n }\n\n const stringValue = String(value).trim();\n\n if (stringValue) return value;\n }\n\n return null;\n}\n\nfunction cleanMessageText(value) {\n return String(value || '')\n .replace(/<users\\/[^>]+>/g, '')\n .replace(/^@\\S+\\s+/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAttachmentArray(value) {\n if (!value) return [];\n\n if (Array.isArray(value)) {\n return value;\n }\n\n if (Array.isArray(value?.attachments)) {\n return value.attachments;\n }\n\n if (Array.isArray(value?.attachment)) {\n return value.attachment;\n }\n\n return [];\n}\n\nlet bodyCandidate = input.body;\nbodyCandidate = safeJsonParse(bodyCandidate);\n\nlet body =\n bodyCandidate && typeof bodyCandidate === 'object'\n ? bodyCandidate\n : input;\n\nif (body && typeof body.body === 'string') {\n const parsedInnerBody = safeJsonParse(body.body);\n\n if (parsedInnerBody && typeof parsedInnerBody === 'object') {\n body = parsedInnerBody;\n }\n}\n\nif (\n body &&\n typeof body === 'object' &&\n !body.message &&\n !body.type &&\n (body.data || body.payload)\n) {\n const nestedCandidate = safeJsonParse(body.data || body.payload);\n\n if (nestedCandidate && typeof nestedCandidate === 'object') {\n body = nestedCandidate;\n }\n}\n\n// Google Chat como Workspace Add-on usa body.chat.messagePayload.\n// Google Chat API clásico usa body.message directamente.\n// Este normalizador soporta ambos.\nconst chatPayload =\n getNested(body, 'chat.messagePayload') ||\n getNested(input, 'body.chat.messagePayload') ||\n null;\n\nconst chatMessage =\n getNested(body, 'chat.messagePayload.message') ||\n getNested(input, 'body.chat.messagePayload.message') ||\n getNested(body, 'message') ||\n getNested(input, 'body.message') ||\n getNested(input, 'message') ||\n null;\n\nconst chatUser =\n getNested(body, 'chat.user') ||\n getNested(input, 'body.chat.user') ||\n getNested(body, 'user') ||\n getNested(input, 'body.user') ||\n getNested(input, 'user') ||\n null;\n\nconst chatSpace =\n getNested(body, 'chat.messagePayload.space') ||\n getNested(input, 'body.chat.messagePayload.space') ||\n getNested(body, 'space') ||\n getNested(body, 'message.space') ||\n getNested(input, 'body.space') ||\n getNested(input, 'body.message.space') ||\n null;\n\nconst eventType = firstNonEmpty([\n getNested(body, 'type'),\n getNested(body, 'eventType'),\n getNested(body, 'chat.type'),\n getNested(body, 'chat.eventType'),\n getNested(body, 'chat.messagePayload.type'),\n getNested(input, 'type'),\n getNested(input, 'eventType'),\n getNested(input, 'body.type'),\n getNested(input, 'body.eventType'),\n getNested(input, 'body.chat.type'),\n getNested(input, 'body.chat.eventType'),\n\n // Si no viene tipo explícito, pero viene messagePayload, lo tratamos como MESSAGE.\n chatMessage ? 'MESSAGE' : null\n]);\n\nconst rawText = firstNonEmpty([\n getNested(chatMessage, 'argumentText'),\n getNested(chatMessage, 'text'),\n getNested(chatMessage, 'formattedText'),\n\n getNested(chatPayload, 'message.argumentText'),\n getNested(chatPayload, 'message.text'),\n getNested(chatPayload, 'message.formattedText'),\n\n getNested(body, 'chat.messagePayload.message.argumentText'),\n getNested(body, 'chat.messagePayload.message.text'),\n getNested(body, 'chat.messagePayload.message.formattedText'),\n\n getNested(input, 'body.chat.messagePayload.message.argumentText'),\n getNested(input, 'body.chat.messagePayload.message.text'),\n getNested(input, 'body.chat.messagePayload.message.formattedText'),\n\n getNested(body, 'message.argumentText'),\n getNested(body, 'message.text'),\n getNested(body, 'argumentText'),\n getNested(body, 'text'),\n\n getNested(input, 'message.argumentText'),\n getNested(input, 'message.text'),\n getNested(input, 'argumentText'),\n getNested(input, 'text'),\n\n getNested(input, 'body.message.argumentText'),\n getNested(input, 'body.message.text'),\n getNested(input, 'body.argumentText'),\n getNested(input, 'body.text')\n]);\n\nconst message = cleanMessageText(rawText);\n\nconst senderEmail = firstNonEmpty([\n getNested(chatUser, 'email'),\n getNested(chatMessage, 'sender.email'),\n\n getNested(body, 'chat.user.email'),\n getNested(body, 'chat.messagePayload.message.sender.email'),\n\n getNested(input, 'body.chat.user.email'),\n getNested(input, 'body.chat.messagePayload.message.sender.email'),\n\n getNested(body, 'message.sender.email'),\n getNested(body, 'user.email'),\n getNested(body, 'sender.email'),\n getNested(body, 'message.user.email'),\n\n getNested(input, 'message.sender.email'),\n getNested(input, 'user.email'),\n getNested(input, 'sender.email'),\n getNested(input, 'body.message.sender.email'),\n getNested(input, 'body.user.email')\n]);\n\nconst senderName = firstNonEmpty([\n getNested(chatUser, 'displayName'),\n getNested(chatMessage, 'sender.displayName'),\n\n getNested(body, 'chat.user.displayName'),\n getNested(body, 'chat.messagePayload.message.sender.displayName'),\n\n getNested(input, 'body.chat.user.displayName'),\n getNested(input, 'body.chat.messagePayload.message.sender.displayName'),\n\n getNested(body, 'message.sender.displayName'),\n getNested(body, 'user.displayName'),\n getNested(body, 'sender.displayName'),\n getNested(body, 'message.user.displayName'),\n\n getNested(input, 'message.sender.displayName'),\n getNested(input, 'user.displayName'),\n getNested(input, 'sender.displayName'),\n getNested(input, 'body.message.sender.displayName'),\n getNested(input, 'body.user.displayName'),\n\n senderEmail,\n 'Usuario Google Chat'\n]);\n\nconst spaceName = firstNonEmpty([\n getNested(chatSpace, 'name'),\n\n getNested(body, 'chat.messagePayload.space.name'),\n getNested(body, 'chat.messagePayload.message.space.name'),\n\n getNested(input, 'body.chat.messagePayload.space.name'),\n getNested(input, 'body.chat.messagePayload.message.space.name'),\n\n getNested(body, 'space.name'),\n getNested(body, 'message.space.name'),\n getNested(input, 'space.name'),\n getNested(input, 'message.space.name'),\n getNested(input, 'body.space.name'),\n getNested(input, 'body.message.space.name')\n]);\n\nconst spaceDisplayName = firstNonEmpty([\n getNested(chatSpace, 'displayName'),\n getNested(chatSpace, 'spaceUri'),\n\n getNested(body, 'chat.messagePayload.space.displayName'),\n getNested(body, 'chat.messagePayload.space.spaceUri'),\n\n getNested(input, 'body.chat.messagePayload.space.displayName'),\n getNested(input, 'body.chat.messagePayload.space.spaceUri'),\n\n getNested(body, 'space.displayName'),\n getNested(body, 'message.space.displayName'),\n getNested(input, 'space.displayName'),\n getNested(input, 'message.space.displayName'),\n getNested(input, 'body.space.displayName'),\n getNested(input, 'body.message.spaceDisplayName')\n]);\n\nconst messageName = firstNonEmpty([\n getNested(chatMessage, 'name'),\n\n getNested(body, 'chat.messagePayload.message.name'),\n getNested(input, 'body.chat.messagePayload.message.name'),\n\n getNested(body, 'message.name'),\n getNested(input, 'message.name'),\n getNested(input, 'body.message.name')\n]);\n\nconst threadName = firstNonEmpty([\n getNested(chatMessage, 'thread.name'),\n\n getNested(body, 'chat.messagePayload.message.thread.name'),\n getNested(input, 'body.chat.messagePayload.message.thread.name'),\n\n getNested(body, 'message.thread.name'),\n getNested(body, 'thread.name'),\n getNested(input, 'message.thread.name'),\n getNested(input, 'thread.name'),\n getNested(input, 'body.message.thread.name'),\n getNested(input, 'body.thread.name')\n]);\n\nconst rawAttachments = normalizeAttachmentArray(\n firstNonEmpty([\n getNested(chatMessage, 'attachment'),\n getNested(chatMessage, 'attachments'),\n\n getNested(chatPayload, 'message.attachment'),\n getNested(chatPayload, 'message.attachments'),\n\n getNested(body, 'chat.messagePayload.message.attachment'),\n getNested(body, 'chat.messagePayload.message.attachments'),\n\n getNested(input, 'body.chat.messagePayload.message.attachment'),\n getNested(input, 'body.chat.messagePayload.message.attachments'),\n\n getNested(body, 'message.attachment'),\n getNested(body, 'message.attachments'),\n\n getNested(input, 'body.message.attachment'),\n getNested(input, 'body.message.attachments')\n ])\n);\n\nconst googleChatAttachments = rawAttachments.map((attachment, index) => {\n const contentName =\n attachment.contentName ||\n attachment.filename ||\n attachment.fileName ||\n attachment.name ||\n `archivo_${index + 1}`;\n\n const contentType =\n attachment.contentType ||\n attachment.mimeType ||\n attachment.mime_type ||\n '';\n\n const attachmentResourceName =\n attachment.attachmentDataRef?.resourceName ||\n attachment.attachmentDataRef?.resource_name ||\n attachment.attachment_data_ref?.resourceName ||\n attachment.attachment_data_ref?.resource_name ||\n null;\n\n const driveFileId =\n attachment.driveDataRef?.driveFileId ||\n attachment.driveDataRef?.drive_file_id ||\n attachment.drive_data_ref?.driveFileId ||\n attachment.drive_data_ref?.drive_file_id ||\n null;\n\n const normalizedContentName = String(contentName || '').toLowerCase();\n const normalizedContentType = String(contentType || '').toLowerCase();\n\n const isPdf =\n normalizedContentType.includes('pdf') ||\n normalizedContentName.endsWith('.pdf');\n\n return {\n index,\n raw_attachment: attachment,\n\n content_name: contentName,\n content_type: contentType,\n\n attachment_resource_name: attachmentResourceName,\n drive_file_id: driveFileId,\n\n is_pdf: isPdf,\n source: attachmentResourceName\n ? 'google_chat_attachment'\n : driveFileId\n ? 'google_drive_attachment'\n : 'unknown'\n };\n});\n\nconst googleChatPdfAttachments = googleChatAttachments.filter(item => item.is_pdf === true);\n\nconst hasGoogleChatAttachments = googleChatAttachments.length > 0;\nconst hasGoogleChatPdfAttachments = googleChatPdfAttachments.length > 0;\n\nconst effectiveMessage =\n message ||\n (\n hasGoogleChatPdfAttachments\n ? 'Subir documento PDF adjunto'\n : hasGoogleChatAttachments\n ? 'Procesar archivo adjunto'\n : ''\n );\n\nconst requestId =\n `gchat_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nconst baseOutput = {\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 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 google_chat_debug: {\n input_keys: Object.keys(input || {}),\n body_keys:\n body && typeof body === 'object'\n ? Object.keys(body)\n : [],\n\n chat_payload_keys:\n chatPayload && typeof chatPayload === 'object'\n ? Object.keys(chatPayload)\n : [],\n\n chat_message_keys:\n chatMessage && typeof chatMessage === 'object'\n ? Object.keys(chatMessage)\n : [],\n\n raw_text_detected: rawText || null,\n message_detected: message || null,\n effective_message_detected: effectiveMessage || null,\n\n sender_email_detected: senderEmail || null,\n sender_name_detected: senderName || null,\n\n event_type_detected: eventType || null,\n\n attachment_count: googleChatAttachments.length,\n pdf_attachment_count: googleChatPdfAttachments.length,\n attachments_detected: googleChatAttachments,\n\n body_preview:\n body && typeof body === 'object'\n ? JSON.stringify(body).slice(0, 2000)\n : String(body || '').slice(0, 2000)\n }\n};\n\nif (eventType === 'ADDED_TO_SPACE') {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_added_to_space',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n 'Hola. Soy GLM BambooHR Agent. Puedes pedirme consultas y reportes de BambooHR, por ejemplo: \"Dame el headcount por país\".'\n }\n };\n}\n\nif (eventType === 'REMOVED_FROM_SPACE') {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_removed_from_space',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n 'La app fue removida del espacio.'\n }\n };\n}\n\nif (!effectiveMessage) {\n return {\n json: {\n ...baseOutput,\n\n message: '',\n original_message: '',\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n mode: 'system',\n intent: 'google_chat_empty_message',\n risk_level: 'low',\n\n skip_core: true,\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No recibí texto ni PDF para procesar. Escribe una consulta o adjunta un PDF indicando empleado y tipo de documento.'\n }\n };\n}\n\nreturn {\n json: {\n ...baseOutput,\n\n message: effectiveMessage,\n original_message: effectiveMessage,\n\n has_google_chat_attachments: hasGoogleChatAttachments,\n has_google_chat_pdf_attachments: hasGoogleChatPdfAttachments,\n google_chat_attachments: googleChatAttachments,\n google_chat_pdf_attachments: googleChatPdfAttachments,\n\n skip_core: false\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20912,
|
||
-2880
|
||
],
|
||
"id": "ca5fb655-16b5-4211-a983-ced7e5adfec5",
|
||
"name": "Code - Normalize Google Chat Input"
|
||
},
|
||
{
|
||
"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": [
|
||
21120,
|
||
-4288
|
||
],
|
||
"id": "8ae4c143-2864-41f7-ac23-c207d86520b1",
|
||
"name": "IF - Is Google Chat Channel?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstNodeJson(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n if (nodeItems && nodeItems.length > 0) {\n return nodeItems[0].json || {};\n }\n } catch (error) {\n return {};\n }\n\n return {};\n}\n\nconst normalizedGoogleChat =\n getFirstNodeJson('Code - Normalize Google Chat Input');\n\nconst spaceName =\n data.google_chat_space_name ||\n data.audit_event?.google_chat_space_name ||\n normalizedGoogleChat.google_chat_space_name ||\n null;\n\nconst threadName =\n data.google_chat_thread_name ||\n data.audit_event?.google_chat_thread_name ||\n normalizedGoogleChat.google_chat_thread_name ||\n null;\n\nconst responseText =\n data.response_text ||\n data.audit_event?.response_text ||\n data.pending_confirmation_payload?.confirmation_prompt ||\n 'Solicitud procesada.';\n\nif (!spaceName) {\n return {\n json: {\n ...data,\n\n google_chat_final_ready: false,\n google_chat_final_error: 'missing_space_name',\n\n final_space_name: null,\n final_thread_name: threadName,\n final_message_text: String(responseText),\n\n google_chat_final_debug: {\n data_google_chat_space_name: data.google_chat_space_name || null,\n audit_google_chat_space_name: data.audit_event?.google_chat_space_name || null,\n normalized_google_chat_space_name: normalizedGoogleChat.google_chat_space_name || null,\n normalized_keys: Object.keys(normalizedGoogleChat || {})\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n google_chat_final_ready: true,\n\n final_space_name: spaceName,\n final_thread_name: threadName,\n final_message_text: String(responseText),\n\n google_chat_final_debug: {\n resolved_from:\n data.google_chat_space_name\n ? 'data.google_chat_space_name'\n : data.audit_event?.google_chat_space_name\n ? 'audit_event.google_chat_space_name'\n : 'Code - Normalize Google Chat Input',\n\n final_space_name: spaceName,\n final_thread_name: threadName\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
21424,
|
||
-4368
|
||
],
|
||
"id": "b1f79026-4a0a-461a-b4ac-746734068837",
|
||
"name": "Code - Build Google Chat Final Message"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "620c7e6c-9848-49ed-8ecc-1d63e5e8a755",
|
||
"leftValue": "={{ $json.google_chat_final_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
21632,
|
||
-4368
|
||
],
|
||
"id": "fd22774c-7a8c-47b9-8cfa-e2c892c41893",
|
||
"name": "IF - Google Chat Final Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.final_space_name }}",
|
||
"messageUi": {
|
||
"text": "={{ $json.response_text }}"
|
||
},
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
22000,
|
||
-4416
|
||
],
|
||
"id": "3de50865-ba0d-4dc9-b16a-b465317e2c4e",
|
||
"name": "Chat - Enviar respuesta final",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "{\n \"text\": \"⏳ Procesando tu solicitud, dame un momento...\"\n}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.5,
|
||
"position": [
|
||
-20480,
|
||
-2880
|
||
],
|
||
"id": "a8fb64d3-ace3-4f5f-8f00-03f084efb70a",
|
||
"name": "Respond - Google Chat Processing"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction normalize(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nconst message = String(data.message || data.original_message || '').trim();\nconst normalized = normalize(message);\n\nconst confirmWords = [\n 'confirmar',\n 'confirmo',\n 'si',\n 'sí',\n 'ok',\n 'okay',\n 'dale',\n 'proceder',\n 'procede',\n 'ejecutar',\n 'ejecuta',\n 'aprobar',\n 'apruebo'\n].map(normalize);\n\nconst cancelWords = [\n 'cancelar',\n 'cancela',\n 'cancelado',\n 'no',\n 'detener',\n 'deten',\n 'no ejecutar',\n 'no ejecutes',\n 'anular',\n 'rechazar',\n 'rechazo'\n].map(normalize);\n\nconst isConfirmationReply =\n confirmWords.includes(normalized) ||\n normalized.startsWith('confirmar ') ||\n normalized.startsWith('confirmo ') ||\n normalized.startsWith('si ') ||\n normalized.startsWith('sí ') ||\n normalized.startsWith('ok ') ||\n normalized.startsWith('ejecutar ');\n\nconst isCancellationReply =\n cancelWords.includes(normalized) ||\n normalized.startsWith('cancelar ') ||\n normalized.startsWith('cancela ') ||\n normalized.startsWith('no ') ||\n normalized.startsWith('rechazar ');\n\nlet confirmationReplyType = null;\n\nif (isConfirmationReply) {\n confirmationReplyType = 'confirm';\n}\n\nif (isCancellationReply) {\n confirmationReplyType = 'cancel';\n}\n\nreturn {\n json: {\n ...data,\n\n is_confirmation_reply: Boolean(confirmationReplyType),\n confirmation_reply_type: confirmationReplyType,\n confirmation_reply_text: message\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20272,
|
||
-2880
|
||
],
|
||
"id": "5fb42bef-9c9b-4b09-8f26-a93eb7b9a060",
|
||
"name": "Code - Detect Confirmation Reply"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c9f980ce-1e9a-45e7-b545-b1ed6e2234e3",
|
||
"leftValue": "={{ $json.is_confirmation_reply }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-20064,
|
||
-2880
|
||
],
|
||
"id": "181f33b5-375d-482f-8119-0ef81d66aab2",
|
||
"name": "IF - Is Confirmation Reply?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?user_email=eq.{{ encodeURIComponent($json.user_email) }}&confirmation_status=eq.pending&expires_at=gt.{{ encodeURIComponent(new Date().toISOString()) }}&order=created_at.desc&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-19712,
|
||
-2992
|
||
],
|
||
"id": "9bad8293-fbd6-4669-ae66-e3712ac3c710",
|
||
"name": "Supabase - Get Pending Confirmation",
|
||
"alwaysOutputData": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const replyContext = $items('Code - Detect Confirmation Reply')[0].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.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.map(item => item.json).filter(Boolean);\n}\n\nconst rows = normalizeRows(items);\nconst pending = rows[0] || null;\n\nif (!pending) {\n return [{\n json: {\n ...replyContext,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No encontré una confirmación pendiente vigente para tu usuario. Vuelve a solicitar la acción si todavía deseas ejecutarla.',\n\n confirmation_lookup_result: 'not_found_or_expired',\n confirmation_id: null,\n\n audit_event: {\n request_id: replyContext.request_id,\n request_started_at: replyContext.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n mode: 'confirmation',\n intent: 'resolve_pending_confirmation',\n risk_level: 'medium',\n\n authorization_result: 'pending_confirmation_not_found',\n execution_result: 'pending_confirmation_not_found'\n }\n }\n }];\n}\n\nconst pendingPayload = pending.pending_payload || {};\nconst auditEvent = pending.audit_event || {};\n\nconst isConfirm = replyContext.confirmation_reply_type === 'confirm';\nconst isCancel = replyContext.confirmation_reply_type === 'cancel';\n\nif (isCancel) {\n return [{\n json: {\n ...replyContext,\n\n confirmation_id: pending.confirmation_id,\n pending_confirmation_row_id: pending.id,\n\n mode: pending.mode || pendingPayload.mode || 'action',\n intent: pending.intent || pendingPayload.intent || 'unknown',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `Solicitud cancelada.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n confirmation_resolution: 'cancelled',\n\n audit_event: {\n ...auditEvent,\n\n request_id: replyContext.request_id,\n request_started_at: replyContext.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n confirmation_id: pending.confirmation_id,\n confirmation_status: 'cancelled',\n\n authorization_result: 'confirmation_cancelled_by_user',\n execution_result: 'confirmation_cancelled_by_user'\n }\n }\n }];\n}\n\nif (isConfirm) {\n const restoredAction =\n pendingPayload.data?.action ||\n pendingPayload.action ||\n pending.action ||\n null;\n\n const restoredEmployeeId =\n pendingPayload.data?.employee_id ||\n pendingPayload.employee_id ||\n pending.target_employee_id ||\n pendingPayload.audit_event?.target_employee_id ||\n null;\n\n const restoredEmployeeName =\n pendingPayload.data?.employee_name ||\n pendingPayload.employee_name ||\n pending.target_employee_name ||\n pendingPayload.audit_event?.target_employee_name ||\n null;\n\n const restoredEmployeeEmail =\n pendingPayload.data?.employee_email ||\n pendingPayload.employee_email ||\n pending.target_employee_email ||\n pendingPayload.audit_event?.target_employee_email ||\n null;\n\n return [{\n json: {\n ...pendingPayload,\n\n request_id:\n replyContext.request_id ||\n pendingPayload.request_id,\n\n request_started_at:\n replyContext.request_started_at ||\n pendingPayload.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n google_chat_space_name: replyContext.google_chat_space_name,\n google_chat_thread_name: replyContext.google_chat_thread_name,\n google_chat_message_name: replyContext.google_chat_message_name,\n\n confirmation_id: pending.confirmation_id,\n pending_confirmation_row_id: pending.id,\n\n confirmation_status: 'confirmed',\n confirmation_resolution: 'confirmed',\n\n action_approved: true,\n confirmed_from_google_chat: true,\n\n can_continue: true,\n\n mode: pending.mode || pendingPayload.mode || 'accion',\n intent: pending.intent || pendingPayload.intent || 'update_employee_basic',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n action: restoredAction,\n\n employee_id: restoredEmployeeId,\n employee_name: restoredEmployeeName,\n employee_email: restoredEmployeeEmail,\n\n resolved_employee_id: restoredEmployeeId,\n resolved_employee_name: restoredEmployeeName,\n resolved_employee_email: restoredEmployeeEmail,\n\n requires_confirmation: true,\n\n data: {\n ...(pendingPayload.data || {}),\n\n employee_id: restoredEmployeeId,\n employee_name: restoredEmployeeName,\n employee_email: restoredEmployeeEmail,\n\n action: restoredAction,\n\n requires_confirmation: true,\n confirmation_status: 'confirmed',\n action_approved: true\n },\n\n audit_event: {\n ...(pendingPayload.audit_event || auditEvent || {}),\n\n request_id:\n replyContext.request_id ||\n pendingPayload.audit_event?.request_id ||\n pendingPayload.request_id,\n\n request_started_at:\n replyContext.request_started_at ||\n pendingPayload.audit_event?.request_started_at ||\n pendingPayload.request_started_at,\n\n user_email: replyContext.user_email,\n user_name: replyContext.user_name,\n channel: replyContext.channel,\n environment: replyContext.environment,\n\n mode: pending.mode || pendingPayload.mode || 'accion',\n intent: pending.intent || pendingPayload.intent || 'update_employee_basic',\n risk_level: pending.risk_level || pendingPayload.risk_level || 'medium',\n\n action: restoredAction,\n\n target_employee_id: restoredEmployeeId,\n target_employee_name: restoredEmployeeName,\n target_employee_email: restoredEmployeeEmail,\n\n confirmation_id: pending.confirmation_id,\n confirmation_status: 'confirmed',\n\n authorization_result: 'confirmation_confirmed_by_user'\n }\n }\n }];\n}\n\nreturn [{\n json: {\n ...replyContext,\n\n can_continue: false,\n response_type: 'error_message',\n response_text:\n 'No pude interpretar si deseas confirmar o cancelar. Responde CONFIRMAR o CANCELAR.',\n\n confirmation_lookup_result: 'unknown_reply_type'\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-19504,
|
||
-2992
|
||
],
|
||
"id": "38c4d2b8-9b0a-4982-972b-80d8e45fada0",
|
||
"name": "Code - Resolve Confirmation Reply"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "da3799e2-4b56-4d61-b899-bcbedfc78064",
|
||
"leftValue": "={{ $json.confirmation_resolution }}",
|
||
"rightValue": "cancelled",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-13584,
|
||
-3504
|
||
],
|
||
"id": "2b77cc64-771f-4758-b414-73e036f188be",
|
||
"name": "IF - Confirmation Cancelled?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?id=eq.{{ $json.pending_confirmation_row_id }}",
|
||
"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": "={{ { confirmation_status: \"cancelled\", cancelled_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12512,
|
||
-3456
|
||
],
|
||
"id": "8bf1e0b7-767c-464e-b3cd-57cdac1136aa",
|
||
"name": "Supabase - Mark Confirmation Cancelled"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction stripExistingConfirmationInstructions(text) {\n const raw = cleanText(text);\n\n if (!raw) {\n return '';\n }\n\n const lines = raw\n .split('\\n')\n .map(line => line.trimEnd());\n\n const filteredLines = lines.filter(line => {\n const normalized = line\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n\n if (normalized.includes('para continuar') && normalized.includes('confirmar')) return false;\n if (normalized.includes('para confirmar') && normalized.includes('confirmar')) return false;\n if (normalized.includes('para cancelar') && normalized.includes('cancelar')) return false;\n if (normalized.includes('esta confirmacion vence')) return false;\n if (normalized.includes('confirmacion vence')) return false;\n\n return true;\n });\n\n return filteredLines.join('\\n').trim();\n}\n\nconst isConfirmationResolution =\n data.confirmation_resolution === 'cancelled' ||\n data.confirmation_resolution === 'confirmed' ||\n data.mode === 'confirmation' ||\n data.intent === 'cancel_pending_confirmation' ||\n data.intent === 'resolve_pending_confirmation' ||\n data.status === 'confirmation_cancelled' ||\n data.status === 'confirmation_confirmed';\n\nconst isAlreadyConfirmedAction =\n data.confirmed_from_google_chat === true ||\n data.confirmed_action_execution === true ||\n data.action_approved === true ||\n data.confirmation_status === 'confirmed' ||\n data.data?.confirmation_status === 'confirmed' ||\n data.data?.action_approved === true ||\n data.data?.confirmed_from_google_chat === true ||\n data.data?.confirmed_action_execution === true ||\n data.audit_event?.confirmation_status === 'confirmed' ||\n data.audit_event?.action_approved === true ||\n data.audit_event?.confirmed_from_google_chat === true ||\n data.audit_event?.confirmed_action_execution === true;\n\nconst needsConfirmation =\n !isConfirmationResolution &&\n !isAlreadyConfirmedAction &&\n (\n data.response_type === 'confirmation_request' ||\n data.status === 'pending_confirmation' ||\n data.requires_confirmation === true ||\n data.data?.requires_confirmation === true ||\n data.audit_event?.requires_confirmation === true\n );\n\nconst isGoogleChat =\n data.channel === 'google_chat' ||\n data.audit_event?.channel === 'google_chat';\n\nconst userEmail =\n data.user_email ||\n data.audit_event?.user_email ||\n null;\n\nconst action =\n data.data?.action ||\n data.action ||\n data.audit_event?.action ||\n null;\n\nconst actionObject =\n action && typeof action === 'object'\n ? action\n : {};\n\nconst employeeId =\n data.data?.employee_id ||\n data.employee_id ||\n data.resolved_employee_id ||\n data.audit_event?.target_employee_id ||\n actionObject.employee_id ||\n actionObject.employeeId ||\n actionObject.target_employee_id ||\n null;\n\nconst employeeName =\n data.data?.employee_name ||\n data.employee_name ||\n data.resolved_employee_name ||\n data.audit_event?.target_employee_name ||\n actionObject.employee_name ||\n actionObject.employeeName ||\n actionObject.target_employee_name ||\n data.employee_query ||\n null;\n\nconst employeeEmail =\n data.data?.employee_email ||\n data.employee_email ||\n data.resolved_employee_email ||\n data.audit_event?.target_employee_email ||\n actionObject.employee_email ||\n actionObject.employeeEmail ||\n null;\n\nconst confirmationId =\n `conf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nconst expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();\n\nif (!needsConfirmation || !isGoogleChat || !userEmail) {\n return {\n json: {\n ...data,\n pending_confirmation_should_save: false,\n pending_confirmation_payload: null\n }\n };\n}\n\nconst rawBaseResponseText =\n data.response_text ||\n 'Esta acción requiere confirmación antes de ejecutarse.';\n\nconst baseResponseText =\n stripExistingConfirmationInstructions(rawBaseResponseText) ||\n 'Esta acción requiere confirmación antes de ejecutarse.';\n\nconst confirmationPrompt =\n `${baseResponseText}\\n\\n` +\n `Para confirmar responde: CONFIRMAR\\n` +\n `Para cancelar responde: CANCELAR\\n\\n` +\n `Esta confirmación vence en 15 minutos.`;\n\nconst pendingPayload = {\n ...data,\n\n response_text: undefined,\n response_type: undefined,\n\n pending_confirmation_should_save: undefined,\n pending_confirmation_payload: undefined\n};\n\nreturn {\n json: {\n ...data,\n\n response_text: confirmationPrompt,\n response_type: 'confirmation_request',\n\n pending_confirmation_should_save: true,\n\n pending_confirmation_payload: {\n confirmation_id: confirmationId,\n request_id: data.request_id || null,\n\n user_email: userEmail,\n user_name: data.user_name || data.audit_event?.user_name || null,\n\n channel: data.channel || data.audit_event?.channel || 'google_chat',\n environment: data.environment || data.audit_event?.environment || 'development',\n\n google_chat_space_name:\n data.google_chat_space_name ||\n data.audit_event?.google_chat_space_name ||\n null,\n\n google_chat_thread_name:\n data.google_chat_thread_name ||\n data.audit_event?.google_chat_thread_name ||\n null,\n\n google_chat_message_name:\n data.google_chat_message_name ||\n data.audit_event?.google_chat_message_name ||\n null,\n\n mode: data.mode || data.audit_event?.mode || null,\n intent: data.intent || data.audit_event?.intent || null,\n risk_level: data.risk_level || data.audit_event?.risk_level || null,\n\n action,\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n confirmation_status: 'pending',\n confirmation_type: 'standard',\n\n original_message: data.original_message || null,\n confirmation_prompt: confirmationPrompt,\n\n pending_payload: pendingPayload,\n audit_event: data.audit_event || {},\n\n expires_at: expiresAt\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
19824,
|
||
-4256
|
||
],
|
||
"id": "a180ee6b-78f5-41d5-ba3e-f4374211a590",
|
||
"name": "Code - Prepare Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "77cce614-8fd1-406d-a80a-e2c86f533c30",
|
||
"leftValue": "={{ $json.pending_confirmation_should_save }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
20032,
|
||
-4256
|
||
],
|
||
"id": "c9b82f38-4065-457e-b511-ccf519e0a8f3",
|
||
"name": "IF - Should Save Pending Confirmation?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations",
|
||
"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.pending_confirmation_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
20704,
|
||
-4544
|
||
],
|
||
"id": "40077809-cb6c-4af8-b7ce-6c0cc80c83d4",
|
||
"name": "Supabase - Insert Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const original = $items('Code - Prepare Pending Confirmation')[0].json;\nconst inserted = $json;\n\nreturn {\n json: {\n ...original,\n\n pending_confirmation_inserted: true,\n pending_confirmation_insert_result: inserted,\n\n response_text:\n original.response_text ||\n original.pending_confirmation_payload?.confirmation_prompt ||\n 'Esta acción requiere confirmación.',\n\n response_type:\n original.response_type ||\n 'confirmation_request'\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
20912,
|
||
-4544
|
||
],
|
||
"id": "c49e1a5f-a732-48d2-ae3b-01f39f614e2f",
|
||
"name": "Code - Restore Response After Pending Confirmation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const resolved = $items('Code - Resolve Confirmation Reply')[0].json;\nconst supabaseResult = $json;\n\nreturn {\n json: {\n ...resolved,\n\n status: 'confirmation_cancelled',\n can_continue: false,\n mode: 'confirmation',\n intent: 'cancel_pending_confirmation',\n response_type: 'chat_message',\n\n response_text:\n resolved.response_text ||\n 'Solicitud cancelada.\\n\\nNo se ejecutó ningún cambio en BambooHR.',\n\n confirmation_resolution: 'cancelled',\n confirmation_update_result: supabaseResult,\n\n // Importante para que Code - Prepare Pending Confirmation NO cree otra confirmación\n requires_confirmation: false,\n pending_confirmation_should_save: false,\n\n audit_event: {\n ...resolved.audit_event,\n\n mode: 'confirmation',\n intent: 'cancel_pending_confirmation',\n confirmation_status: 'cancelled',\n authorization_result: 'confirmation_cancelled_by_user',\n execution_result: 'confirmation_cancelled_by_user',\n\n requires_confirmation: false,\n response_type: 'chat_message',\n response_text:\n resolved.response_text ||\n 'Solicitud cancelada.\\n\\nNo se ejecutó ningún cambio en BambooHR.'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12112,
|
||
-3456
|
||
],
|
||
"id": "919396ee-523d-4db9-8a35-840ee4c7992c",
|
||
"name": "Code - Restore Cancelled Confirmation Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c3a30efa-572e-4e2b-bf4b-a76972cd1312",
|
||
"leftValue": "={{ $json.confirmation_resolution }}",
|
||
"rightValue": "confirmed",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-12912,
|
||
-2896
|
||
],
|
||
"id": "85e01b65-8296-486e-bbca-dc856e77dba8",
|
||
"name": "IF - Confirmation Confirmed?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?id=eq.{{ $json.pending_confirmation_row_id }}",
|
||
"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": "={{ { confirmation_status: \"confirmed\", confirmed_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12704,
|
||
-3056
|
||
],
|
||
"id": "6406f5d1-5c6f-4d40-961a-1903269d777b",
|
||
"name": "Supabase - Mark Confirmation Confirmed"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const resolved = $items('Code - Resolve Confirmation Reply')[0].json;\nconst supabaseResult = $json;\n\nconst action =\n resolved.data?.action ||\n resolved.action ||\n resolved.audit_event?.action ||\n null;\n\nconst employeeId =\n resolved.data?.employee_id ||\n resolved.employee_id ||\n resolved.resolved_employee_id ||\n resolved.audit_event?.target_employee_id ||\n null;\n\nconst employeeName =\n resolved.data?.employee_name ||\n resolved.employee_name ||\n resolved.resolved_employee_name ||\n resolved.audit_event?.target_employee_name ||\n null;\n\nconst employeeEmail =\n resolved.data?.employee_email ||\n resolved.employee_email ||\n resolved.resolved_employee_email ||\n resolved.audit_event?.target_employee_email ||\n null;\n\nreturn {\n json: {\n ...resolved,\n\n confirmation_update_result: supabaseResult,\n\n confirmation_status: 'confirmed',\n confirmation_resolution: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n can_continue: true,\n\n action,\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n resolved_employee_id: employeeId,\n resolved_employee_name: employeeName,\n resolved_employee_email: employeeEmail,\n\n data: {\n ...(resolved.data || {}),\n\n employee_id: employeeId,\n employee_name: employeeName,\n employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true\n },\n\n /*\n Importante:\n No dejamos response_text aquí, porque si lo dejamos,\n puede llegar al final como respuesta definitiva aunque la acción no haya terminado.\n La acción real debe generar su propio mensaje final.\n */\n response_text: null,\n response_type: null,\n\n audit_event: {\n ...(resolved.audit_event || {}),\n\n target_employee_id: employeeId,\n target_employee_name: employeeName,\n target_employee_email: employeeEmail,\n\n action,\n\n confirmation_status: 'confirmed',\n action_approved: true,\n confirmed_from_google_chat: true,\n confirmed_action_execution: true,\n\n authorization_result: 'confirmation_confirmed_by_user',\n execution_result: 'confirmed_pending_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12496,
|
||
-3056
|
||
],
|
||
"id": "1d69b228-521c-45a6-bf25-86ae602881a2",
|
||
"name": "Code - Restore Confirmed Confirmation Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_confirmations?user_email=eq.{{ encodeURIComponent($json.pending_confirmation_payload.user_email) }}&confirmation_status=eq.pending",
|
||
"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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ { confirmation_status: \"superseded\", superseded_at: new Date().toISOString() } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
20272,
|
||
-4496
|
||
],
|
||
"id": "f353cd63-3ebe-4c13-beed-220874b2a038",
|
||
"name": "Supabase - Supersede Previous Pending Confirmations"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const original = $items('Code - Prepare Pending Confirmation')[0].json;\nconst supersedeResult = $json;\n\nreturn {\n json: {\n ...original,\n supersede_previous_pending_result: supersedeResult\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
20496,
|
||
-4528
|
||
],
|
||
"id": "bd9c1254-c7b1-4147-9ef9-9ecd92f344af",
|
||
"name": "Code - Restore Pending Confirmation After Supersede"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst message =\n data.message ||\n data.original_message ||\n '';\n\nconst allowedIntents = [\n 'get_employee_field',\n 'get_employee_table',\n 'get_employee_files',\n 'get_employee_missing_fields',\n\n 'update_employee_basic',\n 'add_employee_table_row',\n 'prepare_create_employee',\n 'create_employee',\n 'prepare_terminate_employee',\n 'terminate_employee',\n\n 'headcount_by_country',\n 'headcount_by_department',\n 'headcount_by_country_department',\n 'employees_active_inactive',\n 'active_inactive_summary',\n 'list_saved_reports',\n 'run_saved_report',\n 'export_saved_report',\n\n 'unknown'\n];\n\nconst allowedFields = [\n 'jobTitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'workEmail',\n 'workPhone',\n 'workPhoneExtension',\n 'employmentStatus',\n 'hireDate',\n 'compensation',\n 'jobInfo',\n 'employmentStatusTable',\n 'files'\n];\n\nconst prompt = `\nEres la capa de interpretación de lenguaje natural del GLM BambooHR Agent.\n\nTu ÚNICA tarea es convertir el mensaje del usuario en JSON estructurado.\nNO ejecutes acciones.\nNO inventes datos.\nNO decidas permisos.\nNO confirmes acciones.\nNO respondas en lenguaje natural.\nSOLO devuelve JSON válido.\n\nContexto técnico:\n- El sistema consulta y ejecuta acciones sobre BambooHR.\n- n8n orquesta.\n- Gemini interpreta intención, entidades, campos y filtros.\n- Código/Python valida.\n- Neo4j autoriza.\n- BambooHR ejecuta.\n- Supabase audita.\n- Google Chat comunica.\n\nMensaje original del usuario:\n\"\"\"${message}\"\"\"\n\nUsuario:\n${JSON.stringify({\n user_email: data.user_email || null,\n user_name: data.user_name || null,\n channel: data.channel || null,\n environment: data.environment || null\n}, null, 2)}\n\nIntenciones permitidas:\n${allowedIntents.map(i => `- ${i}`).join('\\n')}\n\nCampos permitidos:\n${allowedFields.map(f => `- ${f}`).join('\\n')}\n\nModos permitidos:\n- query\n- action\n- report\n- confirmation\n- system\n- unknown\n\nNiveles de riesgo:\n- low: consultas normales o reportes no sensibles.\n- medium: cambios editables no destructivos, como extensión laboral, teléfono laboral o correo laboral.\n- high: creación de empleado, cambios sensibles, compensation, jobInfo o subida de archivos.\n- critical: desvinculación, terminación, baja o cambios destructivos.\n\nReglas generales obligatorias:\n\n1. Devuelve SIEMPRE JSON válido.\n2. No devuelvas markdown.\n3. No devuelvas texto fuera del JSON.\n4. No inventes employee_id si el usuario no lo dio.\n5. No inventes datos del empleado.\n6. No inventes valores de acciones.\n7. Si aparece un ID numérico de empleado, por ejemplo \"empleado 50802\":\n - employee_query: \"50802\"\n - employee_id: \"50802\"\n8. Si aparece un correo, úsalo como employee_query.\n9. Si aparece un nombre de persona, úsalo como employee_query.\n10. Si el usuario es amable, saluda o escribe con ruido adicional, ignora el ruido y conserva la intención real.\n\nReglas de reportes:\n\nREGLA PRIORITARIA PARA REPORTES GUARDADOS:\n\nSi el usuario pide cualquiera de estas cosas:\n- reportes guardados\n- reportes disponibles\n- lista de reportes\n- custom reports\n- reportes custom\n- saved reports\n- reportes configurados\n- reportes existentes en BambooHR\n- muéstrame los reportes guardados de BambooHR\n- lista los custom reports disponibles en BambooHR\n\nEntonces devuelve obligatoriamente:\n{\n \"mode\": \"report\",\n \"intent\": \"list_saved_reports\",\n \"confidence\": 1,\n \"risk_level\": \"low\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {},\n \"report_type\": \"list_saved_reports\",\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"El usuario solicita listar reportes guardados/custom reports disponibles en BambooHR.\"\n}\n\nIMPORTANTE:\n- No confundas \"reportes guardados\" con headcount.\n- No uses headcount_by_country.\n- No uses headcount_by_department.\n- No uses headcount_by_country_department.\n- No uses generate_report.\n\nREGLA PRIORITARIA PARA EJECUTAR REPORTES GUARDADOS:\n\nSi el usuario pide ejecutar, correr, abrir, consultar o generar un reporte guardado/custom report por ID, por ejemplo:\n- Ejecuta el reporte guardado ID 13\n- Corre el custom report 23\n- Genera el reporte ID 7\n- Ejecuta BambooHR report 10\n- Corre el reporte guardado 14\n\nEntonces devuelve obligatoriamente:\n{\n \"mode\": \"report\",\n \"intent\": \"run_saved_report\",\n \"confidence\": 1,\n \"risk_level\": \"high\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {\n \"report_id\": \"ID_NUMERICO\"\n },\n \"report_type\": \"run_saved_report\",\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"El usuario solicita ejecutar un reporte custom guardado de BambooHR por ID.\"\n}\n\nIMPORTANTE:\n- El ID debe venir del mensaje del usuario.\n- Si el usuario no indica ID, usa intent \"list_saved_reports\" o pide aclaración.\n- No inventes report_id.\n- No confundas ejecutar un reporte guardado con headcount.\n\nSi el usuario pide exportar, descargar, generar archivo, generar Excel, generar CSV, guardar en Sheet o sacar completo un reporte guardado:\n- mode: \"report\"\n- intent: \"export_saved_report\"\n- risk_level: \"low\"\n- requires_confirmation: false\n- Si menciona un ID, guarda ese ID en filters.report_id.\n- Si menciona un nombre de reporte, guarda ese nombre en filters.report_name.\n- Si menciona página, también guarda filters.page, aunque la exportación completa normalmente debe traer todas las páginas.\n\n\n11. Si el usuario pide headcount, cantidad de empleados, número de empleados, cuántos empleados hay o total de empleados:\n - mode: \"report\"\n - risk_level: \"low\"\n - requires_confirmation: false\n\n12. Si el usuario pide empleados por país, por ubicación, por location o por país/ubicación:\n - intent: \"headcount_by_country\"\n\n13. Si el usuario pide empleados por departamento, por área o por depto:\n - intent: \"headcount_by_department\"\n\n14. Si el usuario pide empleados por país y departamento al mismo tiempo:\n - intent: \"headcount_by_country_department\"\n\n15. Si el usuario pregunta \"cuántos empleados tenemos en BambooHR\", \"total de empleados\", \"número total de empleados\", \"cuántos empleados tenemos\" o similar, sin pedir desglose:\n - intent: \"headcount_by_country_department\"\n - report_type: \"headcount_total\"\n\n16. Si el usuario pregunta por un país específico, ejemplo:\n - \"Cuántos empleados tenemos en República Dominicana\"\n - \"Headcount de Guatemala\"\n - \"Número de empleados en Panamá\"\n entonces:\n - mode: \"report\"\n - intent: \"headcount_by_country_department\"\n - report_type: \"headcount_total\"\n - filters.country debe contener el país solicitado exactamente como lo escribió el usuario.\n\n17. Si el usuario pregunta por empleados activos e inactivos, activos vs inactivos, empleados dados de baja, empleados terminados, empleados activos o resumen de estatus:\n - mode: \"report\"\n - intent: \"employees_active_inactive\"\n - report_type: \"active_inactive_summary\"\n - risk_level: \"low\"\n - requires_confirmation: false\n\nReglas de consultas de empleado:\n\n18. Si el usuario pide información de un empleado:\n - mode: \"query\"\n - intent: \"get_employee_field\"\n - risk_level: \"low\"\n - requires_confirmation: false\n - employee_query debe contener nombre, correo o ID si aparece.\n - requested_fields debe incluir los campos solicitados.\n\n19. Si el usuario pide \"información\", \"datos\", \"perfil\", \"información básica\", \"datos básicos\" o algo general de un empleado:\n - requested_fields debe ser:\n [\"jobTitle\", \"department\", \"division\", \"location\", \"supervisor\", \"workEmail\", \"workPhone\", \"workPhoneExtension\", \"employmentStatus\", \"hireDate\"]\n\n20. Si el usuario pregunta por cargo, puesto, posición o job title:\n - requested_fields debe incluir \"jobTitle\"\n\n21. Si el usuario pregunta por departamento o área:\n - requested_fields debe incluir \"department\"\n\n22. Si el usuario pregunta por división:\n - requested_fields debe incluir \"division\"\n\n23. Si el usuario pregunta por ubicación, país, location o dónde trabaja:\n - requested_fields debe incluir \"location\"\n\n24. Si el usuario pregunta por supervisor, manager o jefe:\n - requested_fields debe incluir \"supervisor\"\n\n25. Si el usuario pregunta por correo laboral, email laboral o work email:\n - requested_fields debe incluir \"workEmail\"\n\n26. Si el usuario pregunta por teléfono laboral:\n - requested_fields debe incluir \"workPhone\"\n\n27. Si el usuario pregunta por extensión laboral:\n - requested_fields debe incluir \"workPhoneExtension\"\n\n28. Si el usuario pregunta por estatus laboral:\n - requested_fields debe incluir \"employmentStatus\"\n\n29. Si el usuario pregunta por fecha de ingreso:\n - requested_fields debe incluir \"hireDate\"\n\n30. Si el usuario pregunta por historial jobInfo, historial de cargo, historial de puesto, historial laboral o tabla jobInfo:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"jobInfo\"\n - requested_fields: [\"jobInfo\"]\n\n31. Si el usuario pregunta por historial employmentStatus, historial de estatus o tabla employmentStatus:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"employmentStatus\"\n - requested_fields: [\"employmentStatusTable\"]\n\n32. Si el usuario pregunta por compensation, salario, sueldo o compensación:\n - mode: \"query\"\n - intent: \"get_employee_table\"\n - table_name: \"compensation\"\n - requested_fields: [\"compensation\"]\n - risk_level: \"high\"\n\n33. Si el usuario pregunta por archivos o documentos del empleado:\n - mode: \"query\"\n - intent: \"get_employee_files\"\n - requested_fields: [\"files\"]\n\n34. Si el usuario pregunta por datos faltantes del empleado:\n - mode: \"query\"\n - intent: \"get_employee_missing_fields\"\n\nReglas de acciones:\n\n35. Si el usuario pide cambiar extensión laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workPhoneExtension\"\n - action.label: \"extensión laboral\"\n - action.value: nuevo valor solicitado\n\n36. Si el usuario pide cambiar teléfono laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workPhone\"\n - action.label: \"teléfono laboral\"\n - action.value: nuevo valor solicitado\n\n37. Si el usuario pide cambiar correo laboral:\n - mode: \"action\"\n - intent: \"update_employee_basic\"\n - risk_level: \"medium\"\n - requires_confirmation: true\n - action.type: \"update_field\"\n - action.field: \"workEmail\"\n - action.label: \"correo laboral\"\n - action.value: nuevo valor solicitado\n\n38. Si el usuario pide agregar una fila jobInfo:\n - mode: \"action\"\n - intent: \"add_employee_table_row\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - table_name: \"jobInfo\"\n - action.type: \"add_row\"\n - action.table: \"jobInfo\"\n - action.payload debe incluir los datos mencionados: date, jobTitle, department, division, location, si aparecen.\n\n39. Si el usuario pide preparar creación de empleado nuevo:\n - mode: \"action\"\n - intent: \"prepare_create_employee\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - employee_query: null\n - employee_id: null\n - action.type: \"prepare_create_employee\"\n - action.payload debe incluir los datos mencionados.\n\n40. Si el usuario pide crear empleado real:\n - mode: \"action\"\n - intent: \"create_employee\"\n - risk_level: \"high\"\n - requires_confirmation: true\n - employee_query: null\n - employee_id: null\n - action.type: \"create_employee\"\n - action.payload debe incluir los datos mencionados.\n\n41. Si el usuario pide desvincular, terminar, dar de baja o sacar a un empleado:\n - mode: \"action\"\n - risk_level: \"critical\"\n - requires_confirmation: true\n - Si faltan fecha, motivo o tipo, usa intent: \"prepare_terminate_employee\" y confidence menor a 0.8.\n - Si están fecha, motivo y tipo, usa intent: \"prepare_terminate_employee\".\n - Solo usa \"terminate_employee\" cuando el mensaje indique claramente ejecución final de desvinculación ya confirmada.\n\n42. Para desvinculación:\n - action.type: \"terminate_employee\"\n - action.payload.terminationDate debe contener la fecha de salida si aparece.\n - action.payload.terminationReason debe contener el motivo si aparece.\n - action.payload.terminationType debe contener el tipo si aparece.\n\nReglas de confirmación:\n\n43. Si el usuario responde solo \"CONFIRMAR\", \"confirmar\", \"sí confirmo\", \"ejecuta\" o similar:\n - mode: \"confirmation\"\n - intent: \"resolve_pending_confirmation\"\n - confidence: 1\n - risk_level: \"medium\"\n - requires_confirmation: false\n - action: {\"type\": \"confirm\"}\n\n44. Si el usuario responde solo \"CANCELAR\", \"cancelar\", \"no\", \"no ejecutar\" o similar:\n - mode: \"confirmation\"\n - intent: \"cancel_pending_confirmation\"\n - confidence: 1\n - risk_level: \"low\"\n - requires_confirmation: false\n - action: {\"type\": \"cancel\"}\n\nReglas de incertidumbre:\n\n45. Si no estás seguro:\n - intent: \"unknown\"\n - mode: \"unknown\"\n - confidence menor a 0.8\n - missing_information debe explicar qué falta.\n\n46. Si el usuario pide una acción pero falta el empleado:\n - confidence menor a 0.8\n - missing_information debe incluir \"empleado\"\n\n47. Si el usuario pide una acción pero falta el nuevo valor:\n - confidence menor a 0.8\n - missing_information debe incluir \"nuevo valor\"\n\n48. Si el usuario pide una consulta de empleado pero no menciona empleado:\n - confidence menor a 0.8\n - missing_information debe incluir \"empleado\"\n\nFormato obligatorio de respuesta:\n\nDevuelve exactamente este JSON:\n\n{\n \"mode\": \"query | action | report | confirmation | system | unknown\",\n \"intent\": \"una intención permitida\",\n \"confidence\": 0.0,\n \"risk_level\": \"low | medium | high | critical\",\n \"requires_confirmation\": false,\n \"employee_query\": null,\n \"employee_id\": null,\n \"requested_fields\": [],\n \"table_name\": null,\n \"filters\": {},\n \"report_type\": null,\n \"action\": null,\n \"missing_information\": [],\n \"reasoning_summary\": \"explicación breve, sin cadena de pensamiento\"\n}\n`;\n\nreturn {\n json: {\n ...data,\n\n gemini_enabled: true,\n gemini_prompt: prompt,\n\n gemini_allowed_intents: allowedIntents,\n gemini_allowed_fields: allowedFields\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-11312,
|
||
-2592
|
||
],
|
||
"id": "b354a006-560d-4784-a882-d4a8ffa9fc38",
|
||
"name": "Code - Build Gemini Intent Prompt"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"modelName": "models/gemini-3.1-flash-lite",
|
||
"options": {
|
||
"temperature": 0
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
-11232,
|
||
-2384
|
||
],
|
||
"id": "5314a9c0-b3fc-4e41-9a69-d1b396e084af",
|
||
"name": "Google Gemini Chat Model",
|
||
"credentials": {
|
||
"googlePalmApi": {
|
||
"id": "6Jdcd9FONxG35YuJ",
|
||
"name": "Isaac - Gemini Api Gratis"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"promptType": "define",
|
||
"text": "={{ $json.gemini_prompt }}",
|
||
"options": {
|
||
"systemMessage": "Eres una capa de interpretación para BambooHR Agent. Tu única tarea es convertir el mensaje del usuario en JSON válido. No ejecutes acciones, no inventes datos, no decidas permisos, no confirmes operaciones y no respondas en lenguaje natural. Devuelve únicamente JSON válido.",
|
||
"maxIterations": 1
|
||
}
|
||
},
|
||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||
"typeVersion": 3.1,
|
||
"position": [
|
||
-11088,
|
||
-2592
|
||
],
|
||
"id": "3bffe8fe-5617-4805-b10a-f53e50f2af1b",
|
||
"name": "AI Agent - Gemini Intent Interpreter"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const requestContext = $items('Code - Build Gemini Intent Prompt')[0].json;\n\nconst allowedIntents = [\n ...new Set([\n ...(requestContext.gemini_allowed_intents || []),\n\n // Intent oficial para reporte activos/inactivos.\n 'employees_active_inactive',\n\n // Alias viejo temporal por compatibilidad.\n 'active_inactive_summary',\n\n // Intent oficial para listar reportes guardados/custom reports.\n 'list_saved_reports',\n\n 'run_saved_report'\n ])\n];\n\nconst allowedFields =\n requestContext.gemini_allowed_fields ||\n [];\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction extractJsonFromText(text) {\n const raw = cleanText(text);\n\n if (!raw) {\n return null;\n }\n\n let cleaned = raw\n .replace(/^```json/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n const firstBrace = cleaned.indexOf('{');\n const lastBrace = cleaned.lastIndexOf('}');\n\n if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {\n cleaned = cleaned.slice(firstBrace, lastBrace + 1);\n }\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return null;\n }\n}\n\nfunction normalizeMode(value) {\n const mode = cleanText(value).toLowerCase();\n\n const allowed = [\n 'query',\n 'action',\n 'report',\n 'confirmation',\n 'system',\n 'unknown'\n ];\n\n return allowed.includes(mode) ? mode : 'unknown';\n}\n\nfunction normalizeRisk(value) {\n const risk = cleanText(value).toLowerCase();\n\n const allowed = [\n 'low',\n 'medium',\n 'high',\n 'critical'\n ];\n\n return allowed.includes(risk) ? risk : 'low';\n}\n\nfunction normalizeConfidence(value) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return 0;\n }\n\n if (number < 0) return 0;\n if (number > 1) return 1;\n\n return Number(number.toFixed(2));\n}\n\nfunction normalizeArray(value) {\n if (Array.isArray(value)) {\n return value\n .map(item => cleanText(item))\n .filter(Boolean);\n }\n\n if (typeof value === 'string' && value.trim()) {\n return [value.trim()];\n }\n\n return [];\n}\n\nfunction filterAllowedFields(fields) {\n return normalizeArray(fields)\n .filter(field => allowedFields.includes(field));\n}\n\nconst rawGeminiOutput =\n $json.output ||\n $json.text ||\n $json.response ||\n $json.content ||\n $json.message ||\n '';\n\nconst parsed = extractJsonFromText(rawGeminiOutput);\n\nif (!parsed || typeof parsed !== 'object') {\n return {\n json: {\n ...requestContext,\n\n gemini_interpretation_valid: false,\n gemini_interpretation_error: 'invalid_json',\n gemini_raw_output: rawGeminiOutput,\n\n // Permitimos fallback al parser determinístico existente.\n use_deterministic_parser_fallback: true\n }\n };\n}\n\nlet parsedIntent = cleanText(parsed.intent);\n\n// Compatibilidad temporal:\n// Si Gemini o algún prompt viejo devuelve active_inactive_summary,\n// lo convertimos al intent oficial nuevo.\nif (parsedIntent === 'active_inactive_summary') {\n parsedIntent = 'employees_active_inactive';\n parsed.report_type = parsed.report_type || 'active_inactive_summary';\n}\n\n// Compatibilidad por si Gemini devuelve variantes comunes para reportes guardados.\nconst normalizedParsedIntentLower = parsedIntent.toLowerCase();\n\nif (\n normalizedParsedIntentLower === 'saved_reports' ||\n normalizedParsedIntentLower === 'list_reports' ||\n normalizedParsedIntentLower === 'list_custom_reports' ||\n normalizedParsedIntentLower === 'custom_reports' ||\n normalizedParsedIntentLower === 'bamboohr_saved_reports'\n) {\n parsedIntent = 'list_saved_reports';\n parsed.report_type = parsed.report_type || 'list_saved_reports';\n}\n\nif (\n normalizedParsedIntentLower === 'run_report' ||\n normalizedParsedIntentLower === 'execute_report' ||\n normalizedParsedIntentLower === 'execute_saved_report' ||\n normalizedParsedIntentLower === 'run_saved_report' ||\n normalizedParsedIntentLower === 'get_saved_report' ||\n normalizedParsedIntentLower === 'bamboohr_report'\n) {\n parsedIntent = 'run_saved_report';\n parsed.report_type = parsed.report_type || 'run_saved_report';\n}\n\nconst normalizedIntent = allowedIntents.includes(parsedIntent)\n ? parsedIntent\n : 'unknown';\n\nconst normalized = {\n mode: normalizeMode(parsed.mode),\n intent: normalizedIntent,\n confidence: normalizeConfidence(parsed.confidence),\n risk_level: normalizeRisk(parsed.risk_level),\n requires_confirmation: parsed.requires_confirmation === true,\n\n employee_query:\n parsed.employee_query !== undefined && parsed.employee_query !== null\n ? cleanText(parsed.employee_query)\n : null,\n\n employee_id:\n parsed.employee_id !== undefined && parsed.employee_id !== null\n ? cleanText(parsed.employee_id)\n : null,\n\n requested_fields: filterAllowedFields(parsed.requested_fields),\n\n table_name:\n parsed.table_name !== undefined && parsed.table_name !== null\n ? cleanText(parsed.table_name)\n : null,\n\n filters:\n parsed.filters && typeof parsed.filters === 'object' && !Array.isArray(parsed.filters)\n ? parsed.filters\n : {},\n\n report_type:\n parsed.report_type !== undefined && parsed.report_type !== null\n ? cleanText(parsed.report_type)\n : null,\n\n action:\n parsed.action && typeof parsed.action === 'object' && !Array.isArray(parsed.action)\n ? parsed.action\n : null,\n\n missing_information: normalizeArray(parsed.missing_information),\n\n reasoning_summary:\n parsed.reasoning_summary !== undefined && parsed.reasoning_summary !== null\n ? cleanText(parsed.reasoning_summary)\n : null\n};\n\nconst validationIssues = [];\n\nif (normalized.mode === 'unknown') {\n validationIssues.push('mode_unknown');\n}\n\nif (normalized.intent === 'unknown') {\n validationIssues.push('intent_unknown');\n}\n\nif (normalized.confidence < 0.8) {\n validationIssues.push('low_confidence');\n}\n\nif (\n normalized.mode === 'action' &&\n !normalized.action\n) {\n validationIssues.push('missing_action_object');\n}\n\n// Validación especial:\n// list_saved_reports es un reporte de solo lectura.\n// No necesita employee_query, action ni filtros obligatorios.\nif (normalized.intent === 'list_saved_reports') {\n normalized.mode = 'report';\n normalized.risk_level = 'low';\n normalized.requires_confirmation = false;\n normalized.employee_query = null;\n normalized.employee_id = null;\n normalized.report_type = normalized.report_type || 'list_saved_reports';\n}\n\nconst isValid =\n validationIssues.length === 0;\n\nreturn {\n json: {\n ...requestContext,\n\n gemini_interpretation_valid: isValid,\n gemini_interpretation_issues: validationIssues,\n gemini_interpretation: normalized,\n gemini_raw_output: rawGeminiOutput,\n\n use_deterministic_parser_fallback: !isValid,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n gemini_enabled: true,\n gemini_interpretation_valid: isValid,\n gemini_interpretation_issues: validationIssues,\n gemini_interpreted_mode: normalized.mode,\n gemini_interpreted_intent: normalized.intent,\n gemini_confidence: normalized.confidence,\n gemini_risk_level: normalized.risk_level\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10736,
|
||
-2592
|
||
],
|
||
"id": "bab585e3-0cca-40b6-96ef-5e031a6f9759",
|
||
"name": "Code - Normalize Gemini Intent"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://glm.bamboohr.com/api/v1/employees",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendQuery": true,
|
||
"queryParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "fields",
|
||
"value": "displayName,firstName,lastName,preferredName,jobTitle,department,division,location,supervisor,supervisorEmail,workEmail,workPhone,workPhoneExtension,status,hireDate,employeeNumber"
|
||
},
|
||
{
|
||
"name": "page[limit]",
|
||
"value": "2500"
|
||
}
|
||
]
|
||
},
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Name",
|
||
"value": "Accept"
|
||
},
|
||
{
|
||
"name": "Value",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-688,
|
||
-6160
|
||
],
|
||
"id": "9008719a-7dd9-4791-a6a2-567580c6b0af",
|
||
"name": "HTTP - List Employees For Resolver",
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const requestContext = $items('Code - Check Direct Employee ID Fallback')[0].json;\nconst response = $json || {};\n\nfunction normalize(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!.,;:()[\\]{}\"'`´]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction compact(value) {\n return normalize(value).replace(/\\s+/g, '');\n}\n\nfunction getEmployeesFromResponse(body) {\n if (!body) return [];\n\n if (Array.isArray(body)) return body;\n\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.employees)) return body.employees;\n if (Array.isArray(body.rows)) return body.rows;\n\n return [];\n}\n\nfunction getEmployeeId(employee) {\n return (\n employee.employeeId ||\n employee.id ||\n employee.employee_id ||\n null\n );\n}\n\nfunction getEmployeeName(employee) {\n const displayName = employee.displayName || '';\n const firstName = employee.firstName || '';\n const lastName = employee.lastName || '';\n const preferredName = employee.preferredName || '';\n\n return (\n displayName ||\n [firstName, lastName].filter(Boolean).join(' ') ||\n preferredName ||\n 'Empleado sin nombre'\n );\n}\n\nfunction getEmployeeEmail(employee) {\n return (\n employee.workEmail ||\n employee.email ||\n employee.employeeEmail ||\n null\n );\n}\n\nfunction buildSearchText(employee) {\n const values = [\n getEmployeeId(employee),\n employee.employeeNumber,\n employee.displayName,\n employee.firstName,\n employee.lastName,\n employee.preferredName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n getEmployeeEmail(employee),\n employee.jobTitle,\n employee.department,\n employee.division,\n employee.location\n ];\n\n return normalize(values.filter(Boolean).join(' '));\n}\n\nfunction scoreEmployee(employee, query) {\n const normalizedQuery = normalize(query);\n const compactQuery = compact(query);\n\n const employeeId = String(getEmployeeId(employee) || '').trim();\n const employeeNumber = String(employee.employeeNumber || '').trim();\n\n const displayName = normalize(employee.displayName);\n const fullName = normalize(\n [employee.firstName, employee.lastName].filter(Boolean).join(' ')\n );\n const preferredFullName = normalize(\n [employee.preferredName || employee.firstName, employee.lastName]\n .filter(Boolean)\n .join(' ')\n );\n\n const email = normalize(getEmployeeEmail(employee));\n const searchText = buildSearchText(employee);\n const compactDisplayName = compact(employee.displayName);\n const compactFullName = compact(fullName);\n const compactSearchText = compact(searchText);\n\n let score = 0;\n const reasons = [];\n\n if (!normalizedQuery) {\n return { score: 0, reasons: [] };\n }\n\n if (employeeId && normalizedQuery === normalize(employeeId)) {\n score += 1000;\n reasons.push('employee_id_exact');\n }\n\n if (employeeNumber && normalizedQuery === normalize(employeeNumber)) {\n score += 900;\n reasons.push('employee_number_exact');\n }\n\n if (email && normalizedQuery === email) {\n score += 850;\n reasons.push('email_exact');\n }\n\n if (displayName && normalizedQuery === displayName) {\n score += 800;\n reasons.push('display_name_exact');\n }\n\n if (fullName && normalizedQuery === fullName) {\n score += 750;\n reasons.push('full_name_exact');\n }\n\n if (preferredFullName && normalizedQuery === preferredFullName) {\n score += 720;\n reasons.push('preferred_full_name_exact');\n }\n\n if (compactQuery && compactDisplayName && compactQuery === compactDisplayName) {\n score += 700;\n reasons.push('display_name_compact_exact');\n }\n\n if (compactQuery && compactFullName && compactQuery === compactFullName) {\n score += 650;\n reasons.push('full_name_compact_exact');\n }\n\n if (searchText.includes(normalizedQuery)) {\n score += 450;\n reasons.push('search_text_contains_query');\n }\n\n if (compactQuery && compactSearchText.includes(compactQuery)) {\n score += 420;\n reasons.push('compact_search_text_contains_query');\n }\n\n const queryWords = normalizedQuery\n .split(' ')\n .map(word => word.trim())\n .filter(word => word.length >= 2);\n\n if (queryWords.length > 0) {\n const matchedWords = queryWords.filter(word => searchText.includes(word));\n const ratio = matchedWords.length / queryWords.length;\n\n if (ratio === 1) {\n score += 350;\n reasons.push('all_query_words_match');\n } else if (ratio >= 0.75) {\n score += 220;\n reasons.push('most_query_words_match');\n } else if (ratio >= 0.5) {\n score += 100;\n reasons.push('half_query_words_match');\n }\n }\n\n return { score, reasons };\n}\n\nconst employeeQuery =\n requestContext.employee_query ||\n requestContext.employee_id ||\n requestContext.data?.employee_id ||\n requestContext.data?.employee_name ||\n requestContext.employee_name ||\n null;\n\nconst employees = getEmployeesFromResponse(response);\n\nif (!employeeQuery) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_query_missing',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: null,\n\n response_text:\n 'Necesito que indiques el empleado para poder continuar.',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n authorization_result: 'blocked_missing_employee_query',\n execution_result: 'blocked_missing_employee_query'\n }\n }\n };\n}\n\nconst scoredMatches = employees\n .map(employee => {\n const result = scoreEmployee(employee, employeeQuery);\n\n return {\n employee,\n score: result.score,\n reasons: result.reasons\n };\n })\n .filter(match => match.score >= 350)\n .sort((a, b) => b.score - a.score);\n\nconst topScore = scoredMatches[0]?.score || 0;\n\nconst strongMatches = scoredMatches.filter(match => {\n if (topScore >= 700) {\n return match.score >= topScore - 50;\n }\n\n return match.score >= topScore;\n});\n\nif (scoredMatches.length === 0) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_not_found',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n response_text:\n `No encontré ningún empleado que coincida con \"${employeeQuery}\".`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n authorization_result: 'employee_not_found',\n execution_result: 'employee_not_found'\n }\n }\n };\n}\n\nif (strongMatches.length > 1) {\n const preview = strongMatches.slice(0, 8).map((match, index) => {\n const employee = match.employee;\n\n return (\n `${index + 1}. ${getEmployeeName(employee)}` +\n ` — ID ${getEmployeeId(employee) || 'sin ID'}` +\n `${getEmployeeEmail(employee) ? ` — ${getEmployeeEmail(employee)}` : ''}` +\n `${employee.location ? ` — ${employee.location}` : ''}`\n );\n });\n\n return {\n json: {\n ...requestContext,\n\n status: 'multiple_employee_matches',\n can_continue: false,\n response_type: 'clarification_request',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n multiple_matches: true,\n matches: strongMatches.slice(0, 8).map(match => ({\n id: String(getEmployeeId(match.employee) || ''),\n displayName: getEmployeeName(match.employee),\n preferredName: match.employee.preferredName || null,\n workEmail: getEmployeeEmail(match.employee),\n jobTitle: match.employee.jobTitle || null,\n department: match.employee.department || null,\n division: match.employee.division || null,\n location: match.employee.location || null,\n score: match.score,\n reasons: match.reasons\n })),\n\n response_text:\n `Encontré varios empleados que coinciden con \"${employeeQuery}\". ` +\n `Por favor especifica mejor el nombre, correo o ID:\\n\\n` +\n preview.join('\\n'),\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n authorization_result: 'multiple_employee_matches',\n execution_result: 'multiple_employee_matches'\n }\n }\n };\n}\n\nconst selected = scoredMatches[0].employee;\n\nconst selectedId = String(getEmployeeId(selected) || '').trim();\nconst selectedName = getEmployeeName(selected);\nconst selectedEmail = getEmployeeEmail(selected);\n\nif (!selectedId) {\n return {\n json: {\n ...requestContext,\n\n status: 'employee_found_without_internal_id',\n can_continue: false,\n response_type: 'error_message',\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n\n response_text:\n `Encontré el empleado \"${selectedName}\", pero no pude obtener su ID interno de BambooHR.`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n target_employee_name: selectedName,\n target_employee_email: selectedEmail,\n\n authorization_result: 'employee_found_without_internal_id',\n execution_result: 'employee_found_without_internal_id'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...requestContext,\n\n status: 'employee_resolved',\n can_continue: true,\n response_type: 'chat_message',\n\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n employee_query: employeeQuery,\n\n employee_id: selectedId,\n employee_name: selectedName,\n employee_email: selectedEmail,\n\n resolved_employee_id: selectedId,\n resolved_employee_name: selectedName,\n resolved_employee_email: selectedEmail,\n\n employee_match: {\n id: selectedId,\n displayName: selectedName,\n preferredName: selected.preferredName || null,\n workEmail: selectedEmail,\n jobTitle: selected.jobTitle || null,\n department: selected.department || null,\n division: selected.division || null,\n location: selected.location || null,\n status: selected.status || null,\n hireDate: selected.hireDate || null,\n employeeNumber: selected.employeeNumber || null,\n score: scoredMatches[0].score,\n reasons: scoredMatches[0].reasons\n },\n\n data: {\n ...(requestContext.data || {}),\n\n employee_id: selectedId,\n employee_name: selectedName,\n employee_email: selectedEmail\n },\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n employee_query: employeeQuery,\n resolver_source: 'list_employees',\n employees_read_for_resolver: employees.length,\n\n target_employee_id: selectedId,\n target_employee_name: selectedName,\n target_employee_email: selectedEmail,\n\n authorization_result:\n requestContext.audit_event?.authorization_result ||\n requestContext.authorization_result ||\n 'employee_resolved',\n\n execution_result: 'employee_resolved'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
112,
|
||
-6192
|
||
],
|
||
"id": "5d695bb8-10c4-4944-9739-f9c78cf37bd2",
|
||
"name": "Code - Match Employee From List Employees"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "https://glm.bamboohr.com/api/v1/reports/custom?format=JSON&onlyCurrent=false",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "{\n \"title\": \"BambooHR Agent - Active Inactive Employees\",\n \"fields\": [\n \"firstName\",\n \"lastName\",\n \"displayName\",\n \"workEmail\",\n \"status\",\n \"location\",\n \"department\",\n \"division\",\n \"jobTitle\",\n \"hireDate\",\n \"terminationDate\"\n ]\n}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4656,
|
||
-5872
|
||
],
|
||
"id": "72d6f391-cfe1-469d-b5f4-c872df7f02e6",
|
||
"name": "HTTP - Custom Report Active Inactive",
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "employees_active_inactive",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "2a556b28-9ea2-47a3-b2d9-a9a7b152a1c8"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "active_inactive"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "dbb97647-8851-4e96-83b6-3451b1ae0c14",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "run_saved_report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "run_saved_report"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a26808f2-31d1-4124-a4dc-bf8735d1fcc1",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "headcount_by_country",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "headcount"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "edf1db2c-02b0-41fb-8dbc-8ae798cbdd3d",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "headcount_by_department",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "headcount_department"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "4871b0ad-637b-46f0-b4d8-850cd9b35652",
|
||
"leftValue": "={{ ['generate_report', 'headcount_by_country_department'].includes($json.intent) }}",
|
||
"rightValue": "reporte",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "general_report"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "816852dd-9010-4d91-8550-a87cd7dd3b8f",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "list_saved_reports",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "saved_reports"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "cf8a0043-59d8-49b5-8328-936a30476a66",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "export_saved_report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "export_saved_report"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-5408,
|
||
-5296
|
||
],
|
||
"id": "656b8daa-03fa-4229-98ac-342ab64e464f",
|
||
"name": "Switch - Report Type"
|
||
},
|
||
{
|
||
"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 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(\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n ''\n );\n\n return allowedCountries.includes(employeeCountry);\n}\n\nfunction getReportEmployees(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 getEmployeeName(employee) {\n return (\n employee.displayName ||\n [employee.firstName, employee.lastName].filter(Boolean).join(' ') ||\n employee.name ||\n `Empleado ${employee.id || employee.employeeId || ''}`.trim()\n );\n}\n\nfunction getStatus(employee) {\n const status =\n employee.status ||\n employee.employeeStatus ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus ||\n '';\n\n const normalized = normalize(status);\n\n if (normalized === 'active' || normalized === 'activo') return 'active';\n if (normalized === 'inactive' || normalized === 'inactivo') return 'inactive';\n\n if (\n normalized.includes('terminado') ||\n normalized.includes('terminated') ||\n normalized.includes('desvinculado')\n ) {\n return 'inactive';\n }\n\n if (!normalized) return 'unknown';\n\n return normalized;\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]) => ({ name, total }))\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 = getReportEmployees(items);\n\nconst scopedEmployees = allEmployees.filter(employee =>\n employeeIsAllowedByCountry(employee, permission)\n);\n\nconst activeEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'active');\nconst inactiveEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'inactive');\nconst unknownStatusEmployees = scopedEmployees.filter(employee => getStatus(employee) === 'unknown');\n\nconst statusCounts = countBy(scopedEmployees, employee => {\n const status = getStatus(employee);\n\n if (status === 'active') return 'Activos';\n if (status === 'inactive') return 'Inactivos';\n if (status === 'unknown') return 'Sin estatus';\n\n return status;\n});\n\nconst countryCounts = countBy(scopedEmployees, employee =>\n employee.location ||\n employee.country ||\n employee.employee_country ||\n employee.workLocation ||\n 'Sin país/ubicación'\n);\n\nconst hasAllCountries = userHasAllCountryAccess(permission);\nconst allowedCountriesRaw = parseArray(permission?.allowed_employee_countries);\n\nconst scopeText = hasAllCountries\n ? 'Alcance: todos los países.'\n : `Alcance: ${allowedCountriesRaw.join(', ') || 'Sin países permitidos'}.`;\n\nconst responseText =\n `Empleados activos e inactivos\\n\\n` +\n `Total empleados incluidos: ${scopedEmployees.length}\\n` +\n `Activos: ${activeEmployees.length}\\n` +\n `Inactivos: ${inactiveEmployees.length}\\n` +\n `Sin estatus legible: ${unknownStatusEmployees.length}\\n` +\n `${scopeText}\\n\\n` +\n `Por estatus:\\n` +\n `${formatTable(statusCounts, 'estatus')}\\n\\n` +\n `Por país / ubicación:\\n` +\n `${formatTable(countryCounts, 'país / ubicación')}`;\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'report_generated',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: 'employees_active_inactive',\n\n report_total_employees_in_scope: scopedEmployees.length,\n report_active_employees: activeEmployees.length,\n report_inactive_employees: inactiveEmployees.length,\n report_unknown_status_employees: unknownStatusEmployees.length,\n\n report_status_counts: statusCounts,\n report_country_counts: countryCounts,\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: 'employees_active_inactive',\n report_total_employees_in_scope: scopedEmployees.length,\n report_active_employees: activeEmployees.length,\n report_inactive_employees: inactiveEmployees.length,\n report_unknown_status_employees: unknownStatusEmployees.length,\n report_status_counts: statusCounts,\n report_country_counts: countryCounts,\n\n bamboohr_endpoint: '/api/v1/reports/custom?format=JSON&onlyCurrent=false'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4048,
|
||
-5856
|
||
],
|
||
"id": "627482cd-b39e-4738-bbff-910b188d6e97",
|
||
"name": "Code - Format Active Inactive Report"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://glm.bamboohr.com/api/v1/custom-reports",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4736,
|
||
-4896
|
||
],
|
||
"id": "a660dc6e-e76c-4140-a3bf-8762bbccba3f",
|
||
"name": "HTTP - List BambooHR Saved Reports",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Authorization Gate')[0].json;\n\nfunction normalizeItems(items) {\n if (!items || items.length === 0) return [];\n\n if (items.length === 1) {\n const body = items[0].json;\n\n if (!body) return [];\n\n if (Array.isArray(body)) return body;\n if (Array.isArray(body.reports)) return body.reports;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.results)) return body.results;\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 getReportId(report) {\n return (\n report.id ||\n report.reportId ||\n report.report_id ||\n report.customReportId ||\n null\n );\n}\n\nfunction getReportName(report) {\n return (\n report.name ||\n report.title ||\n report.reportName ||\n report.report_name ||\n 'Reporte sin nombre'\n );\n}\n\nconst reports = normalizeItems(items)\n .map(report => ({\n id: getReportId(report),\n name: getReportName(report),\n raw: report\n }))\n .filter(report => report.id || report.name)\n .sort((a, b) => String(a.name).localeCompare(String(b.name)));\n\nconst maxToShow = 30;\nconst visibleReports = reports.slice(0, maxToShow);\n\nlet responseText = '';\n\nif (reports.length === 0) {\n responseText =\n `No encontré reportes custom guardados disponibles en BambooHR.\\n\\n` +\n `Esto no necesariamente es un error. Puede significar que la credencial/API Key no tiene acceso a reportes guardados o que no existen custom reports compartidos con ese usuario.`;\n} else {\n responseText =\n `Reportes custom guardados disponibles en BambooHR\\n\\n` +\n `Total encontrados: ${reports.length}\\n\\n` +\n visibleReports\n .map((report, index) => `${index + 1}. ${report.name} — ID: ${report.id || 'No disponible'}`)\n .join('\\n');\n\n if (reports.length > maxToShow) {\n responseText +=\n `\\n\\nMostrando los primeros ${maxToShow}. Hay ${reports.length - maxToShow} adicionales.`;\n }\n\n responseText +=\n `\\n\\nPara ejecutar uno después, el usuario podrá pedir algo como:\\n` +\n `Ejecuta el reporte guardado ID 123`;\n}\n\nreturn [{\n json: {\n ...requestContext,\n\n status: 'saved_reports_listed',\n can_continue: true,\n response_type: 'chat_message',\n\n mode: 'reporte',\n intent: 'list_saved_reports',\n risk_level: 'low',\n\n saved_reports_count: reports.length,\n saved_reports_preview: visibleReports.map(report => ({\n id: report.id,\n name: report.name\n })),\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: 'reporte',\n intent: 'list_saved_reports',\n risk_level: 'low',\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 'Listado de reportes guardados autorizado.',\n\n execution_result: 'saved_reports_listed',\n\n saved_reports_count: reports.length,\n saved_reports_preview: visibleReports.map(report => ({\n id: report.id,\n name: report.name\n })),\n\n bamboohr_endpoint: '/api/v1/custom-reports'\n }\n }\n}];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4528,
|
||
-4896
|
||
],
|
||
"id": "eb18f9b3-fc6b-413f-927d-314ec37a6d8b",
|
||
"name": "Code - Format Saved Reports List"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports/{{ $json.saved_report_id }}?page={{ $json.saved_report_page }}&page_size={{ $json.saved_report_page_size }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-3184,
|
||
-5616
|
||
],
|
||
"id": "498f5291-2f08-4b70-a8ec-2422b901eace",
|
||
"name": "HTTP - Run BambooHR Saved Report",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function getFirstJsonFromNode(nodeName) {\n try {\n const nodeItems = $items(nodeName);\n\n if (\n Array.isArray(nodeItems) &&\n nodeItems.length > 0 &&\n nodeItems[0] &&\n nodeItems[0].json\n ) {\n return nodeItems[0].json;\n }\n\n return null;\n } catch (error) {\n return null;\n }\n}\n\nfunction hasReportIdContext(context) {\n if (!context || typeof context !== 'object') {\n return false;\n }\n\n return Boolean(\n context.saved_report_id ||\n context.report_id ||\n context.filters?.report_id ||\n context.data?.filters?.report_id ||\n context.audit_event?.saved_report_id\n );\n}\n\nconst resolvedByNameContext =\n getFirstJsonFromNode('Code - Resolve Saved Report By Name');\n\nconst normalizedSavedReportContext =\n getFirstJsonFromNode('Code - Normalize Saved Report Request');\n\nconst authorizationContext =\n getFirstJsonFromNode('Code - Authorization Gate');\n\nconst requestContext =\n [\n resolvedByNameContext,\n normalizedSavedReportContext,\n authorizationContext\n ].find(hasReportIdContext) ||\n resolvedByNameContext ||\n normalizedSavedReportContext ||\n authorizationContext ||\n {};\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 normalizeText(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction cleanValue(value) {\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch (error) {\n return 'No disponible';\n }\n }\n\n return String(value).trim() || 'No disponible';\n}\n\nfunction normalizeReportBody(items) {\n if (!items || items.length === 0) {\n return {\n data: [],\n pagination: null,\n raw: null\n };\n }\n\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) {\n return {\n data: [],\n pagination: null,\n raw: body || null\n };\n }\n\n if (Array.isArray(body)) {\n return {\n data: body,\n pagination: null,\n raw: body\n };\n }\n\n return {\n data: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || null,\n raw: body\n };\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre',\n firstName: 'Nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction isSensitiveKey(key) {\n const normalizedKey = normalizeText(key);\n\n const sensitiveKeywords = [\n 'ssn',\n 'social',\n 'cedula',\n 'dni',\n 'passport',\n 'pasaporte',\n 'bank',\n 'banco',\n 'cuenta',\n 'account',\n 'routing',\n 'salary',\n 'salario',\n 'sueldo',\n 'pay',\n 'rate',\n 'compensation',\n 'compensacion',\n 'bonus',\n 'bono',\n 'birth',\n 'nacimiento',\n 'address',\n 'direccion',\n 'dirección'\n ];\n\n return sensitiveKeywords.some(keyword =>\n normalizedKey.includes(keyword)\n );\n}\n\nfunction maskSensitiveValue(key, value) {\n if (!isSensitiveKey(key)) {\n return cleanValue(value);\n }\n\n if (value === null || value === undefined || value === '') {\n return 'No disponible';\n }\n\n return '[DATO SENSIBLE OCULTO]';\n}\n\nfunction pickFirst(row, keys) {\n for (const key of keys) {\n if (\n row &&\n row[key] !== undefined &&\n row[key] !== null &&\n String(row[key]).trim() !== ''\n ) {\n return row[key];\n }\n }\n\n return null;\n}\n\nfunction buildRowTitle(row) {\n const name =\n pickFirst(row, [\n 'displayName',\n 'lastNameFirstName'\n ]);\n\n if (name) {\n return cleanValue(name);\n }\n\n const firstName = pickFirst(row, ['firstName']);\n const lastName = pickFirst(row, ['lastName']);\n\n if (firstName || lastName) {\n return `${cleanValue(firstName)} ${cleanValue(lastName)}`.trim();\n }\n\n const employeeId =\n pickFirst(row, [\n 'employeeId',\n 'id',\n 'employeeNumber'\n ]);\n\n if (employeeId) {\n return `Empleado ${cleanValue(employeeId)}`;\n }\n\n return 'Registro sin nombre';\n}\n\nfunction getBestFieldsForPreview(row) {\n const preferredKeys = [\n 'jobInformationJobTitle',\n 'jobTitle',\n\n 'jobInformationDepartment',\n 'department',\n\n 'jobInformationDivision',\n 'division',\n\n 'country',\n 'location',\n 'jobInformationLocation',\n\n 'jobInformationReportsTo',\n 'supervisor',\n\n 'workEmail',\n 'email',\n\n 'hireDate',\n 'status',\n 'employmentStatus'\n ];\n\n const fields = [];\n\n const used = new Set([\n 'displayName',\n 'lastNameFirstName',\n 'firstName',\n 'lastName'\n ]);\n\n for (const key of preferredKeys) {\n if (\n row[key] !== undefined &&\n row[key] !== null &&\n String(row[key]).trim() !== '' &&\n !used.has(key)\n ) {\n fields.push({\n key,\n label: getFriendlyColumnName(key),\n value: maskSensitiveValue(key, row[key])\n });\n\n used.add(key);\n }\n\n if (fields.length >= 5) {\n break;\n }\n }\n\n if (fields.length < 5) {\n for (const [key, value] of Object.entries(row || {})) {\n if (used.has(key)) {\n continue;\n }\n\n if (value === undefined || value === null || String(value).trim() === '') {\n continue;\n }\n\n fields.push({\n key,\n label: getFriendlyColumnName(key),\n value: maskSensitiveValue(key, value)\n });\n\n used.add(key);\n\n if (fields.length >= 5) {\n break;\n }\n }\n }\n\n return fields;\n}\n\nfunction formatPreviewRow(row, index) {\n const title = buildRowTitle(row);\n const fields = getBestFieldsForPreview(row);\n\n if (!fields.length) {\n return `${index + 1}. ${title}\\n Sin campos disponibles para vista previa.`;\n }\n\n const fieldLines = fields\n .map(field => ` ${field.label}: ${field.value}`)\n .join('\\n');\n\n return `${index + 1}. ${title}\\n${fieldLines}`;\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nfunction formatColumnsForChat(columns) {\n if (!columns.length) {\n return 'No disponibles';\n }\n\n const friendlyColumns = columns.map(getFriendlyColumnName);\n\n if (friendlyColumns.length <= 12) {\n return friendlyColumns.join(', ');\n }\n\n return (\n friendlyColumns.slice(0, 12).join(', ') +\n `, ... ${friendlyColumns.length - 12} columnas más`\n );\n}\n\nfunction firstPresent(values) {\n for (const value of values || []) {\n if (value === undefined || value === null) {\n continue;\n }\n\n const text = String(value).trim();\n\n if (text !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction extractRequestedPageRaw(context) {\n const directValue = firstPresent([\n context.saved_report_page_raw,\n context.requested_page_raw,\n context.report_page_raw,\n context.page_raw,\n\n context.filters?.page_raw,\n context.filters?.requested_page_raw,\n context.filters?.saved_report_page_raw,\n\n context.data?.saved_report_page_raw,\n context.data?.requested_page_raw,\n context.data?.filters?.page_raw,\n context.data?.filters?.requested_page_raw,\n\n context.action?.page_raw,\n context.action?.payload?.page_raw,\n context.action?.payload?.requested_page_raw,\n\n context.audit_event?.saved_report_page_raw,\n context.audit_event?.requested_page_raw\n ]);\n\n if (directValue !== null) {\n return String(directValue).trim();\n }\n\n const message = String(\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n context.audit_event?.message ||\n ''\n ).trim();\n\n if (!message) {\n return null;\n }\n\n const pageMatch =\n message.match(/\\bp[aá]gina\\s+([a-zA-Z0-9_-]+)/i) ||\n message.match(/\\bpage\\s+([a-zA-Z0-9_-]+)/i);\n\n if (pageMatch) {\n return String(pageMatch[1] || '').trim();\n }\n\n return null;\n}\n\nfunction parseRequestedPage(value) {\n if (value === undefined || value === null || String(value).trim() === '') {\n return {\n was_requested: false,\n raw: null,\n valid: false,\n value: null,\n reason: null\n };\n }\n\n const raw = String(value).trim();\n\n if (!/^\\d+$/.test(raw)) {\n return {\n was_requested: true,\n raw,\n valid: false,\n value: null,\n reason: 'not_numeric'\n };\n }\n\n const number = Number(raw);\n\n if (!Number.isFinite(number) || number < 1) {\n return {\n was_requested: true,\n raw,\n valid: false,\n value: null,\n reason: 'less_than_one'\n };\n }\n\n return {\n was_requested: true,\n raw,\n valid: true,\n value: number,\n reason: null\n };\n}\n\nfunction parsePositiveInteger(value, fallback) {\n const number = Number(value);\n\n if (!Number.isFinite(number) || number < 1) {\n return fallback;\n }\n\n return Math.floor(number);\n}\n\nfunction getRequestedPageSize(context) {\n return parsePositiveInteger(\n firstPresent([\n context.saved_report_page_size,\n context.report_page_size,\n context.page_size,\n\n context.filters?.page_size,\n context.filters?.pageSize,\n\n context.data?.saved_report_page_size,\n context.data?.filters?.page_size,\n context.data?.filters?.pageSize,\n\n context.action?.payload?.page_size,\n context.action?.payload?.pageSize\n ]),\n 50\n );\n}\n\nfunction buildPageAdjustmentNote({\n requestedPageInfo,\n pageNumber,\n totalPages\n}) {\n if (!requestedPageInfo.was_requested) {\n return null;\n }\n\n if (!requestedPageInfo.valid) {\n if (requestedPageInfo.reason === 'less_than_one') {\n return `Nota de página: pediste la página ${requestedPageInfo.raw}, pero las páginas empiezan en 1. Se usó la página ${pageNumber}.`;\n }\n\n return `Nota de página: \"${requestedPageInfo.raw}\" no es una página válida. Se usó la página ${pageNumber}.`;\n }\n\n if (\n totalPages &&\n requestedPageInfo.value > totalPages\n ) {\n return `Nota de página: pediste la página ${requestedPageInfo.value}, pero este reporte solo tiene ${totalPages} página(s). Se devolvió la página ${pageNumber}.`;\n }\n\n if (requestedPageInfo.value !== pageNumber) {\n return `Nota de página: pediste la página ${requestedPageInfo.value}, pero BambooHR devolvió la página ${pageNumber}.`;\n }\n\n return null;\n}\n\nconst reportId =\n requestContext.filters?.report_id ||\n requestContext.report_id ||\n requestContext.data?.filters?.report_id ||\n null;\n\nconst requestedPageRaw = extractRequestedPageRaw(requestContext);\nconst requestedPageInfo = parseRequestedPage(requestedPageRaw);\n\nconst normalizedBody = normalizeReportBody(items);\nconst rows = normalizedBody.data;\n\nconst pagination = normalizedBody.pagination || {};\n\nconst totalRows =\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n rows.length;\n\nconst requestedPageSize = getRequestedPageSize(requestContext);\n\nconst pageSize = parsePositiveInteger(\n firstPresent([\n pagination.page_size,\n pagination.pageSize,\n requestContext.saved_report_page_size,\n requestContext.report_page_size,\n requestContext.filters?.page_size,\n requestContext.filters?.pageSize,\n requestedPageSize\n ]),\n 50\n);\n\nconst pageNumber = parsePositiveInteger(\n firstPresent([\n pagination.page,\n pagination.current_page,\n pagination.currentPage,\n requestContext.saved_report_page,\n requestContext.report_page,\n requestContext.filters?.page,\n requestContext.filters?.pageNumber\n ]),\n 1\n);\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst pageAdjustmentNote = buildPageAdjustmentNote({\n requestedPageInfo,\n pageNumber,\n totalPages\n});\n\nconst previewRows = rows.slice(0, 5);\nconst columns = collectColumns(rows);\n\nconst navigationCurrentPage =\n Math.min(\n Math.max(Number(pageNumber || 1), 1),\n Number(totalPages || 1)\n );\n\nconst navigationTotalPages =\n Number(totalPages || 1);\n\nconst navigationHasPreviousPage =\n navigationCurrentPage > 1;\n\nconst navigationHasNextPage =\n navigationCurrentPage < navigationTotalPages;\n\nconst navigationPreviousPage =\n navigationHasPreviousPage\n ? navigationCurrentPage - 1\n : null;\n\nconst navigationNextPage =\n navigationHasNextPage\n ? navigationCurrentPage + 1\n : null;\n\nconst navigationLines = [];\n\nif (navigationHasPreviousPage) {\n navigationLines.push(\n `Página anterior: Ejecuta el reporte guardado ID ${reportId} página ${navigationPreviousPage}`\n );\n}\n\nif (navigationHasNextPage) {\n navigationLines.push(\n `Página siguiente: Ejecuta el reporte guardado ID ${reportId} página ${navigationNextPage}`\n );\n}\n\nif (navigationTotalPages > 1) {\n navigationLines.push(\n `También puedes pedir una página específica. Ejemplo: Ejecuta el reporte guardado ID ${reportId} página ${navigationCurrentPage}`\n );\n}\n\nconst navigationText =\n navigationLines.length > 0\n ? `\\n\\nNavegación:\\n${navigationLines.join('\\n')}`\n : '';\n\n\nlet responseText = '';\n\nif (!reportId) {\n responseText =\n `No pude ejecutar el reporte porque no encontré el ID del reporte solicitado.`;\n} else if (rows.length === 0) {\n responseText =\n `Reporte guardado ID ${reportId} ejecutado en BambooHR.\\n\\n` +\n `No encontré filas en la página ${pageNumber} del resultado.` +\n `${pageAdjustmentNote ? `\\n\\n${pageAdjustmentNote}` : ''}`;\n} else {\n responseText =\n `Reporte guardado ID ${reportId} ejecutado en BambooHR\\n\\n` +\n `Filas recibidas en esta página: ${rows.length}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Página: ${pageNumber} de ${totalPages}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Columnas detectadas: ${columns.length}\\n` +\n `${formatColumnsForChat(columns)}` +\n `${pageAdjustmentNote ? `\\n\\n${pageAdjustmentNote}` : ''}\\n\\n` +\n `Vista previa de las primeras ${previewRows.length} filas:\\n\\n` +\n `${previewRows.map(formatPreviewRow).join('\\n\\n')}\\n\\n` +\n `Nota: si el reporte contiene campos sensibles, algunos valores se muestran ocultos en el chat.` +\nnavigationText;\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_executed',\n can_continue: true,\n response_type: 'chat_message',\n\n report_type: 'run_saved_report',\n saved_report_id: reportId,\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page: pageNumber,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n saved_report_requested_page_raw: requestedPageRaw,\n saved_report_requested_page_valid: requestedPageInfo.valid,\n saved_report_requested_page:\n requestedPageInfo.valid === true\n ? requestedPageInfo.value\n : null,\n saved_report_page_adjustment_note: pageAdjustmentNote,\n\n saved_report_columns: columns,\n saved_report_preview_rows: previewRows,\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 execution_result: 'saved_report_executed',\n\n report_type: 'run_saved_report',\n saved_report_id: reportId,\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page: pageNumber,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n saved_report_requested_page_raw: requestedPageRaw,\n saved_report_requested_page_valid: requestedPageInfo.valid,\n saved_report_requested_page:\n requestedPageInfo.valid === true\n ? requestedPageInfo.value\n : null,\n saved_report_page_adjustment_note: pageAdjustmentNote,\n saved_report_columns_count: columns.length,\n\n bamboohr_endpoint: `/api/v1/custom-reports/${reportId}`\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2752,
|
||
-5616
|
||
],
|
||
"id": "2e70a6b4-2994-428e-92cd-e68225e38ae9",
|
||
"name": "Code - Format Saved Report Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst originalMessage =\n data.message ||\n data.original_message ||\n data.raw_text_detected ||\n data.chat_message_text ||\n '';\n\nfunction normalizeText(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanText(value) {\n return String(value || '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractReportId(message) {\n const raw = String(message || '');\n\n const patterns = [\n /\\bid\\s*#?\\s*(\\d+)\\b/i,\n /\\breporte\\s+guardado\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\breporte\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\bcustom\\s+report\\s+id\\s*#?\\s*(\\d+)\\b/i,\n /\\breport\\s+id\\s*#?\\s*(\\d+)\\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]).trim();\n }\n }\n\n return null;\n}\n\nfunction extractRequestedPage(message) {\n const raw = String(message || '');\n\n const pageMatch = raw.match(/\\bp[aá]gina\\s+([a-zA-Z0-9]+)\\b/i);\n\n if (!pageMatch || !pageMatch[1]) {\n return {\n requested_page_raw: null,\n page: 1,\n page_note: null\n };\n }\n\n const requestedRaw = String(pageMatch[1]).trim();\n const parsed = Number(requestedRaw);\n\n if (!Number.isFinite(parsed)) {\n return {\n requested_page_raw: requestedRaw,\n page: 1,\n page_note: `Nota de página: \"${requestedRaw}\" no es una página válida. Se usó la página 1.`\n };\n }\n\n const integerPage = Math.floor(parsed);\n\n if (integerPage < 1) {\n return {\n requested_page_raw: requestedRaw,\n page: 1,\n page_note: `Nota de página: pediste la página ${requestedRaw}, pero las páginas empiezan en 1. Se usó la página 1.`\n };\n }\n\n return {\n requested_page_raw: requestedRaw,\n page: integerPage,\n page_note: null\n };\n}\n\nfunction extractPageSize(message) {\n const raw = String(message || '');\n\n const pageSizeMatch =\n raw.match(/\\bpage[_\\s-]*size\\s+(\\d+)\\b/i) ||\n raw.match(/\\btama[nñ]o\\s+de\\s+p[aá]gina\\s+(\\d+)\\b/i) ||\n raw.match(/\\b(\\d+)\\s+filas\\s+por\\s+p[aá]gina\\b/i);\n\n if (!pageSizeMatch || !pageSizeMatch[1]) {\n return 50;\n }\n\n const parsed = Number(pageSizeMatch[1]);\n\n if (!Number.isFinite(parsed) || parsed < 1) {\n return 50;\n }\n\n return Math.min(Math.floor(parsed), 1000);\n}\n\nfunction extractReportNameQuery(message, reportId) {\n let text = cleanText(message);\n\n // Quitamos página y tamaño de página para que no contaminen el nombre.\n text = text\n .replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ')\n .replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ')\n .replace(/\\bpage[_\\s-]*size\\s+\\d+\\b/gi, ' ')\n .replace(/\\btama[nñ]o\\s+de\\s+p[aá]gina\\s+\\d+\\b/gi, ' ')\n .replace(/\\b\\d+\\s+filas\\s+por\\s+p[aá]gina\\b/gi, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Si vino ID directo, no necesitamos resolver por nombre.\n if (reportId) {\n return null;\n }\n\n function cleanReportNameCandidate(value) {\n let candidate = cleanText(value);\n\n candidate = candidate\n .replace(/[¿?¡!]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos ID accidental si quedó pegado.\n candidate = candidate\n .replace(/\\bID\\s*#?\\s*\\d+\\b/gi, ' ')\n .replace(/\\b#\\s*\\d+\\b/gi, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n\n // Quitamos artículos sueltos al inicio.\n candidate = candidate\n .replace(/^\\s*(el|la|los|las|un|una)\\s+/i, '')\n .replace(/\\s+/g, ' ')\n .trim();\n\n if (!candidate) {\n return null;\n }\n\n const blocked = [\n 'guardado',\n 'guardados',\n 'disponibles',\n 'existentes',\n 'configurados',\n 'lista',\n 'listado',\n 'reportes',\n 'reporte',\n 'custom report',\n 'custom reports',\n 'saved report',\n 'saved reports'\n ];\n\n const normalizedCandidate = normalizeText(candidate);\n\n if (blocked.includes(normalizedCandidate)) {\n return null;\n }\n\n return candidate;\n }\n\n // Caso más importante:\n // \"Exporta el reporte guardado Reporte de Activos\"\n // Debe devolver \"Reporte de Activos\", no \"de Activos\".\n const explicitSavedReportNamePatterns = [\n /\\b(?:el\\s+)?reporte\\s+guardado\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i,\n /\\bsaved\\s+report\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i,\n /\\bcustom\\s+report\\s+(?!id\\b|#?\\s*\\d+\\b)(.+)$/i\n ];\n\n for (const pattern of explicitSavedReportNamePatterns) {\n const match = text.match(pattern);\n\n if (match && match[1]) {\n const candidate = cleanReportNameCandidate(match[1]);\n\n if (candidate) {\n return candidate;\n }\n }\n }\n\n // Fallback para frases tipo:\n // \"Exporta el reporte de activos\"\n // \"Ejecuta reporte de activos\"\n // \"Exporta activos\"\n text = text\n .replace(/\\bejecuta\\b/gi, ' ')\n .replace(/\\bejecutar\\b/gi, ' ')\n .replace(/\\bcorre\\b/gi, ' ')\n .replace(/\\bcorrer\\b/gi, ' ')\n .replace(/\\bgenera\\b/gi, ' ')\n .replace(/\\bgenerar\\b/gi, ' ')\n .replace(/\\babre\\b/gi, ' ')\n .replace(/\\babrir\\b/gi, ' ')\n .replace(/\\bconsulta\\b/gi, ' ')\n .replace(/\\bconsultar\\b/gi, ' ')\n .replace(/\\bmuestrame\\b/gi, ' ')\n .replace(/\\bmu[eé]strame\\b/gi, ' ')\n .replace(/\\bmostrar\\b/gi, ' ')\n .replace(/\\bdame\\b/gi, ' ')\n .replace(/\\bexporta\\b/gi, ' ')\n .replace(/\\bexportar\\b/gi, ' ')\n .replace(/\\bdescarga\\b/gi, ' ')\n .replace(/\\bdescargar\\b/gi, ' ');\n\n // Quitamos frases técnicas, pero NO eliminamos \"reporte de activos\",\n // porque \"Reporte de Activos\" puede ser el nombre real.\n text = text\n .replace(/\\bel\\s+reporte\\s+guardado\\s+id\\s*#?\\s*\\d+/gi, ' ')\n .replace(/\\breporte\\s+guardado\\s+id\\s*#?\\s*\\d+/gi, ' ')\n .replace(/\\bel\\s+reporte\\s+guardado\\b/gi, ' ')\n .replace(/\\breporte\\s+guardado\\b/gi, ' ')\n .replace(/\\breportes\\s+guardados\\b/gi, ' ')\n .replace(/\\bcustom\\s+reports?\\b/gi, ' ')\n .replace(/\\bsaved\\s+reports?\\b/gi, ' ')\n .replace(/\\bde\\s+bamboohr\\b/gi, ' ')\n .replace(/\\ben\\s+bamboohr\\b/gi, ' ')\n .replace(/\\bbamboohr\\b/gi, ' ');\n\n return cleanReportNameCandidate(text);\n}\n\nconst reportId = extractReportId(originalMessage);\nconst pageInfo = extractRequestedPage(originalMessage);\nconst pageSize = extractPageSize(originalMessage);\nconst reportNameQuery = extractReportNameQuery(originalMessage, reportId);\n\nconst filters = {\n ...(data.filters || {})\n};\n\nconst dataFilters = {\n ...(data.data?.filters || {})\n};\n\nif (reportId) {\n filters.report_id = String(reportId);\n dataFilters.report_id = String(reportId);\n}\n\nif (reportNameQuery) {\n filters.report_name = reportNameQuery;\n filters.report_name_query = reportNameQuery;\n\n dataFilters.report_name = reportNameQuery;\n dataFilters.report_name_query = reportNameQuery;\n}\n\nconst status = reportId\n ? 'saved_report_request_normalized_with_id'\n : reportNameQuery\n ? 'saved_report_request_normalized_without_id'\n : 'missing_saved_report_id_or_name';\n\nreturn {\n json: {\n ...data,\n\n mode: data.mode || 'reporte',\n intent: data.intent || 'run_saved_report',\n report_type: data.report_type || 'run_saved_report',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n\n saved_report_name_query: reportNameQuery,\n\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize,\n\n filters,\n\n data: {\n ...(data.data || {}),\n filters: dataFilters,\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n saved_report_name_query: reportNameQuery,\n\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize\n },\n\n status,\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n mode: data.mode || 'reporte',\n intent: data.intent || 'run_saved_report',\n report_type: data.report_type || 'run_saved_report',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n\n saved_report_name_query: reportNameQuery,\n saved_report_page: pageInfo.page,\n saved_report_requested_page_raw: pageInfo.requested_page_raw,\n saved_report_page_note: pageInfo.page_note,\n saved_report_page_size: pageSize,\n\n execution_result: status\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4800,
|
||
-5552
|
||
],
|
||
"id": "d9b766a8-f44a-4ddc-9f8f-b72af6f9fe3f",
|
||
"name": "Code - Normalize Saved Report Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "3bfa41c6-1909-40e2-a89e-2daeee56069b",
|
||
"leftValue": "={{ !!$json.saved_report_id }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-4560,
|
||
-5584
|
||
],
|
||
"id": "db5df19c-8f74-47c2-aa1f-fe0f93eec4d0",
|
||
"name": "IF - Saved Report Has ID?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-4352,
|
||
-5488
|
||
],
|
||
"id": "f22b6901-1c8a-4c96-9e6a-9abe5bf87779",
|
||
"name": "HTTP - List Saved Reports For Run",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Normalize Saved Report Request')[0].json;\n\nfunction normalizeText(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanText(value) {\n return String(value || '')\n .trim()\n .replace(/\\s+/g, ' ');\n}\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 normalizeReportsResponse(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.reports)) return body.reports;\n if (Array.isArray(body.data)) return body.data;\n if (Array.isArray(body.rows)) return body.rows;\n\n return [body];\n }\n\n return items\n .map(item => item.json)\n .filter(Boolean);\n}\n\nfunction extractReportName(report) {\n return (\n report.name ||\n report.title ||\n report.reportName ||\n report.displayName ||\n ''\n );\n}\n\nfunction extractReportId(report) {\n return (\n report.id ||\n report.reportId ||\n report.report_id ||\n null\n );\n}\n\nfunction extractRequestedReportName(context) {\n const directName =\n context.saved_report_name_query ||\n context.report_name_query ||\n context.saved_report_name ||\n context.report_name ||\n context.filters?.report_name ||\n context.filters?.report_name_query ||\n context.data?.saved_report_name_query ||\n context.data?.filters?.report_name ||\n context.action?.payload?.report_name ||\n context.action?.payload?.reportName ||\n null;\n\n if (directName) {\n return cleanText(directName);\n }\n\n const message = String(\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n context.audit_event?.message ||\n ''\n ).trim();\n\n if (!message) {\n return null;\n }\n\n let cleaned = message;\n\n cleaned = cleaned.replace(/ejecuta(r)?/gi, ' ');\n cleaned = cleaned.replace(/corre(r)?/gi, ' ');\n cleaned = cleaned.replace(/mu[eé]strame/gi, ' ');\n cleaned = cleaned.replace(/dame/gi, ' ');\n cleaned = cleaned.replace(/el reporte/gi, ' ');\n cleaned = cleaned.replace(/reporte guardado/gi, ' ');\n cleaned = cleaned.replace(/reporte/gi, ' ');\n cleaned = cleaned.replace(/guardado/gi, ' ');\n cleaned = cleaned.replace(/\\bID\\s*\\d+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\bp[aá]gina\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\bpage\\s+[a-zA-Z0-9_-]+\\b/gi, ' ');\n cleaned = cleaned.replace(/\\s+/g, ' ').trim();\n\n return cleaned || null;\n}\n\nfunction scoreReportMatch(report, query) {\n const reportName = extractReportName(report);\n const normalizedReportName = normalizeText(reportName);\n const normalizedQuery = normalizeText(query);\n\n if (!normalizedReportName || !normalizedQuery) {\n return 0;\n }\n\n if (normalizedReportName === normalizedQuery) {\n return 100;\n }\n\n if (normalizedReportName.includes(normalizedQuery)) {\n return 90;\n }\n\n if (normalizedQuery.includes(normalizedReportName)) {\n return 85;\n }\n\n const queryWords = normalizedQuery\n .split(' ')\n .map(word => word.trim())\n .filter(Boolean);\n\n const reportWords = normalizedReportName\n .split(' ')\n .map(word => word.trim())\n .filter(Boolean);\n\n if (!queryWords.length || !reportWords.length) {\n return 0;\n }\n\n let matchedWords = 0;\n\n for (const queryWord of queryWords) {\n if (\n reportWords.some(reportWord =>\n reportWord === queryWord ||\n reportWord.includes(queryWord) ||\n queryWord.includes(reportWord)\n )\n ) {\n matchedWords += 1;\n }\n }\n\n const ratio = matchedWords / queryWords.length;\n\n return Math.round(ratio * 80);\n}\n\nconst reports = normalizeReportsResponse(items);\n\nconst requestedName = extractRequestedReportName(requestContext);\n\nconst scoredReports = reports\n .map(report => {\n const id = extractReportId(report);\n const name = extractReportName(report);\n const score = scoreReportMatch(report, requestedName);\n\n return {\n id,\n name,\n score,\n raw: report\n };\n })\n .filter(report => report.id && report.name)\n .sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return String(a.name).localeCompare(String(b.name));\n });\n\nconst bestMatch = scoredReports[0] || null;\n\nconst exactMatches = scoredReports.filter(report =>\n normalizeText(report.name) === normalizeText(requestedName)\n);\n\nconst strongMatches = scoredReports.filter(report => report.score >= 70);\n\nconst topScore = bestMatch ? bestMatch.score : 0;\n\nconst secondBestMatch = scoredReports[1] || null;\nconst secondScore = secondBestMatch ? secondBestMatch.score : 0;\n\n// Regla de resolución:\n// 1. Si hay coincidencia exacta única, se ejecuta.\n// 2. Si no hay exacta, pero hay un solo match fuerte, se ejecuta.\n// 3. Si el mejor score es mucho mayor que el segundo, se ejecuta.\n// 4. Si hay empate o ambigüedad real, se bloquea y muestra candidatos.\nconst resolved =\n !!bestMatch &&\n (\n exactMatches.length === 1 ||\n (\n bestMatch.score >= 70 &&\n strongMatches.length === 1\n ) ||\n (\n bestMatch.score >= 90 &&\n bestMatch.score - secondScore >= 15\n )\n );\n\nif (!requestedName) {\n return [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: false,\n saved_report_name_resolution_status: 'missing_report_name',\n saved_report_name_query: null,\n saved_report_candidates: scoredReports.slice(0, 10),\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `No encontré el ID ni el nombre del reporte que deseas ejecutar.\\n\\n` +\n `Puedes escribir algo como:\\n` +\n `Ejecuta el reporte guardado ID 13\\n` +\n `o\\n` +\n `Ejecuta el reporte guardado Reporte de Activos`\n }\n }\n ];\n}\n\nif (!resolved) {\n const candidatesText = scoredReports\n .slice(0, 10)\n .map((report, index) => {\n return `${index + 1}. ${report.name} — ID: ${report.id}`;\n })\n .join('\\n');\n\n const reason =\n exactMatches.length > 1\n ? 'multiple_exact_matches'\n : strongMatches.length > 1\n ? 'multiple_possible_matches'\n : 'no_strong_match';\n\n return [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: false,\n saved_report_name_resolution_status: reason,\n saved_report_name_query: requestedName,\n saved_report_candidates: scoredReports.slice(0, 10),\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `No pude identificar con suficiente seguridad el reporte \"${requestedName}\".\\n\\n` +\n `Reportes más parecidos:\\n` +\n `${candidatesText || 'No hay reportes disponibles.'}\\n\\n` +\n `Para evitar ejecutar el reporte incorrecto, usa el ID exacto. Ejemplo:\\n` +\n `Ejecuta el reporte guardado ID 13`\n }\n }\n ];\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n saved_report_name_resolved: true,\n saved_report_name_resolution_status: 'resolved',\n saved_report_name_query: requestedName,\n\n saved_report_id: String(bestMatch.id),\n report_id: String(bestMatch.id),\n\n saved_report_name: bestMatch.name,\n saved_report_match_score: bestMatch.score,\n\n saved_report_second_best_match: secondBestMatch\n ? {\n id: String(secondBestMatch.id),\n name: secondBestMatch.name,\n score: secondBestMatch.score\n }\n : null,\n\n saved_report_exact_match_used: exactMatches.length === 1,\n\n filters: {\n ...(requestContext.filters || {}),\n report_id: String(bestMatch.id),\n report_name: bestMatch.name\n },\n\n data: {\n ...(requestContext.data || {}),\n filters: {\n ...(requestContext.data?.filters || {}),\n report_id: String(bestMatch.id),\n report_name: bestMatch.name\n }\n },\n\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n saved_report_name_resolved: true,\n saved_report_name_query: requestedName,\n saved_report_id: String(bestMatch.id),\n saved_report_name: bestMatch.name,\n saved_report_match_score: bestMatch.score,\n\n saved_report_second_best_match: secondBestMatch\n ? {\n id: String(secondBestMatch.id),\n name: secondBestMatch.name,\n score: secondBestMatch.score\n }\n : null,\n\n saved_report_exact_match_used: exactMatches.length === 1,\n\n execution_result: 'saved_report_name_resolved'\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-4144,
|
||
-5488
|
||
],
|
||
"id": "682cdda5-e171-4cf6-9950-f7e3899b2b60",
|
||
"name": "Code - Resolve Saved Report By Name"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "a0f337d3-a790-4bc0-83a5-0549b9259f29",
|
||
"leftValue": "={{ $json.saved_report_name_resolved }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-3920,
|
||
-5552
|
||
],
|
||
"id": "56f0134c-7e20-4b60-9d52-54a0d42646cc",
|
||
"name": "IF - Saved Report Name Resolved?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nreturn {\n json: {\n ...data,\n\n status: 'saved_report_name_not_resolved',\n can_continue: false,\n response_type: 'chat_message',\n\n response_text:\n data.response_text ||\n `No pude identificar el reporte guardado solicitado. Usa el ID exacto del reporte para evitar errores.`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n execution_result: 'saved_report_name_not_resolved',\n authorization_result:\n data.audit_event?.authorization_result ||\n data.authorization_result ||\n 'authorized_report'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3488,
|
||
-5360
|
||
],
|
||
"id": "3ebb6aed-5106-437b-996f-23c94d04a7d4",
|
||
"name": "Code - Format Saved Report Name Not Found"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "67358279-0a9b-4bd5-a2a9-ef2169cdc58a",
|
||
"leftValue": "={{ $json.intent === 'export_saved_report' || $json.report_type === 'export_saved_report' || $json.saved_report_export_requested === true }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-3568,
|
||
-5744
|
||
],
|
||
"id": "b8272f1d-0012-402c-9724-be29c67ff9db",
|
||
"name": "IF - Saved Report Wants Export?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst reportId =\n data.saved_report_id ||\n data.report_id ||\n data.filters?.report_id ||\n data.data?.filters?.report_id ||\n null;\n\nconst reportName =\n data.saved_report_name ||\n data.filters?.report_name ||\n data.data?.filters?.report_name ||\n null;\n\nif (!reportId) {\n return {\n json: {\n ...data,\n\n can_continue: false,\n status: 'missing_saved_report_id_for_export',\n response_type: 'chat_message',\n response_text:\n 'No pude exportar el reporte porque no encontré el ID del reporte solicitado.',\n\n audit_event: {\n ...(data.audit_event || {}),\n execution_result: 'missing_saved_report_id_for_export'\n }\n }\n };\n}\n\nconst pageSize = 1000;\n\nreturn {\n json: {\n ...data,\n\n saved_report_export_requested: true,\n saved_report_export_mode: 'single_request_large_page',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_page: 1,\n saved_report_page_size: pageSize,\n\n export_file_type: 'google_sheet',\n export_status: 'prepared',\n\n can_continue: true,\n status: 'saved_report_export_request_built',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n saved_report_export_requested: true,\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_page_size: pageSize,\n\n execution_result: 'saved_report_export_request_built'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-3360,
|
||
-5840
|
||
],
|
||
"id": "72202324-7dda-4b3a-ab4c-b5d41e4b7d55",
|
||
"name": "Code - Build Saved Report Export Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "=https://glm.bamboohr.com/api/v1/custom-reports/{{ $json.saved_report_id }}?format=json&page={{ $json.saved_report_page }}&page_size={{ $json.saved_report_page_size }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-3104,
|
||
-5840
|
||
],
|
||
"id": "dde2fac7-d71c-4fcb-bc46-ff8d7d80a191",
|
||
"name": "HTTP - Run BambooHR Saved Report Export",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $items('Code - Build Saved Report Export Request')[0].json;\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 normalizeReportBody(items) {\n if (!items || items.length === 0) {\n return {\n rows: [],\n pagination: null\n };\n }\n\n const body = items[0].json;\n\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: null\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: null\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || null\n };\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nconst normalized = normalizeReportBody(items);\nconst rows = normalized.rows;\nconst pagination = normalized.pagination || {};\n\nconst totalRows =\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n rows.length;\n\nconst pageSize =\n pagination.page_size ||\n pagination.pageSize ||\n requestContext.saved_report_page_size ||\n rows.length;\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst columns = collectColumns(rows);\n\nconst isCompleteInOneRequest =\n rows.length >= totalRows || totalPages <= 1;\n\nconst responseText =\n `Exportación preparada para reporte guardado ID ${requestContext.saved_report_id}\\n\\n` +\n `Reporte: ${requestContext.saved_report_name || 'Nombre no disponible'}\\n` +\n `Filas recibidas en esta primera consulta: ${rows.length}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Columnas detectadas: ${columns.length}\\n` +\n `Páginas estimadas: ${totalPages}\\n\\n` +\n (\n isCompleteInOneRequest\n ? `Estado: este reporte cabe completo en una sola consulta. Próximo paso: generar Google Sheet.`\n : `Estado: este reporte tiene varias páginas. Próximo paso: crear paginación completa antes de generar Google Sheet.`\n );\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_export_preview_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_export_rows_first_page: rows,\n saved_report_export_columns: columns,\n saved_report_export_rows_received: rows.length,\n saved_report_export_total_rows: totalRows,\n saved_report_export_page_size: pageSize,\n saved_report_export_total_pages: totalPages,\n saved_report_export_complete_in_one_request: isCompleteInOneRequest,\n\n response_text: responseText,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_export_preview_ready',\n\n saved_report_export_rows_received: rows.length,\n saved_report_export_total_rows: totalRows,\n saved_report_export_columns_count: columns.length,\n saved_report_export_total_pages: totalPages,\n saved_report_export_complete_in_one_request: isCompleteInOneRequest,\n\n bamboohr_endpoint: `/api/v1/custom-reports/${requestContext.saved_report_id}`\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2096,
|
||
-6112
|
||
],
|
||
"id": "22d28c82-863a-48c0-b9be-d09d23531248",
|
||
"name": "Code - Format Saved Report Export Preview"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const requestContext = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\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 normalizeRowsFromCurrentInput(data) {\n if (!data || isEmptyObject(data)) {\n return [];\n }\n\n if (Array.isArray(data.saved_report_export_rows)) {\n return data.saved_report_export_rows;\n }\n\n if (Array.isArray(data.data)) {\n return data.data;\n }\n\n if (Array.isArray(data.employees)) {\n return data.employees;\n }\n\n if (Array.isArray(data.rows)) {\n return data.rows;\n }\n\n return [];\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre',\n firstName: 'Nombre',\n middleName: 'Segundo nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n originalHireDate: 'Fecha de ingreso original',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral',\n employmentStatusDate: 'Fecha estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction buildSpreadsheetTitle(reportId, reportName) {\n const now = new Date();\n\n const timestamp = now\n .toISOString()\n .replace('T', ' ')\n .slice(0, 16)\n .replace(/:/g, '-');\n\n const safeName = cleanText(reportName || `Reporte ${reportId || 'sin-id'}`)\n .replace(/[\\\\/:*?\"<>|#%{}[\\]~]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 80);\n\n return `BambooHR - ${safeName} - ${timestamp}`;\n}\n\nconst reportId =\n requestContext.saved_report_id ||\n requestContext.report_id ||\n requestContext.filters?.report_id ||\n requestContext.data?.filters?.report_id ||\n 'sin-id';\n\nconst reportName =\n requestContext.saved_report_name ||\n requestContext.filters?.report_name ||\n requestContext.data?.filters?.report_name ||\n requestContext.saved_report_name_query ||\n `Reporte ${reportId}`;\n\nconst rows = normalizeRowsFromCurrentInput(requestContext);\n\nconst columns =\n Array.isArray(requestContext.saved_report_export_columns) &&\n requestContext.saved_report_export_columns.length > 0\n ? requestContext.saved_report_export_columns\n : collectColumns(rows);\n\nconst columnLabels = columns.map(getFriendlyColumnName);\n\nconst totalRows =\n Number(\n requestContext.saved_report_total_records ||\n requestContext.saved_report_export_total_records_expected ||\n rows.length\n ) || rows.length;\n\nconst pageSize =\n Number(\n requestContext.saved_report_page_size ||\n requestContext.saved_report_export_page_size ||\n rows.length\n ) || rows.length;\n\nconst estimatedPages =\n Number(\n requestContext.saved_report_total_pages ||\n requestContext.saved_report_estimated_pages ||\n requestContext.saved_report_export_total_pages_expected ||\n (\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1\n )\n ) || 1;\n\nconst spreadsheetTitle = buildSpreadsheetTitle(reportId, reportName);\n\nif (!rows.length) {\n return [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_google_sheet_blocked_no_rows',\n can_continue: false,\n response_type: 'error_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_rows: [],\n saved_report_export_columns: columns,\n saved_report_export_column_labels: columnLabels,\n\n response_text:\n `No puedo crear el Google Sheet porque el reporte no devolvió filas.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId}`,\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_google_sheet_blocked_no_rows',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_rows_received: 0,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages\n }\n }\n }\n ];\n}\n\nreturn [\n {\n json: {\n ...requestContext,\n\n status: 'saved_report_google_sheet_prepared',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_rows: rows,\n saved_report_export_columns: columns,\n saved_report_export_column_labels: columnLabels,\n\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages,\n\n google_sheet_title: spreadsheetTitle,\n google_sheet_first_sheet_name: 'Reporte',\n\n audit_event: {\n ...(requestContext.audit_event || {}),\n\n execution_result: 'saved_report_google_sheet_prepared',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_rows_received: rows.length,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_estimated_pages: estimatedPages,\n\n google_sheet_title: spreadsheetTitle\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1984,
|
||
-5840
|
||
],
|
||
"id": "17f16acb-4f10-4439-a661-744b680fb30e",
|
||
"name": "Code - Prepare Saved Report Google Sheet"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"resource": "spreadsheet",
|
||
"title": "={{ $json.google_sheet_title }}",
|
||
"sheetsUi": {
|
||
"sheetValues": [
|
||
{
|
||
"title": "={{ $json.google_sheet_first_sheet_name }}"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleSheets",
|
||
"typeVersion": 4.7,
|
||
"position": [
|
||
-1776,
|
||
-5840
|
||
],
|
||
"id": "5266bde5-5482-4ac0-8a54-67eb155a1b70",
|
||
"name": "Google Sheets - Create Saved Report Spreadsheet",
|
||
"credentials": {
|
||
"googleSheetsOAuth2Api": {
|
||
"id": "K0hDZh3a85MpOHCs",
|
||
"name": "Google Sheets account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\n\nfunction cleanValue(value) {\n if (value === null || value === undefined) {\n return '';\n }\n\n if (typeof value === 'object') {\n try {\n return JSON.stringify(value);\n } catch (error) {\n return '';\n }\n }\n\n return String(value);\n}\n\nfunction getFriendlyColumnName(key) {\n const map = {\n id: 'ID',\n employeeId: 'ID empleado',\n employeeNumber: 'Número empleado',\n\n displayName: 'Nombre',\n lastNameFirstName: 'Nombre completo',\n firstName: 'Nombre',\n lastName: 'Apellido',\n preferredName: 'Nombre preferido',\n\n hireDate: 'Fecha de ingreso',\n originalHireDate: 'Fecha de ingreso original',\n terminationDate: 'Fecha de salida',\n\n country: 'País',\n location: 'Ubicación',\n\n department: 'Departamento',\n division: 'División',\n jobTitle: 'Cargo',\n\n jobInformationJobTitle: 'Cargo',\n jobInformationDepartment: 'Departamento',\n jobInformationDivision: 'División',\n jobInformationLocation: 'Ubicación',\n jobInformationReportsTo: 'Supervisor',\n\n supervisor: 'Supervisor',\n supervisorEmail: 'Correo supervisor',\n\n workEmail: 'Correo laboral',\n email: 'Correo',\n workPhone: 'Teléfono laboral',\n workPhoneExtension: 'Extensión laboral',\n\n status: 'Estatus',\n employmentStatus: 'Estatus laboral',\n employmentStatusDate: 'Fecha estatus laboral'\n };\n\n if (map[key]) {\n return map[key];\n }\n\n const customFieldMatch = String(key || '').match(/^customField(\\d+)$/i);\n\n if (customFieldMatch) {\n return `Campo personalizado ${customFieldMatch[1]}`;\n }\n\n return String(key || '')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/_/g, ' ')\n .trim();\n}\n\nfunction makeUniqueColumnLabels(columns) {\n const seen = new Map();\n\n return columns.map(column => {\n const baseLabel = getFriendlyColumnName(column);\n const count = seen.get(baseLabel) || 0;\n\n seen.set(baseLabel, count + 1);\n\n if (count === 0) {\n return baseLabel;\n }\n\n return `${baseLabel} (${column})`;\n });\n}\n\nconst rows = Array.isArray(exportContext.saved_report_export_rows)\n ? exportContext.saved_report_export_rows\n : [];\n\nconst columns = Array.isArray(exportContext.saved_report_export_columns)\n ? exportContext.saved_report_export_columns\n : [];\n\nconst labels = makeUniqueColumnLabels(columns);\n\nif (!rows.length) {\n return [\n {\n json: {\n Sin_datos: 'No hay filas para exportar'\n }\n }\n ];\n}\n\nreturn rows.map(row => {\n const output = {};\n\n columns.forEach((column, index) => {\n const label = labels[index] || column;\n output[label] = cleanValue(row[column]);\n });\n\n return {\n json: output\n };\n});"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1568,
|
||
-5840
|
||
],
|
||
"id": "2276e6f0-09da-4d27-95c8-43d3301c02c3",
|
||
"name": "Code - Prepare Saved Report Sheet Values"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\nconst createSheetResponse = $items('Google Sheets - Create Saved Report Spreadsheet')[0].json;\n\nfunction getSpreadsheetId(response) {\n return (\n response.spreadsheetId ||\n response.id ||\n response.spreadsheet_id ||\n response.data?.spreadsheetId ||\n response.data?.id ||\n null\n );\n}\n\nfunction getSpreadsheetUrl(response, spreadsheetId) {\n return (\n response.spreadsheetUrl ||\n response.webViewLink ||\n response.url ||\n response.data?.spreadsheetUrl ||\n response.data?.webViewLink ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n )\n );\n}\n\nconst spreadsheetId = getSpreadsheetId(createSheetResponse);\nconst spreadsheetUrl = getSpreadsheetUrl(createSheetResponse, spreadsheetId);\n\nconst reportId =\n exportContext.saved_report_id ||\n exportContext.report_id ||\n exportContext.filters?.report_id ||\n 'No disponible';\n\nconst reportName =\n exportContext.saved_report_name ||\n exportContext.filters?.report_name ||\n exportContext.saved_report_name_query ||\n 'Nombre no disponible';\n\nconst rowsExported =\n exportContext.saved_report_rows_received ||\n exportContext.saved_report_export_rows?.length ||\n 0;\n\nconst columnsExported =\n exportContext.saved_report_export_columns?.length ||\n 0;\n\nlet responseText = '';\n\nif (!spreadsheetUrl) {\n responseText =\n `El reporte guardado ID ${reportId} fue consultado, pero no pude obtener el enlace del Google Sheet generado.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `Filas exportadas: ${rowsExported}\\n` +\n `Columnas exportadas: ${columnsExported}\\n\\n` +\n `Revisa el nodo de creación del Google Sheet antes de repetir la exportación.`;\n} else {\n responseText =\n `Exportación completada en Google Sheets.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId}\\n` +\n `Filas exportadas: ${rowsExported}\\n` +\n `Columnas exportadas: ${columnsExported}\\n\\n` +\n `Enlace:\\n${spreadsheetUrl}`;\n}\n\nreturn [\n {\n json: {\n ...exportContext,\n\n status: spreadsheetUrl\n ? 'saved_report_google_sheet_created'\n : 'saved_report_google_sheet_created_without_url',\n\n can_continue: Boolean(spreadsheetUrl),\n response_type: spreadsheetUrl ? 'chat_message' : 'error_message',\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n\n response_text: responseText,\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n\n execution_result: spreadsheetUrl\n ? 'saved_report_google_sheet_created'\n : 'saved_report_google_sheet_created_without_url',\n\n google_spreadsheet_id: spreadsheetId || null,\n google_spreadsheet_url: spreadsheetUrl || null,\n rows_exported: rowsExported,\n columns_exported: columnsExported\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-176,
|
||
-5840
|
||
],
|
||
"id": "62ed6e35-a61a-4001-a2d4-4517638d941f",
|
||
"name": "Code - Format Saved Report Google Sheet Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "appendOrUpdate",
|
||
"documentId": {
|
||
"__rl": true,
|
||
"value": "={{ $items(\"Google Sheets - Create Saved Report Spreadsheet\")[0].json.spreadsheetId || $items(\"Google Sheets - Create Saved Report Spreadsheet\")[0].json.id }}",
|
||
"mode": "id"
|
||
},
|
||
"sheetName": {
|
||
"__rl": true,
|
||
"value": "=Reporte",
|
||
"mode": "name"
|
||
},
|
||
"columns": {
|
||
"mappingMode": "autoMapInputData",
|
||
"value": {},
|
||
"matchingColumns": [],
|
||
"schema": [],
|
||
"attemptToConvertTypes": false,
|
||
"convertFieldsToString": false
|
||
},
|
||
"options": {
|
||
"cellFormat": "USER_ENTERED",
|
||
"locationDefine": {
|
||
"values": {}
|
||
},
|
||
"handlingExtraData": "insertInNewColumn"
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.googleSheets",
|
||
"typeVersion": 4.7,
|
||
"position": [
|
||
-1360,
|
||
-5840
|
||
],
|
||
"id": "71c5ac93-fcc0-4e5b-9d6d-029065dc4a27",
|
||
"name": "Google Sheets - Write Saved Report Rows",
|
||
"credentials": {
|
||
"googleSheetsOAuth2Api": {
|
||
"id": "K0hDZh3a85MpOHCs",
|
||
"name": "Google Sheets account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const exportContext = $items('Code - Build Saved Report Export Request')[0].json;\nconst firstResponse = items[0]?.json || {};\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 normalizeReportBody(body) {\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: {}\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: {}\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || {}\n };\n}\n\nfunction parsePositiveInteger(value, fallback) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) {\n return fallback;\n }\n\n const integer = Math.floor(number);\n\n if (integer < 1) {\n return fallback;\n }\n\n return integer;\n}\n\nconst normalized = normalizeReportBody(firstResponse);\n\nconst firstRows = normalized.rows;\nconst pagination = normalized.pagination || {};\n\nconst reportId =\n exportContext.saved_report_id ||\n exportContext.report_id ||\n exportContext.filters?.report_id ||\n exportContext.data?.filters?.report_id ||\n null;\n\nconst reportName =\n exportContext.saved_report_name ||\n exportContext.filters?.report_name ||\n exportContext.data?.filters?.report_name ||\n exportContext.saved_report_name_query ||\n null;\n\nconst pageSize =\n parsePositiveInteger(\n exportContext.saved_report_export_page_size ||\n exportContext.saved_report_page_size ||\n pagination.page_size ||\n pagination.pageSize ||\n firstRows.length ||\n 1000,\n 1000\n );\n\nconst totalRows =\n parsePositiveInteger(\n pagination.total_records ||\n pagination.totalRecords ||\n pagination.total ||\n pagination.count ||\n exportContext.saved_report_total_records ||\n firstRows.length,\n firstRows.length\n );\n\nconst totalPages =\n totalRows > 0 && pageSize > 0\n ? Math.max(1, Math.ceil(totalRows / pageSize))\n : 1;\n\nconst maxPagesAllowed = 20;\n\nif (!reportId) {\n return [\n {\n json: {\n ...exportContext,\n\n status: 'saved_report_export_blocked_missing_report_id',\n can_continue: false,\n response_type: 'error_message',\n\n response_text:\n 'No puedo exportar el reporte porque no encontré el ID del reporte solicitado.',\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n execution_result: 'saved_report_export_blocked_missing_report_id'\n }\n }\n }\n ];\n}\n\nif (totalPages > maxPagesAllowed) {\n return [\n {\n json: {\n ...exportContext,\n\n status: 'saved_report_export_blocked_too_many_pages',\n can_continue: false,\n response_type: 'error_message',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n\n response_text:\n `El reporte guardado ID ${reportId} tiene demasiadas páginas para exportarlo automáticamente en esta versión.\\n\\n` +\n `Reporte: ${reportName || 'Nombre no disponible'}\\n` +\n `Total reportado por BambooHR: ${totalRows}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Páginas estimadas: ${totalPages}\\n\\n` +\n `Límite actual seguro: ${maxPagesAllowed} páginas.`,\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_total_records: totalRows,\n saved_report_page_size: pageSize,\n saved_report_total_pages: totalPages,\n execution_result: 'saved_report_export_blocked_too_many_pages'\n }\n }\n }\n ];\n}\n\nconst pageRequests = [];\n\nfor (let page = 1; page <= totalPages; page++) {\n pageRequests.push({\n json: {\n ...exportContext,\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n saved_report_export_total_records_expected: totalRows,\n saved_report_export_total_pages_expected: totalPages,\n saved_report_export_page_size: pageSize,\n\n saved_report_export_page: page,\n saved_report_export_is_first_page: page === 1,\n\n saved_report_export_url:\n `https://glm.bamboohr.com/api/v1/custom-reports/${reportId}` +\n `?format=json&page=${page}&page_size=${pageSize}`,\n\n status: 'saved_report_export_page_request_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n audit_event: {\n ...(exportContext.audit_event || {}),\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n saved_report_export_total_records_expected: totalRows,\n saved_report_export_total_pages_expected: totalPages,\n saved_report_export_page_size: pageSize,\n saved_report_export_page: page,\n\n execution_result: 'saved_report_export_page_request_ready'\n }\n }\n });\n}\n\nreturn pageRequests;"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2768,
|
||
-5824
|
||
],
|
||
"id": "e139f31f-c159-4403-8a25-2c9e7bb039b1",
|
||
"name": "Code - Build Saved Report Page Requests"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ $json.saved_report_export_url }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-2560,
|
||
-5824
|
||
],
|
||
"id": "25ac7417-6c02-404e-8847-f7fd0edfab9f",
|
||
"name": "HTTP - Run BambooHR Saved Report Export Page",
|
||
"alwaysOutputData": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const firstPageContext = $items('Code - Build Saved Report Page Requests')[0].json;\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 normalizeReportBody(body) {\n if (!body || isEmptyObject(body)) {\n return {\n rows: [],\n pagination: {}\n };\n }\n\n if (Array.isArray(body)) {\n return {\n rows: body,\n pagination: {}\n };\n }\n\n return {\n rows: Array.isArray(body.data)\n ? body.data\n : Array.isArray(body.employees)\n ? body.employees\n : Array.isArray(body.rows)\n ? body.rows\n : [],\n pagination: body.pagination || {}\n };\n}\n\nfunction collectColumns(rows) {\n const columns = [];\n const seen = new Set();\n\n for (const row of rows) {\n for (const key of Object.keys(row || {})) {\n if (!seen.has(key)) {\n seen.add(key);\n columns.push(key);\n }\n }\n }\n\n return columns;\n}\n\nconst allRows = [];\nconst pageSummaries = [];\n\nfor (let index = 0; index < items.length; index++) {\n const body = items[index].json || {};\n const normalized = normalizeReportBody(body);\n\n const context =\n $items('Code - Build Saved Report Page Requests')[index]?.json ||\n {};\n\n const page =\n context.saved_report_export_page ||\n normalized.pagination.page ||\n normalized.pagination.current_page ||\n index + 1;\n\n pageSummaries.push({\n page,\n rows_received: normalized.rows.length\n });\n\n for (const row of normalized.rows) {\n allRows.push(row);\n }\n}\n\nconst columns = collectColumns(allRows);\n\nconst reportId =\n firstPageContext.saved_report_id ||\n firstPageContext.report_id ||\n firstPageContext.filters?.report_id ||\n null;\n\nconst reportName =\n firstPageContext.saved_report_name ||\n firstPageContext.filters?.report_name ||\n firstPageContext.saved_report_name_query ||\n 'Nombre no disponible';\n\nconst expectedTotal =\n firstPageContext.saved_report_export_total_records_expected ||\n allRows.length;\n\nconst expectedPages =\n firstPageContext.saved_report_export_total_pages_expected ||\n items.length;\n\nconst pageSize =\n firstPageContext.saved_report_export_page_size ||\n 1000;\n\nconst responseText =\n `Exportación completa preparada para Google Sheets.\\n\\n` +\n `Reporte: ${reportName}\\n` +\n `ID BambooHR: ${reportId || 'No disponible'}\\n` +\n `Filas consolidadas: ${allRows.length}\\n` +\n `Total reportado por BambooHR: ${expectedTotal}\\n` +\n `Páginas consultadas: ${items.length} de ${expectedPages}\\n` +\n `Tamaño de página: ${pageSize}\\n` +\n `Columnas detectadas: ${columns.length}\\n\\n` +\n `Siguiente paso: crear Google Sheet y escribir las filas.`;\n\nreturn [\n {\n json: {\n ...firstPageContext,\n\n status: 'saved_report_export_pages_combined',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: reportId ? String(reportId) : null,\n report_id: reportId ? String(reportId) : null,\n saved_report_name: reportName,\n\n saved_report_rows_received: allRows.length,\n saved_report_total_records: expectedTotal,\n saved_report_total_pages: expectedPages,\n saved_report_page_size: pageSize,\n\n saved_report_export_rows: allRows,\n saved_report_export_columns: columns,\n saved_report_export_page_summaries: pageSummaries,\n\n response_text: responseText,\n\n audit_event: {\n ...(firstPageContext.audit_event || {}),\n\n saved_report_id: reportId ? String(reportId) : null,\n saved_report_name: reportName,\n\n saved_report_rows_received: allRows.length,\n saved_report_total_records: expectedTotal,\n saved_report_total_pages: expectedPages,\n saved_report_page_size: pageSize,\n saved_report_columns_count: columns.length,\n saved_report_export_page_summaries: pageSummaries,\n\n execution_result: 'saved_report_export_pages_combined'\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-2272,
|
||
-5840
|
||
],
|
||
"id": "239e2bf3-f528-41db-981d-e1400c0a4e9e",
|
||
"name": "Code - Combine Saved Report Export Pages"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const prepareContext = $items('Code - Prepare Saved Report Google Sheet')[0].json;\nconst createSpreadsheetResponse = $items('Google Sheets - Create Saved Report Spreadsheet')[0].json;\nconst writeRowsResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction firstPresent(values, fallback = null) {\n for (const value of values) {\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n\n return fallback;\n}\n\nfunction extractSpreadsheetId() {\n return firstPresent([\n prepareContext.google_spreadsheet_id,\n prepareContext.spreadsheetId,\n prepareContext.spreadsheet_id,\n\n createSpreadsheetResponse.spreadsheetId,\n createSpreadsheetResponse.spreadsheet_id,\n createSpreadsheetResponse.id,\n\n writeRowsResponse.spreadsheetId,\n writeRowsResponse.spreadsheet_id,\n writeRowsResponse.id\n ]);\n}\n\nfunction extractSpreadsheetUrl(spreadsheetId) {\n return firstPresent([\n prepareContext.google_spreadsheet_url,\n prepareContext.spreadsheetUrl,\n prepareContext.spreadsheet_url,\n\n createSpreadsheetResponse.spreadsheetUrl,\n createSpreadsheetResponse.spreadsheet_url,\n createSpreadsheetResponse.url,\n\n writeRowsResponse.spreadsheetUrl,\n writeRowsResponse.spreadsheet_url,\n writeRowsResponse.url\n ], spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n}\n\nfunction extractSheetId() {\n const sheets =\n createSpreadsheetResponse.sheets ||\n writeRowsResponse.sheets ||\n [];\n\n if (\n Array.isArray(sheets) &&\n sheets.length > 0 &&\n sheets[0].properties &&\n sheets[0].properties.sheetId !== undefined\n ) {\n return Number(sheets[0].properties.sheetId);\n }\n\n // En un spreadsheet nuevo, normalmente la primera hoja tiene sheetId 0.\n return 0;\n}\n\nconst spreadsheetId = extractSpreadsheetId();\nconst spreadsheetUrl = extractSpreadsheetUrl(spreadsheetId);\nconst sheetId = extractSheetId();\n\nconst rows =\n Array.isArray(prepareContext.saved_report_export_rows)\n ? prepareContext.saved_report_export_rows\n : [];\n\nconst columns =\n Array.isArray(prepareContext.saved_report_export_columns)\n ? prepareContext.saved_report_export_columns\n : [];\n\nconst rowCount = Math.max(rows.length + 1, 1);\nconst columnCount = Math.max(columns.length, 1);\n\nconst reportId =\n prepareContext.saved_report_id ||\n prepareContext.report_id ||\n prepareContext.filters?.report_id ||\n 'sin-id';\n\nconst reportName =\n prepareContext.saved_report_name ||\n prepareContext.filters?.report_name ||\n prepareContext.saved_report_name_query ||\n `Reporte guardado ID ${reportId}`;\n\nif (!spreadsheetId) {\n return [\n {\n json: {\n ...prepareContext,\n\n status: 'saved_report_sheet_format_skipped_missing_spreadsheet_id',\n can_continue: true,\n response_type: 'chat_message',\n\n google_sheet_format_ready: false,\n google_sheet_format_skipped_reason: 'missing_spreadsheet_id',\n\n google_spreadsheet_id: null,\n google_spreadsheet_url: spreadsheetUrl,\n\n google_sheets_write_response: writeRowsResponse,\n\n response_text:\n `Exportación completada en Google Sheets, pero no pude preparar el formato porque no encontré el spreadsheetId.`,\n\n audit_event: {\n ...(prepareContext.audit_event || {}),\n\n execution_result: 'saved_report_sheet_created_format_skipped_missing_spreadsheet_id',\n google_sheet_format_ready: false,\n google_sheet_format_skipped_reason: 'missing_spreadsheet_id'\n }\n }\n }\n ];\n}\n\nconst requests = [\n {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: 1\n }\n },\n fields: 'gridProperties.frozenRowCount'\n }\n },\n {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n },\n cell: {\n userEnteredFormat: {\n textFormat: {\n bold: true\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP'\n }\n },\n fields: 'userEnteredFormat(textFormat,horizontalAlignment,verticalAlignment,wrapStrategy)'\n }\n },\n {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 0,\n endRowIndex: rowCount,\n startColumnIndex: 0,\n endColumnIndex: columnCount\n }\n }\n }\n },\n {\n autoResizeDimensions: {\n dimensions: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: 0,\n endIndex: columnCount\n }\n }\n }\n];\n\nreturn [\n {\n json: {\n ...prepareContext,\n\n status: 'saved_report_sheet_format_request_ready',\n can_continue: true,\n response_type: 'chat_message',\n\n saved_report_id: String(reportId),\n report_id: String(reportId),\n saved_report_name: reportName,\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n google_sheet_id: sheetId,\n\n google_sheet_format_ready: true,\n google_sheet_format_row_count: rowCount,\n google_sheet_format_column_count: columnCount,\n\n google_sheets_write_response: writeRowsResponse,\n\n google_sheets_batch_update_body: {\n requests\n },\n\n audit_event: {\n ...(prepareContext.audit_event || {}),\n\n execution_result: 'saved_report_sheet_format_request_ready',\n\n saved_report_id: String(reportId),\n saved_report_name: reportName,\n\n google_spreadsheet_id: spreadsheetId,\n google_spreadsheet_url: spreadsheetUrl,\n google_sheet_id: sheetId,\n\n google_sheet_format_ready: true,\n google_sheet_format_row_count: rowCount,\n google_sheet_format_column_count: columnCount\n }\n }\n }\n];"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-1104,
|
||
-5840
|
||
],
|
||
"id": "6bd791b1-1ba2-4c22-ad67-dd06089cb67e",
|
||
"name": "Code - Build Saved Report Sheet Format Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $json.google_spreadsheet_id }}:batchUpdate",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Content-Type",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.google_sheets_batch_update_body }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-736,
|
||
-5840
|
||
],
|
||
"id": "87440c58-db60-423e-8f1c-832ea43d27fc",
|
||
"name": "HTTP - Format Saved Report Google Sheet",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
},
|
||
"googleOAuth2Api": {
|
||
"id": "zDheCLMV1u5K3EW1",
|
||
"name": "Google account - Isaac"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction normalizeForPdfUpload(value) {\n return String(value || '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[¿?¡!]/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction cleanPdfText(value) {\n return String(value || '')\n .replace(/[¿?¡!.,;:]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction extractPdfUploadEmployeeQuery(messageText) {\n const raw = cleanPdfText(messageText);\n\n const patterns = [\n /\\b(?:a|para|de|del)\\s+(?:empleado|colaborador|persona)?\\s*([a-záéíóúñü\\s]+?)(?=\\s+(?:como|tipo|documento|archivo|pdf|carta|contrato|cedula|cédula|licencia|certificacion|certificación|con|en)|$)/i,\n /\\b(?:empleado|colaborador|persona)\\s+([a-záéíóúñü\\s]+?)(?=\\s+(?:como|tipo|documento|archivo|pdf|carta|contrato|cedula|cédula|licencia|certificacion|certificación|con|en)|$)/i\n ];\n\n for (const pattern of patterns) {\n const match = raw.match(pattern);\n\n if (match && match[1]) {\n const candidate = cleanPdfText(match[1]);\n\n if (\n candidate &&\n !['este', 'esta', 'archivo', 'documento', 'pdf'].includes(normalizeForPdfUpload(candidate))\n ) {\n return candidate;\n }\n }\n }\n\n return null;\n}\n\nfunction extractPdfDocumentType(messageText) {\n const normalized = normalizeForPdfUpload(messageText);\n\n const documentTypes = [\n {\n keys: ['carta de oferta', 'oferta laboral', 'offer letter'],\n value: 'Carta de Oferta'\n },\n {\n keys: ['contrato', 'contrato laboral', 'employment agreement'],\n value: 'Contrato'\n },\n {\n keys: ['cedula', 'cédula', 'identificacion', 'identificación', 'id personal'],\n value: 'Cédula'\n },\n {\n keys: ['pasaporte', 'passport'],\n value: 'Pasaporte'\n },\n {\n keys: ['licencia', 'licencia medica', 'licencia médica'],\n value: 'Licencia'\n },\n {\n keys: ['certificacion', 'certificación', 'certificado'],\n value: 'Certificación'\n },\n {\n keys: ['hoja de vida', 'cv', 'curriculum', 'currículo'],\n value: 'CV'\n },\n {\n keys: ['amonestacion', 'amonestación', 'disciplinario'],\n value: 'Amonestación'\n },\n {\n keys: ['renuncia'],\n value: 'Renuncia'\n },\n {\n keys: ['desvinculacion', 'desvinculación', 'terminacion', 'terminación'],\n value: 'Desvinculación'\n }\n ];\n\n for (const item of documentTypes) {\n if (item.keys.some(key => normalized.includes(normalizeForPdfUpload(key)))) {\n return item.value;\n }\n }\n\n return null;\n}\n\nfunction extractPdfUploadCountry(messageText) {\n const normalized = normalizeForPdfUpload(messageText);\n\n const countries = [\n 'Republica Dominicana',\n 'República Dominicana',\n 'Guatemala',\n 'Honduras',\n 'Colombia',\n 'Costa Rica',\n 'Panamá',\n 'Panama',\n 'Jamaica',\n 'Trinidad',\n 'El Salvador',\n 'Nicaragua',\n 'Puerto Rico'\n ];\n\n for (const country of countries) {\n if (normalized.includes(normalizeForPdfUpload(country))) {\n return country;\n }\n }\n\n return null;\n}\n\nconst pdfUploadAttachments = Array.isArray(input.google_chat_pdf_attachments)\n ? input.google_chat_pdf_attachments\n : [];\n\nconst hasPdfUploadAttachment = pdfUploadAttachments.length > 0;\n\nif (hasPdfUploadAttachment) {\n const originalPdfMessage =\n input.original_message ||\n input.message ||\n '';\n\n const pdfEmployeeQuery = extractPdfUploadEmployeeQuery(originalPdfMessage);\n const pdfDocumentType = extractPdfDocumentType(originalPdfMessage);\n const pdfCountry = extractPdfUploadCountry(originalPdfMessage);\n\n const firstPdf = pdfUploadAttachments[0];\n\n const missingRequired = [];\n\n if (!pdfEmployeeQuery) {\n missingRequired.push('empleado');\n }\n\n if (!pdfDocumentType) {\n missingRequired.push('tipo de documento');\n }\n\n const pdfReady = missingRequired.length === 0;\n\n return {\n json: {\n ...input,\n\n mode: 'accion',\n intent: 'upload_employee_file',\n risk_level: 'high',\n\n confidence: pdfReady ? 0.95 : 0.7,\n requires_confirmation: true,\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n requested_fields: [],\n fields: [],\n\n table_name: null,\n filters: {\n has_pdf_attachment: true,\n document_type: pdfDocumentType,\n country: pdfCountry\n },\n\n action: {\n type: 'upload_employee_file',\n label: 'subir PDF al perfil del empleado',\n payload: {\n document_type: pdfDocumentType,\n country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n google_chat_attachment_resource_name: firstPdf.attachment_resource_name,\n google_drive_file_id: firstPdf.drive_file_id,\n\n attachment_source: firstPdf.source,\n attachment_index: firstPdf.index\n }\n },\n\n can_continue: pdfReady,\n status: pdfReady ? 'ready' : 'needs_clarification',\n\n response_type: pdfReady ? 'chat_message' : 'error_message',\n\n response_text: pdfReady\n ? null\n : (\n `Recibí el PDF, pero faltan datos para subirlo correctamente a BambooHR.\\n\\n` +\n `Falta: ${missingRequired.join(', ')}.\\n\\n` +\n `Ejemplo:\\n` +\n `Sube esta carta de oferta al empleado Bruce Wayne.`\n ),\n\n parser_source: 'deterministic_pdf_upload_priority',\n\n data: {\n ...(input.data || {}),\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n requested_fields: [],\n fields: [],\n\n filters: {\n ...(input.data?.filters || {}),\n has_pdf_attachment: true,\n document_type: pdfDocumentType,\n country: pdfCountry\n },\n\n action: {\n type: 'upload_employee_file',\n label: 'subir PDF al perfil del empleado',\n payload: {\n document_type: pdfDocumentType,\n country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n google_chat_attachment_resource_name: firstPdf.attachment_resource_name,\n google_drive_file_id: firstPdf.drive_file_id,\n\n attachment_source: firstPdf.source,\n attachment_index: firstPdf.index\n }\n },\n\n requires_confirmation: true,\n confidence: pdfReady ? 0.95 : 0.7,\n status: pdfReady ? 'ready' : 'needs_clarification'\n },\n\n audit_event: {\n ...(input.audit_event || {}),\n\n original_message: originalPdfMessage,\n\n parser_source: 'deterministic_pdf_upload_priority',\n\n mode: 'accion',\n intent: 'upload_employee_file',\n risk_level: 'high',\n\n employee_query: pdfEmployeeQuery,\n employee_id: null,\n\n document_type: pdfDocumentType,\n document_country: pdfCountry,\n\n original_file_name: firstPdf.content_name,\n original_content_type: firstPdf.content_type,\n\n has_pdf_attachment: true,\n\n authorization_result: 'pending_authorization',\n execution_result: pdfReady\n ? 'interpreted_pdf_upload_request'\n : 'pdf_upload_needs_clarification'\n }\n }\n };\n}\n\n/*\n Este nodo convierte la interpretación validada de Gemini\n en el formato oficial que usa el resto del workflow.\n\n Gemini NO ejecuta.\n Gemini interpreta.\n\n Este nodo toma:\n - gemini_interpretation\n - use_deterministic_parser_fallback\n\n Y produce:\n - mode\n - intent\n - confidence\n - risk_level\n - requires_confirmation\n - employee_query\n - employee_id\n - requested_fields\n - table_name\n - filters\n - report_type\n - action\n*/\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction normalizeMode(value) {\n const normalized = normalize(value);\n\n const map = {\n query: 'consulta',\n consulta: 'consulta',\n\n action: 'accion',\n accion: 'accion',\n acción: 'accion',\n\n report: 'reporte',\n reporte: 'reporte',\n\n confirmation: 'confirmation',\n confirmacion: 'confirmation',\n confirmación: 'confirmation',\n\n system: 'system',\n sistema: 'system'\n };\n\n return map[normalized] || value || 'unknown';\n}\n\nfunction normalizeIntent(value) {\n const intent = cleanText(value);\n\n const aliases = {\n active_inactive_summary: 'employees_active_inactive',\n employees_active_inactive: 'employees_active_inactive',\n\n list_saved_reports: 'list_saved_reports',\n saved_reports: 'list_saved_reports',\n\n run_saved_report: 'run_saved_report',\n execute_saved_report: 'run_saved_report',\n\n export_saved_report: 'export_saved_report',\n saved_report_export: 'export_saved_report'\n };\n\n return aliases[intent] || intent || 'unknown';\n}\n\nfunction normalizeRisk(value) {\n const normalized = normalize(value);\n\n if (['low', 'medium', 'high', 'critical'].includes(normalized)) {\n return normalized;\n }\n\n if (normalized === 'bajo') return 'low';\n if (normalized === 'medio') return 'medium';\n if (normalized === 'alto') return 'high';\n if (normalized === 'critico' || normalized === 'crítico') return 'critical';\n\n return 'low';\n}\n\nfunction normalizeConfidence(value) {\n const number = Number(value);\n\n if (!Number.isFinite(number)) return 0;\n if (number < 0) return 0;\n if (number > 1) return 1;\n\n return Number(number.toFixed(2));\n}\n\nfunction normalizeArray(value) {\n if (Array.isArray(value)) {\n return value.filter(Boolean);\n }\n\n if (typeof value === 'string' && value.trim()) {\n return [value.trim()];\n }\n\n return [];\n}\n\nfunction normalizeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nconst gemini =\n data.gemini_interpretation &&\n typeof data.gemini_interpretation === 'object'\n ? data.gemini_interpretation\n : null;\n\nconst shouldFallback =\n data.use_deterministic_parser_fallback === true ||\n data.gemini_interpretation_valid !== true ||\n !gemini;\n\nif (shouldFallback) {\n return {\n json: {\n ...data,\n\n ai_interpretation_source: 'deterministic_fallback',\n ai_interpretation_applied: false,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n ai_interpretation_source: 'deterministic_fallback',\n ai_interpretation_applied: false,\n gemini_interpretation_valid: data.gemini_interpretation_valid === true,\n gemini_interpretation_issues: data.gemini_interpretation_issues || []\n }\n }\n };\n}\n\nconst mode = normalizeMode(gemini.mode);\nconst intent = normalizeIntent(gemini.intent);\nconst confidence = normalizeConfidence(gemini.confidence);\nconst riskLevel = normalizeRisk(gemini.risk_level);\n\nconst employeeQuery =\n gemini.employee_query !== undefined && gemini.employee_query !== null\n ? cleanText(gemini.employee_query)\n : data.employee_query || null;\n\nconst employeeId =\n gemini.employee_id !== undefined && gemini.employee_id !== null\n ? cleanText(gemini.employee_id)\n : data.employee_id || null;\n\nconst requestedFields = normalizeArray(gemini.requested_fields);\n\nconst tableName =\n gemini.table_name !== undefined && gemini.table_name !== null\n ? cleanText(gemini.table_name)\n : data.table_name || null;\n\nconst filters = {\n ...(data.filters || {}),\n ...normalizeObject(gemini.filters)\n};\n\nconst reportType =\n gemini.report_type !== undefined && gemini.report_type !== null\n ? cleanText(gemini.report_type)\n : data.report_type || intent;\n\nconst action =\n gemini.action && typeof gemini.action === 'object' && !Array.isArray(gemini.action)\n ? gemini.action\n : data.action || null;\n\nconst missingInformation = normalizeArray(gemini.missing_information);\n\nreturn {\n json: {\n ...data,\n\n mode,\n intent,\n confidence,\n risk_level: riskLevel,\n requires_confirmation: gemini.requires_confirmation === true,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n\n requested_fields: requestedFields,\n table_name: tableName,\n\n filters,\n report_type: reportType,\n\n action,\n\n missing_information: missingInformation,\n reasoning_summary: gemini.reasoning_summary || null,\n\n ai_interpretation_source: 'gemini',\n ai_interpretation_applied: true,\n\n data: {\n ...(data.data || {}),\n\n mode,\n intent,\n confidence,\n risk_level: riskLevel,\n requires_confirmation: gemini.requires_confirmation === true,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n\n requested_fields: requestedFields,\n table_name: tableName,\n\n filters,\n report_type: reportType,\n\n action,\n missing_information: missingInformation\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n ai_interpretation_source: 'gemini',\n ai_interpretation_applied: true,\n\n gemini_interpreted_mode: mode,\n gemini_interpreted_intent: intent,\n gemini_confidence: confidence,\n gemini_risk_level: riskLevel,\n\n employee_query: employeeQuery,\n employee_id: employeeId,\n requested_fields: requestedFields,\n table_name: tableName,\n filters,\n report_type: reportType,\n action,\n\n missing_information: missingInformation,\n reasoning_summary: gemini.reasoning_summary || null\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10112,
|
||
-2304
|
||
],
|
||
"id": "71cc5ff5-97ae-462a-9c2e-4d7052b39b4e",
|
||
"name": "Code - Apply Gemini Interpretation"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n GATE DE APROBACIÓN SENSIBLE DE MÁXIMO\n\n Objetivo:\n - Detectar acciones sensibles.\n - Si el solicitante NO es Máximo, crear aprobación pendiente.\n - Si el solicitante ES Máximo, NO mandarle aprobación a sí mismo.\n - Aunque Máximo no requiera aprobación de Máximo, la acción real todavía debe pasar\n por la confirmación normal estricta si corresponde.\n*/\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\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.map(item => String(item));\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.map(item => String(item));\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 getActionField(context) {\n return (\n context.action?.field ||\n context.data?.action?.field ||\n context.action?.payload?.field ||\n context.data?.action?.payload?.field ||\n context.payload?.field ||\n context.data?.payload?.field ||\n null\n );\n}\n\nfunction getActionType(context) {\n return (\n context.action?.type ||\n context.data?.action?.type ||\n context.action_type ||\n context.audit_event?.action_type ||\n context.intent ||\n null\n );\n}\n\nfunction getMessage(context) {\n return normalize(\n context.original_message ||\n context.message ||\n context.user_message ||\n context.audit_event?.original_message ||\n context.data?.original_message ||\n context.data?.message ||\n ''\n );\n}\n\nfunction messageContainsSensitiveTerms(context) {\n const message = getMessage(context);\n\n const terms = [\n 'salario',\n 'sueldo',\n 'compensacion',\n 'compensación',\n 'compensation',\n 'salary',\n 'pay rate',\n 'pago',\n 'aumento',\n 'incremento salarial',\n 'bono',\n 'bonus',\n\n 'desvincular',\n 'desvinculacion',\n 'desvinculación',\n 'terminar',\n 'dar de baja',\n 'termination',\n 'terminate',\n 'terminated',\n\n 'subir documento',\n 'subir archivo',\n 'documento sensible',\n 'archivo sensible',\n\n 'cuenta bancaria',\n 'banco',\n 'cedula',\n 'cédula',\n 'dni',\n 'pasaporte',\n 'passport'\n ];\n\n return terms.some(term =>\n message.includes(normalize(term))\n );\n}\n\nfunction fieldIsSensitive(field) {\n const normalizedField = normalize(field);\n\n if (!normalizedField) return false;\n\n const sensitiveFields = [\n 'compensation',\n 'salary',\n 'salario',\n 'sueldo',\n 'pay',\n 'payrate',\n 'pay rate',\n 'rate',\n 'bonus',\n 'bono',\n\n 'termination',\n 'terminate',\n 'employmentstatus',\n 'employment status',\n\n 'bank',\n 'banco',\n 'cuenta',\n 'account',\n\n 'ssn',\n 'cedula',\n 'cédula',\n 'dni',\n 'passport',\n 'pasaporte'\n ];\n\n return sensitiveFields.some(item =>\n normalizedField.includes(normalize(item))\n );\n}\n\nfunction requestedFieldsContainSensitiveFields(context) {\n const fields = [\n ...parseArray(context.requested_fields),\n ...parseArray(context.data?.requested_fields),\n ...parseArray(context.action?.requested_fields),\n ...parseArray(context.data?.action?.requested_fields)\n ];\n\n return fields.some(field => fieldIsSensitive(field));\n}\n\nfunction payloadContainsSensitiveKeys(context) {\n const payloads = [\n context.payload,\n context.data?.payload,\n context.action?.payload,\n context.data?.action?.payload\n ].filter(value =>\n value &&\n typeof value === 'object' &&\n !Array.isArray(value)\n );\n\n for (const payload of payloads) {\n for (const key of Object.keys(payload)) {\n if (fieldIsSensitive(key)) {\n return true;\n }\n }\n }\n\n return false;\n}\n\nfunction intentRequiresApproval(context) {\n const intent = normalize(context.intent);\n\n const approvalIntents = [\n 'terminate_employee',\n 'prepare_terminate_employee',\n 'update_compensation',\n 'update_salary',\n 'upload_employee_file',\n 'delete_employee_file'\n ];\n\n return approvalIntents.includes(intent);\n}\n\nfunction actionTypeRequiresApproval(context) {\n const actionType = normalize(getActionType(context));\n\n if (!actionType) return false;\n\n const approvalActions = [\n 'update_salary',\n 'update_compensation',\n 'compensation',\n 'salary',\n 'terminate_employee',\n 'termination',\n 'upload_employee_file',\n 'delete_employee_file'\n ];\n\n return approvalActions.some(action =>\n actionType.includes(normalize(action))\n );\n}\n\nfunction riskRequiresApproval(context) {\n const risk = normalize(context.risk_level);\n\n return (\n risk === 'high' ||\n risk === 'critical'\n );\n}\n\nfunction isRequesterMaximo(context) {\n const email = normalize(\n context.user_email ||\n context.audit_event?.user_email ||\n context.data?.user_email\n );\n\n const maximoEmails = [\n 'mgomez@gomezleemarketing.com',\n 'maximo@gomezleemarketing.com'\n ];\n\n return maximoEmails.includes(email);\n}\n\nfunction wasAlreadyApprovedByMaximo(context) {\n return (\n context.approved_by_maximo === true ||\n context.maximo_approval_status === 'approved' ||\n context.approval_status === 'approved' ||\n context.audit_event?.approval_status === 'approved'\n );\n}\n\nconst actionField = getActionField(data);\nconst actionType = getActionType(data);\n\nconst requesterIsMaximo = isRequesterMaximo(data);\nconst alreadyApprovedByMaximo = wasAlreadyApprovedByMaximo(data);\n\nconst requiresApproval =\n data.requires_maximo_approval === true ||\n intentRequiresApproval(data) ||\n actionTypeRequiresApproval(data) ||\n fieldIsSensitive(actionField) ||\n requestedFieldsContainSensitiveFields(data) ||\n payloadContainsSensitiveKeys(data) ||\n riskRequiresApproval(data) ||\n messageContainsSensitiveTerms(data);\n\n/*\n Regla clave:\n - Si Isaac u otro usuario pide algo sensible: crear aprobación de Máximo.\n - Si Máximo pide algo sensible: NO crear aprobación para él mismo.\n - Si ya vino aprobado por Máximo: no volver a crear aprobación.\n - Si es una acción confirmada desde Google Chat: no volver a crear aprobación.\n*/\nconst shouldCreateMaximoApproval =\n requiresApproval === true &&\n requesterIsMaximo !== true &&\n alreadyApprovedByMaximo !== true &&\n data.confirmed_from_google_chat !== true &&\n data.final_execution_approved !== true;\n\nconst maximoApprovalStatus = shouldCreateMaximoApproval\n ? 'pending_approval_required'\n : requesterIsMaximo && requiresApproval\n ? 'not_required_requester_is_maximo'\n : alreadyApprovedByMaximo\n ? 'not_required_already_approved_by_maximo'\n : 'not_required';\n\nreturn {\n json: {\n ...data,\n\n requires_maximo_approval: requiresApproval,\n should_create_maximo_approval: shouldCreateMaximoApproval,\n\n requester_is_maximo: requesterIsMaximo,\n already_approved_by_maximo: alreadyApprovedByMaximo,\n\n sensitive_approval_action_type: actionType,\n sensitive_approval_action_field: actionField,\n\n maximo_approval_status: maximoApprovalStatus,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n requires_maximo_approval: requiresApproval,\n should_create_maximo_approval: shouldCreateMaximoApproval,\n\n requester_is_maximo: requesterIsMaximo,\n already_approved_by_maximo: alreadyApprovedByMaximo,\n\n sensitive_approval_action_type: actionType,\n sensitive_approval_action_field: actionField,\n\n maximo_approval_status: maximoApprovalStatus\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8704,
|
||
-2624
|
||
],
|
||
"id": "f76ccb84-0963-4fa3-9f3b-1b1966ef1fd5",
|
||
"name": "Code - Sensitive Approval Gate"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b78c2b9a-a183-46f5-9e7f-4fe5f6193003",
|
||
"leftValue": "={{ $json.should_create_maximo_approval }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-8496,
|
||
-2624
|
||
],
|
||
"id": "6649f03d-f59e-478a-afce-ab713886959d",
|
||
"name": "IF - Needs Maximo Approval?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\n/*\n Este nodo prepara una solicitud pendiente de aprobación para Máximo.\n\n Importante:\n - NO ejecuta BambooHR.\n - NO modifica salario.\n - NO desvincula.\n - NO sube archivos.\n - Solo construye el registro que se guardará en Supabase.\n*/\n\nconst MAXIMO_EMAIL = 'mgomez@gomezleemarketing.com';\nconst MAXIMO_NAME = 'Máximo Gómez';\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getFirst(values, fallback = null) {\n for (const value of values) {\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return fallback;\n}\n\nfunction safeJsonObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction createApprovalId() {\n const now = new Date();\n\n const stamp = now\n .toISOString()\n .replace(/[-:]/g, '')\n .replace('T', '')\n .slice(0, 14);\n\n const random = Math.random()\n .toString(36)\n .slice(2, 8)\n .toUpperCase();\n\n return `APR-${stamp}-${random}`;\n}\n\nfunction addHours(date, hours) {\n return new Date(date.getTime() + hours * 60 * 60 * 1000);\n}\n\nfunction getActionType(context) {\n return getFirst([\n context.sensitive_approval_action_type,\n context.action?.type,\n context.data?.action?.type,\n context.action_type,\n context.intent\n ], 'sensitive_action');\n}\n\nfunction getTargetEmployeeId(context) {\n return getFirst([\n context.employee_id,\n context.resolved_employee_id,\n context.data?.employee_id,\n context.data?.resolved_employee_id,\n context.audit_event?.target_employee_id,\n context.action?.payload?.employee_id,\n context.action?.payload?.employeeId,\n context.data?.action?.payload?.employee_id,\n context.data?.action?.payload?.employeeId\n ]);\n}\n\nfunction getTargetEmployeeName(context) {\n return getFirst([\n context.employee_name,\n context.resolved_employee_name,\n context.data?.employee_name,\n context.data?.resolved_employee_name,\n context.audit_event?.target_employee_name,\n context.employee_query\n ]);\n}\n\nfunction getTargetEmployeeEmail(context) {\n return getFirst([\n context.employee_email,\n context.resolved_employee_email,\n context.data?.employee_email,\n context.data?.resolved_employee_email,\n context.audit_event?.target_employee_email\n ]);\n}\n\nfunction getOriginalMessage(context) {\n return getFirst([\n context.original_message,\n context.message,\n context.user_message,\n context.audit_event?.original_message,\n context.data?.original_message,\n context.data?.message\n ], '');\n}\n\nfunction getGoogleChatSpaceName(context) {\n return getFirst([\n context.google_chat_space_name,\n context.space_name,\n context.space?.name,\n context.data?.google_chat_space_name,\n context.data?.space_name,\n context.audit_event?.google_chat_space_name\n ]);\n}\n\nfunction getGoogleChatThreadName(context) {\n return getFirst([\n context.google_chat_thread_name,\n context.thread_name,\n context.thread?.name,\n context.message?.thread?.name,\n context.data?.google_chat_thread_name,\n context.data?.thread_name,\n context.audit_event?.google_chat_thread_name\n ]);\n}\n\nfunction getGoogleChatMessageName(context) {\n return getFirst([\n context.google_chat_message_name,\n context.message_name,\n context.message?.name,\n context.data?.google_chat_message_name,\n context.data?.message_name,\n context.audit_event?.google_chat_message_name\n ]);\n}\n\nfunction buildHumanActionName(actionType, intent) {\n const normalizedAction = normalize(actionType);\n const normalizedIntent = normalize(intent);\n\n if (\n normalizedAction.includes('salary') ||\n normalizedAction.includes('compensation') ||\n normalizedAction.includes('salario') ||\n normalizedIntent.includes('salary') ||\n normalizedIntent.includes('compensation')\n ) {\n return 'Cambio o consulta sensible de salario / compensación';\n }\n\n if (\n normalizedAction.includes('terminate') ||\n normalizedAction.includes('termination') ||\n normalizedIntent.includes('terminate') ||\n normalizedIntent.includes('desvinc')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n normalizedAction.includes('file') ||\n normalizedAction.includes('document') ||\n normalizedIntent.includes('file') ||\n normalizedIntent.includes('document')\n ) {\n return 'Carga o modificación de archivo de empleado';\n }\n\n return actionType || intent || 'Acción sensible';\n}\n\nconst now = new Date();\nconst expiresAt = addHours(now, 6);\n\nconst approvalId = createApprovalId();\n\nconst requestId =\n data.request_id ||\n data.audit_event?.request_id ||\n `request_${approvalId}`;\n\nconst requestedByEmail =\n getFirst([\n data.user_email,\n data.requested_by_email,\n data.audit_event?.user_email\n ], 'usuario_no_disponible');\n\nconst requestedByName =\n getFirst([\n data.user_name,\n data.requested_by_name,\n data.audit_event?.user_name\n ], requestedByEmail);\n\nconst mode = data.mode || data.data?.mode || 'accion';\nconst intent = data.intent || data.data?.intent || 'unknown';\nconst actionType = getActionType(data);\nconst riskLevel = data.risk_level || data.data?.risk_level || 'high';\n\nconst targetEmployeeId = getTargetEmployeeId(data);\nconst targetEmployeeName = getTargetEmployeeName(data);\nconst targetEmployeeEmail = getTargetEmployeeEmail(data);\n\nconst originalMessage = getOriginalMessage(data);\n\nconst proposedAction =\n data.action ||\n data.data?.action ||\n {\n type: actionType,\n intent\n };\n\nconst proposedPayload =\n proposedAction.payload ||\n data.data?.action?.payload ||\n data.payload ||\n {};\n\nconst humanActionName = buildHumanActionName(actionType, intent);\n\nconst approvalTitle = `${humanActionName}`;\n\nconst approvalSummary =\n `Solicitud sensible pendiente de aprobación.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Solicitante: ${requestedByName} <${requestedByEmail}>\\n` +\n `Aprobador: ${MAXIMO_NAME} <${MAXIMO_EMAIL}>\\n` +\n `Acción: ${humanActionName}\\n` +\n `Intent: ${intent}\\n` +\n `Riesgo: ${riskLevel}\\n` +\n `Empleado: ${targetEmployeeName || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Expira: ${expiresAt.toISOString()}\\n\\n` +\n `Mensaje original:\\n${originalMessage || 'No disponible'}`;\n\nconst approvalReason =\n `La solicitud requiere aprobación de Máximo porque fue clasificada como sensible o de alto riesgo.`;\n\nconst pendingPayload = {\n approval_id: approvalId,\n request_id: requestId,\n\n original_context: data,\n\n proposed_action: proposedAction,\n proposed_payload: proposedPayload,\n\n execution_allowed_after_approval: true,\n expires_at: expiresAt.toISOString()\n};\n\nconst row = {\n approval_id: approvalId,\n request_id: requestId,\n\n requested_by_email: requestedByEmail,\n requested_by_name: requestedByName,\n\n approver_email: MAXIMO_EMAIL,\n approver_name: MAXIMO_NAME,\n\n channel: data.channel || data.audit_event?.channel || 'google_chat',\n environment: data.environment || data.audit_event?.environment || 'development',\n\n google_chat_space_name: getGoogleChatSpaceName(data),\n google_chat_thread_name: getGoogleChatThreadName(data),\n google_chat_message_name: getGoogleChatMessageName(data),\n\n mode,\n intent,\n action_type: actionType,\n risk_level: riskLevel,\n\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n target_employee_email: targetEmployeeEmail,\n\n approval_title: approvalTitle,\n approval_summary: approvalSummary,\n approval_reason: approvalReason,\n\n original_message: originalMessage,\n\n proposed_action: proposedAction,\n proposed_payload: proposedPayload,\n pending_payload: pendingPayload,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n maximo_approval_required: true,\n execution_result: 'pending_maximo_approval_created'\n },\n\n approval_status: 'pending',\n approval_type: 'sensitive_action',\n\n expires_at: expiresAt.toISOString()\n};\n\nconst responseText =\n `Solicitud pendiente de aprobación de Máximo.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Acción: ${humanActionName}\\n` +\n `Empleado: ${targetEmployeeName || data.employee_query || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Solicitante: ${requestedByName}\\n` +\n `Expira en: 6 horas\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nconst maximoMessage =\n `Solicitud sensible pendiente de aprobación\\n\\n` +\n `ID: ${approvalId}\\n` +\n `Solicitante: ${requestedByName} <${requestedByEmail}>\\n` +\n `Acción: ${humanActionName}\\n` +\n `Empleado: ${targetEmployeeName || data.employee_query || 'No resuelto todavía'}${targetEmployeeId ? ` (ID ${targetEmployeeId})` : ''}\\n` +\n `Riesgo: ${riskLevel}\\n` +\n `Expira: ${expiresAt.toLocaleString('es-DO')}\\n\\n` +\n `Mensaje original:\\n${originalMessage || 'No disponible'}\\n\\n` +\n `Para aprobar responde:\\nAPROBAR ${approvalId}\\n\\n` +\n `Para rechazar responde:\\nRECHAZAR ${approvalId}`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'approval_pending_message',\n response_text: responseText,\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt.toISOString(),\n\n maximo_approver_email: MAXIMO_EMAIL,\n maximo_approver_name: MAXIMO_NAME,\n\n supabase_pending_approval_row: row,\n\n maximo_approval_message: maximoMessage,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n maximo_approval_required: true,\n execution_result: 'pending_maximo_approval_prepared'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-8240,
|
||
-2816
|
||
],
|
||
"id": "32ecee10-ec1a-447b-af5f-28f2b3ffbd08",
|
||
"name": "Code - Build Maximo Approval Record"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals",
|
||
"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.supabase_pending_approval_row }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-8032,
|
||
-2816
|
||
],
|
||
"id": "5e9e8c59-fea4-4ab4-9265-99250cdbc7ce",
|
||
"name": "HTTP - Insert Pending Maximo Approval",
|
||
"retryOnFail": false
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buildContext = $items('Code - Build Maximo Approval Record')[0].json;\nconst insertResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getActionName(context, approval) {\n const originalMessage = normalize(\n approval?.original_message ||\n context.original_message ||\n context.message ||\n context.audit_event?.original_message ||\n ''\n );\n\n const actionType = normalize(\n approval?.action_type ||\n context.action_type ||\n context.action?.type ||\n context.data?.action?.type ||\n context.intent ||\n ''\n );\n\n const approvalTitle = normalize(\n approval?.approval_title ||\n context.approval_title ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context.proposed_action ||\n context.action ||\n context.data?.action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n\n const proposedField = normalize(\n proposedAction?.field ||\n proposedAction?.payload?.field ||\n ''\n );\n\n const proposedPayload = proposedAction?.payload || {};\n\n const joinedText = [\n originalMessage,\n actionType,\n approvalTitle,\n proposedTable,\n proposedField,\n normalize(JSON.stringify(proposedPayload))\n ].join(' ');\n\n if (\n joinedText.includes('salario') ||\n joinedText.includes('sueldo') ||\n joinedText.includes('compensacion') ||\n joinedText.includes('compensation') ||\n joinedText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n joinedText.includes('desvincular') ||\n joinedText.includes('desvinculacion') ||\n joinedText.includes('terminar') ||\n joinedText.includes('termination') ||\n joinedText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n joinedText.includes('archivo') ||\n joinedText.includes('documento') ||\n joinedText.includes('file') ||\n joinedText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (\n actionType.includes('add_row') ||\n actionType.includes('add_table_row') ||\n proposedAction?.type === 'add_row' ||\n proposedAction?.type === 'add_table_row'\n ) {\n return 'Actualización de historial del empleado';\n }\n\n if (\n actionType.includes('update_field') ||\n actionType.includes('update_employee') ||\n proposedAction?.type === 'update_field'\n ) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst insertedApproval = getFirstRow(insertResponse);\n\nconst approvalId =\n insertedApproval?.approval_id ||\n buildContext.approval_id ||\n 'No disponible';\n\nconst expiresAt =\n insertedApproval?.expires_at ||\n buildContext.approval_expires_at ||\n buildContext.expires_at ||\n null;\n\nconst employeeName =\n insertedApproval?.target_employee_name ||\n insertedApproval?.employee_name ||\n buildContext.target_employee_name ||\n buildContext.employee_name ||\n buildContext.resolved_employee_name ||\n 'empleado indicado';\n\nconst actionName = getActionName(buildContext, insertedApproval);\n\nconst readableExpiresAt = formatDateForDR(expiresAt);\n\nconst responseText =\n `Tu solicitud fue enviada a Máximo para aprobación.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Expira: ${readableExpiresAt}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR todavía.\\n\\n` +\n `Te avisaré automáticamente cuando Máximo apruebe o rechace la solicitud.`;\n\nreturn {\n json: {\n ...buildContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n maximo_approval_status: 'pending_approval_created',\n approval_pending_message_clean: true,\n\n data: {\n ...(buildContext.data || {}),\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n maximo_approval_status: 'pending_approval_created'\n },\n\n audit_event: {\n ...(buildContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n maximo_approval_status: 'pending_approval_created',\n approval_pending_message_clean: true,\n\n requires_confirmation: false,\n confirmation_required: false,\n pending_confirmation_should_save: false,\n suppress_user_confirmation_prompt: true,\n confirmation_status: 'not_required_pending_maximo_approval',\n\n execution_result: 'pending_maximo_approval'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7536,
|
||
-2816
|
||
],
|
||
"id": "9e478fe8-1785-4b92-9329-34fbe14784d5",
|
||
"name": "Code - Format Maximo Approval Pending Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,role,is_active,can_receive_approvals,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent('mgomez@gomezleemarketing.com') + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-7712,
|
||
-3104
|
||
],
|
||
"id": "1d3d89fc-ab65-443e-9056-63208876b673",
|
||
"name": "HTTP - Get Maximo Chat Contact"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buildContext = $items('Code - Build Maximo Approval Record')[0].json;\nconst insertResponse = $items('HTTP - Insert Pending Maximo Approval')[0].json;\nconst contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction escapeHtml(value) {\n return cleanText(value)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanRequestType(context, insertedApproval) {\n const rawAction =\n insertedApproval?.action_type ||\n insertedApproval?.approval_title ||\n context.supabase_pending_approval_row?.action_type ||\n context.supabase_pending_approval_row?.approval_title ||\n context.sensitive_approval_action_type ||\n context.action?.type ||\n context.intent ||\n '';\n\n const originalMessage =\n insertedApproval?.original_message ||\n context.supabase_pending_approval_row?.original_message ||\n context.original_message ||\n context.message ||\n '';\n\n const combined = normalize(`${rawAction} ${originalMessage}`);\n\n if (\n combined.includes('salario') ||\n combined.includes('sueldo') ||\n combined.includes('compensacion') ||\n combined.includes('compensation') ||\n combined.includes('salary')\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n combined.includes('desvincular') ||\n combined.includes('terminar') ||\n combined.includes('termination') ||\n combined.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n combined.includes('archivo') ||\n combined.includes('documento') ||\n combined.includes('file') ||\n combined.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n return 'Acción sensible';\n}\n\nconst insertedApproval = getFirstRow(insertResponse);\nconst maximoContact = getFirstRow(contactResponse);\n\nconst approvalId =\n insertedApproval?.approval_id ||\n buildContext.approval_id;\n\nconst expiresAt =\n insertedApproval?.expires_at ||\n buildContext.approval_expires_at;\n\nconst readableExpiresAt = formatDateForDR(expiresAt);\n\nconst requestType = getHumanRequestType(buildContext, insertedApproval);\n\nconst requesterName =\n insertedApproval?.requested_by_name ||\n buildContext.supabase_pending_approval_row?.requested_by_name ||\n buildContext.user_name ||\n 'Usuario no disponible';\n\nconst requesterEmail =\n insertedApproval?.requested_by_email ||\n buildContext.supabase_pending_approval_row?.requested_by_email ||\n buildContext.user_email ||\n 'Correo no disponible';\n\nconst originalMessage =\n insertedApproval?.original_message ||\n buildContext.supabase_pending_approval_row?.original_message ||\n buildContext.original_message ||\n buildContext.message ||\n 'No disponible';\n\nconst maximoSpaceName =\n maximoContact?.google_chat_space_name ||\n null;\n\nconst canReceiveApprovals =\n maximoContact?.can_receive_approvals === true &&\n cleanText(maximoSpaceName) !== '';\n\nconst cardId =\n `maximo_approval_${String(approvalId || 'sin_id').replace(/[^a-zA-Z0-9_]/g, '_')}`;\n\nconst googleChatCardBody = {\n text: 'Solicitud sensible pendiente de aprobación.',\n cardsV2: [\n {\n cardId,\n card: {\n header: {\n title: 'Solicitud sensible pendiente de aprobación',\n subtitle: 'GLM BambooHR Agent'\n },\n sections: [\n {\n widgets: [\n {\n decoratedText: {\n topLabel: 'Tipo de solicitud',\n text: escapeHtml(requestType),\n wrapText: true\n }\n },\n {\n decoratedText: {\n topLabel: 'Solicitante',\n text: `${escapeHtml(requesterName)} <${escapeHtml(requesterEmail)}>`,\n wrapText: true\n }\n },\n {\n decoratedText: {\n topLabel: 'Expira',\n text: escapeHtml(readableExpiresAt),\n wrapText: true\n }\n },\n {\n textParagraph: {\n text:\n `<b>Mensaje original</b><br>` +\n `${escapeHtml(originalMessage).replace(/\\n/g, '<br>')}`\n }\n },\n {\n textParagraph: {\n text:\n `<i>No se ejecutará ningún cambio en BambooHR hasta que apruebes.</i><br>` +\n `<i>Esta solicitud vence automáticamente en 6 horas.</i>`\n }\n },\n {\n buttonList: {\n buttons: [\n {\n text: '✅ Aprobar solicitud',\n onClick: {\n action: {\n function: 'maximo_approval_decision',\n parameters: [\n {\n key: 'approval_id',\n value: approvalId\n },\n {\n key: 'decision',\n value: 'approve'\n }\n ]\n }\n }\n },\n {\n text: '❌ Rechazar solicitud',\n onClick: {\n action: {\n function: 'maximo_approval_decision',\n parameters: [\n {\n key: 'approval_id',\n value: approvalId\n },\n {\n key: 'decision',\n value: 'reject'\n }\n ]\n }\n }\n }\n ]\n }\n }\n ]\n }\n ]\n }\n }\n ]\n};\n\nreturn {\n json: {\n ...buildContext,\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n request_type_human: requestType,\n\n maximo_chat_contact_found: !!maximoContact,\n maximo_chat_notification_ready: canReceiveApprovals,\n maximo_chat_space_name: maximoSpaceName,\n\n maximo_chat_notification_text:\n `Solicitud sensible pendiente de aprobación.\\n\\n` +\n `Máximo, revisa la tarjeta para aprobar o rechazar.`,\n\n google_chat_message_body: googleChatCardBody,\n\n audit_event: {\n ...(buildContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'pending',\n approval_expires_at: expiresAt,\n approval_expires_at_readable: readableExpiresAt,\n\n request_type_human: requestType,\n\n maximo_chat_contact_found: !!maximoContact,\n maximo_chat_notification_ready: canReceiveApprovals,\n maximo_chat_space_name: maximoSpaceName,\n maximo_notification_status: canReceiveApprovals\n ? 'ready_to_send_card'\n : 'missing_maximo_chat_space'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-7504,
|
||
-3104
|
||
],
|
||
"id": "5606a01e-32b2-4947-9c98-6654fe456347",
|
||
"name": "Code - Build Maximo Chat Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "5169a929-574d-4604-931c-43f7f4dfbcd8",
|
||
"leftValue": "={{ $json.maximo_chat_notification_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-7296,
|
||
-3104
|
||
],
|
||
"id": "d4ce2dbb-fa57-478c-bc57-c67e0e98ce15",
|
||
"name": "IF - Maximo Chat Space Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getOriginalMessage(context) {\n return cleanText(\n context.original_message ||\n context.message ||\n context.text ||\n context.user_message ||\n context.data?.original_message ||\n context.data?.message ||\n context.body?.message?.text ||\n context.body?.message?.argumentText ||\n ''\n );\n}\n\nfunction extractApprovalId(text) {\n const match = String(text || '').match(/\\bAPR-\\d{14}-[A-Z0-9]{6}\\b/i);\n\n return match ? match[0].toUpperCase() : null;\n}\n\nconst originalMessage = getOriginalMessage(data);\nconst normalizedMessage = normalize(originalMessage);\n\nconst approvalId = extractApprovalId(originalMessage);\n\nconst isApproveCommand =\n normalizedMessage.startsWith('aprobar') ||\n normalizedMessage.startsWith('approve') ||\n normalizedMessage.startsWith('aprobado');\n\nconst isRejectCommand =\n normalizedMessage.startsWith('rechazar') ||\n normalizedMessage.startsWith('reject') ||\n normalizedMessage.startsWith('rechazado') ||\n normalizedMessage.startsWith('denegar') ||\n normalizedMessage.startsWith('deny');\n\nconst isApprovalCommand =\n isApproveCommand ||\n isRejectCommand;\n\nlet approvalCommand = null;\n\nif (isApproveCommand) {\n approvalCommand = 'approve';\n}\n\nif (isRejectCommand) {\n approvalCommand = 'reject';\n}\n\nif (isApprovalCommand && !approvalId) {\n return {\n json: {\n ...data,\n\n is_approval_command: true,\n approval_command_valid: false,\n approval_command: approvalCommand,\n approval_id: null,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text:\n `Detecté que quieres ${approvalCommand === 'approve' ? 'aprobar' : 'rechazar'} una solicitud, pero falta el ID de aprobación.\\n\\n` +\n `Usa este formato:\\n` +\n `${approvalCommand === 'approve' ? 'APROBAR' : 'RECHAZAR'} APR-YYYYMMDDHHMMSS-XXXXXX`,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n is_approval_command: true,\n approval_command_valid: false,\n approval_command: approvalCommand,\n approval_id: null,\n execution_result: 'approval_command_missing_approval_id'\n }\n }\n };\n}\n\nreturn {\n json: {\n ...data,\n\n is_approval_command: isApprovalCommand,\n approval_command_valid: isApprovalCommand ? true : null,\n approval_command: approvalCommand,\n approval_id: approvalId,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n is_approval_command: isApprovalCommand,\n approval_command_valid: isApprovalCommand ? true : null,\n approval_command: approvalCommand,\n approval_id: approvalId\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12000,
|
||
-2576
|
||
],
|
||
"id": "b035b321-5407-4e17-88d7-31b5a0868092",
|
||
"name": "Code - Detect Approval Command"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c45ec1e8-a19c-4327-80c2-85d5653f86f2",
|
||
"leftValue": "={{ $json.is_approval_command }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-11792,
|
||
-2576
|
||
],
|
||
"id": "6d150e82-0d1d-498b-8ee0-bbdfe06785d2",
|
||
"name": "IF - Is Approval Command?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c53dac63-614f-4a9f-8d17-8e861ebfa40a",
|
||
"leftValue": "={{ $json.approval_command_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-11536,
|
||
-2736
|
||
],
|
||
"id": "0f227f13-e900-4d06-b1b5-23357b94bf6b",
|
||
"name": "IF - Approval Command Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.{{ $json.approval_id }}&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-11200,
|
||
-2912
|
||
],
|
||
"id": "57fb0092-0cac-4c32-83fa-3355a655f8f7",
|
||
"name": "HTTP - Get Pending Approval By ID",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const commandContext = $items('Code - Detect Approval Command')[0].json;\nconst response = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst approval = getFirstRow(response);\n\nconst approvalId = commandContext.approval_id;\nconst decision = commandContext.approval_command;\n\nconst userEmail =\n commandContext.user_email ||\n commandContext.audit_event?.user_email ||\n commandContext.data?.user_email ||\n null;\n\nconst userName =\n commandContext.user_name ||\n commandContext.audit_event?.user_name ||\n commandContext.data?.user_name ||\n userEmail ||\n 'Usuario no disponible';\n\nif (!approval) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_not_found',\n\n response_type: 'chat_message',\n response_text:\n `No encontré una solicitud pendiente con el ID:\\n${approvalId}\\n\\n` +\n `Verifica el ID e inténtalo de nuevo.`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n execution_result: 'approval_not_found'\n }\n }\n };\n}\n\nconst expectedApproverEmail = normalize(approval.approver_email);\nconst actualUserEmail = normalize(userEmail);\n\nif (expectedApproverEmail && expectedApproverEmail !== actualUserEmail) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approver_mismatch',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `No tienes permiso para decidir esta aprobación.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Aprobador esperado: ${approval.approver_name || approval.approver_email}\\n` +\n `Usuario actual: ${userName} <${userEmail || 'correo no disponible'}>`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n expected_approver_email: approval.approver_email,\n actual_user_email: userEmail,\n execution_result: 'approval_decision_blocked_approver_mismatch'\n }\n }\n };\n}\n\nif (approval.approval_status !== 'pending') {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_not_pending',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `Esta solicitud ya no está pendiente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado actual: ${approval.approval_status}`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n approval_status: approval.approval_status,\n execution_result: 'approval_decision_blocked_not_pending'\n }\n }\n };\n}\n\nconst now = new Date();\nconst expiresAt = new Date(approval.expires_at);\n\nif (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {\n return {\n json: {\n ...commandContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: 'approval_expired',\n\n approval_record: approval,\n\n response_type: 'chat_message',\n response_text:\n `Esta solicitud de aprobación ya venció.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Venció: ${formatDateForDR(approval.expires_at)}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n approval_status: approval.approval_status,\n expires_at: approval.expires_at,\n execution_result: 'approval_decision_blocked_expired'\n }\n }\n };\n}\n\nconst nextStatus = decision === 'approve'\n ? 'approved'\n : 'rejected';\n\nconst decisionAt = new Date().toISOString();\n\nconst updatePayload = decision === 'approve'\n ? {\n approval_status: 'approved',\n approved_at: decisionAt,\n approved_by_email: userEmail,\n approved_by_name: userName,\n execution_result: 'approved_waiting_execution'\n }\n : {\n approval_status: 'rejected',\n rejected_at: decisionAt,\n rejected_by_email: userEmail,\n rejected_by_name: userName,\n rejection_reason: 'Rechazado desde Google Chat',\n execution_result: 'rejected_by_approver'\n };\n\nreturn {\n json: {\n ...commandContext,\n\n can_continue: true,\n\n approval_decision_valid: true,\n approval_decision: decision,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n\n approval_record: approval,\n approval_update_payload: updatePayload,\n\n response_type: 'chat_message',\n\n audit_event: {\n ...(commandContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: true,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n execution_result: `approval_${nextStatus}_validated`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10992,
|
||
-2912
|
||
],
|
||
"id": "039fb415-91a4-48ea-ba57-c623c2083535",
|
||
"name": "Code - Validate Approval Decision"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "62a5b47d-12f1-42ee-a393-ca12ef9db2fb",
|
||
"leftValue": "={{ $json.approval_decision_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-10784,
|
||
-2912
|
||
],
|
||
"id": "d811c9f8-a840-4732-8d75-11f429b9336e",
|
||
"name": "IF - Approval Decision Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.{{ $json.approval_id }}&limit=1",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Prefer",
|
||
"value": "return=representation"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.approval_update_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-10400,
|
||
-3008
|
||
],
|
||
"id": "2b4e5e24-6bc0-4454-82bd-6e14d156a273",
|
||
"name": "HTTP - Update Approval Decision",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const decisionContext = $items('Code - Validate Approval Decision')[0].json;\nconst updateResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst updatedApproval = getFirstRow(updateResponse) || decisionContext.approval_record;\n\nconst approvalId =\n updatedApproval?.approval_id ||\n decisionContext.approval_id;\n\nconst status =\n updatedApproval?.approval_status ||\n decisionContext.approval_next_status;\n\nconst actionName =\n updatedApproval?.approval_title ||\n decisionContext.approval_record?.approval_title ||\n 'Acción sensible';\n\nconst requesterName =\n updatedApproval?.requested_by_name ||\n decisionContext.approval_record?.requested_by_name ||\n 'Solicitante no disponible';\n\nconst employeeName =\n updatedApproval?.target_employee_name ||\n decisionContext.approval_record?.target_employee_name ||\n 'No resuelto todavía';\n\nconst employeeId =\n updatedApproval?.target_employee_id ||\n decisionContext.approval_record?.target_employee_id ||\n null;\n\nconst approvedAtReadable = formatDateForDR(\n updatedApproval?.approved_at ||\n updatedApproval?.rejected_at ||\n decisionContext.approval_decision_at\n);\n\nlet responseText = '';\n\nif (status === 'approved') {\n responseText =\n `Solicitud aprobada correctamente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado: approved\\n` +\n `Acción: ${actionName}\\n` +\n `Empleado: ${employeeName}${employeeId ? ` (ID ${employeeId})` : ''}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Aprobada: ${approvedAtReadable}\\n\\n` +\n `Importante: en este momento la solicitud quedó aprobada, pero todavía no hemos conectado la ejecución automática del cambio sensible.`;\n} else {\n responseText =\n `Solicitud rechazada correctamente.\\n\\n` +\n `ID de aprobación: ${approvalId}\\n` +\n `Estado: rejected\\n` +\n `Acción: ${actionName}\\n` +\n `Empleado: ${employeeName}${employeeId ? ` (ID ${employeeId})` : ''}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Rechazada: ${approvedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approval_id: approvalId,\n approval_status: status,\n approval_updated: true,\n approval_update_response: updateResponse,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: status,\n approval_updated: true,\n execution_result: status === 'approved'\n ? 'approval_approved_waiting_execution'\n : 'approval_rejected_no_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-10192,
|
||
-3008
|
||
],
|
||
"id": "6523a2b2-b3d0-4616-9c21-8637fdf76ff8",
|
||
"name": "Code - Format Approval Decision Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.maximo_chat_space_name }}",
|
||
"jsonParameters": true,
|
||
"messageJson": "={{ $json.google_chat_message_body }}",
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-6912,
|
||
-3200
|
||
],
|
||
"id": "35146802-016a-4d1d-a402-4e4511f1f7d3",
|
||
"name": "Chat - Enviar notificación aprobación Máximo",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const input = $json;\nconst body = input.body || input;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parametersToObject(value) {\n if (!value) return {};\n\n if (Array.isArray(value)) {\n const obj = {};\n\n for (const item of value) {\n const key = item.key || item.name;\n const val =\n item.value ||\n item.stringValue ||\n item.textValue ||\n item.intValue ||\n item.boolValue;\n\n if (key) {\n obj[key] = val;\n }\n }\n\n return obj;\n }\n\n if (typeof value === 'object') {\n return value;\n }\n\n return {};\n}\n\nfunction getActionParameters(eventBody) {\n return {\n ...parametersToObject(eventBody.common?.parameters),\n ...parametersToObject(eventBody.action?.parameters),\n ...parametersToObject(eventBody.action?.actionParameters),\n ...parametersToObject(eventBody.common?.formInputs),\n ...parametersToObject(eventBody.action?.formInputs)\n };\n}\n\nfunction extractApprovalId(text) {\n const match = String(text || '').match(/\\bAPR-\\d{14}-[A-Z0-9]{6}\\b/i);\n return match ? match[0].toUpperCase() : null;\n}\n\nfunction normalizeDecision(value) {\n const normalized = normalize(value);\n\n if (\n normalized === 'approve' ||\n normalized === 'approved' ||\n normalized === 'aprobar' ||\n normalized === 'aprobado'\n ) {\n return 'approve';\n }\n\n if (\n normalized === 'reject' ||\n normalized === 'rejected' ||\n normalized === 'rechazar' ||\n normalized === 'rechazado' ||\n normalized === 'denegar'\n ) {\n return 'reject';\n }\n\n return null;\n}\n\nconst eventType =\n body.type ||\n input.type ||\n null;\n\nconst actionParameters = getActionParameters(body);\n\nconst invokedFunction =\n body.common?.invokedFunction ||\n body.action?.function ||\n body.action?.actionMethodName ||\n body.action?.actionMethod ||\n null;\n\nlet approvalId =\n actionParameters.approval_id ||\n actionParameters.approvalId ||\n null;\n\nlet approvalDecision =\n normalizeDecision(\n actionParameters.decision ||\n actionParameters.approval_decision ||\n null\n );\n\nconst messageText =\n cleanText(\n body.message?.text ||\n body.message?.argumentText ||\n body.message?.formattedText ||\n ''\n );\n\nconst normalizedMessage = normalize(messageText);\n\nconst isCardClicked =\n eventType === 'CARD_CLICKED';\n\nconst isApprovalFunction =\n invokedFunction === 'maximo_approval_decision' ||\n cleanText(actionParameters.approval_id) !== '';\n\n// Fallback por texto manual: APROBAR APR-...\nconst textApprovalId = extractApprovalId(messageText);\n\nconst isApproveTextCommand =\n normalizedMessage.startsWith('aprobar') ||\n normalizedMessage.startsWith('approve') ||\n normalizedMessage.startsWith('aprobado');\n\nconst isRejectTextCommand =\n normalizedMessage.startsWith('rechazar') ||\n normalizedMessage.startsWith('reject') ||\n normalizedMessage.startsWith('rechazado') ||\n normalizedMessage.startsWith('denegar');\n\nlet isApprovalButtonEvent =\n isCardClicked &&\n isApprovalFunction;\n\nif (!isApprovalButtonEvent && (isApproveTextCommand || isRejectTextCommand)) {\n isApprovalButtonEvent = true;\n approvalId = textApprovalId;\n approvalDecision = isApproveTextCommand ? 'approve' : 'reject';\n}\n\nconst userEmail =\n body.user?.email ||\n body.message?.sender?.email ||\n null;\n\nconst userName =\n body.user?.displayName ||\n body.message?.sender?.displayName ||\n userEmail ||\n 'Usuario no disponible';\n\nconst finalSpaceName =\n body.space?.name ||\n body.message?.space?.name ||\n null;\n\nconst finalThreadName =\n body.thread?.name ||\n body.message?.thread?.name ||\n null;\n\nconst requestId =\n `approval_click_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n\nif (!isApprovalButtonEvent) {\n return {\n json: {\n ...input,\n\n is_approval_button_event: false,\n approval_decision_event_valid: null,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters\n }\n };\n}\n\nif (!approvalId || !approvalDecision) {\n return {\n json: {\n ...input,\n\n request_id: requestId,\n\n is_approval_button_event: true,\n approval_decision_event_valid: false,\n\n approval_id: approvalId || null,\n approval_decision: approvalDecision || null,\n\n user_email: userEmail,\n user_name: userName,\n\n final_space_name: finalSpaceName,\n final_thread_name: finalThreadName,\n\n response_type: 'chat_message',\n response_text:\n `No pude procesar la decisión de aprobación.\\n\\n` +\n `Falta el ID de aprobación o la decisión del botón.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters\n }\n };\n}\n\nreturn {\n json: {\n ...input,\n\n request_id: requestId,\n\n is_approval_button_event: true,\n approval_decision_event_valid: true,\n\n approval_id: approvalId,\n approval_decision: approvalDecision,\n\n user_email: userEmail,\n user_name: userName,\n\n final_space_name: finalSpaceName,\n final_thread_name: finalThreadName,\n\n google_chat_event_type: eventType,\n google_chat_invoked_function: invokedFunction,\n google_chat_action_parameters: actionParameters,\n\n response_type: 'approval_decision'\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-21600,
|
||
-2880
|
||
],
|
||
"id": "ef9025d5-315d-421c-b5d8-abf4ce17dfae",
|
||
"name": "Code - Detect Approval Button Event"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "cf3bc951-d0a1-4fd7-b2bb-5f5265dd8b2d",
|
||
"leftValue": "={{ $json.is_approval_button_event }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-21392,
|
||
-2880
|
||
],
|
||
"id": "0fc91f75-294a-4e67-af1d-41fbbf90b081",
|
||
"name": "IF - Is Approval Button Event?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d14b8a65-7635-4be2-b935-2dfd290ae6ea",
|
||
"leftValue": "={{ $json.approval_decision_event_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-21072,
|
||
-3040
|
||
],
|
||
"id": "df4922ce-f5fb-4f15-b241-7bfd99eaca8b",
|
||
"name": "IF - Approval Button Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?select=*&approval_id=eq.' + encodeURIComponent($json.approval_id) + '&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-20720,
|
||
-3152
|
||
],
|
||
"id": "cbe220e6-26b5-462e-a1d9-b61daffbfc3f",
|
||
"name": "HTTP - Get Approval By Button ID"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const buttonContext = $items('Code - Detect Approval Button Event')[0].json;\nconst response = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nconst approval = getFirstRow(response);\n\nconst approvalId = buttonContext.approval_id;\nconst decision = buttonContext.approval_decision;\n\nconst userEmail = buttonContext.user_email || null;\nconst userName = buttonContext.user_name || userEmail || 'Usuario no disponible';\n\nfunction blocked(message, result, extra = {}) {\n return {\n json: {\n ...buttonContext,\n\n can_continue: false,\n approval_decision_valid: false,\n approval_decision_error: result,\n\n approval_record: approval || null,\n\n response_type: 'chat_message',\n response_text: message,\n\n audit_event: {\n ...(buttonContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: false,\n execution_result: result,\n ...extra\n }\n }\n };\n}\n\nif (!approval) {\n return blocked(\n `No encontré una solicitud pendiente con este ID.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_not_found'\n );\n}\n\nconst expectedApproverEmail = normalize(approval.approver_email);\nconst actualUserEmail = normalize(userEmail);\n\nif (expectedApproverEmail && expectedApproverEmail !== actualUserEmail) {\n return blocked(\n `No tienes permiso para decidir esta aprobación.\\n\\n` +\n `Aprobador esperado: ${approval.approver_name || approval.approver_email}\\n` +\n `Usuario actual: ${userName} <${userEmail || 'correo no disponible'}>\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_decision_blocked_approver_mismatch',\n {\n expected_approver_email: approval.approver_email,\n actual_user_email: userEmail\n }\n );\n}\n\nif (approval.approval_status !== 'pending') {\n return blocked(\n `Esta solicitud ya no está pendiente.\\n\\n` +\n `Estado actual: ${approval.approval_status}\\n\\n` +\n `No se ejecutó ningún cambio adicional en BambooHR.`,\n 'approval_decision_blocked_not_pending',\n {\n approval_status: approval.approval_status\n }\n );\n}\n\nconst now = new Date();\nconst expiresAt = new Date(approval.expires_at);\n\nif (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {\n return blocked(\n `Esta solicitud de aprobación ya venció.\\n\\n` +\n `Venció: ${formatDateForDR(approval.expires_at)}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`,\n 'approval_decision_blocked_expired',\n {\n expires_at: approval.expires_at\n }\n );\n}\n\nconst nextStatus = decision === 'approve'\n ? 'approved'\n : 'rejected';\n\nconst decisionAt = new Date().toISOString();\n\nconst updatePayload = decision === 'approve'\n ? {\n approval_status: 'approved',\n approved_at: decisionAt,\n approved_by_email: userEmail,\n approved_by_name: userName,\n execution_result: 'approved_waiting_execution'\n }\n : {\n approval_status: 'rejected',\n rejected_at: decisionAt,\n rejected_by_email: userEmail,\n rejected_by_name: userName,\n rejection_reason: 'Rechazado desde botón de Google Chat',\n execution_result: 'rejected_by_approver'\n };\n\nreturn {\n json: {\n ...buttonContext,\n\n can_continue: true,\n\n approval_decision_valid: true,\n approval_decision: decision,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n\n approval_record: approval,\n approval_update_payload: updatePayload,\n\n response_type: 'chat_message',\n\n audit_event: {\n ...(buttonContext.audit_event || {}),\n\n approval_id: approvalId,\n approval_decision: decision,\n approval_decision_valid: true,\n approval_next_status: nextStatus,\n approval_decision_at: decisionAt,\n execution_result: `approval_${nextStatus}_validated`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-20368,
|
||
-3152
|
||
],
|
||
"id": "54bd1ef8-dad0-4383-876b-566c4e523f1a",
|
||
"name": "Code - Validate Approval Button Decision"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "279fa4a4-9253-485c-8f3f-c020ac2a8413",
|
||
"leftValue": "={{ $json.approval_decision_valid }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-20160,
|
||
-3184
|
||
],
|
||
"id": "f3317ffe-917c-4029-9684-3d0a1b2e09e9",
|
||
"name": "IF - Approval Button Decision Valid?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"name": "Prefer",
|
||
"value": "return=representation"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ $json.approval_update_payload }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-19776,
|
||
-3808
|
||
],
|
||
"id": "a0fea6e6-2885-4b0e-83aa-7250582b9472",
|
||
"name": "HTTP - Update Approval From Button"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const decisionContext = $items('Code - Validate Approval Button Decision')[0].json;\nconst updateResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanActionName(approval, context) {\n const rawText = normalize(\n approval?.approval_title ||\n approval?.action_type ||\n approval?.original_message ||\n context?.approval_record?.approval_title ||\n context?.approval_record?.action_type ||\n context?.approval_record?.original_message ||\n context?.original_message ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context?.approval_record?.proposed_action ||\n context?.proposed_action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n const proposedType = normalize(proposedAction?.type);\n\n if (\n rawText.includes('salario') ||\n rawText.includes('sueldo') ||\n rawText.includes('compensacion') ||\n rawText.includes('compensation') ||\n rawText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n rawText.includes('desvincular') ||\n rawText.includes('desvinculacion') ||\n rawText.includes('terminar') ||\n rawText.includes('termination') ||\n rawText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n rawText.includes('archivo') ||\n rawText.includes('documento') ||\n rawText.includes('file') ||\n rawText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (proposedType === 'add_row' || rawText.includes('add_row')) {\n return 'Actualización de historial del empleado';\n }\n\n if (proposedType === 'update_field' || rawText.includes('update_field')) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst updatedApproval = getFirstRow(updateResponse);\n\nif (!updatedApproval || !updatedApproval.approval_status) {\n const responseText =\n `Recibí la decisión, pero no pude confirmar la actualización en Supabase.\\n\\n` +\n `ID de aprobación: ${decisionContext.approval_id || 'No disponible'}\\n` +\n `Decisión recibida: ${decisionContext.approval_decision || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n\\n` +\n `Revisa el nodo HTTP - Update Approval From Button.`;\n\n return {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n webhook_response_body: {\n text: responseText\n },\n\n approval_updated: false,\n approval_update_response: updateResponse,\n\n final_space_name: decisionContext.final_space_name,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: decisionContext.approval_id || null,\n approval_updated: false,\n execution_result: 'approval_update_failed_no_returned_row'\n }\n }\n };\n}\n\nconst status = updatedApproval.approval_status;\n\nconst actionName = getHumanActionName(updatedApproval, decisionContext);\n\nconst requesterName =\n updatedApproval.requested_by_name ||\n decisionContext.approval_record?.requested_by_name ||\n 'Solicitante no disponible';\n\nconst decidedAtReadable = formatDateForDR(\n updatedApproval.approved_at ||\n updatedApproval.rejected_at ||\n decisionContext.approval_decision_at\n);\n\nlet responseText = '';\n\nif (status === 'approved') {\n responseText =\n `Solicitud aprobada correctamente.\\n\\n` +\n `Acción: ${actionName}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Aprobada: ${decidedAtReadable}\\n\\n` +\n `La solicitud quedó aprobada y pendiente de ejecución automática.`;\n} else if (status === 'rejected') {\n responseText =\n `Solicitud rechazada correctamente.\\n\\n` +\n `Acción: ${actionName}\\n` +\n `Solicitante: ${requesterName}\\n` +\n `Rechazada: ${decidedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n responseText =\n `La solicitud fue procesada, pero quedó con estado inesperado.\\n\\n` +\n `Estado actual: ${status}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...decisionContext,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n webhook_response_body: {\n text: responseText\n },\n\n approval_id: updatedApproval.approval_id,\n approval_status: status,\n approval_updated: true,\n approval_update_response: updateResponse,\n\n final_space_name: decisionContext.final_space_name,\n\n audit_event: {\n ...(decisionContext.audit_event || {}),\n\n approval_id: updatedApproval.approval_id,\n approval_status: status,\n approval_updated: true,\n execution_result: status === 'approved'\n ? 'approval_approved_waiting_execution'\n : 'approval_rejected_no_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-19504,
|
||
-3696
|
||
],
|
||
"id": "c7902820-7b11-4117-bbb4-392198bb2dc5",
|
||
"name": "Code - Format Approval Button Response"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={{ $json.webhook_response_body || { text: $json.response_text || 'Solicitud procesada.' } }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.5,
|
||
"position": [
|
||
-17344,
|
||
-6016
|
||
],
|
||
"id": "595e726e-ed27-4a86-aa34-abe3e364df96",
|
||
"name": "Respond - Approval Button Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getHumanActionName(approval, context) {\n const rawText = normalize(\n approval?.approval_title ||\n approval?.action_type ||\n approval?.original_message ||\n context?.approval_record?.approval_title ||\n context?.approval_record?.action_type ||\n context?.approval_record?.original_message ||\n context?.original_message ||\n ''\n );\n\n const proposedAction =\n approval?.proposed_action ||\n context?.approval_record?.proposed_action ||\n context?.proposed_action ||\n {};\n\n const proposedTable = normalize(proposedAction?.table);\n const proposedType = normalize(proposedAction?.type);\n\n if (\n rawText.includes('salario') ||\n rawText.includes('sueldo') ||\n rawText.includes('compensacion') ||\n rawText.includes('compensation') ||\n rawText.includes('salary') ||\n proposedTable === 'compensation'\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n rawText.includes('desvincular') ||\n rawText.includes('desvinculacion') ||\n rawText.includes('terminar') ||\n rawText.includes('termination') ||\n rawText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n rawText.includes('archivo') ||\n rawText.includes('documento') ||\n rawText.includes('file') ||\n rawText.includes('document')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (proposedType === 'add_row' || rawText.includes('add_row')) {\n return 'Actualización de historial del empleado';\n }\n\n if (proposedType === 'update_field' || rawText.includes('update_field')) {\n return 'Actualización de dato del empleado';\n }\n\n return 'Acción sensible';\n}\n\nconst updatedApproval =\n getFirstRow(data.approval_update_response) ||\n data.approval_record ||\n data;\n\nconst status =\n data.approval_status ||\n updatedApproval?.approval_status ||\n null;\n\nconst requesterEmail =\n updatedApproval?.requested_by_email ||\n data.approval_record?.requested_by_email ||\n data.requested_by_email ||\n null;\n\nconst requesterName =\n updatedApproval?.requested_by_name ||\n data.approval_record?.requested_by_name ||\n data.requested_by_name ||\n requesterEmail ||\n 'Solicitante';\n\nconst deciderEmail =\n updatedApproval?.approved_by_email ||\n updatedApproval?.rejected_by_email ||\n data.user_email ||\n null;\n\nconst deciderName =\n updatedApproval?.approved_by_name ||\n updatedApproval?.rejected_by_name ||\n data.user_name ||\n 'Máximo';\n\nconst employeeName =\n updatedApproval?.target_employee_name ||\n updatedApproval?.employee_name ||\n data.approval_record?.target_employee_name ||\n data.approval_record?.employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n 'empleado indicado';\n\nconst decisionAt =\n updatedApproval?.approved_at ||\n updatedApproval?.rejected_at ||\n data.approval_decision_at ||\n null;\n\nconst readableDecisionAt = formatDateForDR(decisionAt);\n\nconst actionName = getHumanActionName(updatedApproval, data);\n\nconst originalMessage =\n updatedApproval?.original_message ||\n data.approval_record?.original_message ||\n data.original_message ||\n null;\n\nconst approvalId =\n updatedApproval?.approval_id ||\n data.approval_id ||\n null;\n\nconst shouldNotifyRequester =\n !!requesterEmail &&\n ['approved', 'rejected'].includes(status) &&\n normalize(requesterEmail) !== normalize(deciderEmail);\n\nlet requesterNotificationText = '';\n\nif (status === 'approved') {\n requesterNotificationText =\n `✅ Tu solicitud fue aprobada por Máximo.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Aprobada: ${readableDecisionAt}\\n\\n` +\n `Estado: pendiente de ejecución automática.\\n\\n` +\n `No necesitas escribirle a Máximo por WhatsApp. El agente continuará con el proceso.`;\n} else if (status === 'rejected') {\n requesterNotificationText =\n `❌ Tu solicitud fue rechazada por Máximo.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Rechazada: ${readableDecisionAt}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n requesterNotificationText =\n `La solicitud fue procesada, pero quedó con un estado inesperado.\\n\\n` +\n `Estado actual: ${status || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...data,\n\n approval_id: approvalId,\n approval_status: status,\n\n approval_requester_email: requesterEmail,\n approval_requester_name: requesterName,\n\n approval_decider_email: deciderEmail,\n approval_decider_name: deciderName,\n\n approval_employee_name: employeeName,\n approval_action_name: actionName,\n approval_original_message: originalMessage,\n\n should_notify_requester: shouldNotifyRequester,\n requester_notification_text: requesterNotificationText,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: status,\n\n approval_requester_email: requesterEmail,\n approval_requester_name: requesterName,\n\n approval_decider_email: deciderEmail,\n approval_decider_name: deciderName,\n\n should_notify_requester: shouldNotifyRequester,\n requester_notification_prepared: true\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17600,
|
||
-6224
|
||
],
|
||
"id": "47e6d5c4-4354-447f-ae2c-7cde06ff2008",
|
||
"name": "Code - Prepare Requester Approval Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e0b38e3b-0a84-4011-87c5-9985d09af151",
|
||
"leftValue": "={{ $json.should_notify_requester }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-17392,
|
||
-6224
|
||
],
|
||
"id": "6b72019d-6b22-4ce1-806b-6fab4ba68b0f",
|
||
"name": "IF - Should Notify Requester?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,role,is_active,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent($json.approval_requester_email) + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17072,
|
||
-6352
|
||
],
|
||
"id": "9105ea28-b2a3-42d8-bdc6-b11ed7418e7a",
|
||
"name": "HTTP - Get Requester Chat Contact",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const notificationContext = $items('Code - Prepare Requester Approval Notification')[0].json;\nconst contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nconst requesterContact = getFirstRow(contactResponse);\n\nconst requesterChatSpaceName =\n requesterContact?.google_chat_space_name ||\n null;\n\nconst requesterChatAvailable =\n cleanText(requesterChatSpaceName) !== '';\n\nreturn {\n json: {\n ...notificationContext,\n\n requester_chat_contact_found: !!requesterContact,\n requester_chat_space_name: requesterChatSpaceName,\n requester_chat_available: requesterChatAvailable,\n\n requester_chat_notification_text:\n notificationContext.requester_notification_text,\n\n audit_event: {\n ...(notificationContext.audit_event || {}),\n\n requester_chat_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n requester_chat_space_name: requesterChatSpaceName\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16864,
|
||
-6352
|
||
],
|
||
"id": "55eadf4c-a42b-40c6-827e-8f641f5378c5",
|
||
"name": "Code - Build Requester Decision Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "4e80ca2e-d496-4ca1-92bf-66c86407cf04",
|
||
"leftValue": "={{ $json.requester_chat_available }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16656,
|
||
-6352
|
||
],
|
||
"id": "73e8dd34-d8e6-4318-90f9-0d056d53e962",
|
||
"name": "IF - Requester Chat Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"spaceId": "={{ $json.final_space_name || $json.requester_google_chat_space_name }}",
|
||
"messageUi": {
|
||
"text": "={{ $json.requester_chat_notification_text || $json.response_text || 'La solicitud fue actualizada.' }}"
|
||
},
|
||
"additionalFields": {}
|
||
},
|
||
"type": "n8n-nodes-base.googleChat",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
-11056,
|
||
-4656
|
||
],
|
||
"id": "c976153e-dd92-4eef-905e-4c96d5385c8f",
|
||
"name": "Chat - Notify Requester Approval Decision",
|
||
"webhookId": "5b27b6d1-6cba-47d4-9fc7-013774cadaa3",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "602a9028-459d-49fe-99d4-918a98bfe282",
|
||
"leftValue": "={{ $json.approval_status }}",
|
||
"rightValue": "=approved",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-19184,
|
||
-3392
|
||
],
|
||
"id": "2ac456fb-fb23-47ec-9bca-8152ebc4ec12",
|
||
"name": "IF - Maximo Approved?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction safeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction firstNonEmpty(...values) {\n for (const value of values) {\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction getHumanActionName(route, actionField, actionTable, originalMessage) {\n const joinedText = normalize([\n route,\n actionField,\n actionTable,\n originalMessage\n ].join(' '));\n\n if (\n route === 'compensation_update' ||\n joinedText.includes('compensation') ||\n joinedText.includes('salario') ||\n joinedText.includes('sueldo') ||\n joinedText.includes('compensacion') ||\n joinedText.includes('salary')\n ) {\n return 'Cambio de salario / compensación';\n }\n\n if (\n route === 'terminate_employee' ||\n joinedText.includes('desvincular') ||\n joinedText.includes('desvinculacion') ||\n joinedText.includes('terminar') ||\n joinedText.includes('termination') ||\n joinedText.includes('terminate')\n ) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (\n route === 'upload_employee_file' ||\n joinedText.includes('archivo') ||\n joinedText.includes('documento') ||\n joinedText.includes('file')\n ) {\n return 'Carga o modificación de documento';\n }\n\n if (route === 'add_table_row') {\n return 'Actualización de historial del empleado';\n }\n\n if (route === 'employee_basic_update') {\n return 'Actualización de dato básico del empleado';\n }\n\n return 'Acción sensible aprobada';\n}\n\nfunction getApprovedActionRoute(actionType, actionField, actionTable, actionPayload) {\n const normalizedType = normalize(actionType);\n const normalizedField = normalize(actionField);\n const normalizedTable = normalize(actionTable);\n const payloadText = normalize(JSON.stringify(actionPayload || {}));\n\n const basicEmployeeFields = [\n 'workemail',\n 'workphone',\n 'workphoneextension',\n 'jobtitle',\n 'department',\n 'division',\n 'location',\n 'supervisor',\n 'hiredate',\n 'employmentstatus'\n ];\n\n if (\n normalizedField === 'compensation' ||\n normalizedTable === 'compensation' ||\n payloadText.includes('compensation') ||\n payloadText.includes('salary') ||\n payloadText.includes('salario') ||\n payloadText.includes('sueldo')\n ) {\n return 'compensation_update';\n }\n\n if (\n normalizedType === 'terminate_employee' ||\n normalizedType === 'termination' ||\n normalizedType.includes('terminate')\n ) {\n return 'terminate_employee';\n }\n\n if (\n normalizedType === 'upload_employee_file' ||\n normalizedType === 'upload_file' ||\n normalizedType.includes('file')\n ) {\n return 'upload_employee_file';\n }\n\n if (\n normalizedType === 'add_row' ||\n normalizedType === 'add_table_row'\n ) {\n return 'add_table_row';\n }\n\n if (\n normalizedType === 'update_field' &&\n basicEmployeeFields.includes(normalizedField)\n ) {\n return 'employee_basic_update';\n }\n\n return 'unsupported_action';\n}\n\nconst updateResponse =\n getFirstRow(data.approval_update_response) ||\n {};\n\nconst approvalRecord =\n safeObject(data.approval_record);\n\nconst updatedApproval = {\n ...approvalRecord,\n ...safeObject(updateResponse)\n};\n\nconst pendingPayload =\n safeObject(updatedApproval.pending_payload) ||\n safeObject(approvalRecord.pending_payload) ||\n {};\n\nconst originalContext =\n safeObject(pendingPayload.original_context);\n\nconst originalData =\n safeObject(originalContext.data);\n\nconst proposedAction =\n safeObject(updatedApproval.proposed_action) ||\n safeObject(approvalRecord.proposed_action) ||\n safeObject(pendingPayload.proposed_action) ||\n safeObject(originalData.action) ||\n safeObject(originalContext.action) ||\n safeObject(data.action);\n\nconst proposedPayload =\n safeObject(proposedAction.payload) ||\n safeObject(updatedApproval.proposed_payload) ||\n safeObject(approvalRecord.proposed_payload) ||\n safeObject(pendingPayload.proposed_payload);\n\nconst actionType =\n firstNonEmpty(\n proposedAction.type,\n updatedApproval.action_type,\n approvalRecord.action_type,\n originalData.action?.type,\n originalContext.action?.type,\n data.action_type\n );\n\nconst actionField =\n firstNonEmpty(\n proposedAction.field,\n proposedPayload.field,\n originalData.action?.field,\n originalContext.action?.field\n );\n\nconst actionLabel =\n firstNonEmpty(\n proposedAction.label,\n proposedPayload.label,\n originalData.action?.label,\n originalContext.action?.label,\n actionField\n );\n\nconst actionValue =\n firstNonEmpty(\n proposedAction.value,\n proposedPayload.value,\n originalData.action?.value,\n originalContext.action?.value\n );\n\nconst actionTable =\n firstNonEmpty(\n proposedAction.table,\n proposedPayload.table,\n updatedApproval.target_table,\n approvalRecord.target_table,\n originalData.table_name,\n originalContext.table_name,\n normalize(actionField) === 'compensation' ? 'compensation' : null\n );\n\nconst approvalId =\n firstNonEmpty(\n updatedApproval.approval_id,\n approvalRecord.approval_id,\n data.approval_id\n );\n\nconst originalMessage =\n firstNonEmpty(\n updatedApproval.original_message,\n approvalRecord.original_message,\n originalContext.original_message,\n originalContext.message,\n originalData.original_message,\n data.original_message\n );\n\nconst employeeQuery =\n firstNonEmpty(\n updatedApproval.employee_query,\n approvalRecord.employee_query,\n originalData.employee_query,\n originalContext.employee_query,\n updatedApproval.target_employee_name,\n approvalRecord.target_employee_name,\n data.employee_query\n );\n\nconst targetEmployeeId =\n firstNonEmpty(\n updatedApproval.target_employee_id,\n approvalRecord.target_employee_id,\n originalData.employee_id,\n originalContext.employee_id,\n data.target_employee_id,\n data.employee_id\n );\n\nconst targetEmployeeName =\n firstNonEmpty(\n updatedApproval.target_employee_name,\n approvalRecord.target_employee_name,\n originalData.employee_name,\n originalContext.employee_name,\n data.target_employee_name,\n data.employee_name,\n employeeQuery,\n 'empleado indicado'\n );\n\nconst requesterEmail =\n firstNonEmpty(\n updatedApproval.requested_by_email,\n approvalRecord.requested_by_email,\n data.approval_requester_email,\n data.requester_email\n );\n\nconst requesterName =\n firstNonEmpty(\n updatedApproval.requested_by_name,\n approvalRecord.requested_by_name,\n data.approval_requester_name,\n data.requester_name,\n requesterEmail,\n 'Solicitante'\n );\n\nconst approvedByEmail =\n firstNonEmpty(\n updatedApproval.approved_by_email,\n data.approved_by_email,\n data.user_email\n );\n\nconst approvedByName =\n firstNonEmpty(\n updatedApproval.approved_by_name,\n data.approved_by_name,\n data.user_name,\n 'Máximo'\n );\n\nconst approvedActionPayload = {\n type: actionType,\n field: actionField,\n label: actionLabel,\n value: actionValue,\n table: actionTable,\n payload: proposedPayload\n};\n\nconst approvedActionRoute =\n getApprovedActionRoute(\n actionType,\n actionField,\n actionTable,\n approvedActionPayload\n );\n\nconst approvedActionSupported =\n approvedActionRoute !== 'unsupported_action';\n\nconst requiresEmployeeResolution =\n !targetEmployeeId && !!employeeQuery;\n\nconst approvedActionName =\n getHumanActionName(\n approvedActionRoute,\n actionField,\n actionTable,\n originalMessage\n );\n\nreturn {\n json: {\n ...data,\n\n can_continue: true,\n\n approved_by_maximo: true,\n maximo_approval_status: 'approved',\n approval_status: 'approved',\n approval_id: approvalId,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_payload: approvedActionPayload,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution,\n\n mode: originalData.mode || originalContext.mode || updatedApproval.mode || 'accion',\n intent: originalData.intent || originalContext.intent || updatedApproval.intent || data.intent,\n\n employee_query: employeeQuery,\n employee_id: targetEmployeeId,\n\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n\n requester_email: requesterEmail,\n requester_name: requesterName,\n\n approved_by_email: approvedByEmail,\n approved_by_name: approvedByName,\n\n original_message: originalMessage,\n\n action: approvedActionPayload,\n\n data: {\n ...(data.data || {}),\n\n mode: originalData.mode || originalContext.mode || updatedApproval.mode || 'accion',\n intent: originalData.intent || originalContext.intent || updatedApproval.intent || data.intent,\n\n employee_query: employeeQuery,\n employee_id: targetEmployeeId,\n\n action: approvedActionPayload,\n\n approved_by_maximo: true,\n maximo_approval_status: 'approved',\n approval_status: 'approved',\n approval_id: approvalId,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_payload: approvedActionPayload,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution\n },\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approval_id: approvalId,\n approval_status: 'approved',\n\n approved_by_maximo: true,\n approved_by_email: approvedByEmail,\n approved_by_name: approvedByName,\n\n approved_action_ready: true,\n approved_action_supported: approvedActionSupported,\n approved_action_route: approvedActionRoute,\n\n approved_action_type: actionType,\n approved_action_field: actionField,\n approved_action_label: actionLabel,\n approved_action_value: actionValue,\n approved_action_table: actionTable,\n approved_action_name: approvedActionName,\n\n requires_employee_resolution: requiresEmployeeResolution,\n\n employee_query: employeeQuery,\n target_employee_id: targetEmployeeId,\n target_employee_name: targetEmployeeName,\n\n requester_email: requesterEmail,\n requester_name: requesterName,\n\n execution_result: approvedActionSupported\n ? 'approved_action_ready_for_execution'\n : 'approved_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-18752,
|
||
-4144
|
||
],
|
||
"id": "f747051d-5a97-4d7a-af4e-1537e2c4bcfd",
|
||
"name": "Code - Build Approved Action Execution Context"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "71b60975-102e-4bae-8049-62a532798e46",
|
||
"leftValue": "={{ $json.approved_action_supported }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-18544,
|
||
-4144
|
||
],
|
||
"id": "72e7a2f2-8b69-42bb-9960-79e1105f56e2",
|
||
"name": "IF - Approved Action Supported?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst responseText =\n `La solicitud fue aprobada por Máximo, pero esta acción todavía no tiene ejecución automática habilitada.\\n\\n` +\n `Solicitud: ${data.approved_action_name || 'Acción sensible'}\\n` +\n `Empleado: ${data.target_employee_name || 'empleado indicado'}\\n\\n` +\n `Estado: aprobada y pendiente de implementación del ejecutor.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approved_action_execution_status: 'not_supported_yet',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approved_action_execution_status: 'not_supported_yet',\n execution_result: 'approved_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17504,
|
||
-3392
|
||
],
|
||
"id": "c4e46ef8-6615-450d-ae80-b54a2aca6474",
|
||
"name": "Code - Format Approved Action Not Supported"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "compensation_update",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "0d7efa9e-9e75-4de0-8c46-52fa3a0a3e90"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "compensation_update"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d36c496c-77a3-465c-8de6-dd4bf1ba4071",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "employee_basic_update",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "employee_basic_update"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "ca3e23fb-90b5-4d37-a0df-12dbd0365074",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "add_table_row",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "add_table_row"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8738b2f2-e591-4d6e-995e-76b6931c710f",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "terminate_employee",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "terminate_employee"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "1c5beffb-8a31-4ac6-bb80-86ceb3871c57",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "upload_employee_file",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "upload_employee_file"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "d71bfb28-2508-4b38-831c-cf87d496e33f",
|
||
"leftValue": "={{ $json.approved_action_route }}",
|
||
"rightValue": "unsupported_action",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "unsupported_action"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-18320,
|
||
-4384
|
||
],
|
||
"id": "1e7fd9d2-99d2-4ecc-8850-602b18b286f2",
|
||
"name": "Switch - Approved Action Route"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction parseAmount(value) {\n const raw = cleanText(value);\n\n if (!raw) {\n return {\n raw,\n normalized: null,\n is_valid: false,\n reason: 'missing_amount'\n };\n }\n\n const numericText = raw\n .replace(/[^\\d.,-]/g, '')\n .replace(/,/g, '');\n\n const amount = Number(numericText);\n\n if (!Number.isFinite(amount)) {\n return {\n raw,\n normalized: null,\n is_valid: false,\n reason: 'invalid_number'\n };\n }\n\n if (amount <= 0) {\n return {\n raw,\n normalized: amount,\n is_valid: false,\n reason: 'amount_must_be_greater_than_zero'\n };\n }\n\n return {\n raw,\n normalized: amount,\n is_valid: true,\n reason: 'valid'\n };\n}\n\nconst approvedPayload =\n data.approved_action_payload || {};\n\nconst actionValue =\n data.approved_action_value ||\n approvedPayload.value ||\n approvedPayload.payload?.value ||\n approvedPayload.payload?.salary ||\n approvedPayload.payload?.amount ||\n null;\n\nconst amountInfo = parseAmount(actionValue);\n\nconst employeeQuery =\n data.target_employee_id ||\n data.employee_id ||\n data.employee_query ||\n data.target_employee_name ||\n null;\n\nconst canContinuePreflight =\n data.approved_by_maximo === true &&\n data.approval_status === 'approved' &&\n data.approved_action_route === 'compensation_update' &&\n !!employeeQuery;\n\nreturn {\n json: {\n ...data,\n\n compensation_preflight_ready: canContinuePreflight,\n compensation_dry_run: true,\n compensation_execution_enabled: false,\n\n compensation_amount_raw: amountInfo.raw,\n compensation_amount: amountInfo.normalized,\n compensation_amount_valid: amountInfo.is_valid,\n compensation_amount_validation_reason: amountInfo.reason,\n\n employee_query: employeeQuery,\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n compensation_preflight_ready: canContinuePreflight,\n compensation_dry_run: true,\n compensation_execution_enabled: false,\n\n compensation_amount_raw: amountInfo.raw,\n compensation_amount: amountInfo.normalized,\n compensation_amount_valid: amountInfo.is_valid,\n compensation_amount_validation_reason: amountInfo.reason,\n\n execution_result: canContinuePreflight\n ? 'approved_compensation_preflight_ready'\n : 'approved_compensation_preflight_blocked'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17264,
|
||
-5664
|
||
],
|
||
"id": "b944e184-6c54-4fb8-8a41-577024fbec32",
|
||
"name": "Code - Prepare Approved Compensation Preflight"
|
||
},
|
||
{
|
||
"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": [
|
||
-16912,
|
||
-5728
|
||
],
|
||
"id": "bba62412-ebc9-41d5-b10e-aa8dedf25ae1",
|
||
"name": "HTTP - Approved Get Employee Directory",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Prepare Approved Compensation Preflight')[0].json;\nconst directoryResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9@.\\s-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction getEmployeesFromDirectory(value) {\n if (Array.isArray(value?.employees)) {\n return value.employees;\n }\n\n if (Array.isArray(value?.data?.employees)) {\n return value.data.employees;\n }\n\n if (Array.isArray(value)) {\n return value;\n }\n\n return [];\n}\n\nfunction getEmployeeName(employee) {\n return cleanText(\n employee.displayName ||\n employee.fullName1 ||\n employee.fullName2 ||\n employee.preferredName ||\n [\n employee.firstName,\n employee.middleName,\n employee.lastName\n ].filter(Boolean).join(' ')\n );\n}\n\nfunction getEmployeeSearchText(employee) {\n return normalize([\n employee.id,\n employee.employeeNumber,\n employee.workEmail,\n employee.email,\n employee.firstName,\n employee.middleName,\n employee.lastName,\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.preferredName,\n getEmployeeName(employee)\n ].filter(Boolean).join(' '));\n}\n\nfunction scoreEmployee(employee, query) {\n const normalizedQuery = normalize(query);\n const searchText = getEmployeeSearchText(employee);\n\n const employeeId = normalize(employee.id);\n const employeeNumber = normalize(employee.employeeNumber);\n const workEmail = normalize(employee.workEmail || employee.email);\n const displayName = normalize(getEmployeeName(employee));\n\n if (!normalizedQuery) return 0;\n\n if (employeeId && normalizedQuery === employeeId) return 100;\n if (employeeNumber && normalizedQuery === employeeNumber) return 95;\n if (workEmail && normalizedQuery === workEmail) return 95;\n if (displayName && normalizedQuery === displayName) return 90;\n\n const queryParts = normalizedQuery\n .split(' ')\n .filter(part => part.length >= 2);\n\n const allPartsMatch =\n queryParts.length > 0 &&\n queryParts.every(part => searchText.includes(part));\n\n if (allPartsMatch) return 80;\n\n if (searchText.includes(normalizedQuery)) return 70;\n\n return 0;\n}\n\nconst employees = getEmployeesFromDirectory(directoryResponse);\n\nconst employeeQuery =\n context.employee_query ||\n context.target_employee_name ||\n context.target_employee_id ||\n null;\n\nconst scoredMatches = employees\n .map(employee => ({\n employee,\n score: scoreEmployee(employee, employeeQuery)\n }))\n .filter(item => item.score > 0)\n .sort((a, b) => b.score - a.score);\n\nconst bestScore = scoredMatches[0]?.score || 0;\n\nconst topMatches = scoredMatches\n .filter(item => item.score === bestScore)\n .map(item => item.employee);\n\nconst resolvedEmployee =\n topMatches.length === 1\n ? topMatches[0]\n : null;\n\nconst resolvedEmployeeId =\n resolvedEmployee?.id ||\n context.target_employee_id ||\n context.employee_id ||\n null;\n\nconst resolvedEmployeeName =\n resolvedEmployee\n ? getEmployeeName(resolvedEmployee)\n : null;\n\nlet resolutionStatus = 'not_found';\nlet canContinue = false;\nlet responseText = '';\n\nif (!employees.length) {\n resolutionStatus = 'directory_empty_or_unavailable';\n responseText =\n `No pude consultar el directorio de BambooHR para resolver el empleado.\\n\\n` +\n `Empleado buscado: ${employeeQuery || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else if (!employeeQuery) {\n resolutionStatus = 'missing_employee_query';\n responseText =\n `No tengo el empleado necesario para ejecutar la acción aprobada.\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else if (resolvedEmployee) {\n resolutionStatus = 'resolved_unique_employee';\n canContinue = true;\n} else if (topMatches.length > 1) {\n resolutionStatus = 'ambiguous_employee';\n\n const matchList = topMatches\n .slice(0, 10)\n .map(employee => {\n const name = getEmployeeName(employee);\n const id = employee.id || 'sin ID';\n const email = employee.workEmail || employee.email || 'sin correo';\n return `- ${name} — ID ${id} — ${email}`;\n })\n .join('\\n');\n\n responseText =\n `Encontré más de un empleado posible para \"${employeeQuery}\".\\n\\n` +\n `${matchList}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n} else {\n resolutionStatus = 'not_found';\n responseText =\n `No encontré un empleado único para \"${employeeQuery}\".\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n}\n\nreturn {\n json: {\n ...context,\n\n employee_directory_count: employees.length,\n\n approved_employee_resolution_status: resolutionStatus,\n approved_employee_resolved: canContinue,\n\n resolved_employee_id: resolvedEmployeeId,\n resolved_employee_name: resolvedEmployeeName,\n resolved_employee_email:\n resolvedEmployee?.workEmail ||\n resolvedEmployee?.email ||\n null,\n\n target_employee_id: resolvedEmployeeId,\n target_employee_name:\n resolvedEmployeeName ||\n context.target_employee_name ||\n context.employee_query,\n\n bamboohr_directory_match: resolvedEmployee,\n bamboohr_directory_matches_count: topMatches.length,\n\n can_continue: canContinue,\n response_type: canContinue ? 'internal_execution' : 'chat_message',\n response_text: responseText || null,\n\n audit_event: {\n ...(context.audit_event || {}),\n\n approved_employee_resolution_status: resolutionStatus,\n approved_employee_resolved: canContinue,\n\n resolved_employee_id: resolvedEmployeeId,\n resolved_employee_name: resolvedEmployeeName,\n resolved_employee_email:\n resolvedEmployee?.workEmail ||\n resolvedEmployee?.email ||\n null,\n\n execution_result: canContinue\n ? 'approved_employee_resolved'\n : `approved_employee_resolution_${resolutionStatus}`\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16704,
|
||
-5728
|
||
],
|
||
"id": "535885c1-77a3-4ea0-a9f7-ef8ca8dabd9a",
|
||
"name": "Code - Resolve Approved Employee From Directory"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "38cd8264-7eb1-4929-8c74-df7a4a873cbe",
|
||
"leftValue": "={{ $json.approved_employee_resolved }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16496,
|
||
-5728
|
||
],
|
||
"id": "187eee8e-23c5-42db-a998-43a596e68ce1",
|
||
"name": "IF - Approved Employee Resolved?"
|
||
},
|
||
{
|
||
"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": [
|
||
-14048,
|
||
-4144
|
||
],
|
||
"id": "5c1b1674-58f1-46a1-a4a0-71797f0f1e16",
|
||
"name": "HTTP - Approved Get Current Compensation",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Resolve Approved Employee From Directory')[0].json;\nconst compensationResponse = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction safeObject(value) {\n if (value && typeof value === 'object' && !Array.isArray(value)) {\n return value;\n }\n\n return {};\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction getRows(value) {\n if (Array.isArray(value)) return value;\n if (Array.isArray(value?.data)) return value.data;\n if (Array.isArray(value?.rows)) return value.rows;\n if (Array.isArray(value?.employees)) return value.employees;\n return [];\n}\n\nfunction getLatestRow(rows) {\n if (!Array.isArray(rows) || rows.length === 0) {\n return null;\n }\n\n const sorted = [...rows].sort((a, b) => {\n const dateA = new Date(a.date || a.effectiveDate || a.startDate || 0).getTime();\n const dateB = new Date(b.date || b.effectiveDate || b.startDate || 0).getTime();\n return dateB - dateA;\n });\n\n return sorted[0] || rows[0];\n}\n\nfunction extractText(value) {\n if (value === null || value === undefined) return null;\n\n if (typeof value === 'object') {\n return (\n value.value ||\n value.name ||\n value.label ||\n value.id ||\n value.amount ||\n null\n );\n }\n\n return cleanText(value);\n}\n\nfunction extractAmount(value) {\n if (value === null || value === undefined) {\n return null;\n }\n\n if (typeof value === 'object') {\n return extractAmount(\n value.value ??\n value.amount ??\n value.rate ??\n value.salary\n );\n }\n\n const raw = cleanText(value);\n\n if (!raw) return null;\n\n const numberText = raw\n .replace(/[^\\d.,-]/g, '')\n .replace(/,/g, '');\n\n const number = Number(numberText);\n\n return Number.isFinite(number) ? number : null;\n}\n\nfunction formatDateISO(value) {\n const raw = cleanText(value);\n\n if (!raw) return null;\n\n const date = new Date(raw);\n\n if (Number.isNaN(date.getTime())) {\n return null;\n }\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction pickFirst(...values) {\n for (const value of values) {\n if (value !== undefined && value !== null && cleanText(value) !== '') {\n return value;\n }\n }\n\n return null;\n}\n\nfunction findDateInMessage(message) {\n const text = cleanText(message);\n\n const isoMatch = text.match(/\\b(20\\d{2})-(\\d{2})-(\\d{2})\\b/);\n if (isoMatch) return isoMatch[0];\n\n const latinDate = text.match(/\\b(\\d{1,2})[\\/-](\\d{1,2})[\\/-](20\\d{2})\\b/);\n if (latinDate) {\n const day = latinDate[1].padStart(2, '0');\n const month = latinDate[2].padStart(2, '0');\n const year = latinDate[3];\n return `${year}-${month}-${day}`;\n }\n\n return null;\n}\n\nfunction getApprovedEffectiveDate(data) {\n const payload = safeObject(data.approved_action_payload);\n const nested = safeObject(payload.payload);\n\n const explicitDate = pickFirst(\n payload.effectiveDate,\n payload.effective_date,\n payload.date,\n payload.startDate,\n nested.effectiveDate,\n nested.effective_date,\n nested.date,\n nested.startDate,\n data.effectiveDate,\n data.effective_date,\n data.startDate\n );\n\n const formattedExplicitDate = formatDateISO(explicitDate);\n if (formattedExplicitDate) return formattedExplicitDate;\n\n const messageDate = findDateInMessage(data.original_message || '');\n if (messageDate) return messageDate;\n\n return null;\n}\n\nfunction getReason(data) {\n const payload = safeObject(data.approved_action_payload);\n const nested = safeObject(payload.payload);\n\n return cleanText(\n pickFirst(\n payload.reason,\n payload.comment,\n payload.comments,\n nested.reason,\n nested.comment,\n nested.comments,\n data.reason,\n data.original_message,\n 'Aprobado por Máximo vía GLM BambooHR Agent'\n )\n );\n}\n\nconst rows = getRows(compensationResponse);\nconst latestRow = getLatestRow(rows);\n\nconst approvedPayload = safeObject(context.approved_action_payload);\nconst approvedNestedPayload = safeObject(approvedPayload.payload);\n\nconst approvedAmount = extractAmount(\n pickFirst(\n context.approved_action_value,\n approvedPayload.value,\n approvedPayload.amount,\n approvedPayload.salary,\n approvedNestedPayload.value,\n approvedNestedPayload.amount,\n approvedNestedPayload.salary\n )\n);\n\nconst effectiveDate = getApprovedEffectiveDate(context);\n\nconst currentRate = extractAmount(\n pickFirst(\n latestRow?.rate,\n latestRow?.payRate,\n latestRow?.amount,\n latestRow?.salary,\n latestRow?.compensation\n )\n);\n\nconst currentPaySchedule = extractText(\n pickFirst(\n latestRow?.paySchedule,\n latestRow?.payScheduleId,\n latestRow?.pay_schedule,\n latestRow?.pay_schedule_id\n )\n);\n\nconst currentPayType = extractText(\n pickFirst(\n latestRow?.payType,\n latestRow?.payTypeId,\n latestRow?.pay_type,\n latestRow?.pay_type_id\n )\n);\n\nconst currentCurrency = extractText(\n pickFirst(\n latestRow?.currency,\n latestRow?.currencyCode,\n latestRow?.currency_code\n )\n);\n\nconst reason = getReason(context);\n\nconst blockers = [];\n\nif (context.approved_by_maximo !== true) {\n blockers.push('La acción no viene marcada como aprobada por Máximo.');\n}\n\nif (context.approval_status !== 'approved') {\n blockers.push('La aprobación no está en estado approved.');\n}\n\nif (context.approved_action_route !== 'compensation_update') {\n blockers.push('La ruta aprobada no es compensation_update.');\n}\n\nif (!context.resolved_employee_id) {\n blockers.push('No se resolvió el ID del empleado.');\n}\n\nif (!approvedAmount || approvedAmount <= 0) {\n blockers.push('El monto aprobado no es válido o es menor/igual a cero.');\n}\n\nif (!effectiveDate) {\n blockers.push('Falta fecha efectiva del cambio salarial.');\n}\n\nif (!latestRow) {\n blockers.push('No se encontró una fila actual de compensation para copiar estructura.');\n}\n\nif (!currentPaySchedule) {\n blockers.push('No se detectó paySchedule/calendario actual.');\n}\n\nif (!currentPayType) {\n blockers.push('No se detectó payType/tipo de pago actual.');\n}\n\nconst bamboohrBody = {\n date: effectiveDate,\n rate: String(approvedAmount),\n paySchedule: currentPaySchedule,\n payType: currentPayType,\n reason\n};\n\nif (currentCurrency) {\n bamboohrBody.currency = currentCurrency;\n}\n\nconst readyToExecute = blockers.length === 0;\n\nconst bamboohrUrl =\n `https://glm.bamboohr.com/api/v1/employees/${context.resolved_employee_id}/tables/compensation`;\n\nreturn {\n json: {\n ...context,\n\n compensation_rows_count: rows.length,\n compensation_latest_row: latestRow,\n\n compensation_current_rate: currentRate,\n compensation_current_pay_schedule: currentPaySchedule,\n compensation_current_pay_type: currentPayType,\n compensation_current_currency: currentCurrency,\n\n compensation_new_rate: approvedAmount,\n compensation_effective_date: effectiveDate,\n compensation_reason: reason,\n\n compensation_execution_ready: readyToExecute,\n compensation_execution_blockers: blockers,\n\n bamboohr_execute_method: 'POST',\n bamboohr_execute_url: bamboohrUrl,\n bamboohr_execute_body: bamboohrBody,\n\n response_type: 'internal_execution',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n compensation_rows_count: rows.length,\n\n compensation_current_rate: currentRate,\n compensation_current_pay_schedule: currentPaySchedule,\n compensation_current_pay_type: currentPayType,\n compensation_current_currency: currentCurrency,\n\n compensation_new_rate: approvedAmount,\n compensation_effective_date: effectiveDate,\n\n compensation_execution_ready: readyToExecute,\n compensation_execution_blockers: blockers,\n\n bamboohr_endpoint: bamboohrUrl,\n execution_result: readyToExecute\n ? 'approved_compensation_ready_to_execute'\n : 'approved_compensation_blocked_before_execution'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13840,
|
||
-4144
|
||
],
|
||
"id": "384ba03f-82bf-4b54-b649-4846e0ed27ca",
|
||
"name": "Code - Build Approved Compensation Write Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "8ca09828-0392-4fc8-af7b-a8eed8ab7cce",
|
||
"leftValue": "={{ $json.compensation_execution_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-13632,
|
||
-4144
|
||
],
|
||
"id": "c1793f86-dbc6-4927-abb6-62025398087c",
|
||
"name": "IF - Approved Compensation Ready To Execute?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.compensation_execution_blockers)\n ? data.compensation_execution_blockers\n : [];\n\nconst responseText =\n `⚠️ Máximo aprobó tu solicitud, pero el agente no pudo ejecutarla automáticamente todavía.\\n\\n` +\n `Solicitud: ${data.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${data.resolved_employee_name || data.target_employee_name || 'No disponible'}\\n` +\n `Monto solicitado: ${data.compensation_new_rate || data.approved_action_value || 'No disponible'}\\n` +\n `Fecha efectiva: ${data.compensation_effective_date || 'No disponible'}\\n\\n` +\n `Motivo:\\n` +\n `${blockers.length ? blockers.map(item => `- ${item}`).join('\\n') : '- Faltan datos para ejecutar con seguridad.'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'approved_but_blocked_before_bamboohr_write',\n\n approval_execution_patch_body: {\n execution_result: 'approved_but_blocked_before_bamboohr_write',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'approved_but_blocked_before_bamboohr_write',\n execution_result: 'approved_but_blocked_before_bamboohr_write'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13328,
|
||
-4032
|
||
],
|
||
"id": "72721d77-a1a3-4e93-8dd7-6768ddae99c0",
|
||
"name": "Code - Format Compensation Execution Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13072,
|
||
-3920
|
||
],
|
||
"id": "5712e9ec-27dd-4ab5-aa58-6e48e65ea91a",
|
||
"name": "HTTP - Mark Approval Execution Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "={{ $json.bamboohr_execute_method }}",
|
||
"url": "={{ $json.bamboohr_execute_url }}",
|
||
"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_execute_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13424,
|
||
-4240
|
||
],
|
||
"id": "1974427f-a8db-4cd7-a289-4d190158e1f7",
|
||
"name": "HTTP - Execute Approved Compensation Update",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const context = $items('Code - Build Approved Compensation Write Request')[0].json;\nconst bamboohrResponse = $json;\n\nfunction hasError(value) {\n const text = JSON.stringify(value || {}).toLowerCase();\n\n return (\n value?.error ||\n value?.errors ||\n value?.message?.toLowerCase?.().includes('error') ||\n text.includes('unauthorized') ||\n text.includes('forbidden') ||\n text.includes('bad request') ||\n text.includes('not found')\n );\n}\n\nconst executionFailed = hasError(bamboohrResponse);\n\nconst responseText = executionFailed\n ? (\n `❌ Máximo aprobó tu solicitud, pero BambooHR devolvió un error al intentar ejecutarla.\\n\\n` +\n `Solicitud: ${context.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n` +\n `Monto solicitado: ${context.compensation_new_rate || 'No disponible'}\\n` +\n `Fecha efectiva: ${context.compensation_effective_date || 'No disponible'}\\n\\n` +\n `No se pudo confirmar el cambio en BambooHR. IT debe revisar la ejecución.`\n )\n : (\n `✅ Solicitud aprobada y ejecutada correctamente en BambooHR.\\n\\n` +\n `Solicitud: ${context.approved_action_name || 'Cambio de salario / compensación'}\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n` +\n `Monto anterior detectado: ${context.compensation_current_rate || 'No disponible'}\\n` +\n `Monto nuevo: ${context.compensation_new_rate || 'No disponible'}\\n` +\n `Fecha efectiva: ${context.compensation_effective_date || 'No disponible'}\\n\\n` +\n `El cambio quedó registrado en BambooHR.`\n );\n\nconst executionResult = executionFailed\n ? 'bamboohr_execution_failed'\n : 'executed_successfully';\n\nreturn {\n json: {\n ...context,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n bamboohr_execution_response: bamboohrResponse,\n bamboohr_execution_failed: executionFailed,\n\n final_execution_status: executionFailed ? 'failed' : 'executed',\n final_execution_result: executionResult,\n\n approval_execution_patch_body: {\n execution_result: executionResult,\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_execution_failed: executionFailed,\n final_execution_status: executionFailed ? 'failed' : 'executed',\n final_execution_result: executionResult,\n execution_result: executionResult\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-13216,
|
||
-4240
|
||
],
|
||
"id": "dd1cb3da-c560-4fc7-ad68-f8d5cd078fbd",
|
||
"name": "Code - Format Compensation Execution Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13040,
|
||
-4256
|
||
],
|
||
"id": "fabd1fe2-2dd2-4d5d-b567-f4af69afbef1",
|
||
"name": "HTTP - Mark Approval Executed",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const contactResponse = $json;\n\nfunction getFirstRow(value) {\n if (Array.isArray(value) && value.length > 0) {\n return value[0];\n }\n\n if (Array.isArray(value?.data) && value.data.length > 0) {\n return value.data[0];\n }\n\n if (value && typeof value === 'object' && Object.keys(value).length > 0) {\n return value;\n }\n\n return null;\n}\n\nfunction getContextFromNode(nodeName) {\n try {\n const items = $items(nodeName);\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction buildFallbackText(context) {\n const status = cleanText(context.final_execution_status || context.final_execution_result);\n\n if (status === 'executed' || status === 'executed_successfully') {\n return (\n `✅ Tu solicitud fue aprobada y ejecutada correctamente.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `El cambio quedó registrado en BambooHR.`\n );\n }\n\n if (status === 'blocked') {\n return (\n `⚠️ Máximo aprobó tu solicitud, pero el agente no pudo ejecutarla automáticamente.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.`\n );\n }\n\n if (status === 'failed') {\n return (\n `❌ Máximo aprobó tu solicitud, pero BambooHR devolvió un error durante la ejecución.\\n\\n` +\n `Empleado: ${context.resolved_employee_name || context.target_employee_name || 'No disponible'}\\n\\n` +\n `IT debe revisar la ejecución.`\n );\n }\n\n return 'La solicitud fue actualizada.';\n}\n\n/*\n Este nodo puede venir de dos ramas:\n 1. Ejecución real completada:\n Code - Format Compensation Execution Result\n\n 2. Ejecución bloqueada por seguridad:\n Code - Format Compensation Execution Blocked\n*/\nconst executionContext =\n getContextFromNode('Code - Format PDF Upload Result') ||\n getContextFromNode('Code - Format BambooHR PDF Upload Precheck Blocked') ||\n getContextFromNode('Code - Format PDF Download Blocked') ||\n getContextFromNode('Code - Format PDF Upload Blocked') ||\n getContextFromNode('Code - Format Compensation Execution Result') ||\n getContextFromNode('Code - Format Compensation Execution Blocked') ||\n getContextFromNode('Code - Format Approved Action Not Supported') ||\n {};\n\nconst requesterContact = getFirstRow(contactResponse);\n\nconst requesterSpaceName =\n requesterContact?.google_chat_space_name ||\n executionContext.requester_google_chat_space_name ||\n executionContext.google_chat_space_name ||\n null;\n\nconst requesterThreadName =\n requesterContact?.google_chat_thread_name ||\n executionContext.requester_google_chat_thread_name ||\n executionContext.google_chat_thread_name ||\n null;\n\nconst notificationText =\n cleanText(executionContext.response_text) ||\n cleanText(executionContext.requester_chat_notification_text) ||\n buildFallbackText(executionContext);\n\nconst requesterChatAvailable = !!requesterSpaceName;\n\nreturn {\n json: {\n ...executionContext,\n\n requester_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n\n requester_user_email:\n requesterContact?.user_email ||\n executionContext.requested_by_email ||\n executionContext.user_email ||\n executionContext.audit_event?.user_email ||\n null,\n\n requester_user_name:\n requesterContact?.user_name ||\n executionContext.requested_by_name ||\n executionContext.user_name ||\n executionContext.audit_event?.user_name ||\n null,\n\n requester_google_chat_space_name: requesterSpaceName,\n requester_google_chat_thread_name: requesterThreadName,\n\n final_space_name: requesterSpaceName,\n final_thread_name: requesterThreadName,\n\n requester_chat_notification_text: notificationText,\n response_text: notificationText,\n\n chat_message_body: {\n text: notificationText\n },\n\n audit_event: {\n ...(executionContext.audit_event || {}),\n\n requester_contact_found: !!requesterContact,\n requester_chat_available: requesterChatAvailable,\n requester_google_chat_space_name: requesterSpaceName,\n requester_google_chat_thread_name: requesterThreadName,\n\n requester_final_notification_prepared: requesterChatAvailable\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-12592,
|
||
-4288
|
||
],
|
||
"id": "4ae2afb1-e4d7-4cc1-bfba-df1d4351a09c",
|
||
"name": "Code - Build Requester Final Execution Notification"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "6455a1ea-92ac-47fc-aa4c-61256ceb6a47",
|
||
"leftValue": "={{ $json.requester_chat_available }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-12384,
|
||
-4288
|
||
],
|
||
"id": "0f7323f7-9d63-4dcb-971f-06cf54b0e513",
|
||
"name": "IF - Requester Final Chat Available?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://TU_SUPABASE_URL/rest/v1/bamboohr_agent_user_permissions?select=user_email,user_name,is_active,google_chat_space_name,google_chat_thread_name,last_chat_seen_at&user_email=eq.' + encodeURIComponent($json.requested_by_email || $json.user_email || $json.audit_event?.user_email || '') + '&is_active=eq.true&limit=1' }}",
|
||
"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": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-12832,
|
||
-4256
|
||
],
|
||
"id": "3db19bb6-7ac7-420e-9dd2-46d31f900af5",
|
||
"name": "HTTP - Get Requester Final Chat Contact",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction formatDateForDR(value) {\n if (!value) return 'No disponible';\n\n const date = new Date(value);\n\n if (Number.isNaN(date.getTime())) {\n return String(value);\n }\n\n return new Intl.DateTimeFormat('es-DO', {\n timeZone: 'America/Santo_Domingo',\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n hour12: true\n }).format(date) + ' hora RD';\n}\n\nfunction getFriendlyActionName(route, context) {\n const text = [\n route,\n context.approved_action_name,\n context.action_type,\n context.intent,\n context.original_message\n ].map(cleanText).join(' ').toLowerCase();\n\n if (text.includes('salary') || text.includes('salario') || text.includes('compensation')) {\n return 'Cambio de salario / compensación';\n }\n\n if (text.includes('terminate') || text.includes('termination') || text.includes('desvincular')) {\n return 'Desvinculación / terminación de empleado';\n }\n\n if (text.includes('file') || text.includes('archivo') || text.includes('documento')) {\n return 'Carga o modificación de documento';\n }\n\n if (route === 'employee_basic_update') {\n return 'Actualización de dato básico del empleado';\n }\n\n if (route === 'add_table_row') {\n return 'Actualización de historial del empleado';\n }\n\n return 'Acción aprobada pendiente de implementación';\n}\n\nconst route =\n cleanText(data.approved_action_route) ||\n cleanText(data.action_type) ||\n cleanText(data.intent) ||\n 'unsupported_action';\n\nconst actionName = getFriendlyActionName(route, data);\n\nconst employeeName =\n data.resolved_employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n 'No disponible';\n\nconst approvedAtReadable = formatDateForDR(\n data.approved_at ||\n data.updated_at ||\n new Date().toISOString()\n);\n\nconst responseText =\n `⚠️ Máximo aprobó tu solicitud, pero esta acción todavía no tiene ejecución automática activa en el agente.\\n\\n` +\n `Solicitud: ${actionName}\\n` +\n `Empleado: ${employeeName}\\n` +\n `Aprobada: ${approvedAtReadable}\\n\\n` +\n `No se ejecutó ningún cambio en BambooHR.\\n\\n` +\n `IT debe completar el ejecutor automático para esta acción antes de usarla en producción.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n approved_action_route: route,\n final_execution_status: 'not_supported',\n final_execution_result: 'approved_but_action_not_supported_yet',\n\n approval_execution_patch_body: {\n execution_result: 'approved_but_action_not_supported_yet',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n approved_action_route: route,\n final_execution_status: 'not_supported',\n final_execution_result: 'approved_but_action_not_supported_yet',\n execution_result: 'approved_but_action_not_supported_yet'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17744,
|
||
-3744
|
||
],
|
||
"id": "f1b3bb9b-c32f-46bf-a961-cb4386f18b11",
|
||
"name": "Code - Format Approved Action Not Supported1"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17520,
|
||
-3744
|
||
],
|
||
"id": "a4838fbd-ed49-4e7f-a7ef-42ff663cfff4",
|
||
"name": "HTTP - Mark Approved Action Not Supported",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction sanitizeFilePart(value) {\n return cleanText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s-]/g, '')\n .replace(/\\s+/g, '_')\n .replace(/_+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 80);\n}\n\nfunction formatDateYYYYMMDD(value) {\n const date = value ? new Date(value) : new Date();\n\n if (Number.isNaN(date.getTime())) {\n return new Date().toISOString().slice(0, 10).replace(/-/g, '');\n }\n\n return date.toISOString().slice(0, 10).replace(/-/g, '');\n}\n\nconst action =\n data.approved_action ||\n data.action ||\n data.data?.action ||\n {};\n\nconst payload =\n action.payload ||\n data.approved_action_payload ||\n data.data?.action?.payload ||\n {};\n\nconst employeeId =\n data.resolved_employee_id ||\n data.target_employee_id ||\n data.employee_id ||\n data.data?.employee_id ||\n null;\n\nconst employeeName =\n data.resolved_employee_name ||\n data.target_employee_name ||\n data.employee_name ||\n data.data?.employee_name ||\n data.employee_query ||\n 'Empleado';\n\nconst country =\n payload.country ||\n data.filters?.country ||\n data.audit_event?.document_country ||\n 'GLM';\n\nconst documentType =\n payload.document_type ||\n data.filters?.document_type ||\n data.audit_event?.document_type ||\n 'Documento';\n\nconst originalFileName =\n payload.original_file_name ||\n data.filters?.original_file_name ||\n data.audit_event?.original_file_name ||\n 'documento.pdf';\n\nconst originalContentType =\n payload.original_content_type ||\n data.filters?.original_content_type ||\n data.audit_event?.original_content_type ||\n 'application/pdf';\n\nconst attachmentResourceName =\n payload.google_chat_attachment_resource_name ||\n data.audit_event?.google_chat_attachment_resource_name ||\n null;\n\nconst driveFileId =\n payload.google_drive_file_id ||\n data.audit_event?.google_drive_file_id ||\n null;\n\nconst attachmentSource =\n payload.attachment_source ||\n data.filters?.attachment_source ||\n data.audit_event?.attachment_source ||\n 'unknown';\n\nconst datePart = formatDateYYYYMMDD(new Date().toISOString());\n\nconst finalFileName =\n [\n 'GLM',\n sanitizeFilePart(country),\n sanitizeFilePart(documentType),\n sanitizeFilePart(employeeName),\n datePart\n ]\n .filter(Boolean)\n .join('_') + '.pdf';\n\nconst blockers = [];\n\nif (!employeeId) {\n blockers.push('No se encontró employee_id resuelto para subir el archivo.');\n}\n\nif (!attachmentResourceName && !driveFileId) {\n blockers.push('No se encontró referencia descargable del PDF.');\n}\n\nif (!originalContentType.toLowerCase().includes('pdf') && !originalFileName.toLowerCase().endsWith('.pdf')) {\n blockers.push('El archivo adjunto no parece ser PDF.');\n}\n\nconst canContinue =\n blockers.length === 0;\n\nreturn {\n json: {\n ...data,\n\n pdf_upload_ready: canContinue,\n pdf_upload_blockers: blockers,\n\n pdf_employee_id: employeeId,\n pdf_employee_name: employeeName,\n\n pdf_document_type: documentType,\n pdf_country: country,\n\n pdf_original_file_name: originalFileName,\n pdf_original_content_type: originalContentType,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource,\n\n pdf_final_file_name: finalFileName,\n\n final_execution_status: canContinue ? 'ready_to_download_pdf' : 'blocked',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_upload_ready: canContinue,\n pdf_upload_blockers: blockers,\n\n pdf_employee_id: employeeId,\n pdf_employee_name: employeeName,\n\n pdf_document_type: documentType,\n pdf_country: country,\n\n pdf_original_file_name: originalFileName,\n pdf_original_content_type: originalContentType,\n pdf_final_file_name: finalFileName,\n\n execution_result: canContinue\n ? 'pdf_upload_ready_to_download'\n : 'pdf_upload_blocked_before_download'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-18016,
|
||
-4064
|
||
],
|
||
"id": "5b534071-4084-4b7c-9176-e36a2caf2b75",
|
||
"name": "Code - Prepare Approved PDF Upload"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e4b3f896-c42c-4420-9272-d8054aac986d",
|
||
"leftValue": "={{ $json.pdf_upload_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-17792,
|
||
-4096
|
||
],
|
||
"id": "60b17828-2c0e-4f9c-8f10-093d9b1f704d",
|
||
"name": "IF - Approved PDF Upload Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_upload_blockers)\n ? data.pdf_upload_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo ejecutarla automáticamente.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nNo se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_blocked_before_download',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_upload_blocked_before_download',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_blocked_before_download',\n execution_result: 'pdf_upload_blocked_before_download'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17408,
|
||
-4000
|
||
],
|
||
"id": "5bc08e8d-5ad0-4bc0-b4a7-27dc3e947ca5",
|
||
"name": "Code - Format PDF Upload Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17200,
|
||
-4000
|
||
],
|
||
"id": "06c7229e-a917-4aa7-92b2-4bf0fdf24ca8",
|
||
"name": "HTTP - Mark PDF Upload Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://chat.googleapis.com/v1/media/' + String($json.pdf_attachment_resource_name || '').replace(/^\\/+/, '') + '?alt=media' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleApi",
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17056,
|
||
-4944
|
||
],
|
||
"id": "980a99a3-7c3e-442f-b856-dc5d793f4af6",
|
||
"name": "HTTP - Download Approved PDF From Google Chat",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "eHseMeH39kRcXgOF",
|
||
"name": "Google account 2"
|
||
},
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
},
|
||
"googleApi": {
|
||
"id": "8tM4ESFMZq6pzFqP",
|
||
"name": "Google Chat - BambooHR Service Account"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const currentJson = $json || {};\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nconst context =\n currentJson.pdf_employee_id\n ? currentJson\n : (\n getNodeJson('Code - Decide Approved PDF Download Source') ||\n getNodeJson('Code - Prepare Approved PDF Upload') ||\n currentJson\n );\n\nconst binary = $binary || {};\nconst fileBinary = binary.data || null;\n\nconst blockers = [];\n\nif (!fileBinary) {\n blockers.push('No se descargó el binario del PDF desde Google Chat o Google Drive.');\n}\n\nconst mimeType =\n fileBinary?.mimeType ||\n context.pdf_original_content_type ||\n 'application/pdf';\n\nconst fileName =\n context.pdf_final_file_name ||\n fileBinary?.fileName ||\n 'documento.pdf';\n\nif (!String(mimeType).toLowerCase().includes('pdf')) {\n blockers.push(`El archivo descargado no parece PDF. MIME detectado: ${mimeType}`);\n}\n\nconst fileSize = Number(fileBinary?.fileSize || fileBinary?.fileSizeBytes || 0);\n\nif (fileSize > 0 && fileSize > 20 * 1024 * 1024) {\n blockers.push('El PDF supera 20 MB, límite soportado para subida de archivo en BambooHR.');\n}\n\nconst ready = blockers.length === 0;\n\nreturn {\n json: {\n ...context,\n\n pdf_download_ready: ready,\n pdf_download_blockers: blockers,\n\n pdf_downloaded_mime_type: mimeType,\n pdf_downloaded_file_name: fileName,\n pdf_downloaded_file_size_bytes: fileSize || null,\n\n final_execution_status: ready ? 'pdf_downloaded' : 'blocked',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n pdf_download_ready: ready,\n pdf_download_blockers: blockers,\n pdf_downloaded_mime_type: mimeType,\n pdf_downloaded_file_name: fileName,\n pdf_downloaded_file_size_bytes: fileSize || null,\n\n execution_result: ready\n ? 'pdf_downloaded_from_google_attachment'\n : 'pdf_download_blocked'\n }\n },\n binary: {\n data: fileBinary\n ? {\n ...fileBinary,\n fileName,\n mimeType\n }\n : undefined\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16800,
|
||
-4944
|
||
],
|
||
"id": "18f3510c-55a1-4ea6-bd1c-17d31ec3f4df",
|
||
"name": "Code - Validate Downloaded PDF Binary"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "e395ce23-29c1-4126-8f02-2fbf1668df31",
|
||
"leftValue": "={{ $json.pdf_download_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-16640,
|
||
-4944
|
||
],
|
||
"id": "d9d5f9ea-8fa4-4cf8-a93d-7afb1fa23871",
|
||
"name": "IF - Downloaded PDF Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_download_blockers)\n ? data.pdf_download_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo descargar el archivo desde Google Chat.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nNo se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_blocked',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_download_blocked',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_blocked',\n execution_result: 'pdf_download_blocked'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16432,
|
||
-4848
|
||
],
|
||
"id": "dda5f754-63b3-45d9-be12-9e767dee108e",
|
||
"name": "Code - Format PDF Download Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-16224,
|
||
-4848
|
||
],
|
||
"id": "fccb68ee-e3e7-416b-a140-4a30f10d3c66",
|
||
"name": "HTTP - Mark PDF Download Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction mapDocumentCategory(documentType) {\n const normalized = cleanText(documentType)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n\n if (normalized.includes('contrato')) return 'Contracts';\n if (normalized.includes('carta de oferta')) return 'Offer Letters';\n if (normalized.includes('cedula')) return 'Personal Documents';\n if (normalized.includes('pasaporte')) return 'Personal Documents';\n if (normalized.includes('licencia')) return 'Personal Documents';\n if (normalized.includes('certificacion')) return 'Certifications';\n if (normalized.includes('cv')) return 'Resumes and Applications';\n if (normalized.includes('renuncia')) return 'Termination Documents';\n if (normalized.includes('desvinculacion')) return 'Termination Documents';\n if (normalized.includes('amonestacion')) return 'Disciplinary Documents';\n if (normalized.includes('evaluacion')) return 'Performance Documents';\n\n return 'Employee Documents';\n}\n\nconst employeeId =\n data.pdf_employee_id ||\n data.resolved_employee_id ||\n data.target_employee_id ||\n data.employee_id ||\n null;\n\nconst finalFileName =\n data.pdf_final_file_name ||\n data.pdf_downloaded_file_name ||\n 'documento.pdf';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst category =\n mapDocumentCategory(documentType);\n\nconst shareWithEmployee = false;\n\nconst blockers = [];\n\nif (!employeeId) {\n blockers.push('No se encontró employee_id para subir el archivo a BambooHR.');\n}\n\nif (!$binary?.data) {\n blockers.push('No existe binary.data para enviar a BambooHR.');\n}\n\nconst ready = blockers.length === 0;\n\nreturn {\n json: {\n ...data,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n bamboohr_employee_file_upload_url:\n employeeId\n ? `https://glm.bamboohr.com/api/v1/employees/${employeeId}/files`\n : null,\n\n bamboohr_file_category: category,\n bamboohr_file_name: finalFileName,\n bamboohr_file_share: shareWithEmployee ? 'yes' : 'no',\n\n final_execution_status: ready ? 'ready_to_upload_to_bamboohr' : 'blocked',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n bamboohr_file_category: category,\n bamboohr_file_name: finalFileName,\n bamboohr_file_share: shareWithEmployee ? 'yes' : 'no',\n\n execution_result: ready\n ? 'pdf_ready_to_upload_to_bamboohr'\n : 'pdf_upload_to_bamboohr_blocked_before_request'\n }\n },\n binary: {\n data: {\n ...$binary.data,\n fileName: finalFileName,\n mimeType: 'application/pdf'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16480,
|
||
-5136
|
||
],
|
||
"id": "6d9a58cd-c46d-4609-a284-c16421d047ec",
|
||
"name": "Code - Prepare BambooHR PDF Upload Request"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "6a928b50-bacd-4dc9-ac38-6d01811e7316",
|
||
"leftValue": "={{ $json.bamboohr_pdf_upload_ready }}",
|
||
"rightValue": "",
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true",
|
||
"singleValue": true
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.3,
|
||
"position": [
|
||
-15712,
|
||
-4336
|
||
],
|
||
"id": "223e685e-1544-4e6e-a874-5862b723ec12",
|
||
"name": "IF - BambooHR PDF Upload Ready?"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.bamboohr_pdf_upload_blockers)\n ? data.bamboohr_pdf_upload_blockers\n : [];\n\nconst employeeName =\n data.pdf_employee_name ||\n data.resolved_employee_name ||\n data.target_employee_name ||\n 'No disponible';\n\nconst documentType =\n data.pdf_document_type ||\n 'Documento';\n\nconst fileName =\n data.bamboohr_file_name ||\n data.pdf_final_file_name ||\n 'documento.pdf';\n\nconst desiredCategory =\n data.bamboohr_file_category_desired_label ||\n data.bamboohr_file_category ||\n 'No disponible';\n\nconst detectedCategories = Array.isArray(data.bamboohr_file_categories_uploadable)\n ? data.bamboohr_file_categories_uploadable\n : [];\n\nconst categoriesText =\n detectedCategories.length > 0\n ? detectedCategories\n .slice(0, 10)\n .map(category => `- ${category.id}: ${category.name}`)\n .join('\\n')\n : '- No se detectaron categorías disponibles para subir archivos.';\n\nconst responseText =\n `⚠️ Máximo aprobó la subida del PDF, pero el agente no pudo preparar la solicitud final hacia BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Documento: ${documentType}\\n` +\n `Archivo: ${fileName}\\n` +\n `Categoría buscada: ${desiredCategory}\\n\\n` +\n `Motivo:\\n` +\n blockers.map(item => `- ${item}`).join('\\n') +\n `\\n\\nCategorías detectadas en BambooHR:\\n` +\n `${categoriesText}\\n\\n` +\n `No se subió ningún archivo a BambooHR.`;\n\nreturn {\n json: {\n ...data,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: responseText,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n\n approval_execution_patch_body: {\n execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(data.audit_event || {}),\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_upload_to_bamboohr_blocked_before_request',\n execution_result: 'pdf_upload_to_bamboohr_blocked_before_request'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-15504,
|
||
-4240
|
||
],
|
||
"id": "ea50aaa2-1c16-4775-9fe4-9da45f2faaa1",
|
||
"name": "Code - Format BambooHR PDF Upload Precheck Blocked"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15296,
|
||
-4240
|
||
],
|
||
"id": "57e82484-beb9-4050-9f5a-3b457fe95b36",
|
||
"name": "HTTP - Mark BambooHR PDF Upload Precheck Blocked",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "={{ $json.bamboohr_employee_file_upload_url }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"contentType": "multipart-form-data",
|
||
"bodyParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "category",
|
||
"value": "={{ $json.bamboohr_file_category_id }}"
|
||
},
|
||
{
|
||
"name": "fileName",
|
||
"value": "={{ $json.bamboohr_file_name }}"
|
||
},
|
||
{
|
||
"name": "share",
|
||
"value": "={{ $json.bamboohr_file_share }}"
|
||
},
|
||
{
|
||
"parameterType": "formBinaryData",
|
||
"name": "file",
|
||
"inputDataFieldName": "data"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15488,
|
||
-4496
|
||
],
|
||
"id": "8ee9bb94-9a80-417a-9036-54284f4cf578",
|
||
"name": "HTTP - Upload PDF To BambooHR",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const httpResult = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction safeStringify(value) {\n try {\n if (value === undefined || value === null) return '';\n if (typeof value === 'string') return value;\n return JSON.stringify(value);\n } catch (error) {\n return String(value || '');\n }\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nconst context =\n getNodeJson('Code - Prepare BambooHR PDF Upload Request') ||\n {};\n\nconst statusCode =\n Number(\n httpResult.statusCode ||\n httpResult.status ||\n httpResult.response?.statusCode ||\n httpResult.error?.statusCode ||\n 0\n );\n\nconst rawErrorMessage =\n httpResult.error?.message ||\n httpResult.error?.description ||\n httpResult.message ||\n httpResult.description ||\n '';\n\nconst responseTextRaw = safeStringify(httpResult);\n\nconst hasExplicitError =\n !!rawErrorMessage ||\n responseTextRaw.toLowerCase().includes('error') ||\n responseTextRaw.toLowerCase().includes('bad request') ||\n responseTextRaw.toLowerCase().includes('unauthorized') ||\n responseTextRaw.toLowerCase().includes('forbidden');\n\nconst hasHttpFailure =\n statusCode >= 400;\n\nconst uploadSucceeded =\n !hasExplicitError &&\n !hasHttpFailure;\n\nconst employeeName =\n context.pdf_employee_name ||\n context.resolved_employee_name ||\n context.target_employee_name ||\n 'No disponible';\n\nconst employeeId =\n context.pdf_employee_id ||\n context.resolved_employee_id ||\n context.target_employee_id ||\n 'No disponible';\n\nconst documentType =\n context.pdf_document_type ||\n 'Documento';\n\nconst fileName =\n context.bamboohr_file_name ||\n context.pdf_final_file_name ||\n 'documento.pdf';\n\nconst category =\n context.bamboohr_file_category_name ||\n context.bamboohr_file_category ||\n context.bamboohr_file_category_id ||\n 'No disponible';\n\nconst categoryId =\n context.bamboohr_file_category_id ||\n 'No disponible';\n\nconst resultMessage =\n uploadSucceeded\n ? (\n `✅ Máximo aprobó la subida del PDF y el archivo fue cargado correctamente en BambooHR.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Employee ID: ${employeeId}\\n` +\n `Documento: ${documentType}\\n` +\n `Categoría BambooHR: ${category}\\n` +\n `Category ID: ${categoryId}\\n` +\n `Archivo: ${fileName}`\n )\n : (\n `❌ Máximo aprobó la subida del PDF, pero BambooHR devolvió un error durante la carga.\\n\\n` +\n `Empleado: ${employeeName}\\n` +\n `Employee ID: ${employeeId}\\n` +\n `Documento: ${documentType}\\n` +\n `Categoría BambooHR: ${category}\\n` +\n `Category ID: ${categoryId}\\n` +\n `Archivo: ${fileName}\\n\\n` +\n `No puedo confirmar que el archivo haya sido subido.\\n\\n` +\n `Detalle técnico:\\n` +\n `${cleanText(rawErrorMessage) || responseTextRaw.slice(0, 1000) || 'Error no especificado'}`\n );\n\nconst executionResult =\n uploadSucceeded\n ? 'pdf_uploaded_to_bamboohr'\n : 'pdf_upload_to_bamboohr_failed';\n\nreturn {\n json: {\n ...context,\n\n bamboohr_upload_http_result: httpResult,\n bamboohr_upload_status_code: statusCode || null,\n\n bamboohr_pdf_upload_succeeded: uploadSucceeded,\n\n can_continue: false,\n response_type: 'chat_message',\n response_text: resultMessage,\n\n final_execution_status: uploadSucceeded ? 'executed' : 'failed',\n final_execution_result: executionResult,\n\n approval_execution_patch_body: {\n execution_result: executionResult,\n updated_at: new Date().toISOString()\n },\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_upload_status_code: statusCode || null,\n bamboohr_pdf_upload_succeeded: uploadSucceeded,\n\n final_execution_status: uploadSucceeded ? 'executed' : 'failed',\n final_execution_result: executionResult,\n execution_result: executionResult\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-15280,
|
||
-4496
|
||
],
|
||
"id": "3e3a4d95-812b-4704-98af-58a7a0d5e45d",
|
||
"name": "Code - Format PDF Upload Result"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "PATCH",
|
||
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_pending_approvals?approval_id=eq.' + encodeURIComponent($json.approval_id) }}",
|
||
"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.stringify($json.approval_execution_patch_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-15072,
|
||
-4496
|
||
],
|
||
"id": "e1e4f580-b6bd-45b8-9557-ec3375deb38b",
|
||
"name": "HTTP - Mark PDF Upload Finished",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction preview(value, maxLength = 1500) {\n try {\n if (value === undefined || value === null) return null;\n\n const text =\n typeof value === 'string'\n ? value\n : JSON.stringify(value);\n\n return text.slice(0, maxLength);\n } catch (error) {\n return String(value || '').slice(0, maxLength);\n }\n}\n\nconst uploadStatus =\n data.bamboohr_pdf_upload_succeeded === true\n ? 'success'\n : 'failed';\n\nconst metadataLogBody = {\n request_id: cleanText(data.request_id) || null,\n approval_id: cleanText(data.approval_id) || null,\n\n requester_email:\n cleanText(data.requested_by_email) ||\n cleanText(data.user_email) ||\n cleanText(data.audit_event?.user_email) ||\n null,\n\n requester_name:\n cleanText(data.requested_by_name) ||\n cleanText(data.user_name) ||\n cleanText(data.audit_event?.user_name) ||\n null,\n\n approver_email:\n cleanText(data.approved_by_email) ||\n cleanText(data.approver_email) ||\n cleanText(data.audit_event?.approved_by_email) ||\n null,\n\n approver_name:\n cleanText(data.approved_by_name) ||\n cleanText(data.approver_name) ||\n cleanText(data.audit_event?.approved_by_name) ||\n null,\n\n target_employee_id:\n cleanText(data.pdf_employee_id) ||\n cleanText(data.resolved_employee_id) ||\n cleanText(data.target_employee_id) ||\n null,\n\n target_employee_name:\n cleanText(data.pdf_employee_name) ||\n cleanText(data.resolved_employee_name) ||\n cleanText(data.target_employee_name) ||\n null,\n\n document_type:\n cleanText(data.pdf_document_type) ||\n cleanText(data.audit_event?.document_type) ||\n null,\n\n document_country:\n cleanText(data.pdf_country) ||\n cleanText(data.audit_event?.document_country) ||\n null,\n\n original_file_name:\n cleanText(data.pdf_original_file_name) ||\n cleanText(data.audit_event?.original_file_name) ||\n null,\n\n final_file_name:\n cleanText(data.bamboohr_file_name) ||\n cleanText(data.pdf_final_file_name) ||\n null,\n\n bamboohr_file_category:\n [\n cleanText(data.bamboohr_file_category_id),\n cleanText(data.bamboohr_file_category_name)\n ].filter(Boolean).join(' - ') ||\n cleanText(data.bamboohr_file_category) ||\n null,\n\n attachment_source:\n cleanText(data.pdf_attachment_source) ||\n cleanText(data.audit_event?.attachment_source) ||\n null,\n\n google_chat_attachment_resource_name:\n cleanText(data.pdf_attachment_resource_name) ||\n cleanText(data.audit_event?.google_chat_attachment_resource_name) ||\n null,\n\n google_drive_file_id:\n cleanText(data.pdf_drive_file_id) ||\n cleanText(data.audit_event?.google_drive_file_id) ||\n null,\n\n upload_status: uploadStatus,\n\n execution_result:\n cleanText(data.final_execution_result) ||\n cleanText(data.audit_event?.execution_result) ||\n null,\n\n bamboohr_status_code:\n data.bamboohr_upload_status_code !== undefined &&\n data.bamboohr_upload_status_code !== null\n ? String(data.bamboohr_upload_status_code)\n : null,\n\n bamboohr_response_preview:\n preview(data.bamboohr_upload_http_result)\n};\n\nreturn {\n json: {\n ...data,\n pdf_upload_metadata_log_body: metadataLogBody\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-14944,
|
||
-4688
|
||
],
|
||
"id": "6e63b7d9-182d-4ed9-96b9-ec1e3ea287e3",
|
||
"name": "Code - Build PDF Upload Metadata Log"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_file_upload_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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.pdf_upload_metadata_log_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-14736,
|
||
-4688
|
||
],
|
||
"id": "e4b522ab-a8cd-4b70-a0c2-c3dc34f61193",
|
||
"name": "Supabase - Log PDF Upload Metadata",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction preview(value, maxLength = 1500) {\n try {\n if (value === undefined || value === null) return null;\n\n const text =\n typeof value === 'string'\n ? value\n : JSON.stringify(value);\n\n return text.slice(0, maxLength);\n } catch (error) {\n return String(value || '').slice(0, maxLength);\n }\n}\n\nconst blockers =\n data.pdf_upload_blockers ||\n data.pdf_download_blockers ||\n data.bamboohr_pdf_upload_blockers ||\n [];\n\nconst blockerText = Array.isArray(blockers)\n ? blockers.join(' | ')\n : cleanText(blockers);\n\nconst metadataLogBody = {\n request_id: cleanText(data.request_id) || null,\n approval_id: cleanText(data.approval_id) || null,\n\n requester_email:\n cleanText(data.requested_by_email) ||\n cleanText(data.user_email) ||\n cleanText(data.audit_event?.user_email) ||\n null,\n\n requester_name:\n cleanText(data.requested_by_name) ||\n cleanText(data.user_name) ||\n cleanText(data.audit_event?.user_name) ||\n null,\n\n approver_email:\n cleanText(data.approved_by_email) ||\n cleanText(data.approver_email) ||\n cleanText(data.audit_event?.approved_by_email) ||\n null,\n\n approver_name:\n cleanText(data.approved_by_name) ||\n cleanText(data.approver_name) ||\n cleanText(data.audit_event?.approved_by_name) ||\n null,\n\n target_employee_id:\n cleanText(data.pdf_employee_id) ||\n cleanText(data.resolved_employee_id) ||\n cleanText(data.target_employee_id) ||\n null,\n\n target_employee_name:\n cleanText(data.pdf_employee_name) ||\n cleanText(data.resolved_employee_name) ||\n cleanText(data.target_employee_name) ||\n null,\n\n document_type:\n cleanText(data.pdf_document_type) ||\n cleanText(data.audit_event?.document_type) ||\n null,\n\n document_country:\n cleanText(data.pdf_country) ||\n cleanText(data.audit_event?.document_country) ||\n null,\n\n original_file_name:\n cleanText(data.pdf_original_file_name) ||\n cleanText(data.audit_event?.original_file_name) ||\n null,\n\n final_file_name:\n cleanText(data.bamboohr_file_name) ||\n cleanText(data.pdf_final_file_name) ||\n null,\n\n bamboohr_file_category:\n [\n cleanText(data.bamboohr_file_category_id),\n cleanText(data.bamboohr_file_category_name)\n ].filter(Boolean).join(' - ') ||\n cleanText(data.bamboohr_file_category) ||\n null,\n\n attachment_source:\n cleanText(data.pdf_attachment_source) ||\n cleanText(data.audit_event?.attachment_source) ||\n null,\n\n google_chat_attachment_resource_name:\n cleanText(data.pdf_attachment_resource_name) ||\n cleanText(data.audit_event?.google_chat_attachment_resource_name) ||\n null,\n\n google_drive_file_id:\n cleanText(data.pdf_drive_file_id) ||\n cleanText(data.audit_event?.google_drive_file_id) ||\n null,\n\n upload_status: 'blocked',\n\n execution_result:\n cleanText(data.final_execution_result) ||\n cleanText(data.audit_event?.execution_result) ||\n 'pdf_upload_blocked',\n\n bamboohr_status_code: null,\n\n bamboohr_response_preview:\n preview({\n blockers: blockerText,\n response_text: data.response_text || null\n })\n};\n\nreturn {\n json: {\n ...data,\n pdf_upload_metadata_log_body: metadataLogBody\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-14112,
|
||
-4528
|
||
],
|
||
"id": "948083aa-269e-4f2d-9f4d-76e4e6edfd01",
|
||
"name": "Code - Build PDF Blocked Metadata Log"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "=https://dbit.digitalcompass.agency/rest/v1/bamboohr_agent_file_upload_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=minimal"
|
||
}
|
||
]
|
||
},
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={{ JSON.stringify($json.pdf_upload_metadata_log_body) }}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-13904,
|
||
-4528
|
||
],
|
||
"id": "ddfdb8cb-5f64-41a0-8ecc-099213f8c51f",
|
||
"name": "Supabase - Log PDF Blocked Metadata",
|
||
"retryOnFail": true
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://glm.bamboohr.com/api/v1/employees/' + encodeURIComponent($json.pdf_employee_id) + '/files/view/' }}",
|
||
"authentication": "genericCredentialType",
|
||
"genericAuthType": "httpBasicAuth",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-16272,
|
||
-5136
|
||
],
|
||
"id": "12327d4c-bfc0-4658-96ec-1be2ccb34ad0",
|
||
"name": "HTTP - List BambooHR Employee File Categories",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"httpBasicAuth": {
|
||
"id": "7VrpNZ2jBLmiJ35q",
|
||
"name": "BambooHR GLM Full Access"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const httpResult = $json;\n\nfunction getNodeJson(nodeName) {\n try {\n const items = $items(nodeName);\n\n if (items && items[0] && items[0].json) {\n return items[0].json;\n }\n } catch (error) {\n return null;\n }\n\n return null;\n}\n\nfunction cleanText(value) {\n return String(value || '').trim();\n}\n\nfunction normalize(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^\\w\\s-]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction asArray(value) {\n if (Array.isArray(value)) return value;\n if (value && typeof value === 'object') return [value];\n return [];\n}\n\nfunction findCategoriesDeep(value, depth = 0) {\n if (!value || depth > 5) return [];\n\n if (Array.isArray(value)) {\n let results = [];\n\n for (const item of value) {\n results = results.concat(findCategoriesDeep(item, depth + 1));\n }\n\n return results;\n }\n\n if (typeof value !== 'object') return [];\n\n let found = [];\n\n const possibleArrays = [\n value.categories,\n value.employeeFileCategories,\n value.employee_file_categories,\n value.fileCategories,\n value.file_categories,\n value.files,\n value.data\n ];\n\n for (const possible of possibleArrays) {\n if (Array.isArray(possible)) {\n found = found.concat(possible);\n }\n }\n\n for (const item of Object.values(value)) {\n if (item && typeof item === 'object') {\n found = found.concat(findCategoriesDeep(item, depth + 1));\n }\n }\n\n return found;\n}\n\nfunction normalizeCategory(item) {\n if (!item || typeof item !== 'object') return null;\n\n const id =\n item.id ||\n item.categoryId ||\n item.category_id ||\n item.fileCategoryId ||\n item.file_category_id ||\n item.sectionId ||\n item.section_id ||\n null;\n\n const name =\n item.name ||\n item.category ||\n item.categoryName ||\n item.category_name ||\n item.displayName ||\n item.display_name ||\n item.sectionName ||\n item.section_name ||\n null;\n\n if (!id || !name) return null;\n\n const canUploadRaw =\n item.canUpload ??\n item.can_upload ??\n item.canUploadFiles ??\n item.can_upload_files ??\n item.canAddFiles ??\n item.can_add_files ??\n item.permissions?.canUpload ??\n item.permissions?.can_upload ??\n null;\n\n const canUpload =\n canUploadRaw === null ||\n canUploadRaw === undefined ||\n canUploadRaw === true ||\n canUploadRaw === 'true' ||\n canUploadRaw === 'yes' ||\n canUploadRaw === 1;\n\n return {\n id: String(id),\n name: cleanText(name),\n normalized_name: normalize(name),\n can_upload: canUpload,\n raw: item\n };\n}\n\nfunction uniqueCategories(categories) {\n const map = new Map();\n\n for (const category of categories) {\n const normalized = normalizeCategory(category);\n\n if (!normalized) continue;\n\n const key = `${normalized.id}_${normalized.normalized_name}`;\n\n if (!map.has(key)) {\n map.set(key, normalized);\n }\n }\n\n return Array.from(map.values());\n}\n\nfunction getDesiredKeywords(documentType, desiredLabel) {\n const text = normalize(`${documentType} ${desiredLabel}`);\n\n if (text.includes('oferta') || text.includes('offer')) {\n return ['oferta', 'offer', 'carta', 'letter'];\n }\n\n if (text.includes('contrato') || text.includes('contract')) {\n return ['contrato', 'contract', 'agreement'];\n }\n\n if (\n text.includes('cedula') ||\n text.includes('pasaporte') ||\n text.includes('licencia') ||\n text.includes('personal')\n ) {\n return ['personal', 'documento', 'documentos', 'identificacion', 'identification'];\n }\n\n if (text.includes('certificacion') || text.includes('certificado')) {\n return ['certificacion', 'certification', 'certificado'];\n }\n\n if (text.includes('cv') || text.includes('resume') || text.includes('hoja de vida')) {\n return ['cv', 'resume', 'curriculum', 'application', 'aplicacion'];\n }\n\n if (\n text.includes('renuncia') ||\n text.includes('desvinculacion') ||\n text.includes('terminacion') ||\n text.includes('termination')\n ) {\n return ['termination', 'terminacion', 'desvinculacion', 'renuncia', 'salida'];\n }\n\n if (text.includes('amonestacion') || text.includes('disciplinario')) {\n return ['disciplinario', 'disciplinary', 'amonestacion'];\n }\n\n if (text.includes('evaluacion') || text.includes('performance')) {\n return ['performance', 'evaluacion', 'evaluaciones'];\n }\n\n return ['employee', 'empleado', 'document', 'documento', 'general', 'otros', 'other'];\n}\n\nfunction scoreCategory(category, keywords) {\n let score = 0;\n\n if (!category.can_upload) {\n score -= 100;\n }\n\n const name = category.normalized_name;\n\n for (const keyword of keywords) {\n const normalizedKeyword = normalize(keyword);\n\n if (!normalizedKeyword) continue;\n\n if (name === normalizedKeyword) {\n score += 100;\n } else if (name.includes(normalizedKeyword)) {\n score += 25;\n }\n }\n\n const genericWords = ['employee', 'empleado', 'document', 'documento', 'documents', 'documentos', 'general', 'otros', 'other'];\n\n for (const word of genericWords) {\n if (name.includes(normalize(word))) {\n score += 5;\n }\n }\n\n return score;\n}\n\nconst context =\n getNodeJson('Code - Prepare BambooHR PDF Upload Request') ||\n {};\n\nconst rawCategories = uniqueCategories(findCategoriesDeep(httpResult));\n\nconst uploadableCategories = rawCategories.filter(category => category.can_upload);\n\nconst desiredLabel =\n context.bamboohr_file_category ||\n context.bamboohr_file_category_name ||\n context.pdf_document_type ||\n 'Employee Documents';\n\nconst documentType =\n context.pdf_document_type ||\n desiredLabel ||\n 'Documento';\n\nconst keywords = getDesiredKeywords(documentType, desiredLabel);\n\nconst scored = uploadableCategories\n .map(category => ({\n ...category,\n score: scoreCategory(category, keywords)\n }))\n .sort((a, b) => b.score - a.score);\n\nconst bestCategory =\n scored.find(category => category.score > 0) ||\n uploadableCategories.find(category =>\n category.normalized_name.includes('employee') ||\n category.normalized_name.includes('empleado') ||\n category.normalized_name.includes('document') ||\n category.normalized_name.includes('documento') ||\n category.normalized_name.includes('general')\n ) ||\n null;\n\nconst blockers = Array.isArray(context.bamboohr_pdf_upload_blockers)\n ? [...context.bamboohr_pdf_upload_blockers]\n : [];\n\nif (!bestCategory) {\n blockers.push(\n 'No se pudo resolver un ID de categoría válido para archivos del empleado en BambooHR.'\n );\n}\n\nconst ready =\n context.bamboohr_pdf_upload_ready === true &&\n !!bestCategory &&\n blockers.length === 0;\n\nreturn {\n json: {\n ...context,\n\n bamboohr_file_categories_response: httpResult,\n bamboohr_file_categories_detected: rawCategories,\n bamboohr_file_categories_uploadable: uploadableCategories,\n\n bamboohr_file_category_desired_label: desiredLabel,\n\n bamboohr_file_category_id: bestCategory?.id || null,\n bamboohr_file_category_name: bestCategory?.name || null,\n\n bamboohr_file_category_match_score: bestCategory?.score || null,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n final_execution_status: ready\n ? 'ready_to_upload_to_bamboohr'\n : 'blocked',\n\n audit_event: {\n ...(context.audit_event || {}),\n\n bamboohr_file_category_desired_label: desiredLabel,\n bamboohr_file_category_id: bestCategory?.id || null,\n bamboohr_file_category_name: bestCategory?.name || null,\n bamboohr_file_category_match_score: bestCategory?.score || null,\n\n bamboohr_file_categories_count: rawCategories.length,\n bamboohr_file_uploadable_categories_count: uploadableCategories.length,\n\n bamboohr_pdf_upload_ready: ready,\n bamboohr_pdf_upload_blockers: blockers,\n\n execution_result: ready\n ? 'pdf_ready_to_upload_to_bamboohr'\n : 'pdf_upload_to_bamboohr_blocked_category_not_resolved'\n }\n },\n binary: {\n data: {\n ...$binary.data,\n fileName: context.bamboohr_file_name || context.pdf_final_file_name || 'documento.pdf',\n mimeType: 'application/pdf'\n }\n }\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-16064,
|
||
-5136
|
||
],
|
||
"id": "1b404504-66a1-4151-a2ed-fa616ae0bf63",
|
||
"name": "Code - Resolve BambooHR PDF Category ID"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst attachmentResourceName =\n data.pdf_attachment_resource_name ||\n data.pdf_google_chat_attachment_resource_name ||\n data.audit_event?.google_chat_attachment_resource_name ||\n null;\n\nconst driveFileId =\n data.pdf_drive_file_id ||\n data.google_drive_file_id ||\n data.audit_event?.google_drive_file_id ||\n null;\n\nconst attachmentSource =\n data.pdf_attachment_source ||\n data.attachment_source ||\n data.audit_event?.attachment_source ||\n 'unknown';\n\nlet route = 'unsupported_source';\nconst blockers = [];\n\nif (attachmentResourceName) {\n route = 'google_chat_media';\n} else if (driveFileId) {\n route = 'google_drive_file';\n} else {\n blockers.push('El PDF no tiene attachmentResourceName de Google Chat ni driveFileId de Google Drive.');\n}\n\nreturn {\n json: {\n ...data,\n\n pdf_download_source_route: route,\n pdf_download_source_blockers: blockers,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource,\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_download_source_route: route,\n pdf_download_source_blockers: blockers,\n\n pdf_attachment_resource_name: attachmentResourceName,\n pdf_drive_file_id: driveFileId,\n pdf_attachment_source: attachmentSource\n }\n },\n binary: $binary\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17552,
|
||
-4320
|
||
],
|
||
"id": "f5bebc99-90b7-411f-8ef6-94544373506f",
|
||
"name": "Code - Decide Approved PDF Download Source"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "google_chat_media",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
},
|
||
"id": "ca967a0f-d67b-4799-b2ca-9f81ebebcd97"
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "google_chat_media"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "c0f75af3-de22-4161-b383-c38301495faf",
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "google_drive_file",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "google_drive_file"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict",
|
||
"version": 3
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "b2875375-4d5c-4060-8965-d0d341f94636",
|
||
"leftValue": "={{ $json.pdf_download_source_route }}",
|
||
"rightValue": "unsupported_source",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals",
|
||
"name": "filter.operator.equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"renameOutput": true,
|
||
"outputKey": "unsupported_source"
|
||
}
|
||
]
|
||
},
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.4,
|
||
"position": [
|
||
-17360,
|
||
-4448
|
||
],
|
||
"id": "33ee8521-e9fb-4095-9a32-11f7fbd9bc0d",
|
||
"name": "Switch - Approved PDF Download Source"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"url": "={{ 'https://www.googleapis.com/drive/v3/files/' + encodeURIComponent($json.pdf_drive_file_id) + '?alt=media' }}",
|
||
"authentication": "predefinedCredentialType",
|
||
"nodeCredentialType": "googleOAuth2Api",
|
||
"sendHeaders": true,
|
||
"headerParameters": {
|
||
"parameters": [
|
||
{
|
||
"name": "Accept",
|
||
"value": "application/pdf"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"response": {
|
||
"response": {
|
||
"responseFormat": "file"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.4,
|
||
"position": [
|
||
-17104,
|
||
-4592
|
||
],
|
||
"id": "ff2a8f74-1b3f-4296-ba14-feba62b13330",
|
||
"name": "HTTP - Download Approved PDF From Google Drive",
|
||
"retryOnFail": true,
|
||
"credentials": {
|
||
"googleOAuth2Api": {
|
||
"id": "eHseMeH39kRcXgOF",
|
||
"name": "Google account 2"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"mode": "runOnceForEachItem",
|
||
"jsCode": "const data = $json;\n\nconst blockers = Array.isArray(data.pdf_download_source_blockers)\n ? data.pdf_download_source_blockers\n : ['No se pudo determinar la fuente descargable del PDF.'];\n\nreturn {\n json: {\n ...data,\n\n pdf_download_ready: false,\n pdf_download_blockers: blockers,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_source_not_supported',\n\n audit_event: {\n ...(data.audit_event || {}),\n\n pdf_download_ready: false,\n pdf_download_blockers: blockers,\n\n final_execution_status: 'blocked',\n final_execution_result: 'pdf_download_source_not_supported',\n execution_result: 'pdf_download_source_not_supported'\n }\n },\n binary: $binary\n};"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
-17072,
|
||
-4384
|
||
],
|
||
"id": "42b9d5b3-55b3-438f-a158-505d3fbe9917",
|
||
"name": "Code - Prepare PDF Download Source Blocker"
|
||
}
|
||
],
|
||
"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 - Apply Gemini Interpretation",
|
||
"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": "Switch - Report Type",
|
||
"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 - Detect Approval Command",
|
||
"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": "Code - Prepare Pending Confirmation",
|
||
"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": "HTTP - List Employees For Resolver",
|
||
"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 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 - 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": "Code - Sensitive Approval Gate",
|
||
"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 - Detect Approval Button Event",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Google Chat Input": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respond - Google Chat Processing",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Google Chat Channel?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Google Chat Final Message",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Google Chat Final Message": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Google Chat Final Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Google Chat Final Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Enviar respuesta final",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Respond - Google Chat Processing": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Detect Confirmation Reply",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Confirmation Reply": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Confirmation Reply?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Confirmation Reply?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Get Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Request Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Get Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Confirmation Reply",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Confirmation Reply": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Confirmation Cancelled?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Confirmation Cancelled?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Mark Confirmation Cancelled",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - Confirmation Confirmed?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Mark Confirmation Cancelled": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Cancelled Confirmation Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Should Save Pending Confirmation?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Should Save Pending Confirmation?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Supersede Previous Pending Confirmations",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - Is Google Chat Channel?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Insert Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Response After Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Response After Pending Confirmation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Google Chat Channel?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Cancelled Confirmation Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Confirmation Confirmed?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Mark Confirmation Confirmed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Mark Confirmation Confirmed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Confirmed Confirmation Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Confirmed Confirmation Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Request Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Supersede Previous Pending Confirmations": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Restore Pending Confirmation After Supersede",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Restore Pending Confirmation After Supersede": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Insert Pending Confirmation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Gemini Intent Prompt": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "AI Agent - Gemini Intent Interpreter",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Gemini Chat Model": {
|
||
"ai_languageModel": [
|
||
[
|
||
{
|
||
"node": "AI Agent - Gemini Intent Interpreter",
|
||
"type": "ai_languageModel",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"AI Agent - Gemini Intent Interpreter": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Normalize Gemini Intent",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Gemini Intent": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Intent Parser",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List Employees For Resolver": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Match Employee From List Employees",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Match Employee From List Employees": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Employee Scope Gate",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Custom Report Active Inactive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Active Inactive Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Report Type": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Custom Report Active Inactive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Saved Report Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Employees Directory Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - List BambooHR Saved Reports",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Saved Report Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Active Inactive Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List BambooHR Saved Reports": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Reports List",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Reports List": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Audit Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Normalize Saved Report Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Has ID?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Has ID?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Wants Export?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - List Saved Reports For Run",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List Saved Reports For Run": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Saved Report By Name",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Saved Report By Name": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Name Resolved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Name Resolved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Saved Report Wants Export?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Name Not Found",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Name Not Found": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Saved Report Wants Export?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Export Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Export Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report Export",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report Export": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Page Requests",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Export Preview": {
|
||
"main": [
|
||
[]
|
||
]
|
||
},
|
||
"Code - Prepare Saved Report Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Google Sheets - Create Saved Report Spreadsheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Sheets - Create Saved Report Spreadsheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Saved Report Sheet Values",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Saved Report Sheet Values": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Google Sheets - Write Saved Report Rows",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Saved Report Google Sheet Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Google Sheets - Write Saved Report Rows": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Saved Report Sheet Format Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Page Requests": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Run BambooHR Saved Report Export Page",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Run BambooHR Saved Report Export Page": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Combine Saved Report Export Pages",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Combine Saved Report Export Pages": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Saved Report Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Saved Report Sheet Format Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Format Saved Report Google Sheet",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Format Saved Report Google Sheet": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Saved Report Google Sheet Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Apply Gemini Interpretation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Intent",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Sensitive Approval Gate": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Needs Maximo Approval?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Needs Maximo Approval?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Maximo Approval Record",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "IF - User Authorized?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Maximo Approval Record": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Insert Pending Maximo Approval",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Insert Pending Maximo Approval": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Maximo Approval Pending Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "HTTP - Get Maximo Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Maximo Approval Pending Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Maximo Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Maximo Chat Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Maximo Chat Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Maximo Chat Space Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Maximo Chat Space Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Enviar notificación aprobación Máximo",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Approval Command": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Approval Command?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Approval Command?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Command Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Build Gemini Intent Prompt",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Command Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Pending Approval By ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Pending Approval By ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Approval Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Decision Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Decision Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Update Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Update Approval Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Approval Decision Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approval Decision Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Agent Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Detect Approval Button Event": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Is Approval Button Event?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Is Approval Button Event?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Button Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Normalize Google Chat Input",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Button Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Approval By Button ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Approval By Button ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Approval Button Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Approval Button Decision": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approval Button Decision Valid?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approval Button Decision Valid?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Update Approval From Button",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Update Approval From Button": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Approval Button Response",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approval Button Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Respond - Approval Button Result",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Prepare Requester Approval Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "IF - Maximo Approved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Requester Approval Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Should Notify Requester?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Should Notify Requester?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Requester Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Requester Decision Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Requester Decision Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Requester Chat Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Requester Chat Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Maximo Approved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Approved Action Execution Context",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Approved Action Execution Context": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Action Supported?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Action Supported?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Switch - Approved Action Route",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Approved Action Not Supported",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approved Action Not Supported": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Approved Action Route": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare Approved Compensation Preflight",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[],
|
||
[],
|
||
[],
|
||
[
|
||
{
|
||
"node": "Code - Prepare Approved PDF Upload",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Approved Action Not Supported1",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Approved Compensation Preflight": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Approved Get Employee Directory",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Approved Get Employee Directory": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve Approved Employee From Directory",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve Approved Employee From Directory": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Employee Resolved?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Employee Resolved?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Approved Get Current Compensation",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Approved Get Current Compensation": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Approved Compensation Write Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Approved Compensation Write Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved Compensation Ready To Execute?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved Compensation Ready To Execute?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Execute Approved Compensation Update",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format Compensation Execution Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Compensation Execution Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approval Execution Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Execute Approved Compensation Update": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format Compensation Execution Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Compensation Execution Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approval Executed",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approval Executed": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approval Execution Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build Requester Final Execution Notification": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Requester Final Chat Available?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Requester Final Chat Available?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Chat - Notify Requester Approval Decision",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Get Requester Final Chat Contact": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build Requester Final Execution Notification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format Approved Action Not Supported1": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark Approved Action Not Supported",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark Approved Action Not Supported": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare Approved PDF Upload": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Approved PDF Upload Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Approved PDF Upload Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Decide Approved PDF Download Source",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Upload Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Upload Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Upload Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Upload Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Download Approved PDF From Google Chat": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Downloaded PDF Binary",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Validate Downloaded PDF Binary": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - Downloaded PDF Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - Downloaded PDF Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Prepare BambooHR PDF Upload Request",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Download Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Download Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare BambooHR PDF Upload Request": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - List BambooHR Employee File Categories",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"IF - BambooHR PDF Upload Ready?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Upload PDF To BambooHR",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Format BambooHR PDF Upload Precheck Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format BambooHR PDF Upload Precheck Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark BambooHR PDF Upload Precheck Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark BambooHR PDF Upload Precheck Blocked": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Upload PDF To BambooHR": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Upload Result",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Format PDF Upload Result": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Mark PDF Upload Finished",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Upload Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build PDF Upload Metadata Log": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Log PDF Upload Metadata",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Supabase - Log PDF Upload Metadata": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Build PDF Blocked Metadata Log": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Supabase - Log PDF Blocked Metadata",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - List BambooHR Employee File Categories": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Resolve BambooHR PDF Category ID",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Resolve BambooHR PDF Category ID": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "IF - BambooHR PDF Upload Ready?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Mark PDF Upload Finished": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Get Requester Final Chat Contact",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Decide Approved PDF Download Source": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Switch - Approved PDF Download Source",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Switch - Approved PDF Download Source": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP - Download Approved PDF From Google Chat",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "HTTP - Download Approved PDF From Google Drive",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Code - Prepare PDF Download Source Blocker",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"HTTP - Download Approved PDF From Google Drive": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Validate Downloaded PDF Binary",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Code - Prepare PDF Download Source Blocker": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Code - Format PDF Download Blocked",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Code - Build PDF Blocked Metadata Log",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"authors": "Isaac Aracena",
|
||
"name": "Version bdb63908",
|
||
"description": "",
|
||
"autosaved": true,
|
||
"workflowPublishHistory": [
|
||
{
|
||
"createdAt": "2026-07-02T22:04:39.386Z",
|
||
"id": 2300,
|
||
"workflowId": "5VOiadzdgbs2Qq1N",
|
||
"versionId": "bdb63908-b79f-4b9e-957a-fe3f12332942",
|
||
"event": "activated",
|
||
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
||
}
|
||
]
|
||
}
|
||
} |