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:
2026-08-23 07:17:45 +08:00
parent 21e3a3b45f
commit 89b769cfd9
4 changed files with 100 additions and 73 deletions
+29 -18
View File
@@ -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' -------------------------------------------------------