Files
tablero-cdc-sync-registro-h…/tablero-cdc-sync-registro-horas.json
T

891 lines
47 KiB
JSON

{
"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 <iaracena@gomezleemarketing.com>",
"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"
}
]
}
}