// ============================================================================ // SHARED: calculateEffectiveGrants — single source of truth for "what scopes // should customer X have right now", reused by W1 (active resume), W2 // (CustomerProduct sync) and W3 (expiry sweep). // // This file is INLINED into each workflow's Code node at build time (see // gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained: // no require/import, no process.exit, no top-level return outside functions. // // Config is read via $env (the ONLY reliable channel in n8n Code nodes for // custom env vars — process.env is unreliable there). The caller passes a // `ctx` with { $env, helpers } so this file is also unit-testable outside n8n. // // ============================================================================ // --- helpers (depend on ctx, so they are functions not top-level consts) --- function ncUrl(ctx, path) { return ctx.$env.NOCODB_URL + path; } async function ncGet(ctx, path) { return await ctx.helpers.httpRequest({ method: 'GET', url: ncUrl(ctx, path), headers: { 'xc-token': ctx.$env.NOCODB_TOKEN }, json: true, }); } async function alistReq(ctx, method, path, body, token) { // NOTE: AList expects the RAW token in Authorization (no "Bearer " prefix). return await ctx.helpers.httpRequest({ method, url: ctx.$env.ALIST_URL + path, headers: token ? { Authorization: token } : {}, body, json: true, }); } // NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on // whether the API was asked to expand them. Normalise to a single id or null. function linkId(v) { if (v == null) return null; if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null; return v; } // --- core algorithm -------------------------------------------------------- // // desired scopes for a customer = union of: // (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct // (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant // // For (a) the SAME resource coming from MULTIPLE products keeps the MAX // expires_at, exactly matching the "two products share a resource" semantics // the user tested. Expiry boundary is `<= today` (expiring today is expired). async function calculateEffectiveGrants(ctx, customerId) { const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS; const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES; const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS; const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD' // index all Resources by Id -> alist_path (cheap, one query) const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || []; const resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; }); const desired = {}; // path -> { expires, permission } // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on // the same path. // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the // foreign-key column `nc_jitu___Customers_id`). const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || []; for (const c of cps) { if (c.status && c.status !== 'active') continue; if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today) const pid = linkId(c.product); if (!pid) continue; const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || []; const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry) for (const r of res) { const p = resPath[r.Id]; if (!p) continue; const cur = desired[p]; if (!cur || !cur.expires || (exp && exp > cur.expires)) { desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 }; } } } // (b) AccessGrants -> Resources (manual / materialised direct grants) const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || []; for (const g of grants) { if (g.status && g.status !== 'active') continue; if (g.expires_at && String(g.expires_at) <= today) continue; const p = resPath[linkId(g.resource)]; if (!p) continue; const exp = g.expires_at; const cur = desired[p]; if (!cur || !cur.expires || (exp && exp > cur.expires)) { desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 }; } } const desiredPaths = Object.keys(desired).sort(); return { customerId, desired, // { path: {expires, permission} } desiredPaths, // sorted array of paths newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })), }; } // --- export shim: kept only when unit-tested, stripped when inlined ------- /* SHARED_EXPORT_BEGIN */ if (typeof module !== 'undefined' && module.exports) { module.exports = { calculateEffectiveGrants, ncGet, alistReq, linkId, ncUrl }; }