From 0677e6ac6cb30d5d4d6dac41441d1bef19d0c1ed Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sun, 16 Aug 2026 04:00:45 +0000 Subject: [PATCH] Update backup: Tablero CDC - Sync Listas --- tablero-cdc-sync-listas.json | 1783 +++++++++++++++++++++++++++++++--- 1 file changed, 1646 insertions(+), 137 deletions(-) diff --git a/tablero-cdc-sync-listas.json b/tablero-cdc-sync-listas.json index b913b40..bec2ce4 100644 --- a/tablero-cdc-sync-listas.json +++ b/tablero-cdc-sync-listas.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-04T19:09:20.329Z", + "updatedAt": "2026-08-14T13:16:59.698Z", "createdAt": "2026-05-27T15:07:19.943Z", "id": "MU5GdxLtFzcRcfyM", "name": "Tablero CDC - Sync Listas", @@ -9,30 +9,26 @@ "nodes": [ { "parameters": { - "documentId": { - "__rl": true, - "value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q", - "mode": "list", - "cachedResultName": "APROBACIONES PROYECTOS", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk" - }, - "sheetName": { - "__rl": true, - "value": 1332738713, - "mode": "list", - "cachedResultName": "listas", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1332738713" - }, + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'RAW', data: $('Code - Preparar Marca Sheet (schedule)').first().json.write_data } }}", "options": {} }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, "position": [ - 208, - 0 + -2496, + -832 ], - "id": "cd67f274-a366-4efb-841a-3a8bceda956f", - "name": "Sheets - Leer listas", + "id": "e52f3d8d-1230-4b6e-b7fd-df403cbb70c6", + "name": "Google Sheets API - Inicializar Marca (schedule)", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", @@ -42,52 +38,387 @@ }, { "parameters": { - "jsCode": "function normalizeText(value) {\n return String(value || '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction pick(row, possibleHeaders) {\n const normalizedRow = {};\n\n for (const [key, value] of Object.entries(row || {})) {\n normalizedRow[normalizeKey(key)] = value;\n }\n\n for (const header of possibleHeaders) {\n const value = normalizedRow[normalizeKey(header)];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return normalizeText(value);\n }\n }\n\n return '';\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return;\n if (isInvalidValue(cleanValue)) return;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n}\n\nconst rows = $input.all().map(item => item.json || {});\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (const row of rows) {\n const client = pick(row, [\n 'Cliente',\n 'Clientes',\n 'CLIENTE',\n 'client',\n ]);\n\n const brand = pick(row, [\n 'Marca',\n 'Marcas',\n 'MARCA',\n 'brand',\n ]);\n\n const country = pick(row, [\n 'Pais / BU',\n 'País / BU',\n 'Pais',\n 'País',\n 'Business Unit',\n 'BU',\n 'Country',\n ]);\n\n const countryManager = pick(row, [\n 'CM',\n 'Country Manager',\n 'County Manager',\n 'Manager',\n ]);\n\n const workedBy = pick(row, [\n 'Trabajado',\n 'Trabajado por',\n 'Responsable',\n 'Diseñador',\n 'Creativo',\n ]);\n\n const workType = pick(row, [\n 'Tipo de trabajo',\n 'Tipo Trabajo',\n 'Trabajo',\n 'Tipo',\n ]);\n\n const executedWon = pick(row, [\n 'Ejecutado / Ganado',\n 'Ejecutado/Ganado',\n 'Ejecutado',\n 'Ganado',\n 'Resultado',\n ]);\n\n const timeStatus = pick(row, [\n 'Time',\n 'Tiempo',\n 'Estado tiempo',\n 'Estado de tiempo',\n ]);\n\n const status = pick(row, [\n 'estatus',\n 'Estatus',\n 'Status',\n 'Estado',\n 'ESTATUS',\n ]);\n\n const month = pick(row, [\n 'mes',\n 'Mes',\n 'MES',\n 'month',\n ]);\n\n for (const value of splitValues(client)) {\n addRecord(records, 'client', value, value, sortOrder++);\n }\n\n for (const value of splitValues(brand)) {\n addRecord(records, 'brand', value, value, sortOrder++);\n }\n\n for (const value of splitValues(country)) {\n addRecord(records, 'country', value, value, sortOrder++);\n }\n\n if (country && countryManager) {\n addRecord(records, 'country_manager', country, countryManager, sortOrder++);\n }\n\n for (const value of splitValues(workedBy)) {\n addRecord(records, 'worked_by', value, value, sortOrder++);\n }\n\n for (const value of splitValues(workType)) {\n addRecord(records, 'work_type', value, value, sortOrder++);\n }\n\n for (const value of splitValues(executedWon)) {\n addRecord(records, 'execution_result', value, value, sortOrder++);\n }\n\n for (const value of splitValues(timeStatus)) {\n addRecord(records, 'time_status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(status)) {\n addRecord(records, 'status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(month)) {\n addRecord(records, 'month', value, value, sortOrder++);\n }\n}\n\n// Deduplicar por category + value\nconst uniqueMap = new Map();\n\nfor (const record of records) {\n const key = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n\n if (!uniqueMap.has(key)) {\n uniqueMap.set(key, record);\n }\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values())\n .map((record, index) => ({\n ...record,\n sort_order: index + 1,\n }));\n\nreturn [\n {\n json: {\n total_rows_read: rows.length,\n total_records_to_sync: uniqueRecords.length,\n categories_found: [...new Set(uniqueRecords.map(item => item.category))],\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n },\n },\n];" + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.brand&is_active=eq.true&select=value,label,sort_order&order=sort_order.asc&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3696, + -752 + ], + "id": "8b5f643f-9e48-4143-a803-82eed4198115", + "name": "Supabase - Leer marcas actuales (schedule)", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const brands = [];\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nconst seen = new Set();\nfor (const item of $input.all()) {\n const raw = item.json;\n const rows = Array.isArray(raw) ? raw : [raw];\n for (const row of rows) {\n const value = clean(row?.label || row?.value);\n const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized);\n brands.push(value);\n }\n}\nreturn [{ json: { brands } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 416, - 0 + -3456, + -752 ], - "id": "6c6131fe-f6df-434c-9596-c9d29a90ed0a", - "name": "Code - Normalizar listas para Supabase" + "id": "a7f79f20-f230-4024-9238-b075bc36ee14", + "name": "Code - Consolidar marcas actuales (schedule)" }, { "parameters": { - "content": "## Flujo que se ejecuta cada vez que se agrega una fila nueva, lee las listas del Sheet luego normaliza las listas y por último hace el Upsert en Supabase", - "height": 352, - "width": 912, - "color": "#632313" + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3216, + -752 + ], + "id": "57306c6f-7374-4212-8407-03a786bd9902", + "name": "Google Sheets API - Leer Listas (schedule)", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetResponse=$input.first().json||{};\nconst rows=Array.isArray(sheetResponse.values)?sheetResponse.values:[];\nconst currentBrands=$('Code - Consolidar marcas actuales (schedule)').first().json.brands||[];\nconst fallbackBrands=[\"Carnation\", \"GLM RRHH\", \"La Lechera\", \"Malher\", \"US Meat\", \"Coffee Mate y Nescafé\", \"Nestum y Nido\", \"Whirlpool\", \"Motorola\", \"Philip Morris\", \"US Meat Guatemala\", \"KitKat, Crunch, Choco Trío\", \"Colgate\", \"Kit Kat\", \"UMA\", \"Purina\", \"Maggi e Ideal\", \"Nestlé\", \"Jimador\", \"Multimarcas\", \"Maggi\", \"Covo\", \"El Machetazo\", \"Grupo Bocel\", \"Supply Chain\", \"Carnation y La Lechera\", \"GLM Administración\", \"Nestlé Bice\", \"GLM\", \"Nescafé\", \"Idea, Qué Rico y Maggi\", \"EY\", \"Nesquik\", \"Maggi, Ketchup, Qué Rico\", \"Ideal\", \"Claro Empresas\", \"Nido\", \"Presto\", \"Nestlé RD\", \"GLM Colombia\", \"Motorola RD\", \"GLM Caribe\", \"Mandino\", \"Coffee Mate\", \"GLM Latam\", \"RRHH RD\", \"Philip Morris TT\", \"GLM RD\", \"Smirnoff\", \"Kaspersky\", \"US Pork\", \"Qué Rico y Maggi\", \"Claro y Motorola\", \"Sedal\", \"Maytag\", \"Qué Rico, Maggi y Ketchup\", \"Multimarca\", \"Aviva y Princesa\", \"KitchenAid\", \"Enredé\", \"Ascenda\", \"Lipton Té\", \"Heiniken\", \"Oral B\", \"Aafresh\", \"IQOS\", \"Natures Heart\"];\nconst INDEX=17;\nfunction clean(value){return String(value??'').trim().replace(/\\s+/g,' ');}\nfunction key(value){return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction dedupe(values){const seen=new Set(),out=[];for(const raw of values){const value=clean(raw),k=key(value);if(!value||!k||seen.has(k))continue;seen.add(k);out.push(value);}return out;}\nconst header=clean(rows[0]?.[INDEX]);\nconst existing=[];for(let i=1;i[value])]}]}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2976, + -752 + ], + "id": "408f660b-646f-46da-a002-6fd22d194ec5", + "name": "Code - Preparar Marca Sheet (schedule)" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "a4de0272-638a-4065-8118-bb339035c00a", + "leftValue": "={{ $json.needs_seed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -2736, + -752 + ], + "id": "6c7a6f77-5cd7-4277-a5db-cd5d3d36ad20", + "name": "IF - Inicializar Marca en Sheet?" + }, + { + "parameters": { + "content": "## ADMINISTRACIÓN INSTANTÁNEA DESDE TABLERO CDC — LECTURA COMPLETA SEGURA\n\n• Webhook: `tablero-cdc-sync-listas-admin`.\n• Valida la sesión Supabase y el permiso `can_manage_tariff_catalog`.\n• Crea / edita / elimina Clientes, Marcas, País / BU + CM y Estatus.\n• Solo limpia y reescribe la(s) columna(s) de la lista modificada; nunca elimina filas completas.\n• Después de cada escritura relee `Listas!A1:R1000` con Google Sheets API.\n• Reconstruye TODAS las categorías antes de sincronizar Supabase.\n• Si detecta lectura parcial o encabezados incorrectos, cancela antes de tocar Supabase.\n• `refresh` permite editar manualmente el Sheet y sincronizarlo de inmediato.", + "height": 656, + "width": 3920, + "color": 5 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - -64, - -144 + -3984, + -64 ], - "id": "2ec4d347-68e4-4358-9a82-3748c01af9a6", - "name": "Sticky Note" + "id": "75325b2d-5a61-4e7d-a096-86528cdb2a18", + "name": "Sticky Note - Administración desde app" }, { "parameters": { - "rule": { - "interval": [ + "httpMethod": "POST", + "path": "tablero-cdc-sync-listas-admin", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -3872, + 176 + ], + "id": "c068bd56-9b45-408b-aeb6-81b682f0de6d", + "name": "Webhook - Administrar listas", + "webhookId": "b80743ea-73e3-4f48-9e0e-518790a5912a" + }, + { + "parameters": { + "jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nconst action = clean(body.action).toLowerCase();\nconst category = clean(body.category).toLowerCase();\nconst value = clean(body.value);\nconst previousValue = clean(body.previous_value || body.previousValue);\nconst manager = clean(body.manager);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nconst allowedActions = new Set(['create', 'update', 'delete', 'refresh']);\nconst allowedCategories = new Set(['client', 'brand', 'country', 'status']);\n\nif (!allowedActions.has(action)) {\n throw new Error('Acción de listas inválida.');\n}\n\nif (action !== 'refresh' && !allowedCategories.has(category)) {\n throw new Error('Categoría de lista inválida.');\n}\n\nif (action !== 'refresh' && !value && action === 'create') {\n throw new Error('Falta el valor que se desea agregar.');\n}\n\nif ((action === 'update' || action === 'delete') && !previousValue && !value) {\n throw new Error('Falta el valor original de la opción.');\n}\n\nif (action === 'update' && !value) {\n throw new Error('Falta el nuevo valor de la opción.');\n}\n\nif (!accessToken) {\n throw new Error('No se recibió una sesión válida de Supabase.');\n}\n\nreturn [{\n json: {\n action,\n category,\n value,\n previous_value: previousValue,\n manager,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc',\n received_at: new Date().toISOString(),\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3632, + 176 + ], + "id": "8cf79221-21b6-4c07-a2fd-5428898735ef", + "name": "Code - Preparar solicitud admin" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/auth/v1/user", + "sendHeaders": true, + "headerParameters": { + "parameters": [ { - "field": "hours", - "hoursInterval": 3 + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud admin').first().json.access_token }}" } ] - } + }, + "options": {} }, - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, "position": [ - 16, - 0 + -3392, + 176 ], - "id": "4639502f-e8b1-4373-8f7f-d122598d2051", - "name": "Schedule Trigger" + "id": "9b6f34e8-1348-41c4-834c-f8b55c70693e", + "name": "Supabase - Validar sesión" + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_allowed_users?email=eq.' + encodeURIComponent($json.email || '') + '&is_active=eq.true&can_manage_tariff_catalog=eq.true&select=email,full_name&limit=1' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3152, + 176 + ], + "id": "6403d212-8fd9-404f-9214-2a9ea59ff4e5", + "name": "Supabase - Validar permiso listas", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const request = $('Code - Preparar solicitud admin').first().json || {};\nconst authUser = $('Supabase - Validar sesión').first().json || {};\n\nconst permissionRows = [];\nfor (const item of $input.all()) {\n const value = item.json;\n if (Array.isArray(value)) permissionRows.push(...value);\n else if (value && typeof value === 'object' && Object.keys(value).length) permissionRows.push(value);\n}\n\nconst email = String(authUser.email || '').trim().toLowerCase();\nif (!email) {\n throw new Error('No se pudo validar el usuario autenticado en Supabase.');\n}\n\nconst allowed = permissionRows.some(row =>\n String(row.email || '').trim().toLowerCase() === email\n);\n\nif (!allowed) {\n throw new Error('Tu usuario no tiene permiso para administrar las listas del Tablero CDC.');\n}\n\nreturn [{\n json: {\n ...request,\n actor_email: email,\n actor_name: authUser.user_metadata?.full_name || authUser.user_metadata?.name || email,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2912, + 176 + ], + "id": "ab073505-4759-4340-ab5f-3ea70b3d22b2", + "name": "Code - Verificar permiso admin" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.brand&is_active=eq.true&select=value,label,sort_order&order=sort_order.asc&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2672, + 176 + ], + "id": "70a19084-9657-4420-9b9c-68fff3a5ac95", + "name": "Supabase - Leer marcas actuales", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const brands = [];\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nconst seen = new Set();\nfor (const item of $input.all()) {\n const raw = item.json;\n const rows = Array.isArray(raw) ? raw : [raw];\n for (const row of rows) {\n const value = clean(row?.label || row?.value);\n const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized);\n brands.push(value);\n }\n}\nreturn [{ json: { brands } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2432, + 176 + ], + "id": "fa663dc9-18a6-463c-8a61-3ef99bbe2083", + "name": "Code - Consolidar marcas actuales" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2192, + 176 + ], + "id": "38441df7-b6ca-4a7f-9041-330403107dfa", + "name": "Google Sheets API - Leer Listas", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const request = $('Code - Verificar permiso admin').first().json || {};\nconst sheetResponse = $input.first().json || {};\nconst rows = Array.isArray(sheetResponse.values) ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : []) : [];\nconst currentSupabaseBrands = $('Code - Consolidar marcas actuales').first().json.brands || [];\nconst fallbackBrands = [\"Carnation\", \"GLM RRHH\", \"La Lechera\", \"Malher\", \"US Meat\", \"Coffee Mate y Nescafé\", \"Nestum y Nido\", \"Whirlpool\", \"Motorola\", \"Philip Morris\", \"US Meat Guatemala\", \"KitKat, Crunch, Choco Trío\", \"Colgate\", \"Kit Kat\", \"UMA\", \"Purina\", \"Maggi e Ideal\", \"Nestlé\", \"Jimador\", \"Multimarcas\", \"Maggi\", \"Covo\", \"El Machetazo\", \"Grupo Bocel\", \"Supply Chain\", \"Carnation y La Lechera\", \"GLM Administración\", \"Nestlé Bice\", \"GLM\", \"Nescafé\", \"Idea, Qué Rico y Maggi\", \"EY\", \"Nesquik\", \"Maggi, Ketchup, Qué Rico\", \"Ideal\", \"Claro Empresas\", \"Nido\", \"Presto\", \"Nestlé RD\", \"GLM Colombia\", \"Motorola RD\", \"GLM Caribe\", \"Mandino\", \"Coffee Mate\", \"GLM Latam\", \"RRHH RD\", \"Philip Morris TT\", \"GLM RD\", \"Smirnoff\", \"Kaspersky\", \"US Pork\", \"Qué Rico y Maggi\", \"Claro y Motorola\", \"Sedal\", \"Maytag\", \"Qué Rico, Maggi y Ketchup\", \"Multimarca\", \"Aviva y Princesa\", \"KitchenAid\", \"Enredé\", \"Ascenda\", \"Lipton Té\", \"Heiniken\", \"Oral B\", \"Aafresh\", \"IQOS\", \"Natures Heart\"];\nconst BRAND_COLUMN_INDEX = 17; // R\nconst BRAND_RANGE = 'Listas!R1:R1000';\n\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); }\nfunction dedupe(values) {\n const seen = new Set(); const result = [];\n for (const raw of values) {\n const value = clean(raw); const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized); result.push(value);\n }\n return result;\n}\nfunction columnValues(index) {\n const values = [];\n for (let row = 1; row < rows.length; row += 1) {\n const value = clean(rows[row]?.[index]); if (value) values.push(value);\n }\n return dedupe(values);\n}\nfunction applySingle(values, action, previousValue, nextValue) {\n const result = [...values];\n const previousKey = key(previousValue || nextValue); const nextKey = key(nextValue);\n const matches = result.map((item,index) => key(item) === previousKey ? index : -1).filter(index => index >= 0);\n if (action === 'create') {\n if (!nextValue) throw new Error('Falta el valor que se desea agregar.');\n if (result.some(item => key(item) === nextKey)) throw new Error('Esa opción ya existe en la lista.');\n result.push(nextValue); return result;\n }\n if (matches.length !== 1) {\n throw new Error(matches.length === 0 ? 'No se encontró exactamente la opción original en el Google Sheet.' : 'La opción original está duplicada en el Google Sheet. Corrige el duplicado antes de editarla.');\n }\n const index = matches[0];\n if (action === 'delete') { result.splice(index,1); return result; }\n if (result.some((item,itemIndex) => itemIndex !== index && key(item) === nextKey)) throw new Error('El nuevo valor ya existe en la lista.');\n result[index] = nextValue; return result;\n}\n\nconst action = request.action; const category = request.category;\nconst previousValue = request.previous_value || request.value; const nextValue = request.value; const manager = request.manager || '';\nlet clearRanges = []; let writeData = []; let countAfter = 0;\n\n// Marca no tenía una columna oficial en la exportación recibida. R está vacía y sin referencias en el workbook.\n// La primera operación inicializa R como lista oficial de Marca sin tocar la data existente de otras columnas.\nconst brandHeader = clean(rows[0]?.[BRAND_COLUMN_INDEX]);\nconst rawBrandValues = columnValues(BRAND_COLUMN_INDEX);\nconst brandHeaderIsValid = ['marca','marcas'].includes(key(brandHeader));\nif (!brandHeaderIsValid && (brandHeader || rawBrandValues.length)) {\n throw new Error('SEGURIDAD: la columna R de la hoja Listas contiene datos ajenos y no tiene encabezado Marca. No se modificó el Sheet.');\n}\nconst brandSeed = brandHeaderIsValid ? rawBrandValues : dedupe([...currentSupabaseBrands, ...fallbackBrands]);\n\nif (action === 'refresh') {\n if (!brandHeaderIsValid) {\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range: `Listas!R1:R${brandSeed.length + 1}`, majorDimension:'ROWS', values:[['Marca'], ...brandSeed.map(value => [value])] });\n countAfter = brandSeed.length;\n }\n} else if (category === 'client') {\n const values = applySingle(columnValues(0), action, previousValue, nextValue);\n clearRanges.push('Listas!A1:A1000');\n writeData.push({ range:`Listas!A1:A${values.length+1}`, majorDimension:'ROWS', values:[['Cliente'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'status') {\n const values = applySingle(columnValues(13), action, previousValue, nextValue);\n clearRanges.push('Listas!N1:N1000');\n writeData.push({ range:`Listas!N1:N${values.length+1}`, majorDimension:'ROWS', values:[['estatus'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'brand') {\n const values = applySingle(brandSeed, action, previousValue, nextValue);\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range:`Listas!R1:R${values.length+1}`, majorDimension:'ROWS', values:[['Marca'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'country') {\n const pairs=[]; const seen=new Set();\n for (let row=1; rowkey(item.value)===previousKey?index:-1).filter(index=>index>=0);\n if (action==='create') {\n if (!nextValue) throw new Error('Falta el País / BU que se desea agregar.');\n if (pairs.some(item=>key(item.value)===nextKey)) throw new Error('Ese País / BU ya existe.');\n pairs.push({value:nextValue,manager});\n } else {\n if (matches.length!==1) throw new Error(matches.length===0?'No se encontró exactamente el País / BU original en el Google Sheet.':'El País / BU está duplicado en el Google Sheet. Corrige el duplicado antes de editarlo.');\n const index=matches[0];\n if (action==='delete') pairs.splice(index,1);\n else {\n if (pairs.some((item,itemIndex)=>itemIndex!==index&&key(item.value)===nextKey)) throw new Error('El nuevo País / BU ya existe.');\n pairs[index]={value:nextValue,manager};\n }\n }\n clearRanges.push('Listas!C1:D1000');\n writeData.push({ range:`Listas!C1:D${pairs.length+1}`, majorDimension:'ROWS', values:[['Pais / BU','CM'], ...pairs.map(item=>[item.value,item.manager])] });\n countAfter=pairs.length;\n} else {\n throw new Error('Categoría no soportada.');\n}\n\n// Si se modificó otra categoría y Marca todavía no estaba inicializada, inicializarla en la misma transacción lógica.\nif (action !== 'refresh' && category !== 'brand' && !brandHeaderIsValid) {\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range:`Listas!R1:R${brandSeed.length+1}`, majorDimension:'ROWS', values:[['Marca'], ...brandSeed.map(value=>[value])] });\n}\n\nreturn [{json:{...request,clear_ranges:clearRanges,write_data:writeData,count_after:countAfter,needs_write:writeData.length>0}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1952, + 176 + ], + "id": "7c5580e5-42c8-481d-8b4a-17d72c8dcdec", + "name": "Code - Preparar escritura Sheet" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "94ac6fcf-dd19-45f0-abc6-725165cf666e", + "leftValue": "={{ $json.needs_write }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -1712, + 176 + ], + "id": "83518b42-9297-4e64-b382-54d82087b1e4", + "name": "IF - Requiere escritura Sheet?" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -992, + 176 + ], + "id": "0faf59e5-27cc-4203-87e1-1b538a9593dd", + "name": "Google Sheets API - Releer listas completas (admin)", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return sortOrder;\n if (isInvalidValue(cleanValue)) return sortOrder;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n\n return sortOrder + 1;\n}\n\nfunction headerMatches(row, index, accepted) {\n const header = normalizeKey(row?.[index]);\n return accepted.map(normalizeKey).includes(header);\n}\n\nconst response = $input.first().json || {};\nconst rows = Array.isArray(response.values) ? response.values : [];\n\nif (!rows.length) {\n throw new Error('SEGURIDAD: Google Sheets API no devolvió datos de la hoja Listas. No se sincronizó Supabase.');\n}\n\nconst header = Array.isArray(rows[0]) ? rows[0] : [];\n\n// Validar que estamos leyendo la hoja/rango correcto antes de construir p_records.\nconst requiredHeaders = [\n { index: 0, names: ['Cliente', 'Clientes'] },\n { index: 2, names: ['Pais / BU', 'País / BU', 'Pais', 'País', 'BU'] },\n { index: 3, names: ['CM', 'Country Manager', 'County Manager', 'Manager'] },\n { index: 5, names: ['Trabajado', 'Trabajado por', 'Responsable', 'Diseñador', 'Creativo'] },\n { index: 7, names: ['Tipo de trabajo', 'Tipo Trabajo', 'Trabajo', 'Tipo'] },\n { index: 9, names: ['Ejecutado / Ganado', 'Ejecutado/Ganado', 'Ejecutado', 'Ganado', 'Resultado'] },\n { index: 11, names: ['Time', 'Tiempo', 'Estado tiempo', 'Estado de tiempo'] },\n { index: 13, names: ['estatus', 'Estatus', 'Status', 'Estado'] },\n { index: 15, names: ['mes', 'Mes', 'MES', 'month'] },\n];\n\nconst badHeaders = requiredHeaders.filter(item => !headerMatches(header, item.index, item.names));\nif (badHeaders.length) {\n const columns = badHeaders.map(item => item.index + 1).join(', ');\n throw new Error(`SEGURIDAD: la lectura de la hoja Listas no contiene los encabezados esperados en las columnas ${columns}. No se sincronizó Supabase.`);\n}\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {\n const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];\n\n const client = normalizeText(row[0]);\n const country = normalizeText(row[2]);\n const countryManager = normalizeText(row[3]);\n const workedBy = normalizeText(row[5]);\n const workType = normalizeText(row[7]);\n const executedWon = normalizeText(row[9]);\n const timeStatus = normalizeText(row[11]);\n const status = normalizeText(row[13]);\n const month = normalizeText(row[15]);\n const brand = normalizeText(row[17]);\n\n for (const value of splitValues(client)) {\n sortOrder = addRecord(records, 'client', value, value, sortOrder);\n }\n\n for (const value of splitValues(brand)) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n\n for (const value of splitValues(country)) {\n sortOrder = addRecord(records, 'country', value, value, sortOrder);\n }\n\n if (country && countryManager) {\n sortOrder = addRecord(records, 'country_manager', country, countryManager, sortOrder);\n }\n\n for (const value of splitValues(workedBy)) {\n sortOrder = addRecord(records, 'worked_by', value, value, sortOrder);\n }\n\n for (const value of splitValues(workType)) {\n sortOrder = addRecord(records, 'work_type', value, value, sortOrder);\n }\n\n for (const value of splitValues(executedWon)) {\n sortOrder = addRecord(records, 'execution_result', value, value, sortOrder);\n }\n\n for (const value of splitValues(timeStatus)) {\n sortOrder = addRecord(records, 'time_status', value, value, sortOrder);\n }\n\n for (const value of splitValues(status)) {\n sortOrder = addRecord(records, 'status', value, value, sortOrder);\n }\n\n for (const value of splitValues(month)) {\n sortOrder = addRecord(records, 'month', value, value, sortOrder);\n }\n}\n\n// Si por cualquier motivo R todavía no trae Marca, preservar las marcas activas de Supabase.\n// Esto evita que una lectura parcial pueda desactivarlas.\nif (!records.some(item => item.category === 'brand')) {\n let preservedBrands = [];\n try {\n preservedBrands = $('Code - Consolidar marcas actuales').first().json.brands || [];\n } catch (error) {\n preservedBrands = [];\n }\n\n for (const value of preservedBrands) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n}\n\n// Deduplicar por category + value, conservando el primer orden leído del Sheet.\nconst uniqueMap = new Map();\nfor (const record of records) {\n const dedupeKey = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n if (!uniqueMap.has(dedupeKey)) uniqueMap.set(dedupeKey, record);\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values()).map((record, index) => ({\n ...record,\n sort_order: index + 1,\n}));\n\nconst counts = uniqueRecords.reduce((acc, item) => {\n acc[item.category] = (acc[item.category] || 0) + 1;\n return acc;\n}, {});\n\n// Protección adicional: nunca llamar la RPC si la lectura está incompleta.\n// Estas categorías existen en la hoja oficial y no son administradas como listas vaciables.\nconst requiredCategories = [\n 'client',\n 'brand',\n 'country',\n 'country_manager',\n 'worked_by',\n 'work_type',\n 'execution_result',\n 'time_status',\n 'status',\n 'month',\n];\n\nconst missingCategories = requiredCategories.filter(category => !counts[category]);\nif (missingCategories.length) {\n throw new Error(\n `SEGURIDAD: lectura incompleta de Listas. Faltan categorías: ${missingCategories.join(', ')}. ` +\n 'No se envió nada a Supabase.'\n );\n}\n\nreturn [\n {\n json: {\n total_rows_read: Math.max(rows.length - 1, 0),\n total_records_to_sync: uniqueRecords.length,\n categories_found: Object.keys(counts),\n category_counts: counts,\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n source_range: response.range || 'Listas!A1:R1000',\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -752, + 176 + ], + "id": "39944bee-6864-43c0-93e8-38f9cc07f4f3", + "name": "Code - Normalizar listas para Supabase (admin)" }, { "parameters": { @@ -118,41 +449,470 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 624, - 0 + -512, + 176 ], - "id": "df494429-1c90-4e18-9bb1-d9b0d9b46cad", - "name": "Supabase - Sincronizar listas completas" + "id": "605c438f-2206-4854-9b80-8062978667c9", + "name": "Supabase - Sincronizar listas completas (admin)", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 800 + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Google Sheet y Supabase sincronizados correctamente.', action: $('Code - Verificar permiso admin').first().json.action, category: $('Code - Verificar permiso admin').first().json.category || null, total_records: $('Code - Normalizar listas para Supabase (admin)').first().json.total_records_to_sync } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -272, + 176 + ], + "id": "de64b72e-854b-4fc8-b830-1824fe359135", + "name": "Respond - Listas sincronizadas" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchClear", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { ranges: $('Code - Preparar escritura Sheet').first().json.clear_ranges } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -1472, + 304 + ], + "id": "d68616ab-4301-4059-a87c-16bedbd1b57f", + "name": "Google Sheets API - Limpiar lista objetivo", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'RAW', data: $('Code - Preparar escritura Sheet').first().json.write_data } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -1232, + 304 + ], + "id": "b955dbe7-d463-46ca-b988-9902bf7a51a2", + "name": "Google Sheets API - Escribir lista objetivo", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "content": "## SINCRONIZACIÓN PROGRAMADA DE LISTAS — LECTURA COMPLETA SEGURA\n\n• La hoja oficial es `Listas` de `CD_Latam_Registro Horas_2024`.\n• Conserva el intervalo existente del workflow.\n• La lectura final usa Google Sheets API sobre `Listas!A1:R1000`, no el nodo Read Sheet.\n• Esto evita lecturas parciales por columnas vacías/discontinuas.\n• Antes de llamar la RPC valida encabezados y las 10 categorías esperadas.\n• Si la columna Marca aún no existe, la inicializa de forma segura en R.\n• Solo después de reconstruir TODAS las listas sincroniza `tablero_cdc_app_lists`.", + "height": 720, + "width": 1888, + "color": "#632313" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3968, + -928 + ], + "id": "f030db17-7877-4d77-8e8c-e9320cbaeacf", + "name": "Sticky Note1" + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 3 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + -3888, + -480 + ], + "id": "96aeffd7-e7da-4619-b3c7-01b0e8b61f0d", + "name": "Schedule Trigger1" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3696, + -480 + ], + "id": "7b0a1685-32df-4d2e-9786-77a679f0ae03", + "name": "Google Sheets API - Releer listas completas (schedule)", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return sortOrder;\n if (isInvalidValue(cleanValue)) return sortOrder;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n\n return sortOrder + 1;\n}\n\nfunction headerMatches(row, index, accepted) {\n const header = normalizeKey(row?.[index]);\n return accepted.map(normalizeKey).includes(header);\n}\n\nconst response = $input.first().json || {};\nconst rows = Array.isArray(response.values) ? response.values : [];\n\nif (!rows.length) {\n throw new Error('SEGURIDAD: Google Sheets API no devolvió datos de la hoja Listas. No se sincronizó Supabase.');\n}\n\nconst header = Array.isArray(rows[0]) ? rows[0] : [];\n\n// Validar que estamos leyendo la hoja/rango correcto antes de construir p_records.\nconst requiredHeaders = [\n { index: 0, names: ['Cliente', 'Clientes'] },\n { index: 2, names: ['Pais / BU', 'País / BU', 'Pais', 'País', 'BU'] },\n { index: 3, names: ['CM', 'Country Manager', 'County Manager', 'Manager'] },\n { index: 5, names: ['Trabajado', 'Trabajado por', 'Responsable', 'Diseñador', 'Creativo'] },\n { index: 7, names: ['Tipo de trabajo', 'Tipo Trabajo', 'Trabajo', 'Tipo'] },\n { index: 9, names: ['Ejecutado / Ganado', 'Ejecutado/Ganado', 'Ejecutado', 'Ganado', 'Resultado'] },\n { index: 11, names: ['Time', 'Tiempo', 'Estado tiempo', 'Estado de tiempo'] },\n { index: 13, names: ['estatus', 'Estatus', 'Status', 'Estado'] },\n { index: 15, names: ['mes', 'Mes', 'MES', 'month'] },\n];\n\nconst badHeaders = requiredHeaders.filter(item => !headerMatches(header, item.index, item.names));\nif (badHeaders.length) {\n const columns = badHeaders.map(item => item.index + 1).join(', ');\n throw new Error(`SEGURIDAD: la lectura de la hoja Listas no contiene los encabezados esperados en las columnas ${columns}. No se sincronizó Supabase.`);\n}\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {\n const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];\n\n const client = normalizeText(row[0]);\n const country = normalizeText(row[2]);\n const countryManager = normalizeText(row[3]);\n const workedBy = normalizeText(row[5]);\n const workType = normalizeText(row[7]);\n const executedWon = normalizeText(row[9]);\n const timeStatus = normalizeText(row[11]);\n const status = normalizeText(row[13]);\n const month = normalizeText(row[15]);\n const brand = normalizeText(row[17]);\n\n for (const value of splitValues(client)) {\n sortOrder = addRecord(records, 'client', value, value, sortOrder);\n }\n\n for (const value of splitValues(brand)) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n\n for (const value of splitValues(country)) {\n sortOrder = addRecord(records, 'country', value, value, sortOrder);\n }\n\n if (country && countryManager) {\n sortOrder = addRecord(records, 'country_manager', country, countryManager, sortOrder);\n }\n\n for (const value of splitValues(workedBy)) {\n sortOrder = addRecord(records, 'worked_by', value, value, sortOrder);\n }\n\n for (const value of splitValues(workType)) {\n sortOrder = addRecord(records, 'work_type', value, value, sortOrder);\n }\n\n for (const value of splitValues(executedWon)) {\n sortOrder = addRecord(records, 'execution_result', value, value, sortOrder);\n }\n\n for (const value of splitValues(timeStatus)) {\n sortOrder = addRecord(records, 'time_status', value, value, sortOrder);\n }\n\n for (const value of splitValues(status)) {\n sortOrder = addRecord(records, 'status', value, value, sortOrder);\n }\n\n for (const value of splitValues(month)) {\n sortOrder = addRecord(records, 'month', value, value, sortOrder);\n }\n}\n\n// Si por cualquier motivo R todavía no trae Marca, preservar las marcas activas de Supabase.\n// Esto evita que una lectura parcial pueda desactivarlas.\nif (!records.some(item => item.category === 'brand')) {\n let preservedBrands = [];\n try {\n preservedBrands = $('Code - Consolidar marcas actuales (schedule)').first().json.brands || [];\n } catch (error) {\n preservedBrands = [];\n }\n\n for (const value of preservedBrands) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n}\n\n// Deduplicar por category + value, conservando el primer orden leído del Sheet.\nconst uniqueMap = new Map();\nfor (const record of records) {\n const dedupeKey = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n if (!uniqueMap.has(dedupeKey)) uniqueMap.set(dedupeKey, record);\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values()).map((record, index) => ({\n ...record,\n sort_order: index + 1,\n}));\n\nconst counts = uniqueRecords.reduce((acc, item) => {\n acc[item.category] = (acc[item.category] || 0) + 1;\n return acc;\n}, {});\n\n// Protección adicional: nunca llamar la RPC si la lectura está incompleta.\n// Estas categorías existen en la hoja oficial y no son administradas como listas vaciables.\nconst requiredCategories = [\n 'client',\n 'brand',\n 'country',\n 'country_manager',\n 'worked_by',\n 'work_type',\n 'execution_result',\n 'time_status',\n 'status',\n 'month',\n];\n\nconst missingCategories = requiredCategories.filter(category => !counts[category]);\nif (missingCategories.length) {\n throw new Error(\n `SEGURIDAD: lectura incompleta de Listas. Faltan categorías: ${missingCategories.join(', ')}. ` +\n 'No se envió nada a Supabase.'\n );\n}\n\nreturn [\n {\n json: {\n total_rows_read: Math.max(rows.length - 1, 0),\n total_records_to_sync: uniqueRecords.length,\n categories_found: Object.keys(counts),\n category_counts: counts,\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n source_range: response.range || 'Listas!A1:R1000',\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3488, + -480 + ], + "id": "8e8c901b-9e8a-4447-af79-0ed03fdb2caa", + "name": "Code - Normalizar listas para Supabase1" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tablero_cdc_sync_app_lists", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({\n p_records: $json.records\n}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3280, + -480 + ], + "id": "7c50c9d1-71b4-4b45-8a00-37f3f72bd3a9", + "name": "Supabase - Sincronizar listas completas1" } ], "connections": { - "Sheets - Leer listas": { + "Supabase - Leer marcas actuales (schedule)": { "main": [ [ { - "node": "Code - Normalizar listas para Supabase", + "node": "Code - Consolidar marcas actuales (schedule)", "type": "main", "index": 0 } ] ] }, - "Code - Normalizar listas para Supabase": { + "Code - Consolidar marcas actuales (schedule)": { "main": [ [ { - "node": "Supabase - Sincronizar listas completas", + "node": "Google Sheets API - Leer Listas (schedule)", "type": "main", "index": 0 } ] ] }, - "Schedule Trigger": { + "Google Sheets API - Leer Listas (schedule)": { "main": [ [ { - "node": "Sheets - Leer listas", + "node": "Code - Preparar Marca Sheet (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Marca Sheet (schedule)": { + "main": [ + [ + { + "node": "IF - Inicializar Marca en Sheet?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Inicializar Marca en Sheet?": { + "main": [ + [ + { + "node": "Google Sheets API - Inicializar Marca (schedule)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Google Sheets API - Releer listas completas (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Inicializar Marca (schedule)": { + "main": [ + [ + { + "node": "Google Sheets API - Releer listas completas (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook - Administrar listas": { + "main": [ + [ + { + "node": "Code - Preparar solicitud admin", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar solicitud admin": { + "main": [ + [ + { + "node": "Supabase - Validar sesión", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar sesión": { + "main": [ + [ + { + "node": "Supabase - Validar permiso listas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar permiso listas": { + "main": [ + [ + { + "node": "Code - Verificar permiso admin", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Verificar permiso admin": { + "main": [ + [ + { + "node": "Supabase - Leer marcas actuales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer marcas actuales": { + "main": [ + [ + { + "node": "Code - Consolidar marcas actuales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar marcas actuales": { + "main": [ + [ + { + "node": "Google Sheets API - Leer Listas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Leer Listas": { + "main": [ + [ + { + "node": "Code - Preparar escritura Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar escritura Sheet": { + "main": [ + [ + { + "node": "IF - Requiere escritura Sheet?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Requiere escritura Sheet?": { + "main": [ + [ + { + "node": "Google Sheets API - Limpiar lista objetivo", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Google Sheets API - Releer listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Limpiar lista objetivo": { + "main": [ + [ + { + "node": "Google Sheets API - Escribir lista objetivo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Escribir lista objetivo": { + "main": [ + [ + { + "node": "Google Sheets API - Releer listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Releer listas completas (admin)": { + "main": [ + [ + { + "node": "Code - Normalizar listas para Supabase (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar listas para Supabase (admin)": { + "main": [ + [ + { + "node": "Supabase - Sincronizar listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Sincronizar listas completas (admin)": { + "main": [ + [ + { + "node": "Respond - Listas sincronizadas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger1": { + "main": [ + [ + { + "node": "Supabase - Leer marcas actuales (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Releer listas completas (schedule)": { + "main": [ + [ + { + "node": "Code - Normalizar listas para Supabase1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar listas para Supabase1": { + "main": [ + [ + { + "node": "Supabase - Sincronizar listas completas1", "type": "main", "index": 0 } @@ -172,7 +932,7 @@ "staticData": { "node:Schedule Trigger": { "recurrenceRules": [ - 0 + 15 ] }, "node:Google Sheets Trigger": { @@ -181,15 +941,20 @@ "lastIndexChecked": 91, "lastRevision": 9616, "lastRevisionLink": "https://docs.google.com/spreadsheets/export?id=1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q&revision=9616&exportFormat=xlsx" + }, + "node:Schedule Trigger1": { + "recurrenceRules": [ + 0 + ] } }, "meta": { "templateCredsSetupCompleted": true }, - "versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", - "activeVersionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", - "versionCounter": 546, - "triggerCount": 1, + "versionId": "eb89b9a7-7960-4f49-8331-50b189e14a5d", + "activeVersionId": "eb89b9a7-7960-4f49-8331-50b189e14a5d", + "versionCounter": 625, + "triggerCount": 2, "shared": [ { "updatedAt": "2026-05-27T15:07:19.948Z", @@ -211,37 +976,33 @@ ], "tags": [], "activeVersion": { - "updatedAt": "2026-07-18T13:04:24.000Z", - "createdAt": "2026-07-18T13:04:19.436Z", - "versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", + "updatedAt": "2026-08-14T13:17:02.000Z", + "createdAt": "2026-08-14T13:16:59.703Z", + "versionId": "eb89b9a7-7960-4f49-8331-50b189e14a5d", "workflowId": "MU5GdxLtFzcRcfyM", "nodes": [ { "parameters": { - "documentId": { - "__rl": true, - "value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q", - "mode": "list", - "cachedResultName": "APROBACIONES PROYECTOS", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk" - }, - "sheetName": { - "__rl": true, - "value": 1332738713, - "mode": "list", - "cachedResultName": "listas", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1332738713" - }, + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'RAW', data: $('Code - Preparar Marca Sheet (schedule)').first().json.write_data } }}", "options": {} }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, "position": [ - 208, - 0 + -2496, + -832 ], - "id": "cd67f274-a366-4efb-841a-3a8bceda956f", - "name": "Sheets - Leer listas", + "id": "e52f3d8d-1230-4b6e-b7fd-df403cbb70c6", + "name": "Google Sheets API - Inicializar Marca (schedule)", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", @@ -251,52 +1012,387 @@ }, { "parameters": { - "jsCode": "function normalizeText(value) {\n return String(value || '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction pick(row, possibleHeaders) {\n const normalizedRow = {};\n\n for (const [key, value] of Object.entries(row || {})) {\n normalizedRow[normalizeKey(key)] = value;\n }\n\n for (const header of possibleHeaders) {\n const value = normalizedRow[normalizeKey(header)];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return normalizeText(value);\n }\n }\n\n return '';\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return;\n if (isInvalidValue(cleanValue)) return;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n}\n\nconst rows = $input.all().map(item => item.json || {});\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (const row of rows) {\n const client = pick(row, [\n 'Cliente',\n 'Clientes',\n 'CLIENTE',\n 'client',\n ]);\n\n const brand = pick(row, [\n 'Marca',\n 'Marcas',\n 'MARCA',\n 'brand',\n ]);\n\n const country = pick(row, [\n 'Pais / BU',\n 'País / BU',\n 'Pais',\n 'País',\n 'Business Unit',\n 'BU',\n 'Country',\n ]);\n\n const countryManager = pick(row, [\n 'CM',\n 'Country Manager',\n 'County Manager',\n 'Manager',\n ]);\n\n const workedBy = pick(row, [\n 'Trabajado',\n 'Trabajado por',\n 'Responsable',\n 'Diseñador',\n 'Creativo',\n ]);\n\n const workType = pick(row, [\n 'Tipo de trabajo',\n 'Tipo Trabajo',\n 'Trabajo',\n 'Tipo',\n ]);\n\n const executedWon = pick(row, [\n 'Ejecutado / Ganado',\n 'Ejecutado/Ganado',\n 'Ejecutado',\n 'Ganado',\n 'Resultado',\n ]);\n\n const timeStatus = pick(row, [\n 'Time',\n 'Tiempo',\n 'Estado tiempo',\n 'Estado de tiempo',\n ]);\n\n const status = pick(row, [\n 'estatus',\n 'Estatus',\n 'Status',\n 'Estado',\n 'ESTATUS',\n ]);\n\n const month = pick(row, [\n 'mes',\n 'Mes',\n 'MES',\n 'month',\n ]);\n\n for (const value of splitValues(client)) {\n addRecord(records, 'client', value, value, sortOrder++);\n }\n\n for (const value of splitValues(brand)) {\n addRecord(records, 'brand', value, value, sortOrder++);\n }\n\n for (const value of splitValues(country)) {\n addRecord(records, 'country', value, value, sortOrder++);\n }\n\n if (country && countryManager) {\n addRecord(records, 'country_manager', country, countryManager, sortOrder++);\n }\n\n for (const value of splitValues(workedBy)) {\n addRecord(records, 'worked_by', value, value, sortOrder++);\n }\n\n for (const value of splitValues(workType)) {\n addRecord(records, 'work_type', value, value, sortOrder++);\n }\n\n for (const value of splitValues(executedWon)) {\n addRecord(records, 'execution_result', value, value, sortOrder++);\n }\n\n for (const value of splitValues(timeStatus)) {\n addRecord(records, 'time_status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(status)) {\n addRecord(records, 'status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(month)) {\n addRecord(records, 'month', value, value, sortOrder++);\n }\n}\n\n// Deduplicar por category + value\nconst uniqueMap = new Map();\n\nfor (const record of records) {\n const key = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n\n if (!uniqueMap.has(key)) {\n uniqueMap.set(key, record);\n }\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values())\n .map((record, index) => ({\n ...record,\n sort_order: index + 1,\n }));\n\nreturn [\n {\n json: {\n total_rows_read: rows.length,\n total_records_to_sync: uniqueRecords.length,\n categories_found: [...new Set(uniqueRecords.map(item => item.category))],\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n },\n },\n];" + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.brand&is_active=eq.true&select=value,label,sort_order&order=sort_order.asc&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3696, + -752 + ], + "id": "8b5f643f-9e48-4143-a803-82eed4198115", + "name": "Supabase - Leer marcas actuales (schedule)", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const brands = [];\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nconst seen = new Set();\nfor (const item of $input.all()) {\n const raw = item.json;\n const rows = Array.isArray(raw) ? raw : [raw];\n for (const row of rows) {\n const value = clean(row?.label || row?.value);\n const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized);\n brands.push(value);\n }\n}\nreturn [{ json: { brands } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 416, - 0 + -3456, + -752 ], - "id": "6c6131fe-f6df-434c-9596-c9d29a90ed0a", - "name": "Code - Normalizar listas para Supabase" + "id": "a7f79f20-f230-4024-9238-b075bc36ee14", + "name": "Code - Consolidar marcas actuales (schedule)" }, { "parameters": { - "content": "## Flujo que se ejecuta cada vez que se agrega una fila nueva, lee las listas del Sheet luego normaliza las listas y por último hace el Upsert en Supabase", - "height": 352, - "width": 912, - "color": "#632313" + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3216, + -752 + ], + "id": "57306c6f-7374-4212-8407-03a786bd9902", + "name": "Google Sheets API - Leer Listas (schedule)", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const sheetResponse=$input.first().json||{};\nconst rows=Array.isArray(sheetResponse.values)?sheetResponse.values:[];\nconst currentBrands=$('Code - Consolidar marcas actuales (schedule)').first().json.brands||[];\nconst fallbackBrands=[\"Carnation\", \"GLM RRHH\", \"La Lechera\", \"Malher\", \"US Meat\", \"Coffee Mate y Nescafé\", \"Nestum y Nido\", \"Whirlpool\", \"Motorola\", \"Philip Morris\", \"US Meat Guatemala\", \"KitKat, Crunch, Choco Trío\", \"Colgate\", \"Kit Kat\", \"UMA\", \"Purina\", \"Maggi e Ideal\", \"Nestlé\", \"Jimador\", \"Multimarcas\", \"Maggi\", \"Covo\", \"El Machetazo\", \"Grupo Bocel\", \"Supply Chain\", \"Carnation y La Lechera\", \"GLM Administración\", \"Nestlé Bice\", \"GLM\", \"Nescafé\", \"Idea, Qué Rico y Maggi\", \"EY\", \"Nesquik\", \"Maggi, Ketchup, Qué Rico\", \"Ideal\", \"Claro Empresas\", \"Nido\", \"Presto\", \"Nestlé RD\", \"GLM Colombia\", \"Motorola RD\", \"GLM Caribe\", \"Mandino\", \"Coffee Mate\", \"GLM Latam\", \"RRHH RD\", \"Philip Morris TT\", \"GLM RD\", \"Smirnoff\", \"Kaspersky\", \"US Pork\", \"Qué Rico y Maggi\", \"Claro y Motorola\", \"Sedal\", \"Maytag\", \"Qué Rico, Maggi y Ketchup\", \"Multimarca\", \"Aviva y Princesa\", \"KitchenAid\", \"Enredé\", \"Ascenda\", \"Lipton Té\", \"Heiniken\", \"Oral B\", \"Aafresh\", \"IQOS\", \"Natures Heart\"];\nconst INDEX=17;\nfunction clean(value){return String(value??'').trim().replace(/\\s+/g,' ');}\nfunction key(value){return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g,'').toLowerCase();}\nfunction dedupe(values){const seen=new Set(),out=[];for(const raw of values){const value=clean(raw),k=key(value);if(!value||!k||seen.has(k))continue;seen.add(k);out.push(value);}return out;}\nconst header=clean(rows[0]?.[INDEX]);\nconst existing=[];for(let i=1;i[value])]}]}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2976, + -752 + ], + "id": "408f660b-646f-46da-a002-6fd22d194ec5", + "name": "Code - Preparar Marca Sheet (schedule)" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "a4de0272-638a-4065-8118-bb339035c00a", + "leftValue": "={{ $json.needs_seed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -2736, + -752 + ], + "id": "6c7a6f77-5cd7-4277-a5db-cd5d3d36ad20", + "name": "IF - Inicializar Marca en Sheet?" + }, + { + "parameters": { + "content": "## ADMINISTRACIÓN INSTANTÁNEA DESDE TABLERO CDC — LECTURA COMPLETA SEGURA\n\n• Webhook: `tablero-cdc-sync-listas-admin`.\n• Valida la sesión Supabase y el permiso `can_manage_tariff_catalog`.\n• Crea / edita / elimina Clientes, Marcas, País / BU + CM y Estatus.\n• Solo limpia y reescribe la(s) columna(s) de la lista modificada; nunca elimina filas completas.\n• Después de cada escritura relee `Listas!A1:R1000` con Google Sheets API.\n• Reconstruye TODAS las categorías antes de sincronizar Supabase.\n• Si detecta lectura parcial o encabezados incorrectos, cancela antes de tocar Supabase.\n• `refresh` permite editar manualmente el Sheet y sincronizarlo de inmediato.", + "height": 656, + "width": 3920, + "color": 5 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ - -64, - -144 + -3984, + -64 ], - "id": "2ec4d347-68e4-4358-9a82-3748c01af9a6", - "name": "Sticky Note" + "id": "75325b2d-5a61-4e7d-a096-86528cdb2a18", + "name": "Sticky Note - Administración desde app" }, { "parameters": { - "rule": { - "interval": [ + "httpMethod": "POST", + "path": "tablero-cdc-sync-listas-admin", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -3872, + 176 + ], + "id": "c068bd56-9b45-408b-aeb6-81b682f0de6d", + "name": "Webhook - Administrar listas", + "webhookId": "b80743ea-73e3-4f48-9e0e-518790a5912a" + }, + { + "parameters": { + "jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nconst action = clean(body.action).toLowerCase();\nconst category = clean(body.category).toLowerCase();\nconst value = clean(body.value);\nconst previousValue = clean(body.previous_value || body.previousValue);\nconst manager = clean(body.manager);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nconst allowedActions = new Set(['create', 'update', 'delete', 'refresh']);\nconst allowedCategories = new Set(['client', 'brand', 'country', 'status']);\n\nif (!allowedActions.has(action)) {\n throw new Error('Acción de listas inválida.');\n}\n\nif (action !== 'refresh' && !allowedCategories.has(category)) {\n throw new Error('Categoría de lista inválida.');\n}\n\nif (action !== 'refresh' && !value && action === 'create') {\n throw new Error('Falta el valor que se desea agregar.');\n}\n\nif ((action === 'update' || action === 'delete') && !previousValue && !value) {\n throw new Error('Falta el valor original de la opción.');\n}\n\nif (action === 'update' && !value) {\n throw new Error('Falta el nuevo valor de la opción.');\n}\n\nif (!accessToken) {\n throw new Error('No se recibió una sesión válida de Supabase.');\n}\n\nreturn [{\n json: {\n action,\n category,\n value,\n previous_value: previousValue,\n manager,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc',\n received_at: new Date().toISOString(),\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3632, + 176 + ], + "id": "8cf79221-21b6-4c07-a2fd-5428898735ef", + "name": "Code - Preparar solicitud admin" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/auth/v1/user", + "sendHeaders": true, + "headerParameters": { + "parameters": [ { - "field": "hours", - "hoursInterval": 3 + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE" + }, + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $('Code - Preparar solicitud admin').first().json.access_token }}" } ] - } + }, + "options": {} }, - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, "position": [ - 16, - 0 + -3392, + 176 ], - "id": "4639502f-e8b1-4373-8f7f-d122598d2051", - "name": "Schedule Trigger" + "id": "9b6f34e8-1348-41c4-834c-f8b55c70693e", + "name": "Supabase - Validar sesión" + }, + { + "parameters": { + "url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_allowed_users?email=eq.' + encodeURIComponent($json.email || '') + '&is_active=eq.true&can_manage_tariff_catalog=eq.true&select=email,full_name&limit=1' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3152, + 176 + ], + "id": "6403d212-8fd9-404f-9214-2a9ea59ff4e5", + "name": "Supabase - Validar permiso listas", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const request = $('Code - Preparar solicitud admin').first().json || {};\nconst authUser = $('Supabase - Validar sesión').first().json || {};\n\nconst permissionRows = [];\nfor (const item of $input.all()) {\n const value = item.json;\n if (Array.isArray(value)) permissionRows.push(...value);\n else if (value && typeof value === 'object' && Object.keys(value).length) permissionRows.push(value);\n}\n\nconst email = String(authUser.email || '').trim().toLowerCase();\nif (!email) {\n throw new Error('No se pudo validar el usuario autenticado en Supabase.');\n}\n\nconst allowed = permissionRows.some(row =>\n String(row.email || '').trim().toLowerCase() === email\n);\n\nif (!allowed) {\n throw new Error('Tu usuario no tiene permiso para administrar las listas del Tablero CDC.');\n}\n\nreturn [{\n json: {\n ...request,\n actor_email: email,\n actor_name: authUser.user_metadata?.full_name || authUser.user_metadata?.name || email,\n },\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2912, + 176 + ], + "id": "ab073505-4759-4340-ab5f-3ea70b3d22b2", + "name": "Code - Verificar permiso admin" + }, + { + "parameters": { + "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.brand&is_active=eq.true&select=value,label,sort_order&order=sort_order.asc&limit=1000", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Accept", + "value": "application/json" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2672, + 176 + ], + "id": "70a19084-9657-4420-9b9c-68fff3a5ac95", + "name": "Supabase - Leer marcas actuales", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const brands = [];\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase(); }\nconst seen = new Set();\nfor (const item of $input.all()) {\n const raw = item.json;\n const rows = Array.isArray(raw) ? raw : [raw];\n for (const row of rows) {\n const value = clean(row?.label || row?.value);\n const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized);\n brands.push(value);\n }\n}\nreturn [{ json: { brands } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -2432, + 176 + ], + "id": "fa663dc9-18a6-463c-8a61-3ef99bbe2083", + "name": "Code - Consolidar marcas actuales" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -2192, + 176 + ], + "id": "38441df7-b6ca-4a7f-9041-330403107dfa", + "name": "Google Sheets API - Leer Listas", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "const request = $('Code - Verificar permiso admin').first().json || {};\nconst sheetResponse = $input.first().json || {};\nconst rows = Array.isArray(sheetResponse.values) ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : []) : [];\nconst currentSupabaseBrands = $('Code - Consolidar marcas actuales').first().json.brands || [];\nconst fallbackBrands = [\"Carnation\", \"GLM RRHH\", \"La Lechera\", \"Malher\", \"US Meat\", \"Coffee Mate y Nescafé\", \"Nestum y Nido\", \"Whirlpool\", \"Motorola\", \"Philip Morris\", \"US Meat Guatemala\", \"KitKat, Crunch, Choco Trío\", \"Colgate\", \"Kit Kat\", \"UMA\", \"Purina\", \"Maggi e Ideal\", \"Nestlé\", \"Jimador\", \"Multimarcas\", \"Maggi\", \"Covo\", \"El Machetazo\", \"Grupo Bocel\", \"Supply Chain\", \"Carnation y La Lechera\", \"GLM Administración\", \"Nestlé Bice\", \"GLM\", \"Nescafé\", \"Idea, Qué Rico y Maggi\", \"EY\", \"Nesquik\", \"Maggi, Ketchup, Qué Rico\", \"Ideal\", \"Claro Empresas\", \"Nido\", \"Presto\", \"Nestlé RD\", \"GLM Colombia\", \"Motorola RD\", \"GLM Caribe\", \"Mandino\", \"Coffee Mate\", \"GLM Latam\", \"RRHH RD\", \"Philip Morris TT\", \"GLM RD\", \"Smirnoff\", \"Kaspersky\", \"US Pork\", \"Qué Rico y Maggi\", \"Claro y Motorola\", \"Sedal\", \"Maytag\", \"Qué Rico, Maggi y Ketchup\", \"Multimarca\", \"Aviva y Princesa\", \"KitchenAid\", \"Enredé\", \"Ascenda\", \"Lipton Té\", \"Heiniken\", \"Oral B\", \"Aafresh\", \"IQOS\", \"Natures Heart\"];\nconst BRAND_COLUMN_INDEX = 17; // R\nconst BRAND_RANGE = 'Listas!R1:R1000';\n\nfunction clean(value) { return String(value ?? '').trim().replace(/\\s+/g, ' '); }\nfunction key(value) { return clean(value).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); }\nfunction dedupe(values) {\n const seen = new Set(); const result = [];\n for (const raw of values) {\n const value = clean(raw); const normalized = key(value);\n if (!value || !normalized || seen.has(normalized)) continue;\n seen.add(normalized); result.push(value);\n }\n return result;\n}\nfunction columnValues(index) {\n const values = [];\n for (let row = 1; row < rows.length; row += 1) {\n const value = clean(rows[row]?.[index]); if (value) values.push(value);\n }\n return dedupe(values);\n}\nfunction applySingle(values, action, previousValue, nextValue) {\n const result = [...values];\n const previousKey = key(previousValue || nextValue); const nextKey = key(nextValue);\n const matches = result.map((item,index) => key(item) === previousKey ? index : -1).filter(index => index >= 0);\n if (action === 'create') {\n if (!nextValue) throw new Error('Falta el valor que se desea agregar.');\n if (result.some(item => key(item) === nextKey)) throw new Error('Esa opción ya existe en la lista.');\n result.push(nextValue); return result;\n }\n if (matches.length !== 1) {\n throw new Error(matches.length === 0 ? 'No se encontró exactamente la opción original en el Google Sheet.' : 'La opción original está duplicada en el Google Sheet. Corrige el duplicado antes de editarla.');\n }\n const index = matches[0];\n if (action === 'delete') { result.splice(index,1); return result; }\n if (result.some((item,itemIndex) => itemIndex !== index && key(item) === nextKey)) throw new Error('El nuevo valor ya existe en la lista.');\n result[index] = nextValue; return result;\n}\n\nconst action = request.action; const category = request.category;\nconst previousValue = request.previous_value || request.value; const nextValue = request.value; const manager = request.manager || '';\nlet clearRanges = []; let writeData = []; let countAfter = 0;\n\n// Marca no tenía una columna oficial en la exportación recibida. R está vacía y sin referencias en el workbook.\n// La primera operación inicializa R como lista oficial de Marca sin tocar la data existente de otras columnas.\nconst brandHeader = clean(rows[0]?.[BRAND_COLUMN_INDEX]);\nconst rawBrandValues = columnValues(BRAND_COLUMN_INDEX);\nconst brandHeaderIsValid = ['marca','marcas'].includes(key(brandHeader));\nif (!brandHeaderIsValid && (brandHeader || rawBrandValues.length)) {\n throw new Error('SEGURIDAD: la columna R de la hoja Listas contiene datos ajenos y no tiene encabezado Marca. No se modificó el Sheet.');\n}\nconst brandSeed = brandHeaderIsValid ? rawBrandValues : dedupe([...currentSupabaseBrands, ...fallbackBrands]);\n\nif (action === 'refresh') {\n if (!brandHeaderIsValid) {\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range: `Listas!R1:R${brandSeed.length + 1}`, majorDimension:'ROWS', values:[['Marca'], ...brandSeed.map(value => [value])] });\n countAfter = brandSeed.length;\n }\n} else if (category === 'client') {\n const values = applySingle(columnValues(0), action, previousValue, nextValue);\n clearRanges.push('Listas!A1:A1000');\n writeData.push({ range:`Listas!A1:A${values.length+1}`, majorDimension:'ROWS', values:[['Cliente'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'status') {\n const values = applySingle(columnValues(13), action, previousValue, nextValue);\n clearRanges.push('Listas!N1:N1000');\n writeData.push({ range:`Listas!N1:N${values.length+1}`, majorDimension:'ROWS', values:[['estatus'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'brand') {\n const values = applySingle(brandSeed, action, previousValue, nextValue);\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range:`Listas!R1:R${values.length+1}`, majorDimension:'ROWS', values:[['Marca'], ...values.map(value=>[value])] });\n countAfter = values.length;\n} else if (category === 'country') {\n const pairs=[]; const seen=new Set();\n for (let row=1; rowkey(item.value)===previousKey?index:-1).filter(index=>index>=0);\n if (action==='create') {\n if (!nextValue) throw new Error('Falta el País / BU que se desea agregar.');\n if (pairs.some(item=>key(item.value)===nextKey)) throw new Error('Ese País / BU ya existe.');\n pairs.push({value:nextValue,manager});\n } else {\n if (matches.length!==1) throw new Error(matches.length===0?'No se encontró exactamente el País / BU original en el Google Sheet.':'El País / BU está duplicado en el Google Sheet. Corrige el duplicado antes de editarlo.');\n const index=matches[0];\n if (action==='delete') pairs.splice(index,1);\n else {\n if (pairs.some((item,itemIndex)=>itemIndex!==index&&key(item.value)===nextKey)) throw new Error('El nuevo País / BU ya existe.');\n pairs[index]={value:nextValue,manager};\n }\n }\n clearRanges.push('Listas!C1:D1000');\n writeData.push({ range:`Listas!C1:D${pairs.length+1}`, majorDimension:'ROWS', values:[['Pais / BU','CM'], ...pairs.map(item=>[item.value,item.manager])] });\n countAfter=pairs.length;\n} else {\n throw new Error('Categoría no soportada.');\n}\n\n// Si se modificó otra categoría y Marca todavía no estaba inicializada, inicializarla en la misma transacción lógica.\nif (action !== 'refresh' && category !== 'brand' && !brandHeaderIsValid) {\n clearRanges.push(BRAND_RANGE);\n writeData.push({ range:`Listas!R1:R${brandSeed.length+1}`, majorDimension:'ROWS', values:[['Marca'], ...brandSeed.map(value=>[value])] });\n}\n\nreturn [{json:{...request,clear_ranges:clearRanges,write_data:writeData,count_after:countAfter,needs_write:writeData.length>0}}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -1952, + 176 + ], + "id": "7c5580e5-42c8-481d-8b4a-17d72c8dcdec", + "name": "Code - Preparar escritura Sheet" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "94ac6fcf-dd19-45f0-abc6-725165cf666e", + "leftValue": "={{ $json.needs_write }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -1712, + 176 + ], + "id": "83518b42-9297-4e64-b382-54d82087b1e4", + "name": "IF - Requiere escritura Sheet?" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -992, + 176 + ], + "id": "0faf59e5-27cc-4203-87e1-1b538a9593dd", + "name": "Google Sheets API - Releer listas completas (admin)", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return sortOrder;\n if (isInvalidValue(cleanValue)) return sortOrder;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n\n return sortOrder + 1;\n}\n\nfunction headerMatches(row, index, accepted) {\n const header = normalizeKey(row?.[index]);\n return accepted.map(normalizeKey).includes(header);\n}\n\nconst response = $input.first().json || {};\nconst rows = Array.isArray(response.values) ? response.values : [];\n\nif (!rows.length) {\n throw new Error('SEGURIDAD: Google Sheets API no devolvió datos de la hoja Listas. No se sincronizó Supabase.');\n}\n\nconst header = Array.isArray(rows[0]) ? rows[0] : [];\n\n// Validar que estamos leyendo la hoja/rango correcto antes de construir p_records.\nconst requiredHeaders = [\n { index: 0, names: ['Cliente', 'Clientes'] },\n { index: 2, names: ['Pais / BU', 'País / BU', 'Pais', 'País', 'BU'] },\n { index: 3, names: ['CM', 'Country Manager', 'County Manager', 'Manager'] },\n { index: 5, names: ['Trabajado', 'Trabajado por', 'Responsable', 'Diseñador', 'Creativo'] },\n { index: 7, names: ['Tipo de trabajo', 'Tipo Trabajo', 'Trabajo', 'Tipo'] },\n { index: 9, names: ['Ejecutado / Ganado', 'Ejecutado/Ganado', 'Ejecutado', 'Ganado', 'Resultado'] },\n { index: 11, names: ['Time', 'Tiempo', 'Estado tiempo', 'Estado de tiempo'] },\n { index: 13, names: ['estatus', 'Estatus', 'Status', 'Estado'] },\n { index: 15, names: ['mes', 'Mes', 'MES', 'month'] },\n];\n\nconst badHeaders = requiredHeaders.filter(item => !headerMatches(header, item.index, item.names));\nif (badHeaders.length) {\n const columns = badHeaders.map(item => item.index + 1).join(', ');\n throw new Error(`SEGURIDAD: la lectura de la hoja Listas no contiene los encabezados esperados en las columnas ${columns}. No se sincronizó Supabase.`);\n}\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {\n const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];\n\n const client = normalizeText(row[0]);\n const country = normalizeText(row[2]);\n const countryManager = normalizeText(row[3]);\n const workedBy = normalizeText(row[5]);\n const workType = normalizeText(row[7]);\n const executedWon = normalizeText(row[9]);\n const timeStatus = normalizeText(row[11]);\n const status = normalizeText(row[13]);\n const month = normalizeText(row[15]);\n const brand = normalizeText(row[17]);\n\n for (const value of splitValues(client)) {\n sortOrder = addRecord(records, 'client', value, value, sortOrder);\n }\n\n for (const value of splitValues(brand)) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n\n for (const value of splitValues(country)) {\n sortOrder = addRecord(records, 'country', value, value, sortOrder);\n }\n\n if (country && countryManager) {\n sortOrder = addRecord(records, 'country_manager', country, countryManager, sortOrder);\n }\n\n for (const value of splitValues(workedBy)) {\n sortOrder = addRecord(records, 'worked_by', value, value, sortOrder);\n }\n\n for (const value of splitValues(workType)) {\n sortOrder = addRecord(records, 'work_type', value, value, sortOrder);\n }\n\n for (const value of splitValues(executedWon)) {\n sortOrder = addRecord(records, 'execution_result', value, value, sortOrder);\n }\n\n for (const value of splitValues(timeStatus)) {\n sortOrder = addRecord(records, 'time_status', value, value, sortOrder);\n }\n\n for (const value of splitValues(status)) {\n sortOrder = addRecord(records, 'status', value, value, sortOrder);\n }\n\n for (const value of splitValues(month)) {\n sortOrder = addRecord(records, 'month', value, value, sortOrder);\n }\n}\n\n// Si por cualquier motivo R todavía no trae Marca, preservar las marcas activas de Supabase.\n// Esto evita que una lectura parcial pueda desactivarlas.\nif (!records.some(item => item.category === 'brand')) {\n let preservedBrands = [];\n try {\n preservedBrands = $('Code - Consolidar marcas actuales').first().json.brands || [];\n } catch (error) {\n preservedBrands = [];\n }\n\n for (const value of preservedBrands) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n}\n\n// Deduplicar por category + value, conservando el primer orden leído del Sheet.\nconst uniqueMap = new Map();\nfor (const record of records) {\n const dedupeKey = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n if (!uniqueMap.has(dedupeKey)) uniqueMap.set(dedupeKey, record);\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values()).map((record, index) => ({\n ...record,\n sort_order: index + 1,\n}));\n\nconst counts = uniqueRecords.reduce((acc, item) => {\n acc[item.category] = (acc[item.category] || 0) + 1;\n return acc;\n}, {});\n\n// Protección adicional: nunca llamar la RPC si la lectura está incompleta.\n// Estas categorías existen en la hoja oficial y no son administradas como listas vaciables.\nconst requiredCategories = [\n 'client',\n 'brand',\n 'country',\n 'country_manager',\n 'worked_by',\n 'work_type',\n 'execution_result',\n 'time_status',\n 'status',\n 'month',\n];\n\nconst missingCategories = requiredCategories.filter(category => !counts[category]);\nif (missingCategories.length) {\n throw new Error(\n `SEGURIDAD: lectura incompleta de Listas. Faltan categorías: ${missingCategories.join(', ')}. ` +\n 'No se envió nada a Supabase.'\n );\n}\n\nreturn [\n {\n json: {\n total_rows_read: Math.max(rows.length - 1, 0),\n total_records_to_sync: uniqueRecords.length,\n categories_found: Object.keys(counts),\n category_counts: counts,\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n source_range: response.range || 'Listas!A1:R1000',\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -752, + 176 + ], + "id": "39944bee-6864-43c0-93e8-38f9cc07f4f3", + "name": "Code - Normalizar listas para Supabase (admin)" }, { "parameters": { @@ -327,41 +1423,470 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ - 624, - 0 + -512, + 176 ], - "id": "df494429-1c90-4e18-9bb1-d9b0d9b46cad", - "name": "Supabase - Sincronizar listas completas" + "id": "605c438f-2206-4854-9b80-8062978667c9", + "name": "Supabase - Sincronizar listas completas (admin)", + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 800 + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ { ok: true, message: 'Google Sheet y Supabase sincronizados correctamente.', action: $('Code - Verificar permiso admin').first().json.action, category: $('Code - Verificar permiso admin').first().json.category || null, total_records: $('Code - Normalizar listas para Supabase (admin)').first().json.total_records_to_sync } }}", + "options": { + "responseCode": 200 + } + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -272, + 176 + ], + "id": "de64b72e-854b-4fc8-b830-1824fe359135", + "name": "Respond - Listas sincronizadas" + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchClear", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { ranges: $('Code - Preparar escritura Sheet').first().json.clear_ranges } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -1472, + 304 + ], + "id": "d68616ab-4301-4059-a87c-16bedbd1b57f", + "name": "Google Sheets API - Limpiar lista objetivo", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { valueInputOption: 'RAW', data: $('Code - Preparar escritura Sheet').first().json.write_data } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -1232, + 304 + ], + "id": "b955dbe7-d463-46ca-b988-9902bf7a51a2", + "name": "Google Sheets API - Escribir lista objetivo", + "retryOnFail": true, + "maxTries": 5, + "waitBetweenTries": 5000, + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "content": "## SINCRONIZACIÓN PROGRAMADA DE LISTAS — LECTURA COMPLETA SEGURA\n\n• La hoja oficial es `Listas` de `CD_Latam_Registro Horas_2024`.\n• Conserva el intervalo existente del workflow.\n• La lectura final usa Google Sheets API sobre `Listas!A1:R1000`, no el nodo Read Sheet.\n• Esto evita lecturas parciales por columnas vacías/discontinuas.\n• Antes de llamar la RPC valida encabezados y las 10 categorías esperadas.\n• Si la columna Marca aún no existe, la inicializa de forma segura en R.\n• Solo después de reconstruir TODAS las listas sincroniza `tablero_cdc_app_lists`.", + "height": 720, + "width": 1888, + "color": "#632313" + }, + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + -3968, + -928 + ], + "id": "f030db17-7877-4d77-8e8c-e9320cbaeacf", + "name": "Sticky Note1" + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 3 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + -3888, + -480 + ], + "id": "96aeffd7-e7da-4619-b3c7-01b0e8b61f0d", + "name": "Schedule Trigger1" + }, + { + "parameters": { + "url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/Listas!A1:R1000?majorDimension=ROWS", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "googleSheetsOAuth2Api", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3696, + -480 + ], + "id": "7b0a1685-32df-4d2e-9786-77a679f0ae03", + "name": "Google Sheets API - Releer listas completas (schedule)", + "credentials": { + "googleSheetsOAuth2Api": { + "id": "K0hDZh3a85MpOHCs", + "name": "Google Sheets account 2" + } + } + }, + { + "parameters": { + "jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return sortOrder;\n if (isInvalidValue(cleanValue)) return sortOrder;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n\n return sortOrder + 1;\n}\n\nfunction headerMatches(row, index, accepted) {\n const header = normalizeKey(row?.[index]);\n return accepted.map(normalizeKey).includes(header);\n}\n\nconst response = $input.first().json || {};\nconst rows = Array.isArray(response.values) ? response.values : [];\n\nif (!rows.length) {\n throw new Error('SEGURIDAD: Google Sheets API no devolvió datos de la hoja Listas. No se sincronizó Supabase.');\n}\n\nconst header = Array.isArray(rows[0]) ? rows[0] : [];\n\n// Validar que estamos leyendo la hoja/rango correcto antes de construir p_records.\nconst requiredHeaders = [\n { index: 0, names: ['Cliente', 'Clientes'] },\n { index: 2, names: ['Pais / BU', 'País / BU', 'Pais', 'País', 'BU'] },\n { index: 3, names: ['CM', 'Country Manager', 'County Manager', 'Manager'] },\n { index: 5, names: ['Trabajado', 'Trabajado por', 'Responsable', 'Diseñador', 'Creativo'] },\n { index: 7, names: ['Tipo de trabajo', 'Tipo Trabajo', 'Trabajo', 'Tipo'] },\n { index: 9, names: ['Ejecutado / Ganado', 'Ejecutado/Ganado', 'Ejecutado', 'Ganado', 'Resultado'] },\n { index: 11, names: ['Time', 'Tiempo', 'Estado tiempo', 'Estado de tiempo'] },\n { index: 13, names: ['estatus', 'Estatus', 'Status', 'Estado'] },\n { index: 15, names: ['mes', 'Mes', 'MES', 'month'] },\n];\n\nconst badHeaders = requiredHeaders.filter(item => !headerMatches(header, item.index, item.names));\nif (badHeaders.length) {\n const columns = badHeaders.map(item => item.index + 1).join(', ');\n throw new Error(`SEGURIDAD: la lectura de la hoja Listas no contiene los encabezados esperados en las columnas ${columns}. No se sincronizó Supabase.`);\n}\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {\n const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];\n\n const client = normalizeText(row[0]);\n const country = normalizeText(row[2]);\n const countryManager = normalizeText(row[3]);\n const workedBy = normalizeText(row[5]);\n const workType = normalizeText(row[7]);\n const executedWon = normalizeText(row[9]);\n const timeStatus = normalizeText(row[11]);\n const status = normalizeText(row[13]);\n const month = normalizeText(row[15]);\n const brand = normalizeText(row[17]);\n\n for (const value of splitValues(client)) {\n sortOrder = addRecord(records, 'client', value, value, sortOrder);\n }\n\n for (const value of splitValues(brand)) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n\n for (const value of splitValues(country)) {\n sortOrder = addRecord(records, 'country', value, value, sortOrder);\n }\n\n if (country && countryManager) {\n sortOrder = addRecord(records, 'country_manager', country, countryManager, sortOrder);\n }\n\n for (const value of splitValues(workedBy)) {\n sortOrder = addRecord(records, 'worked_by', value, value, sortOrder);\n }\n\n for (const value of splitValues(workType)) {\n sortOrder = addRecord(records, 'work_type', value, value, sortOrder);\n }\n\n for (const value of splitValues(executedWon)) {\n sortOrder = addRecord(records, 'execution_result', value, value, sortOrder);\n }\n\n for (const value of splitValues(timeStatus)) {\n sortOrder = addRecord(records, 'time_status', value, value, sortOrder);\n }\n\n for (const value of splitValues(status)) {\n sortOrder = addRecord(records, 'status', value, value, sortOrder);\n }\n\n for (const value of splitValues(month)) {\n sortOrder = addRecord(records, 'month', value, value, sortOrder);\n }\n}\n\n// Si por cualquier motivo R todavía no trae Marca, preservar las marcas activas de Supabase.\n// Esto evita que una lectura parcial pueda desactivarlas.\nif (!records.some(item => item.category === 'brand')) {\n let preservedBrands = [];\n try {\n preservedBrands = $('Code - Consolidar marcas actuales (schedule)').first().json.brands || [];\n } catch (error) {\n preservedBrands = [];\n }\n\n for (const value of preservedBrands) {\n sortOrder = addRecord(records, 'brand', value, value, sortOrder);\n }\n}\n\n// Deduplicar por category + value, conservando el primer orden leído del Sheet.\nconst uniqueMap = new Map();\nfor (const record of records) {\n const dedupeKey = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n if (!uniqueMap.has(dedupeKey)) uniqueMap.set(dedupeKey, record);\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values()).map((record, index) => ({\n ...record,\n sort_order: index + 1,\n}));\n\nconst counts = uniqueRecords.reduce((acc, item) => {\n acc[item.category] = (acc[item.category] || 0) + 1;\n return acc;\n}, {});\n\n// Protección adicional: nunca llamar la RPC si la lectura está incompleta.\n// Estas categorías existen en la hoja oficial y no son administradas como listas vaciables.\nconst requiredCategories = [\n 'client',\n 'brand',\n 'country',\n 'country_manager',\n 'worked_by',\n 'work_type',\n 'execution_result',\n 'time_status',\n 'status',\n 'month',\n];\n\nconst missingCategories = requiredCategories.filter(category => !counts[category]);\nif (missingCategories.length) {\n throw new Error(\n `SEGURIDAD: lectura incompleta de Listas. Faltan categorías: ${missingCategories.join(', ')}. ` +\n 'No se envió nada a Supabase.'\n );\n}\n\nreturn [\n {\n json: {\n total_rows_read: Math.max(rows.length - 1, 0),\n total_records_to_sync: uniqueRecords.length,\n categories_found: Object.keys(counts),\n category_counts: counts,\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n source_range: response.range || 'Listas!A1:R1000',\n },\n },\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -3488, + -480 + ], + "id": "8e8c901b-9e8a-4447-af79-0ed03fdb2caa", + "name": "Code - Normalizar listas para Supabase1" + }, + { + "parameters": { + "method": "POST", + "url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tablero_cdc_sync_app_lists", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "apikey", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Authorization", + "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({\n p_records: $json.records\n}) }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + -3280, + -480 + ], + "id": "7c50c9d1-71b4-4b45-8a00-37f3f72bd3a9", + "name": "Supabase - Sincronizar listas completas1" } ], "connections": { - "Sheets - Leer listas": { + "Supabase - Leer marcas actuales (schedule)": { "main": [ [ { - "node": "Code - Normalizar listas para Supabase", + "node": "Code - Consolidar marcas actuales (schedule)", "type": "main", "index": 0 } ] ] }, - "Code - Normalizar listas para Supabase": { + "Code - Consolidar marcas actuales (schedule)": { "main": [ [ { - "node": "Supabase - Sincronizar listas completas", + "node": "Google Sheets API - Leer Listas (schedule)", "type": "main", "index": 0 } ] ] }, - "Schedule Trigger": { + "Google Sheets API - Leer Listas (schedule)": { "main": [ [ { - "node": "Sheets - Leer listas", + "node": "Code - Preparar Marca Sheet (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar Marca Sheet (schedule)": { + "main": [ + [ + { + "node": "IF - Inicializar Marca en Sheet?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Inicializar Marca en Sheet?": { + "main": [ + [ + { + "node": "Google Sheets API - Inicializar Marca (schedule)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Google Sheets API - Releer listas completas (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Inicializar Marca (schedule)": { + "main": [ + [ + { + "node": "Google Sheets API - Releer listas completas (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook - Administrar listas": { + "main": [ + [ + { + "node": "Code - Preparar solicitud admin", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar solicitud admin": { + "main": [ + [ + { + "node": "Supabase - Validar sesión", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar sesión": { + "main": [ + [ + { + "node": "Supabase - Validar permiso listas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Validar permiso listas": { + "main": [ + [ + { + "node": "Code - Verificar permiso admin", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Verificar permiso admin": { + "main": [ + [ + { + "node": "Supabase - Leer marcas actuales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Leer marcas actuales": { + "main": [ + [ + { + "node": "Code - Consolidar marcas actuales", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Consolidar marcas actuales": { + "main": [ + [ + { + "node": "Google Sheets API - Leer Listas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Leer Listas": { + "main": [ + [ + { + "node": "Code - Preparar escritura Sheet", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Preparar escritura Sheet": { + "main": [ + [ + { + "node": "IF - Requiere escritura Sheet?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF - Requiere escritura Sheet?": { + "main": [ + [ + { + "node": "Google Sheets API - Limpiar lista objetivo", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Google Sheets API - Releer listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Limpiar lista objetivo": { + "main": [ + [ + { + "node": "Google Sheets API - Escribir lista objetivo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Escribir lista objetivo": { + "main": [ + [ + { + "node": "Google Sheets API - Releer listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Releer listas completas (admin)": { + "main": [ + [ + { + "node": "Code - Normalizar listas para Supabase (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar listas para Supabase (admin)": { + "main": [ + [ + { + "node": "Supabase - Sincronizar listas completas (admin)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Supabase - Sincronizar listas completas (admin)": { + "main": [ + [ + { + "node": "Respond - Listas sincronizadas", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger1": { + "main": [ + [ + { + "node": "Supabase - Leer marcas actuales (schedule)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Google Sheets API - Releer listas completas (schedule)": { + "main": [ + [ + { + "node": "Code - Normalizar listas para Supabase1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code - Normalizar listas para Supabase1": { + "main": [ + [ + { + "node": "Supabase - Sincronizar listas completas1", "type": "main", "index": 0 } @@ -370,33 +1895,17 @@ } }, "authors": "Isaac Aracena", - "name": "Version f4df1ba5", + "name": "Version eb89b9a7", "description": "", "autosaved": true, "workflowPublishHistory": [ { - "createdAt": "2026-07-18T13:04:23.921Z", - "id": 3104, + "createdAt": "2026-08-14T13:17:02.727Z", + "id": 4690, "workflowId": "MU5GdxLtFzcRcfyM", - "versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", + "versionId": "eb89b9a7-7960-4f49-8331-50b189e14a5d", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" - }, - { - "createdAt": "2026-07-25T17:11:21.681Z", - "id": 3432, - "workflowId": "MU5GdxLtFzcRcfyM", - "versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", - "event": "activated", - "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" - }, - { - "createdAt": "2026-07-25T17:11:21.609Z", - "id": 3431, - "workflowId": "MU5GdxLtFzcRcfyM", - "versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0", - "event": "deactivated", - "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } ] }