Root cause: expires_at == today was NOT filtered (used '< today'), so a product expiring ON today kept its resources in desired scopes -> no diff -> no removal. Changed to '<='. Applied to W3 (CP + grant loops) and W2 (active filter). Also W2 previously had NO expiry filter on active CPs at all.
251 lines
11 KiB
JSON
251 lines
11 KiB
JSON
{
|
|
"name": "W2 - CustomerProduct Sync (AList Role Scopes)",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "alist-cp-sync",
|
|
"responseMode": "onReceived",
|
|
"options": {}
|
|
},
|
|
"id": "trigger-webhook",
|
|
"name": "NocoDB: CustomerProduct Webhook",
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
0,
|
|
0
|
|
],
|
|
"webhookId": "cp-sync"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CP record Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n"
|
|
},
|
|
"id": "extract-id",
|
|
"name": "Extract CP Record Id",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
220,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerProductId.\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n"
|
|
},
|
|
"id": "load-env",
|
|
"name": "Load Env",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
220,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"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 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) {\n return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };\n}\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`);\nconst today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n if (r.expires_at && String(r.expires_at) <= today) return false; // expired (incl. today)\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",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
440,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/update",
|
|
"authentication": "none",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ $('Compute Effective Grants').item.json.token }}"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ id: $('Compute Effective Grants').item.json.roleId, name: $('Compute Effective Grants').item.json.roleName, description: $('Compute Effective Grants').item.json.roleDesc, permission_scopes: $('Compute Effective Grants').item.json.newScopes }) }}",
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"responseFormat": "json"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "alist-apply-role",
|
|
"name": "AList Update Role Scopes",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
660,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "PATCH",
|
|
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERPRODUCTS }}/records",
|
|
"authentication": "none",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "xc-token",
|
|
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync_error: null }]) }}",
|
|
"options": {}
|
|
},
|
|
"id": "nocodb-writeback",
|
|
"name": "Write Back CP Sync Status",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
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": {
|
|
"NocoDB: CustomerProduct Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Extract CP Record Id",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Extract CP Record Id": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Load Env",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Compute Effective Grants": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Skip if no change",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"AList Update Role Scopes": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Write Back CP Sync Status",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Load Env": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Compute Effective Grants",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Skip if no change": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Skip Echo (No-op)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "AList Update Role Scopes",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
},
|
|
"pinData": {}
|
|
}
|