From 160169e6f239aa6236c3564f74a4522ccb06660d Mon Sep 17 00:00:00 2001 From: hoelee Date: Sat, 22 Aug 2026 22:34:27 +0800 Subject: [PATCH] Fix W1/W2 webhook loop (skip-if-already-synced guard) + fix W2 link API alist_path bug --- n8n/W1-customer-provision.json | 74 ++++++++++++++++++++++++++++++-- n8n/W2-customerproduct-sync.json | 72 +++++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/n8n/W1-customer-provision.json b/n8n/W1-customer-provision.json index 45d1526..cef76aa 100644 --- a/n8n/W1-customer-provision.json +++ b/n8n/W1-customer-provision.json @@ -77,7 +77,7 @@ }, { "parameters": { - "jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nreturn { json: { customerId: cust.Id ?? cust.id, username, password, displayName: cust.display_name ?? username, basePath: cust.base_path ?? '/Class', alistUserId: cust.alist_user_id ?? null, alistRoleId: cust.alist_role_id ?? null, isCreate: !(cust.alist_user_id) } };\n" + "jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nconst newUserId = cust.alist_user_id ?? null;\nconst newRoleId = cust.alist_role_id ?? null;\nconst isCreate = !newUserId;\n// Break loop: only write back if semantic fields would actually change.\n// If the record already has alist_user_id + role_id + sync_status=synced,\n// this webhook was fired by our own write-back (echo) -> skip.\nconst skipWriteBack = (!isCreate) && (cust.sync_status === 'synced');\nreturn { json: {\n customerId: cust.Id ?? cust.id,\n username, password,\n displayName: cust.display_name ?? username,\n basePath: cust.base_path ?? '/Class',\n alistUserId: newUserId,\n alistRoleId: newRoleId,\n isCreate,\n skipWriteBack,\n} };\n" }, "id": "prepare", "name": "Prepare Provision Payload", @@ -333,7 +333,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync_error: null }]) }}", "options": {} }, "id": "nocodb-writeback-create", @@ -395,7 +395,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync_error: null }]) }}", "options": {} }, "id": "nocodb-writeback-update", @@ -406,6 +406,54 @@ 1760, 0 ] + }, + { + "parameters": { + "jsCode": "// Loop guard: echo webhook already synced -> stop here, no login, no write-back.\nreturn { json: { skipped: true, customerId: $('Prepare Provision Payload').item.json.customerId } };" + }, + "id": "skip-echo-end", + "name": "Skip Echo (No-op)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1540, + -520 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeName": "boolean", + "ignoreCase": true + }, + "conditions": [ + { + "id": "cond-skip", + "leftValue": "={{ $('Prepare Provision Payload').item.json.skipWriteBack }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals", + "singleValue": true, + "rightType": "boolean" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-skip", + "name": "Skip if already synced", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1320, + 0 + ] } ], "connections": { @@ -446,7 +494,7 @@ "main": [ [ { - "node": "AList Admin Login", + "node": "Skip if already synced", "type": "main", "index": 0 } @@ -586,6 +634,24 @@ } ] ] + }, + "Skip if already synced": { + "main": [ + [ + { + "node": "Skip Echo (No-op)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "AList Admin Login", + "type": "main", + "index": 0 + } + ] + ] } } } \ No newline at end of file diff --git a/n8n/W2-customerproduct-sync.json b/n8n/W2-customerproduct-sync.json index 64cfcf8..a1b4f5e 100644 --- a/n8n/W2-customerproduct-sync.json +++ b/n8n/W2-customerproduct-sync.json @@ -46,7 +46,7 @@ }, { "parameters": { - "jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) throw new Error('CP has no customer link');\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n return true;\n});\n\n// 3. build resource path -> max expires_at\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const path = res.alist_path;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n },\n};\n" + "jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_RES = $env.NOCODB_TABLE_RESOURCES;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) throw new Error('CP has no customer link');\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n return true;\n});\n\n// 3. build resource path -> max expires_at\n// NocoDB link API returns only {Id, name} for linked rows (no alist_path),\n// so fetch all Resources once and index by Id to resolve alist_path.\nconst allRes = await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`);\nconst resById = {};\nfor (const r of (allRes.list || [])) resById[r.Id] = r;\n\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const rid = res.Id;\n const full = resById[rid];\n const path = full ? full.alist_path : null;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\n// --- Loop guard ---\n// Re-fetch CP sync_status to detect echo (our own write-back firing this webhook again).\nconst cpSyncStatus = cp.sync_status;\nconst noChange = (toAdd.length === 0 && toRemove.length === 0);\nconst alreadySynced = (cpSyncStatus === 'synced');\n// skip if nothing changed AND already synced (echo) — avoids rewrite loop\nconst skipWriteBack = (noChange && alreadySynced);\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n skipWriteBack,\n },\n};\n" }, "id": "compute", "name": "Compute Effective Grants", @@ -107,7 +107,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}", + "jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync_error: null }]) }}", "options": {} }, "id": "nocodb-writeback", @@ -118,6 +118,54 @@ 880, 0 ] + }, + { + "parameters": { + "jsCode": "// Loop guard: no permission change + already synced -> stop, no AList write, no write-back.\nreturn { json: { skipped: true, customerProductId: $('Compute Effective Grants').item.json.customerId } };" + }, + "id": "skip-echo-end", + "name": "Skip Echo (No-op)", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + -300 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeName": "boolean", + "ignoreCase": true + }, + "conditions": [ + { + "id": "cond-skip", + "leftValue": "={{ $('Compute Effective Grants').item.json.skipWriteBack }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals", + "singleValue": true, + "rightType": "boolean" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-skip", + "name": "Skip if no change", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 660, + 0 + ] } ], "connections": { @@ -147,7 +195,7 @@ "main": [ [ { - "node": "AList Update Role Scopes", + "node": "Skip if no change", "type": "main", "index": 0 } @@ -175,6 +223,24 @@ } ] ] + }, + "Skip if no change": { + "main": [ + [ + { + "node": "Skip Echo (No-op)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "AList Update Role Scopes", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": {