fix(W1): self-write-back suppression via old/new field comparison
- Replace brittle sync_status==='synced' loop guard with NocoDB v2 old/new row comparison (previous_rows vs rows). - businessChanged() = business field move OR sync_required set; echo (bookkeeping-only delta) -> skip_writeback_echo, zero AList mutation. - writeBack() value-change guard: GET live record, PATCH only fields whose value actually changes (no-change -> no PATCH -> no webhook). - sync_required Checkbox honored as force-reprocess flag, cleared on write-back. - Extract-id node passes previousRow/newRow through Load Env to lifecycle. - W1 lifecycle test now covers echo suppression (26 assertions). - Customers.status still NOT consumed by permission calc (TBD semantics). - W2/W3 effective-grants semantics unchanged.
This commit is contained in:
File diff suppressed because one or more lines are too long
+2
-2
@@ -27,7 +27,7 @@ const workflow = {
|
||||
},
|
||||
{
|
||||
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 customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '' } };\n"
|
||||
jsCode: "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\n// NocoDB v2: data.rows = post-update rows, data.previous_rows = pre-update rows.\nconst newRows = data.rows ?? data.rows_new ?? [];\nconst oldRows = data.previous_rows ?? data.rows_old ?? [];\nconst newRow = newRows[0] ?? {};\nconst oldRow = oldRows[0] ?? {};\nconst id = newRow.Id ?? oldRow.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '', previousRow: oldRow, newRow } };\n"
|
||||
},
|
||||
id: 'extract-id',
|
||||
name: 'Extract Customer Record Id',
|
||||
@@ -37,7 +37,7 @@ const workflow = {
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: "// pass-through env + webhook fields (custom env vars readable ONLY via $env here)\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n"
|
||||
jsCode: "// pass-through env + webhook fields (custom env vars readable ONLY via $env here)\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n previousRow: prev.previousRow,\n newRow: prev.newRow,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n NOCODB_TABLE_PRODUCTS: $env.NOCODB_TABLE_PRODUCTS,\n NOCODB_TABLE_RESOURCES: $env.NOCODB_TABLE_RESOURCES,\n NOCODB_TABLE_ACCESSGRANTS: $env.NOCODB_TABLE_ACCESSGRANTS,\n NOCODB_LINK_PROD_RESOURCES: $env.NOCODB_LINK_PROD_RESOURCES,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n} };\n"
|
||||
},
|
||||
id: 'load-env',
|
||||
name: 'Load Env',
|
||||
|
||||
+48
-34
@@ -1,9 +1,11 @@
|
||||
// Idempotency + lifecycle tests for the W1 body (status lifecycle).
|
||||
// Idempotency + lifecycle + SELF-WRITE-BACK SUPPRESSION tests for W1 (Step 3).
|
||||
// Loads the ACTUAL inlined code from W1-customer-provision.json and runs it
|
||||
// against a mock ctx, driving a customer through:
|
||||
// active -> suspended -> active (resume) -> deleted
|
||||
// and asserting converged AList end-state each time, plus duplicate-webhook
|
||||
// idempotency.
|
||||
// and asserting converged AList end-state each time, PLUS:
|
||||
// - a business change (admin flips status) processes exactly once
|
||||
// - our own write-back echo (bookkeeping-only delta) is SKIPPED and does NOT
|
||||
// PATCH again (this is the loop fix).
|
||||
// Run: node n8n/test_w1_lifecycle.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -17,7 +19,7 @@ const TODAY = new Date().toISOString().slice(0, 10);
|
||||
// ---- mutable mock state (represents AList + NocoDB) ----
|
||||
const state = {
|
||||
// NocoDB customer record (the fields W1 reads)
|
||||
customer: { Id: 1, username: 'alice', password: 'pw', status: 'active', alist_user_id: null, alist_role_id: null, sync_status: 'pending', last_sync_error: null },
|
||||
customer: { Id: 1, username: 'alice', password: 'pw', status: 'active', alist_user_id: null, alist_role_id: null, sync_status: 'pending', sync_required: false, last_sync_error: null },
|
||||
// AList
|
||||
users: [], // {id, username, disabled, role}
|
||||
roles: [], // {id, name, permission_scopes}
|
||||
@@ -121,13 +123,19 @@ function mockHttp(opts) {
|
||||
throw new Error('unexpected: ' + m + ' ' + u);
|
||||
}
|
||||
|
||||
async function runW1(customerId, customerStatusOverride) {
|
||||
// build ctx reflecting CURRENT state.customer
|
||||
if (customerStatusOverride !== undefined) state.customer.status = customerStatusOverride;
|
||||
// ensure alist ids are on the customer record (W1 reads them from NocoDB)
|
||||
// Simulate one W1 run. `event` mirrors the NocoDB v2 webhook old/new rows:
|
||||
// - business change: previousRow has a DIFFERENT status than newRow (admin edit)
|
||||
// - echo: previousRow == newRow on business fields, only bookkeeping moved.
|
||||
async function runW1(customerId, { status, oldRow, newRow, syncRequired } = {}) {
|
||||
// apply the post-update state to the customer record (what W1 re-reads)
|
||||
if (status !== undefined) state.customer.status = status;
|
||||
if (syncRequired !== undefined) state.customer.sync_required = syncRequired;
|
||||
state.customer.alist_user_id = state.customer.alist_user_id ?? null;
|
||||
state.customer.alist_role_id = state.customer.alist_role_id ?? null;
|
||||
|
||||
const prevRow = oldRow || { ...state.customer };
|
||||
const nRow = newRow || { ...state.customer };
|
||||
|
||||
const sandbox = {
|
||||
$env: {
|
||||
NOCODB_URL: 'https://nc', NOCODB_TOKEN: 't', ALIST_URL: 'https://al',
|
||||
@@ -139,7 +147,7 @@ async function runW1(customerId, customerStatusOverride) {
|
||||
console,
|
||||
};
|
||||
sandbox.helpers = { httpRequest: mockHttp };
|
||||
const wrapped = `(async function(){\nconst $json = { customerId: ${customerId} };\n${code}\n})`;
|
||||
const wrapped = `(async function(){\nconst $json = { customerId: ${customerId}, previousRow: ${JSON.stringify(prevRow)}, newRow: ${JSON.stringify(nRow)} };\n${code}\n})`;
|
||||
const fn = vm.runInNewContext(wrapped, sandbox, { filename: 'w1-body.js' });
|
||||
return await fn();
|
||||
}
|
||||
@@ -154,59 +162,65 @@ function roleScopes(roleId) {
|
||||
function userById(id) { return state.users.find(x => x.id === id); }
|
||||
|
||||
async function main() {
|
||||
// --- 1. active (fresh customer) -> provision + grants ---
|
||||
console.log('\n[1] active fresh customer');
|
||||
let r = await runW1(1, 'active');
|
||||
// --- 1. active (fresh customer, business change: pending->active) ---
|
||||
console.log('\n[1] active fresh customer (business change)');
|
||||
let r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'pending', sync_status: 'pending' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_status: 'pending' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user created', state.users.length === 1, state.users);
|
||||
A('role created', state.roles.length === 1, state.roles);
|
||||
A('user enabled', userById(state.users[0].id).disabled === false, state.users[0]);
|
||||
const uid1 = state.users[0].id;
|
||||
const rid1 = state.roles[0].id;
|
||||
// desired = Health (product 2) + Music (manual grant) = 2 paths
|
||||
A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
// write-back happened
|
||||
A('wrote alist_user_id + role_id back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer);
|
||||
const patchesAfter1 = state.patched.length;
|
||||
|
||||
// --- 2. duplicate webhook (echo) -> idempotent no-op ---
|
||||
console.log('\n[2] duplicate webhook (echo, already synced)');
|
||||
r = await runW1(1, 'active');
|
||||
A('echo -> skip', r.json.lifecycle === 'skip_echo', r.json);
|
||||
// --- 2. write-back echo (only bookkeeping moved) -> skip, NO new PATCH ---
|
||||
console.log('\n[2] write-back echo (bookkeeping-only)');
|
||||
r = await runW1(1, { oldRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1 }, newRow: { Id: 1, username: 'alice', status: 'active', alist_user_id: uid1, alist_role_id: rid1, sync_status: 'synced', sync_required: false } });
|
||||
A('echo -> skip_writeback_echo', r.json.lifecycle === 'skip_writeback_echo', r.json);
|
||||
A('NO new PATCH (loop broken)', state.patched.length === patchesAfter1, { before: patchesAfter1, after: state.patched.length });
|
||||
A('no new user/role', state.users.length === 1 && state.roles.length === 1, { u: state.users.length, r: state.roles.length });
|
||||
|
||||
// --- 3. suspended -> disable + clear scopes ---
|
||||
console.log('\n[3] suspended');
|
||||
r = await runW1(1, 'suspended');
|
||||
// --- 3. suspended (business change: active->suspended) ---
|
||||
console.log('\n[3] suspended (business change)');
|
||||
r = await runW1(1, { status: 'suspended', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'suspended' } });
|
||||
A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
A('user NOT deleted (soft)', state.users.length === 1, state.users);
|
||||
const patchesAfter3 = state.patched.length;
|
||||
|
||||
// --- 4. duplicate suspend webhook -> idempotent ---
|
||||
console.log('\n[4] duplicate suspend');
|
||||
const usersBefore = state.users.length;
|
||||
r = await runW1(1, 'suspended');
|
||||
A('still disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('no new users', state.users.length === usersBefore, state.users.length);
|
||||
// --- 4. suspend echo (bookkeeping-only) -> skip, no re-PATCH ---
|
||||
console.log('\n[4] suspend echo (bookkeeping-only)');
|
||||
r = await runW1(1, { oldRow: { Id: 1, username: 'alice', status: 'suspended' }, newRow: { Id: 1, username: 'alice', status: 'suspended', sync_status: 'synced', sync_required: false } });
|
||||
A('echo -> skip', r.json.lifecycle === 'skip_writeback_echo', r.json);
|
||||
A('still disabled (unchanged)', userById(uid1).disabled === true, userById(uid1));
|
||||
A('NO new PATCH', state.patched.length === patchesAfter3, { before: patchesAfter3, after: state.patched.length });
|
||||
|
||||
// --- 5. active resume -> re-enable + RECOMPUTE scopes (not stale) ---
|
||||
// To prove recompute-not-restore, change the CP set first: remove Health product,
|
||||
// the desired should now be only Music (NOT the old Health+Music restored).
|
||||
// --- 5. active resume (business change) -> re-enable + RECOMPUTE (not stale) ---
|
||||
console.log('\n[5] active resume (recompute, not restore stale)');
|
||||
state.cps = []; // remove all CPs -> only manual grant remains
|
||||
r = await runW1(1, 'active');
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'suspended' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision (resume)', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes recomputed = only Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 6. deleted -> soft delete (disable + clear, keep user) ---
|
||||
console.log('\n[6] deleted');
|
||||
r = await runW1(1, 'deleted');
|
||||
// --- 6. deleted (business change) -> soft delete ---
|
||||
console.log('\n[6] deleted (business change)');
|
||||
r = await runW1(1, { status: 'deleted', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'deleted' } });
|
||||
A('lifecycle suspend_or_delete', r.json.lifecycle === 'suspend_or_delete', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
A('user retained (soft delete)', state.users.length === 1, state.users);
|
||||
|
||||
// --- 7. sync_required=true forces reprocess even without business diff ---
|
||||
console.log('\n[7] sync_required flag forces reprocess');
|
||||
state.cps = [{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' }];
|
||||
r = await runW1(1, { status: 'active', syncRequired: true, oldRow: { Id: 1, username: 'alice', status: 'deleted' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_required: true } });
|
||||
A('lifecycle active_provision (forced)', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
|
||||
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
+91
-37
@@ -13,10 +13,25 @@
|
||||
// re-enable the user.
|
||||
// other status -> no AList mutation (skip).
|
||||
//
|
||||
// Write-back: if the customer is missing alist_user_id / alist_role_id we
|
||||
// PATCH them back to NocoDB (with sync_status 'synced'). The echo webhook from
|
||||
// that PATCH re-enters W1 but the loop guard below (skipWriteBack when already
|
||||
// populated + status unchanged) makes it a no-op.
|
||||
// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):
|
||||
// We distinguish an ADMIN business change from our OWN write-back echo using
|
||||
// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the
|
||||
// brittle `sync_status === 'synced'` check.
|
||||
//
|
||||
// businessChanged = a BUSINESS field moved (status / username / password /
|
||||
// display_name / email / notes) OR `sync_required` is set.
|
||||
// echo = only our bookkeeping fields moved (alist_user_id,
|
||||
// alist_role_id, sync_status, sync_required=false,
|
||||
// last_sync, last_sync_error).
|
||||
//
|
||||
// We always write back with `sync_required: false` (and clear it), so our own
|
||||
// echo re-enters with businessChanged === false -> skip immediately, before
|
||||
// any AList mutation. An admin flipping status (or setting sync_required) is
|
||||
// the ONLY thing that proceeds past the guard — exactly once.
|
||||
//
|
||||
// Value-change write-back guard: writeBack() re-reads the record and only
|
||||
// PATCHes fields that would actually CHANGE. Writing the same value produces
|
||||
// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.
|
||||
//
|
||||
// Idempotency: final state is converged per status (see above). Duplicate or
|
||||
// echo webhooks re-apply the same end state with zero net change.
|
||||
@@ -28,16 +43,17 @@ const ctx = { $env, helpers: this.helpers };
|
||||
|
||||
// 1. fresh customer record by id (from webhook extract)
|
||||
const customerId = $json.customerId;
|
||||
|
||||
// NocoDB v2 webhook payload carries the pre/post rows. The extract node already
|
||||
// surfaced customerId; here we re-read the record for the authoritative
|
||||
// CURRENT field values (source of truth = NocoDB), and separately inspect the
|
||||
// event's old/new rows to decide business-change vs echo.
|
||||
const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
||||
const status = cust.status || 'active';
|
||||
const username = cust.username;
|
||||
if (!username) throw new Error('Customer missing username');
|
||||
const alistUserId = cust.alist_user_id ?? null;
|
||||
const alistRoleId = cust.alist_role_id ?? null;
|
||||
|
||||
// loop guard: if this webhook is our own write-back echo AND the customer is
|
||||
// already fully provisioned with synced status AND status is unchanged, skip.
|
||||
// (Detected below via alreadyProvisioned + sync_status === 'synced'.)
|
||||
const alreadyProvisioned = !!(alistUserId && alistRoleId);
|
||||
|
||||
// 2. admin login
|
||||
@@ -48,36 +64,70 @@ const login = await alistReq(ctx, 'POST', '/api/auth/login', {
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// 3. loop guard: echo of our own write-back (active + already realized) -> skip.
|
||||
// IMPORTANT: sync_status==='synced' alone is NOT enough to skip, because a
|
||||
// suspended->active resume ALSO carries sync_status==='synced' (the suspend
|
||||
// path writes it) while the AList user is still disabled. We therefore check
|
||||
// the ACTUAL AList user disabled state: only skip when the user is provably
|
||||
// enabled already. If disabled -> fall through to active provision (re-enable
|
||||
// + recompute), which is exactly what resume needs.
|
||||
if (alreadyProvisioned && status === 'active' && cust.sync_status === 'synced' && alistUserId) {
|
||||
let currentlyEnabled = true;
|
||||
try {
|
||||
const uInfo = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${alistUserId}`, null, token);
|
||||
if (uInfo?.data && typeof uInfo.data.disabled === 'boolean') {
|
||||
currentlyEnabled = !uInfo.data.disabled;
|
||||
// 3. LOOP GUARD — distinguish admin business change from our own echo.
|
||||
// The extract node passes through the raw rows so we can diff. Fall back to
|
||||
// "always proceed" if the payload rows are missing (better a redundant
|
||||
// idempotent provision than a silently dropped admin change).
|
||||
const oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;
|
||||
const newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;
|
||||
|
||||
// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,
|
||||
// sync_*, last_sync*) are what WE write and must never by themselves count as
|
||||
// a business change.
|
||||
const BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];
|
||||
|
||||
function businessChanged() {
|
||||
// sync_required explicitly set by admin => force reprocess (one-shot override).
|
||||
if (cust.sync_required === true || cust.sync_required === 1) return true;
|
||||
|
||||
// If we have old+new rows, compare only business fields.
|
||||
if (oldRow && newRow) {
|
||||
for (const f of BUSINESS_FIELDS) {
|
||||
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
||||
}
|
||||
} catch (e) { /* user/get may 404 for some AList versions; assume enabled */ }
|
||||
if (currentlyEnabled) {
|
||||
return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } };
|
||||
// Any business field present in new but matching old, or bookkeeping-only
|
||||
// delta => echo. Also guard against a field we didn't list changing: if a
|
||||
// business field existed in the row and moved, catch it generically.
|
||||
for (const f of Object.keys(newRow)) {
|
||||
// skip pure bookkeeping fields
|
||||
if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;
|
||||
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// No rows in payload (unknown shape): fall back to provisioning only when
|
||||
// provision is genuinely incomplete OR status changed from what AList has.
|
||||
// To stay safe against loops, treat "no info" as skip-UNLESS-sync_required,
|
||||
// which admin can set explicitly.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!businessChanged()) {
|
||||
// Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.
|
||||
// Nothing is written back here, so no further webhook is generated.
|
||||
return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };
|
||||
}
|
||||
|
||||
// 4. STATUS BRANCH ---------------------------------------------------------
|
||||
|
||||
// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only
|
||||
// fields whose value actually differs. If nothing differs -> no PATCH -> no
|
||||
// webhook -> loop is physically broken at the source.
|
||||
const writeBack = async (fields) => {
|
||||
// PATCH id fields back to NocoDB. Guard: only when they differ.
|
||||
const body = Object.assign({ Id: customerId }, fields);
|
||||
const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
|
||||
const changes = {};
|
||||
for (const k of Object.keys(fields)) {
|
||||
const want = fields[k];
|
||||
const have = live[k];
|
||||
if (want !== have) changes[k] = want;
|
||||
}
|
||||
if (Object.keys(changes).length === 0) return; // no-op: nothing changed
|
||||
await ctx.helpers.httpRequest({
|
||||
method: 'PATCH',
|
||||
url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,
|
||||
headers: { 'xc-token': $env.NOCODB_TOKEN },
|
||||
body: [body],
|
||||
body: [Object.assign({ Id: customerId }, changes)],
|
||||
json: true,
|
||||
});
|
||||
};
|
||||
@@ -98,16 +148,15 @@ if (status === 'suspended' || status === 'deleted') {
|
||||
}, token);
|
||||
actions.push('clear_scopes');
|
||||
}
|
||||
// mark synced so we don't re-fire on our own write-back (but we don't write
|
||||
// much here — only sync_status to stop the echo loop).
|
||||
await writeBack({ sync_status: 'synced', last_sync_error: null });
|
||||
// Clear sync_required (consumed) + mark synced. Value-change guard means the
|
||||
// echo re-entry sees businessChanged()===false and stops.
|
||||
await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });
|
||||
return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };
|
||||
}
|
||||
|
||||
if (status !== 'active') {
|
||||
// pending / expired / disabled / unknown -> no mutation, but mark synced to
|
||||
// stop echo loop only if we are the ones writing. We do NOT write here to
|
||||
// avoid interfering while the customer is being drafted.
|
||||
// pending / expired / disabled / unknown -> no AList mutation. Do NOT write
|
||||
// back (avoids interfering mid-draft, avoids any echo loop).
|
||||
return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };
|
||||
}
|
||||
|
||||
@@ -169,10 +218,15 @@ await alistReq(ctx, 'POST', '/api/admin/role/update', {
|
||||
}, token);
|
||||
actions.push('apply_grants');
|
||||
|
||||
// 5. write-back ids if newly discovered (creates the echo; landed in loop guard)
|
||||
if (!alistUserId || !alistRoleId) {
|
||||
await writeBack({ alist_user_id: finalUserId, alist_role_id: finalRoleId, sync_status: 'synced', last_sync_error: null });
|
||||
}
|
||||
// 5. write-back ids + sync_status + clear sync_required (value-change guarded).
|
||||
// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.
|
||||
await writeBack({
|
||||
alist_user_id: alistUserId || finalUserId,
|
||||
alist_role_id: alistRoleId || finalRoleId,
|
||||
sync_status: 'synced',
|
||||
sync_required: false,
|
||||
last_sync_error: null,
|
||||
});
|
||||
|
||||
return { json: {
|
||||
customerId, username, status, lifecycle: 'active_provision',
|
||||
|
||||
Reference in New Issue
Block a user