feat(W1): full 5-status account lifecycle (pending/active/suspended/expired/deleted)
- Non-active statuses (pending/suspended/expired/deleted) now uniformly: disable AList user + clear role scopes (soft, keeps user/role/CP/history); no-op when the customer has no AList user yet (pure NocoDB label). - Resume transitions (X -> active) recompute effective grants from current CustomerProducts + expiry, never restore stale scopes. - businessChanged() now also treats after-insert (new row, no previous) as a business change so fresh customers are processed on first webhook. - Lifecycle test covers the full matrix: 10 scenarios, all green. - AccessGrants still not materialized (deferred to W4 per decision). - Customers.status stays account-level, outside permission calc.
This commit is contained in:
+29
-18
@@ -5,13 +5,21 @@
|
||||
// Single self-contained Code node (plus webhook + id extract). Replaces the
|
||||
// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.
|
||||
//
|
||||
// status suspended/deleted -> disable AList user + clear role scopes (soft
|
||||
// delete: user + NocoDB history preserved).
|
||||
// status active -> provision (create/update user+role) then
|
||||
// RECOMPUTE desired scopes via shared
|
||||
// calculateEffectiveGrants and write them, then
|
||||
// re-enable the user.
|
||||
// other status -> no AList mutation (skip).
|
||||
// LIFECYCLE MATRIX (account-level state, 2026-08-23):
|
||||
//
|
||||
// status active (no user yet) -> provision (create role + user), recompute
|
||||
// effective grants, enable user.
|
||||
// status active (has user) -> re-enable + RECOMPUTE effective grants
|
||||
// (never restore stale scopes), apply.
|
||||
//
|
||||
// status pending/suspended/expired/deleted:
|
||||
// - has user/role -> disable AList user + clear role scopes (soft; keep
|
||||
// user + role + CustomerProducts + history).
|
||||
// - no user yet -> no-op (pure NocoDB label; do NOT create AList user).
|
||||
//
|
||||
// All resume transitions (pending/suspended/expired/deleted -> active) recompute
|
||||
// from current CustomerProducts + Product Resources + expiry; never restore old
|
||||
// scopes, and never mutate CustomerProducts automatically.
|
||||
//
|
||||
// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):
|
||||
// We distinguish an ADMIN business change from our OWN write-back echo using
|
||||
@@ -80,6 +88,10 @@ function businessChanged() {
|
||||
// sync_required explicitly set by admin => force reprocess (one-shot override).
|
||||
if (cust.sync_required === true || cust.sync_required === 1) return true;
|
||||
|
||||
// After-INSERT: there is a new row but no previous row. This is a fresh
|
||||
// customer creation -> treat as a business change (provision / deactivate).
|
||||
if (newRow && !oldRow) return true;
|
||||
|
||||
// If we have old+new rows, compare only business fields.
|
||||
if (oldRow && newRow) {
|
||||
for (const f of BUSINESS_FIELDS) {
|
||||
@@ -96,7 +108,7 @@ function businessChanged() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No rows in payload (unknown shape): fall back to provisioning only when
|
||||
// Both rows missing (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.
|
||||
@@ -132,7 +144,13 @@ const writeBack = async (fields) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (status === 'suspended' || status === 'deleted') {
|
||||
if (status !== 'active') {
|
||||
// pending / suspended / expired / deleted / unknown -> deactivate.
|
||||
// - has user/role: disable AList user + clear role scopes (soft).
|
||||
// - no user yet: no-op (pure label; do NOT create AList user).
|
||||
if (!alistUserId && !alistRoleId) {
|
||||
return { json: { customerId, username, status, lifecycle: 'noop_not_provisioned', actions: [] } };
|
||||
}
|
||||
const actions = [];
|
||||
if (alistUserId) {
|
||||
await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);
|
||||
@@ -148,16 +166,9 @@ if (status === 'suspended' || status === 'deleted') {
|
||||
}, token);
|
||||
actions.push('clear_scopes');
|
||||
}
|
||||
// Clear sync_required (consumed) + mark synced. Value-change guard means the
|
||||
// echo re-entry sees businessChanged()===false and stops.
|
||||
// value-change guarded write-back; echo re-entry sees businessChanged()===false.
|
||||
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 AList mutation. Do NOT write
|
||||
// back (avoids interfering mid-draft, avoids any echo loop).
|
||||
return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };
|
||||
return { json: { customerId, username, status, lifecycle: 'deactivate', actions } };
|
||||
}
|
||||
|
||||
// status === 'active' -------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user