// ============================================================================ // W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY). // Trigger: GET webhook /dk-purchase-check?u= // Returns: { username, known, purchases: [{product, expires_at, display_state, paths:[...]}] } // // v30 (2026-08-28): adds `paths` per purchase — each product's resources' alist_path // (via Products→Resources mm link NOCODB_LINK_PROD_RESOURCES), so the footer modal // can show a collapsible resource-path list. Products with no linked resources // return paths: []. // // Security model (no HMAC — key would ship to the client, so it is theatre): // - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com // → only the AList portal origin may READ the response from a browser. // - Minimal data: product name + expiry + display_state + public folder paths only // (no internal NocoDB Ids, no customer PII). // - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit + // Origin/Referer rule (see delivery guide). // - No AList auth requests of any kind (username comes from the caller's own // JWT, decoded client-side in the footer) → no empty-client device drift. // ============================================================================ // n8n Code node context (same pattern as W1–W4). const ctx = { $env, helpers: this.helpers }; // GET webhook: n8n puts query-string params under $json.query (fallback: body). const item = $json ?? ($input && $input.item ? $input.item.json : {}); const q = item.query ?? item.body ?? {}; const username = String(q.u ?? '').trim(); 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; } async function ncGet(path) { return await ctx.helpers.httpRequest({ method: 'GET', url: ctx.$env.NOCODB_URL + path, headers: { 'xc-token': ctx.$env.NOCODB_TOKEN }, json: true, }); } const empty = () => ({ username, known: false, purchases: [] }); if (!username) return { json: empty() }; try { const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS; const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES; const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg const today = new Date().toISOString().slice(0, 10); // 1. resolve username -> customer (username is unique in Customers) const custRows = (await ncGet( `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1` )).list || []; const customer = custRows[0]; if (!customer) return { json: empty() }; // 2. this customer's products (link query by Id, per NocoDB v2 gotchas) const cps = (await ncGet( `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500` )).list || []; // 3. resolve paths per product (Products→Resources mm link → alist_path). // Fetch all Resources once, index Id → alist_path (link API only returns // {Id, name}, not alist_path — see effective-grants.js pattern). const allRes = (await ncGet( `/api/v2/tables/${T_RES}/records?limit=10000&fields=Id,alist_path` )).list || []; const resPath = {}; allRes.forEach((r) => { resPath[r.Id] = r.alist_path; }); const prodIds = [...new Set(cps.map((c) => linkId(c.product)).filter(Boolean))]; const pathsByProduct = {}; // pid -> [alist_path, ...] for (const pid of prodIds) { let paths = []; try { const res = (await ncGet( `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}` )).list || []; paths = res.map((r) => resPath[r.Id]).filter((p) => p != null && String(p).length > 0); } catch (e) { paths = []; // graceful: no paths rather than failing the whole lookup } pathsByProduct[pid] = paths.sort(); } const purchases = cps .map((c) => { const productObj = (typeof c.product === 'object' && c.product) ? c.product : null; const name = productObj && productObj.name ? String(productObj.name) : 'Product'; const pid = linkId(c.product); const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null; let state = (c.status && c.status !== 'active') ? c.status : 'active'; if (state === 'active' && expires && expires <= today) state = 'expired'; const paths = pid ? (pathsByProduct[pid] || []) : []; return { product: name, expires_at: expires, display_state: state, paths }; }) .sort((a, b) => { const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 }; if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state]; return String(a.expires_at || '').localeCompare(String(b.expires_at || '')); }); return { json: { username, known: true, purchases } }; } catch (err) { // graceful: footer renders static rows only on any error return { json: { ...empty(), error: String((err && err.message) || err) } }; }