{ "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" } ] } }