From 2de82ff3af963698eb415cd0ef72d43a7623e998 Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 23 Aug 2026 03:48:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(W2/W3):=20expiry=20boundary=20=E2=80=94=20u?= =?UTF-8?q?se=20<=3D=20today=20(expiry=20on=20today=20counts=20as=20expire?= =?UTF-8?q?d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- n8n/W2-customerproduct-sync.json | 2 +- n8n/W3-DESIGN.md | 2 +- n8n/W3-expiry-sync.json | 2 +- n8n/w2-compute.js | 4 +++- n8n/w3-compute.js | 4 ++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/n8n/W2-customerproduct-sync.json b/n8n/W2-customerproduct-sync.json index ec92265..5502c93 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 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 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" + "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", diff --git a/n8n/W3-DESIGN.md b/n8n/W3-DESIGN.md index 6482d45..617cc9f 100644 --- a/n8n/W3-DESIGN.md +++ b/n8n/W3-DESIGN.md @@ -16,7 +16,7 @@ 1. 读该 customer 全部 CustomerProducts(`where=(customer.Id,eq,${cid})&limit=1000`)。 2. desired paths = 仅取 **status=active 且未过期** 的 CP 的 Product→Resources `alist_path` 集合并。 - - 过期判定:`status==='expired'` 或 `expires_at < today`,一律**排除**。 + - 过期判定:`status==='expired'` 或 `expires_at <= today`,一律**排除**(含当天到期)。 - 有效 CP 无 `expires_at` → 视为长期有效,保留。 3. 读 AList role 当前 scopes(`role/get?id=`)。 4. `desired paths` vs `current paths` → 差异即要改。 diff --git a/n8n/W3-expiry-sync.json b/n8n/W3-expiry-sync.json index 7ffbc19..dd0e6a2 100644 --- a/n8n/W3-expiry-sync.json +++ b/n8n/W3-expiry-sync.json @@ -23,7 +23,7 @@ }, { "parameters": { - "jsCode": "// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write).\n// Runs every 5 min. idempotent. get current -> compare -> apply diff.\n// Reads config via $env (works in n8n Code node).\n// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes).\n// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty.\n// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status\n// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop).\n\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_RES = $env.NOCODB_TABLE_RESOURCES;\nconst T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET', url: NC + path,\n headers: { 'xc-token': NC_TOKEN }, json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method, url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body, 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\n// login once\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// index all Resources by Id -> alist_path\nconst allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\nconst resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n// all access grants (materialized + manual), grouped by customer\nconst grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || [];\nconst gByCust = {};\nfor (const g of grants) {\n const cid = linkId(g.customer); if (!cid) continue;\n (gByCust[cid] = gByCust[cid] || []).push(g);\n}\n\nconst customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned\n const cid = cust.Id;\n // desired = CP-derived paths (active, not expired) union effective grants\n const desired = {}; // path -> true\n const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && c.expires_at < today) continue; // expired\n const pid = linkId(c.product); if (!pid) continue;\n const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; }\n }\n for (const g of (gByCust[cid] || [])) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && g.expires_at < today) continue;\n const p = resPath[linkId(g.resource)]; if (p) desired[p] = true;\n }\n const desiredPaths = Object.keys(desired).sort();\n\n const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toRemove = currentPaths.filter(p => !desired[p]);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n // WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable.\n // Guard currentPaths>0: never disable a fresh customer with an empty role (avoids\n // killing a just-provisioned user before they buy anything; W2 won't re-enable them).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n let applied = [];\n if (scopesChanged) {\n const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 }));\n if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update',\n { id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description,\n permission_scopes: newScopes }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" + "jsCode": "// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write).\n// Runs every 5 min. idempotent. get current -> compare -> apply diff.\n// Reads config via $env (works in n8n Code node).\n// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes).\n// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty.\n// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status\n// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop).\n\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_RES = $env.NOCODB_TABLE_RESOURCES;\nconst T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET', url: NC + path,\n headers: { 'xc-token': NC_TOKEN }, json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method, url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body, 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\n// login once\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// index all Resources by Id -> alist_path\nconst allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\nconst resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n// all access grants (materialized + manual), grouped by customer\nconst grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || [];\nconst gByCust = {};\nfor (const g of grants) {\n const cid = linkId(g.customer); if (!cid) continue;\n (gByCust[cid] = gByCust[cid] || []).push(g);\n}\n\nconst customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned\n const cid = cust.Id;\n // desired = CP-derived paths (active, not expired) union effective grants\n const desired = {}; // path -> true\n const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && c.expires_at <= today) continue; // expired (incl. today)\n const pid = linkId(c.product); if (!pid) continue;\n const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; }\n }\n for (const g of (gByCust[cid] || [])) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && g.expires_at <= today) continue;\n const p = resPath[linkId(g.resource)]; if (p) desired[p] = true;\n }\n const desiredPaths = Object.keys(desired).sort();\n\n const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toRemove = currentPaths.filter(p => !desired[p]);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n // WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable.\n // Guard currentPaths>0: never disable a fresh customer with an empty role (avoids\n // killing a just-provisioned user before they buy anything; W2 won't re-enable them).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n let applied = [];\n if (scopesChanged) {\n const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 }));\n if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update',\n { id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description,\n permission_scopes: newScopes }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" }, "id": "reconcile", "name": "Reconcile & Apply (Expiry Sweep)", diff --git a/n8n/w2-compute.js b/n8n/w2-compute.js index 141a275..3e62d57 100644 --- a/n8n/w2-compute.js +++ b/n8n/w2-compute.js @@ -44,10 +44,12 @@ if (!customerId) { return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } }; } -// 2. load all active CPs for this customer +// 2. load all active (and not-yet-expired) CPs for this customer +const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD' const cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`); const active = (cps.list || []).filter(r => { if (r.status && r.status !== 'active') return false; + if (r.expires_at && String(r.expires_at) <= today) return false; // expired (incl. today) return true; }); diff --git a/n8n/w3-compute.js b/n8n/w3-compute.js index 7a19805..9fc3905 100644 --- a/n8n/w3-compute.js +++ b/n8n/w3-compute.js @@ -72,14 +72,14 @@ for (const cust of customers) { const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || []; for (const c of cps) { if (c.status && c.status !== 'active') continue; - if (c.expires_at && c.expires_at < today) continue; // expired + if (c.expires_at && c.expires_at <= today) continue; // expired (incl. today) const pid = linkId(c.product); if (!pid) continue; const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; } } for (const g of (gByCust[cid] || [])) { if (g.status && g.status !== 'active') continue; - if (g.expires_at && g.expires_at < today) continue; + if (g.expires_at && g.expires_at <= today) continue; const p = resPath[linkId(g.resource)]; if (p) desired[p] = true; } const desiredPaths = Object.keys(desired).sort();