diff --git a/tablero-cdc-sync-registro-horas.json b/tablero-cdc-sync-registro-horas.json new file mode 100644 index 0000000..e50ff78 --- /dev/null +++ b/tablero-cdc-sync-registro-horas.json @@ -0,0 +1,891 @@ +{ + "updatedAt": "2026-08-16T19:46:37.104Z", + "createdAt": "2026-08-16T17:10:19.586Z", + "id": "zOosAqUTV2qS1oro", + "name": "Tablero CDC - Sync Registro Horas", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "tablero-cdc-sync-registro-horas", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 496, + 624 + ], + "id": "147264a4-72cc-4237-83bf-cc0c6574215d", + "name": "Webhook - Sync registro horas", + "webhookId": "b8e063ca-065b-4b29-848c-cfcbe98cc35c" + }, + { + "parameters": { + "jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) { return String(value ?? '').trim(); }\n\nconst action = clean(body.action || 'sync_project').toLowerCase();\nconst projectId = clean(body.project_id || body.projectId);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nif (action !== 'sync_project') throw new Error('Acción no soportada.');\nif (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectId)) {\n throw new Error('Project ID inválido.');\n}\nif (!accessToken) throw new Error('No se recibió una sesión válida de Supabase.');\n\nreturn [{ json: {\n action,\n project_id: projectId,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc-time',\n received_at: new Date().toISOString(),\n} }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 720, + 624 + ], + "id": "f3f6b85d-8ba0-40fe-ae6d-d9bac4d81f74", + "name": "Code - Preparar solicitud" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/auth/v1/user", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 944, + 624 + ], + "id": "7f4c5187-b496-4929-bcab-b0af2b5076ce", + "name": "Supabase - Validar sesión", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200 + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,title,client,country,country_manager,created_at&limit=1' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1168, + 512 + ], + "id": "a45b14ed-76df-4ea5-8e18-fbe03a76c3e3", + "name": "Supabase - Leer proyecto", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_time_entries?project_id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,project_id,country,task_name,duration_minutes,work_date,notes,created_by_name,created_by_email,created_at&order=work_date.asc,created_at.asc&limit=5000' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1376, + 512 + ], + "id": "99d8832a-52f9-477a-a857-e5d3bce9811f", + "name": "Supabase - Leer tiempos proyecto", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const entries = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) entries.push(...raw);\n else if (raw && Array.isArray(raw.body)) entries.push(...raw.body);\n else if (raw && raw.id) entries.push(raw);\n}\nreturn [{ json: { entries } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1600, + 512 + ], + "id": "dba4abe6-1dca-4bbe-8840-5a9c388687ae", + "name": "Code - Consolidar tiempos" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.country_manager&is_active=eq.true&select=value,label&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1824, + 512 + ], + "id": "c46811a4-5a9d-4696-b083-2e85bdf945f9", + "name": "Supabase - Leer Country Managers", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const managers = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) managers.push(...raw);\n else if (raw && Array.isArray(raw.body)) managers.push(...raw.body);\n else if (raw && (raw.value || raw.label)) managers.push(raw);\n}\nreturn [{ json: { managers } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2048, + 512 + ], + "id": "2d307078-a969-42b3-9e1d-5b47b02ddf2e", + "name": "Code - Consolidar Country Managers" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/%27Registro%20horas%27!B2:U20000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2256, + 512 + ], + "id": "b4ac9069-f9ed-4938-af31-f16ceb68143d", + "name": "Google Sheets API - Leer Registro horas", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetResponse = $input.first().json || {};\nconst values = Array.isArray(sheetResponse.values)\n ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : [])\n : [];\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction key(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction escSheetName(name) {\n return `'${String(name).replace(/'/g, \"''\")}'`;\n}\n\nfunction extractProjectFromNode(nodeName) {\n let items = [];\n try {\n items = $(nodeName).all();\n } catch (error) {\n items = [];\n }\n\n const candidates = [];\n\n for (const item of items) {\n const raw = item?.json;\n\n if (Array.isArray(raw)) {\n candidates.push(...raw);\n continue;\n }\n\n if (!raw || typeof raw !== 'object') continue;\n\n candidates.push(raw);\n\n if (Array.isArray(raw.body)) candidates.push(...raw.body);\n else if (raw.body && typeof raw.body === 'object') candidates.push(raw.body);\n\n if (Array.isArray(raw.data)) candidates.push(...raw.data);\n else if (raw.data && typeof raw.data === 'object') candidates.push(raw.data);\n\n if (raw.response && typeof raw.response === 'object') {\n if (Array.isArray(raw.response.body)) candidates.push(...raw.response.body);\n else if (raw.response.body && typeof raw.response.body === 'object') {\n candidates.push(raw.response.body);\n }\n }\n }\n\n return candidates.find(candidate =>\n candidate &&\n typeof candidate === 'object' &&\n clean(candidate.id)\n ) || null;\n}\n\nconst project = extractProjectFromNode('Supabase - Leer proyecto');\nconst entries = $('Code - Consolidar tiempos').first().json.entries || [];\nconst projectId = clean($('Code - Preparar solicitud').first().json.project_id);\n\nif (!project) {\n throw new Error(`No se encontró el proyecto ${projectId} en la respuesta de Supabase.`);\n}\n\nif (clean(project.id).toLowerCase() !== projectId.toLowerCase()) {\n throw new Error(`SEGURIDAD: Supabase devolvió un proyecto diferente al solicitado (${clean(project.id)}).`);\n}\n\n// La lectura empieza en B2, por eso los índices son relativos a B:U.\n// C, D, H, R y S son columnas calculadas/protegidas en el Sheet y NUNCA se escriben.\n// L:P son campos manuales y NUNCA se sobrescriben.\nconst header = values[0] || [];\nconst timeHeader = clean(header[18]); // T\nconst projectHeader = clean(header[19]); // U\nconst expectedTimeHeader = 'Tablero Time ID';\nconst expectedProjectHeader = 'Tablero Project ID';\n\nif (timeHeader && key(timeHeader) !== key(expectedTimeHeader)) {\n throw new Error(`SEGURIDAD: la columna T ya tiene el encabezado “${timeHeader}”. No se modificó Registro horas.`);\n}\n\nif (projectHeader && key(projectHeader) !== key(expectedProjectHeader)) {\n throw new Error(`SEGURIDAD: la columna U ya tiene el encabezado “${projectHeader}”. No se modificó Registro horas.`);\n}\n\nif ((!timeHeader || !projectHeader)) {\n for (let i = 1; i < values.length; i += 1) {\n const row = values[i] || [];\n if (clean(row[18]) || clean(row[19])) {\n throw new Error('SEGURIDAD: existen datos en T/U sin los encabezados de integración. No se modificó Registro horas.');\n }\n }\n}\n\nconst currentIds = new Set(entries.map(entry => clean(entry.id)).filter(Boolean));\nconst existingByEntryId = new Map();\nconst projectRows = [];\nconst blankRows = [];\n\n// Estas son las columnas que una persona puede completar manualmente o que pertenecen\n// a la integración. Si cualquiera tiene contenido, la fila NO se considera libre.\n// Se excluyen C,D,H,R,S porque son calculadas por el propio Sheet.\nconst meaningfulIndexes = [0, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19];\n\nfor (let rowNum = 3; rowNum <= 20000; rowNum += 1) {\n const row = values[rowNum - 2] || [];\n const timeId = clean(row[18]);\n const rowProjectId = clean(row[19]);\n\n if (timeId) {\n if (existingByEntryId.has(timeId)) {\n throw new Error(`SEGURIDAD: el Tablero Time ID ${timeId} aparece duplicado en Registro horas.`);\n }\n existingByEntryId.set(timeId, { rowNum, row });\n }\n\n if (rowProjectId === projectId) {\n projectRows.push({ rowNum, row, timeId });\n }\n\n const isTrulyBlank = meaningfulIndexes.every(index => !clean(row[index]));\n if (isTrulyBlank) {\n blankRows.push({ rowNum, row });\n }\n}\n\nconst staleRows = projectRows.filter(item => !item.timeId || !currentIds.has(item.timeId));\n\n// Una fila vieja solo se reutiliza si L:P están vacías. Si alguien completó algo manualmente,\n// se limpia únicamente lo que pertenece a Tablero CDC y esa fila se deja fuera del pool reutilizable.\nconst reusableStaleRows = staleRows\n .filter(item => [10, 11, 12, 13, 14].every(index => !clean(item.row[index])))\n .map(item => ({ rowNum: item.rowNum, row: item.row }));\n\nconst reusableRows = [...reusableStaleRows, ...blankRows];\nconst usedRows = new Set();\nconst data = [];\nconst sheet = escSheetName('Registro horas');\n\nfunction pushRange(range, rowValues) {\n data.push({\n range,\n majorDimension: 'ROWS',\n values: [rowValues],\n });\n}\n\nfunction clearOwnedColumns(rowNum) {\n // No tocamos columnas calculadas/protegidas (C,D,H,R,S) ni manuales (L:P).\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, ['']);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, ['']);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, ['', '']);\n}\n\nfunction writeEntry(rowNum, entry) {\n const entryId = clean(entry.id);\n const dateText = clean(entry.work_date);\n\n if (!entryId || !/^\\d{4}-\\d{2}-\\d{2}$/.test(dateText)) {\n throw new Error(`Registro de tiempo inválido: ${entryId || 'sin ID'}.`);\n }\n\n const [yearText, monthText, dayText] = dateText.split('-');\n const year = Number(yearText);\n const month = Number(monthText);\n const day = Number(dayText);\n const durationMinutes = Number(entry.duration_minutes || 0);\n\n if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {\n throw new Error(`Duración inválida en ${entryId}.`);\n }\n\n const country = clean(entry.country) || clean(project.country);\n const actor = clean(entry.created_by_name) || clean(entry.created_by_email);\n const task = clean(entry.task_name);\n const notes = clean(entry.notes);\n const hours = Math.round((durationMinutes / 60) * 10000) / 10000;\n const dateFormula = `=DATE(${year},${month},${day})`;\n\n // Solo escribimos columnas editables/no protegidas.\n // El Sheet calcula automáticamente Mes, Día, CM, # MES y AÑO.\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, [dateFormula]);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, [clean(project.title), clean(project.client), country]);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, [actor, task, hours]);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, [notes]);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, [entryId, projectId]);\n}\n\n// Inicializar los encabezados técnicos únicamente si faltan; no reescribirlos en cada ejecución.\nif (!timeHeader || !projectHeader) {\n pushRange(`${sheet}!T2:U2`, [expectedTimeHeader, expectedProjectHeader]);\n}\n\nfor (const stale of staleRows) {\n usedRows.add(stale.rowNum);\n clearOwnedColumns(stale.rowNum);\n}\n\nlet createdCount = 0;\nlet updatedCount = 0;\n\nfor (const entry of entries) {\n const entryId = clean(entry.id);\n const existing = existingByEntryId.get(entryId);\n let target;\n\n if (existing) {\n target = existing;\n updatedCount += 1;\n } else {\n target = reusableRows.find(candidate => !usedRows.has(candidate.rowNum));\n if (!target) {\n throw new Error('No quedan filas realmente libres en Registro horas hasta la fila 20000.');\n }\n createdCount += 1;\n }\n\n usedRows.add(target.rowNum);\n writeEntry(target.rowNum, entry);\n}\n\nreturn [{\n json: {\n data,\n project_id: projectId,\n total_entries: entries.length,\n created_rows: createdCount,\n updated_rows: updatedCount,\n cleared_rows: staleRows.length,\n first_available_row: blankRows.length ? blankRows[0].rowNum : null,\n write_strategy: 'segmented_unprotected_ranges',\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2480, + 512 + ], + "id": "9fae728c-885c-44e6-9327-5e8ad869b19d", + "name": "Code - Planificar sincronización" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'USER_ENTERED', data: $('Code - Planificar sincronización').first().json.data } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2720, + 512 + ], + "id": "7ddca426-9515-40e0-93e0-ea970324c589", + "name": "Google Sheets API - Escribir Registro horas", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Registro horas sincronizado correctamente.', projectId: $('Code - Planificar sincronización').first().json.project_id, syncedEntries: $('Code - Planificar sincronización').first().json.total_entries, createdRows: $('Code - Planificar sincronización').first().json.created_rows, updatedRows: $('Code - Planificar sincronización').first().json.updated_rows, clearedEntries: $('Code - Planificar sincronización').first().json.cleared_rows } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 2960, + 512 + ], + "id": "d732c902-c587-4167-84b7-77f8196883d9", + "name": "Respond - Registro horas sincronizado" + }, + { + "parameters": { + "content": "## TABLERO CDC → REGISTRO HORAS — FIX COLUMNAS PROTEGIDAS\n\n• Sincroniza automáticamente el módulo **Tiempo** con `Registro horas`.\n• FIX: nunca escribe sobre las columnas calculadas/protegidas C, D, H, R y S.\n• Solo escribe B, E:G, I:K, Q y T:U; L:P se preservan porque son campos manuales.\n• Una fila solo se reutiliza si está realmente libre; ya no toma como libre una fila parcialmente completada (por ejemplo, con País/BU).\n• Crear, reintentar o resincronizar no duplica registros porque T/U conservan los IDs técnicos.\n• Si se elimina un tiempo, se limpian únicamente las columnas propiedad de Tablero CDC; los campos manuales nunca se borran automáticamente.\n• Google Sheets mantiene reintentos automáticos para errores temporales (503).", + "height": 300, + "width": 1280, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 576, + 16 + ], + "id": "65781b4a-63ef-482b-9b91-e4c60c5f50bd", + "name": "Sticky Note" + } + ], + "connections": { + "Webhook - Sync registro horas": { + "main": [ + [ + { + "node": "Code - Preparar solicitud", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar solicitud": { + "main": [ + [ + { + "node": "Supabase - Validar sesión", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar sesión": { + "main": [ + [ + { + "node": "Supabase - Leer proyecto", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer proyecto": { + "main": [ + [ + { + "node": "Supabase - Leer tiempos proyecto", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer tiempos proyecto": { + "main": [ + [ + { + "node": "Code - Consolidar tiempos", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar tiempos": { + "main": [ + [ + { + "node": "Supabase - Leer Country Managers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer Country Managers": { + "main": [ + [ + { + "node": "Code - Consolidar Country Managers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar Country Managers": { + "main": [ + [ + { + "node": "Google Sheets API - Leer Registro horas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Leer Registro horas": { + "main": [ + [ + { + "node": "Code - Planificar sincronización", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Planificar sincronización": { + "main": [ + [ + { + "node": "Google Sheets API - Escribir Registro horas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Escribir Registro horas": { + "main": [ + [ + { + "node": "Respond - Registro horas sincronizado", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "staticData": null, + "meta": null, + "versionId": "b2b96202-a028-4084-a436-07b791eef620", + "activeVersionId": "b2b96202-a028-4084-a436-07b791eef620", + "versionCounter": 14, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-08-16T17:10:19.594Z", + "createdAt": "2026-08-16T17:10:19.594Z", + "role": "workflow:owner", + "workflowId": "zOosAqUTV2qS1oro", + "projectId": "PJpTANzTXIFibWsW", + "project": { + "updatedAt": "2026-04-22T14:25:09.686Z", + "createdAt": "2026-04-22T14:22:54.790Z", + "id": "PJpTANzTXIFibWsW", + "name": "Isaac Aracena ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-08-16T19:43:08.000Z", + "createdAt": "2026-08-16T19:43:07.044Z", + "versionId": "b2b96202-a028-4084-a436-07b791eef620", + "workflowId": "zOosAqUTV2qS1oro", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "tablero-cdc-sync-registro-horas", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 496, + 624 + ], + "id": "147264a4-72cc-4237-83bf-cc0c6574215d", + "name": "Webhook - Sync registro horas", + "webhookId": "b8e063ca-065b-4b29-848c-cfcbe98cc35c" + }, + { + "parameters": { + "jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) { return String(value ?? '').trim(); }\n\nconst action = clean(body.action || 'sync_project').toLowerCase();\nconst projectId = clean(body.project_id || body.projectId);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nif (action !== 'sync_project') throw new Error('Acción no soportada.');\nif (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectId)) {\n throw new Error('Project ID inválido.');\n}\nif (!accessToken) throw new Error('No se recibió una sesión válida de Supabase.');\n\nreturn [{ json: {\n action,\n project_id: projectId,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc-time',\n received_at: new Date().toISOString(),\n} }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 720, + 624 + ], + "id": "f3f6b85d-8ba0-40fe-ae6d-d9bac4d81f74", + "name": "Code - Preparar solicitud" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/auth/v1/user", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 944, + 624 + ], + "id": "7f4c5187-b496-4929-bcab-b0af2b5076ce", + "name": "Supabase - Validar sesión", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200 + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,title,client,country,country_manager,created_at&limit=1' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1168, + 512 + ], + "id": "a45b14ed-76df-4ea5-8e18-fbe03a76c3e3", + "name": "Supabase - Leer proyecto", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_time_entries?project_id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,project_id,country,task_name,duration_minutes,work_date,notes,created_by_name,created_by_email,created_at&order=work_date.asc,created_at.asc&limit=5000' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1376, + 512 + ], + "id": "99d8832a-52f9-477a-a857-e5d3bce9811f", + "name": "Supabase - Leer tiempos proyecto", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const entries = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) entries.push(...raw);\n else if (raw && Array.isArray(raw.body)) entries.push(...raw.body);\n else if (raw && raw.id) entries.push(raw);\n}\nreturn [{ json: { entries } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1600, + 512 + ], + "id": "dba4abe6-1dca-4bbe-8840-5a9c388687ae", + "name": "Code - Consolidar tiempos" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.country_manager&is_active=eq.true&select=value,label&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1824, + 512 + ], + "id": "c46811a4-5a9d-4696-b083-2e85bdf945f9", + "name": "Supabase - Leer Country Managers", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 1200, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const managers = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) managers.push(...raw);\n else if (raw && Array.isArray(raw.body)) managers.push(...raw.body);\n else if (raw && (raw.value || raw.label)) managers.push(raw);\n}\nreturn [{ json: { managers } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2048, + 512 + ], + "id": "2d307078-a969-42b3-9e1d-5b47b02ddf2e", + "name": "Code - Consolidar Country Managers" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/%27Registro%20horas%27!B2:U20000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2256, + 512 + ], + "id": "b4ac9069-f9ed-4938-af31-f16ceb68143d", + "name": "Google Sheets API - Leer Registro horas", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetResponse = $input.first().json || {};\nconst values = Array.isArray(sheetResponse.values)\n ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : [])\n : [];\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction key(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction escSheetName(name) {\n return `'${String(name).replace(/'/g, \"''\")}'`;\n}\n\nfunction extractProjectFromNode(nodeName) {\n let items = [];\n try {\n items = $(nodeName).all();\n } catch (error) {\n items = [];\n }\n\n const candidates = [];\n\n for (const item of items) {\n const raw = item?.json;\n\n if (Array.isArray(raw)) {\n candidates.push(...raw);\n continue;\n }\n\n if (!raw || typeof raw !== 'object') continue;\n\n candidates.push(raw);\n\n if (Array.isArray(raw.body)) candidates.push(...raw.body);\n else if (raw.body && typeof raw.body === 'object') candidates.push(raw.body);\n\n if (Array.isArray(raw.data)) candidates.push(...raw.data);\n else if (raw.data && typeof raw.data === 'object') candidates.push(raw.data);\n\n if (raw.response && typeof raw.response === 'object') {\n if (Array.isArray(raw.response.body)) candidates.push(...raw.response.body);\n else if (raw.response.body && typeof raw.response.body === 'object') {\n candidates.push(raw.response.body);\n }\n }\n }\n\n return candidates.find(candidate =>\n candidate &&\n typeof candidate === 'object' &&\n clean(candidate.id)\n ) || null;\n}\n\nconst project = extractProjectFromNode('Supabase - Leer proyecto');\nconst entries = $('Code - Consolidar tiempos').first().json.entries || [];\nconst projectId = clean($('Code - Preparar solicitud').first().json.project_id);\n\nif (!project) {\n throw new Error(`No se encontró el proyecto ${projectId} en la respuesta de Supabase.`);\n}\n\nif (clean(project.id).toLowerCase() !== projectId.toLowerCase()) {\n throw new Error(`SEGURIDAD: Supabase devolvió un proyecto diferente al solicitado (${clean(project.id)}).`);\n}\n\n// La lectura empieza en B2, por eso los índices son relativos a B:U.\n// C, D, H, R y S son columnas calculadas/protegidas en el Sheet y NUNCA se escriben.\n// L:P son campos manuales y NUNCA se sobrescriben.\nconst header = values[0] || [];\nconst timeHeader = clean(header[18]); // T\nconst projectHeader = clean(header[19]); // U\nconst expectedTimeHeader = 'Tablero Time ID';\nconst expectedProjectHeader = 'Tablero Project ID';\n\nif (timeHeader && key(timeHeader) !== key(expectedTimeHeader)) {\n throw new Error(`SEGURIDAD: la columna T ya tiene el encabezado “${timeHeader}”. No se modificó Registro horas.`);\n}\n\nif (projectHeader && key(projectHeader) !== key(expectedProjectHeader)) {\n throw new Error(`SEGURIDAD: la columna U ya tiene el encabezado “${projectHeader}”. No se modificó Registro horas.`);\n}\n\nif ((!timeHeader || !projectHeader)) {\n for (let i = 1; i < values.length; i += 1) {\n const row = values[i] || [];\n if (clean(row[18]) || clean(row[19])) {\n throw new Error('SEGURIDAD: existen datos en T/U sin los encabezados de integración. No se modificó Registro horas.');\n }\n }\n}\n\nconst currentIds = new Set(entries.map(entry => clean(entry.id)).filter(Boolean));\nconst existingByEntryId = new Map();\nconst projectRows = [];\nconst blankRows = [];\n\n// Estas son las columnas que una persona puede completar manualmente o que pertenecen\n// a la integración. Si cualquiera tiene contenido, la fila NO se considera libre.\n// Se excluyen C,D,H,R,S porque son calculadas por el propio Sheet.\nconst meaningfulIndexes = [0, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19];\n\nfor (let rowNum = 3; rowNum <= 20000; rowNum += 1) {\n const row = values[rowNum - 2] || [];\n const timeId = clean(row[18]);\n const rowProjectId = clean(row[19]);\n\n if (timeId) {\n if (existingByEntryId.has(timeId)) {\n throw new Error(`SEGURIDAD: el Tablero Time ID ${timeId} aparece duplicado en Registro horas.`);\n }\n existingByEntryId.set(timeId, { rowNum, row });\n }\n\n if (rowProjectId === projectId) {\n projectRows.push({ rowNum, row, timeId });\n }\n\n const isTrulyBlank = meaningfulIndexes.every(index => !clean(row[index]));\n if (isTrulyBlank) {\n blankRows.push({ rowNum, row });\n }\n}\n\nconst staleRows = projectRows.filter(item => !item.timeId || !currentIds.has(item.timeId));\n\n// Una fila vieja solo se reutiliza si L:P están vacías. Si alguien completó algo manualmente,\n// se limpia únicamente lo que pertenece a Tablero CDC y esa fila se deja fuera del pool reutilizable.\nconst reusableStaleRows = staleRows\n .filter(item => [10, 11, 12, 13, 14].every(index => !clean(item.row[index])))\n .map(item => ({ rowNum: item.rowNum, row: item.row }));\n\nconst reusableRows = [...reusableStaleRows, ...blankRows];\nconst usedRows = new Set();\nconst data = [];\nconst sheet = escSheetName('Registro horas');\n\nfunction pushRange(range, rowValues) {\n data.push({\n range,\n majorDimension: 'ROWS',\n values: [rowValues],\n });\n}\n\nfunction clearOwnedColumns(rowNum) {\n // No tocamos columnas calculadas/protegidas (C,D,H,R,S) ni manuales (L:P).\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, ['']);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, ['']);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, ['', '']);\n}\n\nfunction writeEntry(rowNum, entry) {\n const entryId = clean(entry.id);\n const dateText = clean(entry.work_date);\n\n if (!entryId || !/^\\d{4}-\\d{2}-\\d{2}$/.test(dateText)) {\n throw new Error(`Registro de tiempo inválido: ${entryId || 'sin ID'}.`);\n }\n\n const [yearText, monthText, dayText] = dateText.split('-');\n const year = Number(yearText);\n const month = Number(monthText);\n const day = Number(dayText);\n const durationMinutes = Number(entry.duration_minutes || 0);\n\n if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {\n throw new Error(`Duración inválida en ${entryId}.`);\n }\n\n const country = clean(entry.country) || clean(project.country);\n const actor = clean(entry.created_by_name) || clean(entry.created_by_email);\n const task = clean(entry.task_name);\n const notes = clean(entry.notes);\n const hours = Math.round((durationMinutes / 60) * 10000) / 10000;\n const dateFormula = `=DATE(${year},${month},${day})`;\n\n // Solo escribimos columnas editables/no protegidas.\n // El Sheet calcula automáticamente Mes, Día, CM, # MES y AÑO.\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, [dateFormula]);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, [clean(project.title), clean(project.client), country]);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, [actor, task, hours]);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, [notes]);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, [entryId, projectId]);\n}\n\n// Inicializar los encabezados técnicos únicamente si faltan; no reescribirlos en cada ejecución.\nif (!timeHeader || !projectHeader) {\n pushRange(`${sheet}!T2:U2`, [expectedTimeHeader, expectedProjectHeader]);\n}\n\nfor (const stale of staleRows) {\n usedRows.add(stale.rowNum);\n clearOwnedColumns(stale.rowNum);\n}\n\nlet createdCount = 0;\nlet updatedCount = 0;\n\nfor (const entry of entries) {\n const entryId = clean(entry.id);\n const existing = existingByEntryId.get(entryId);\n let target;\n\n if (existing) {\n target = existing;\n updatedCount += 1;\n } else {\n target = reusableRows.find(candidate => !usedRows.has(candidate.rowNum));\n if (!target) {\n throw new Error('No quedan filas realmente libres en Registro horas hasta la fila 20000.');\n }\n createdCount += 1;\n }\n\n usedRows.add(target.rowNum);\n writeEntry(target.rowNum, entry);\n}\n\nreturn [{\n json: {\n data,\n project_id: projectId,\n total_entries: entries.length,\n created_rows: createdCount,\n updated_rows: updatedCount,\n cleared_rows: staleRows.length,\n first_available_row: blankRows.length ? blankRows[0].rowNum : null,\n write_strategy: 'segmented_unprotected_ranges',\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2480, + 512 + ], + "id": "9fae728c-885c-44e6-9327-5e8ad869b19d", + "name": "Code - Planificar sincronización" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'USER_ENTERED', data: $('Code - Planificar sincronización').first().json.data } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 2720, + 512 + ], + "id": "7ddca426-9515-40e0-93e0-ea970324c589", + "name": "Google Sheets API - Escribir Registro horas", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Registro horas sincronizado correctamente.', projectId: $('Code - Planificar sincronización').first().json.project_id, syncedEntries: $('Code - Planificar sincronización').first().json.total_entries, createdRows: $('Code - Planificar sincronización').first().json.created_rows, updatedRows: $('Code - Planificar sincronización').first().json.updated_rows, clearedEntries: $('Code - Planificar sincronización').first().json.cleared_rows } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 2960, + 512 + ], + "id": "d732c902-c587-4167-84b7-77f8196883d9", + "name": "Respond - Registro horas sincronizado" + }, + { + "parameters": { + "content": "## TABLERO CDC → REGISTRO HORAS — FIX COLUMNAS PROTEGIDAS\n\n• Sincroniza automáticamente el módulo **Tiempo** con `Registro horas`.\n• FIX: nunca escribe sobre las columnas calculadas/protegidas C, D, H, R y S.\n• Solo escribe B, E:G, I:K, Q y T:U; L:P se preservan porque son campos manuales.\n• Una fila solo se reutiliza si está realmente libre; ya no toma como libre una fila parcialmente completada (por ejemplo, con País/BU).\n• Crear, reintentar o resincronizar no duplica registros porque T/U conservan los IDs técnicos.\n• Si se elimina un tiempo, se limpian únicamente las columnas propiedad de Tablero CDC; los campos manuales nunca se borran automáticamente.\n• Google Sheets mantiene reintentos automáticos para errores temporales (503).", + "height": 300, + "width": 1280, + "color": 5 + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 576, + 16 + ], + "id": "65781b4a-63ef-482b-9b91-e4c60c5f50bd", + "name": "Sticky Note" + } + ], + "connections": { + "Webhook - Sync registro horas": { + "main": [ + [ + { + "node": "Code - Preparar solicitud", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar solicitud": { + "main": [ + [ + { + "node": "Supabase - Validar sesión", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar sesión": { + "main": [ + [ + { + "node": "Supabase - Leer proyecto", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer proyecto": { + "main": [ + [ + { + "node": "Supabase - Leer tiempos proyecto", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer tiempos proyecto": { + "main": [ + [ + { + "node": "Code - Consolidar tiempos", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar tiempos": { + "main": [ + [ + { + "node": "Supabase - Leer Country Managers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer Country Managers": { + "main": [ + [ + { + "node": "Code - Consolidar Country Managers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar Country Managers": { + "main": [ + [ + { + "node": "Google Sheets API - Leer Registro horas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Leer Registro horas": { + "main": [ + [ + { + "node": "Code - Planificar sincronización", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Planificar sincronización": { + "main": [ + [ + { + "node": "Google Sheets API - Escribir Registro horas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Escribir Registro horas": { + "main": [ + [ + { + "node": "Respond - Registro horas sincronizado", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "Isaac Aracena", + "name": "Version b2b96202", + "description": "", + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-08-16T19:43:08.063Z", + "id": 4841, + "workflowId": "zOosAqUTV2qS1oro", + "versionId": "b2b96202-a028-4084-a436-07b791eef620", + "event": "activated", + "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" + } + ] + } +} \ No newline at end of file