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:
@@ -43,17 +43,20 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
|
||||
### 2. W1 客户开通 + Status Lifecycle — **实测跑通** ✅
|
||||
- 文件:`n8n/W1-customer-provision.json`(由 `n8n/w1-body.js` + 共享函数库经 `gen_w1.js` 生成)
|
||||
- 流程:Webhook → Extract customerId(同时透传 webhook 的 old/new rows)→ Load Env → **单 Code 节点 "Provision & Apply Lifecycle"**(含 login + status 分支 + write-back)
|
||||
- **Step 3 新增 status lifecycle**:
|
||||
- `suspended`/`deleted` → disable AList user + 清空 role scopes(soft delete,保留 user + NocoDB 历史)
|
||||
- `active` → provision(建/更新 user+role)→ **重算 desired scopes(复用共享 calculateEffectiveGrants,不恢复旧值)** → re-enable
|
||||
- 其他 → no-op
|
||||
- **幂等**:见 `n8n/test_w1_lifecycle.js`(26 断言全过,含 echo/suspend 复用/suspend→active resume 重算不恢复陈旧/sync_required 强制重算)
|
||||
- **Lifecycle matrix(5 状态,2026-08-23 定稿)**:
|
||||
- `active`(无 user)→ provision(建 role→建 user)+ recompute + enable
|
||||
- `active`(有 user)→ re-enable + recompute(不恢复旧 scopes)+ apply
|
||||
- `pending`/`suspended`/`expired`/`deleted`(有 user/role)→ disable AList user + 清空 role scopes(soft,保留 user/role/CP/history)
|
||||
- `pending`/`suspended`/`expired`/`deleted`(无 user)→ **no-op**(纯 NocoDB 标签,不建 AList user)
|
||||
- 所有 `X → active` resume → recompute(从当前有效 CP + expiry),**永不恢复旧 scopes**,**不自动 mutate CustomerProducts**
|
||||
- **幂等 + loop 防护**:见 `n8n/test_w1_lifecycle.js`(10 场景全过,覆盖完整 matrix + echo 短路 + sync_required 强制重算)
|
||||
- **Loop guard(2026-08-23 重写,核心)**:不再用 `sync_status==='synced'`,改用 **old/new field comparison + `sync_required` 标志**:
|
||||
- `businessChanged()` = 业务字段(status/username/password/display_name/email/notes)在 `previous_rows`→`rows` 间变化,**或** `sync_required` 置位
|
||||
- `businessChanged()` = 业务字段(status/username/password/display_name/email/notes)在 `previous_rows`→`rows` 间变化,**或** `sync_required` 置位,**或 after-insert(有 new 无 old)**
|
||||
- echo(只动了 alist_user_id/alist_role_id/sync_*/last_sync*)→ 立即 `skip_writeback_echo`,零 AList 变更、零 PATCH
|
||||
- **write-back 值比对守卫**:writeBack() 先 GET 当前记录,只 PATCH 值真会变的字段;值没变 → 无 PATCH → 无 webhook → 从物理上掐断 loop
|
||||
- 写回时始终清 `sync_required: false`
|
||||
- base_path 硬编码 `'/'`;空 role 数组先建 role 再建 user
|
||||
- ⚠ **AccessGrants 本次不物化**:不作为 lifecycle source-of-truth,保持现状,等 W4 统一设计(用户明确指示)
|
||||
|
||||
### 3. W2 CustomerProduct 权限同步 — **实测跑通** ✅
|
||||
- 文件:`n8n/W2-customerproduct-sync.json`(`n8n/w2-body.js` + 共享库 → `gen_w2.js`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+61
-48
@@ -1,11 +1,13 @@
|
||||
// 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
|
||||
// against a mock ctx, driving a customer through the full 5-status lifecycle:
|
||||
// pending -> active -> suspended -> active -> expired -> active -> deleted -> active
|
||||
// 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).
|
||||
// - resume (X -> active) recomputes scopes, never restores stale paths.
|
||||
// - pending/expired/deleted with NO user yet -> no-op (no AList user created).
|
||||
// Run: node n8n/test_w1_lifecycle.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -19,7 +21,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', sync_required: false, last_sync_error: null },
|
||||
customer: { Id: 1, username: 'alice', password: 'pw', status: 'pending', 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}
|
||||
@@ -55,7 +57,6 @@ function mockHttp(opts) {
|
||||
if (u.includes('/tc/records') && m === 'PATCH') {
|
||||
const fields = body[0] || body;
|
||||
state.patched.push(Object.assign({}, fields));
|
||||
// apply to customer state
|
||||
Object.assign(state.customer, fields);
|
||||
return { code: 200 };
|
||||
}
|
||||
@@ -81,36 +82,29 @@ function mockHttp(opts) {
|
||||
state.roles.push(r);
|
||||
return { data: r };
|
||||
}
|
||||
// role list
|
||||
if (u.endsWith('/api/admin/role/list?page=1&per_page=100')) return { data: { content: state.roles } };
|
||||
// role get
|
||||
const rg = u.match(/role\/get\?id=(\d+)/);
|
||||
if (rg) {
|
||||
const r = state.roles.find(x => x.id === Number(rg[1]));
|
||||
if (!r) return { data: {} };
|
||||
return { data: { name: r.name, description: r.description, permission_scopes: r.permission_scopes } };
|
||||
}
|
||||
// role update
|
||||
if (u.endsWith('/api/admin/role/update')) {
|
||||
const r = state.roles.find(x => x.id === body.id);
|
||||
if (r) r.permission_scopes = body.permission_scopes;
|
||||
return { code: 200 };
|
||||
}
|
||||
// user create
|
||||
if (u.endsWith('/api/admin/user/create')) {
|
||||
const us = { id: state.nextUserId++, username: body.username, disabled: body.disabled ?? false, role: body.role };
|
||||
state.users.push(us);
|
||||
return { data: us };
|
||||
}
|
||||
// user get (loop guard checks actual disabled state)
|
||||
const ug = u.match(/user\/get\?id=(\d+)/);
|
||||
if (ug) {
|
||||
const us = state.users.find(x => x.id === Number(ug[1]));
|
||||
return { data: us || {} };
|
||||
}
|
||||
// user list
|
||||
if (u.endsWith('/api/admin/user/list?page=1&per_page=100')) return { data: { content: state.users } };
|
||||
// user update
|
||||
if (u.endsWith('/api/admin/user/update')) {
|
||||
const us = state.users.find(x => x.id === body.id);
|
||||
if (us) {
|
||||
@@ -123,11 +117,7 @@ function mockHttp(opts) {
|
||||
throw new Error('unexpected: ' + m + ' ' + u);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -162,9 +152,16 @@ function roleScopes(roleId) {
|
||||
function userById(id) { return state.users.find(x => x.id === id); }
|
||||
|
||||
async function main() {
|
||||
// --- 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' } });
|
||||
// --- 1. pending with NO user yet -> no-op (no AList user created) ---
|
||||
console.log('\n[1] pending (no user yet, after-insert) -> no-op');
|
||||
let r = await runW1(1, { status: 'pending', oldRow: {}, newRow: { Id: 1, username: 'alice', status: 'pending', sync_status: 'pending' } });
|
||||
A('lifecycle noop_not_provisioned', r.json.lifecycle === 'noop_not_provisioned', r.json);
|
||||
A('no user created', state.users.length === 0, state.users);
|
||||
A('no role created', state.roles.length === 0, state.roles);
|
||||
|
||||
// --- 2. pending -> active (business change) -> provision ---
|
||||
console.log('\n[2] pending -> active (provision)');
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'pending' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
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);
|
||||
@@ -172,54 +169,70 @@ async function main() {
|
||||
const uid1 = state.users[0].id;
|
||||
const rid1 = state.roles[0].id;
|
||||
A('scopes = Health + Music', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
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;
|
||||
A('wrote ids back', state.customer.alist_user_id === uid1 && state.customer.alist_role_id === rid1, state.customer);
|
||||
const patchesAfter2 = state.patched.length;
|
||||
|
||||
// --- 2. write-back echo (only bookkeeping moved) -> skip, NO new PATCH ---
|
||||
console.log('\n[2] write-back echo (bookkeeping-only)');
|
||||
// --- 3. write-back echo -> skip, NO new PATCH ---
|
||||
console.log('\n[3] 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 });
|
||||
A('NO new PATCH', state.patched.length === patchesAfter2, { before: patchesAfter2, after: state.patched.length });
|
||||
|
||||
// --- 3. suspended (business change: active->suspended) ---
|
||||
console.log('\n[3] suspended (business change)');
|
||||
// --- 4. active -> suspended -> disable + clear ---
|
||||
console.log('\n[4] active -> suspended');
|
||||
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('lifecycle deactivate', r.json.lifecycle === 'deactivate', 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. 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 (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
|
||||
// --- 5. suspended -> active (resume, recompute not restore stale) ---
|
||||
console.log('\n[5] suspended -> active (recompute)');
|
||||
state.cps = []; // remove CPs -> only manual grant remains
|
||||
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 (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);
|
||||
// --- 6. active -> expired -> disable + clear ---
|
||||
console.log('\n[6] active -> expired');
|
||||
r = await runW1(1, { status: 'expired', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'expired' } });
|
||||
A('lifecycle deactivate', r.json.lifecycle === 'deactivate', 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);
|
||||
// --- 7. expired -> active (recompute; only non-expired products) ---
|
||||
console.log('\n[7] expired -> active (restore only valid)');
|
||||
// make product 2 non-expired + add an EXPIRED CP that must NOT restore
|
||||
state.cps = [
|
||||
{ Id: 1, customer: 1, product: 2, status: 'active', expires_at: '2099-01-01' },
|
||||
{ Id: 8, customer: 1, product: 2, status: 'active', expires_at: '2020-01-01' }, // expired -> should not add anything new (product 2 already yields Health)
|
||||
];
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'expired' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes = Health + Music (expired CP ignored)', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 8. active -> deleted -> disable + clear (soft delete) ---
|
||||
console.log('\n[8] active -> deleted (soft)');
|
||||
r = await runW1(1, { status: 'deleted', oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'deleted' } });
|
||||
A('lifecycle deactivate', r.json.lifecycle === 'deactivate', r.json);
|
||||
A('user disabled', userById(uid1).disabled === true, userById(uid1));
|
||||
A('scopes cleared', roleScopes(rid1).length === 0, roleScopes(rid1));
|
||||
A('user retained (soft)', state.users.length === 1, state.users);
|
||||
|
||||
// --- 9. deleted -> active (recompute) ---
|
||||
console.log('\n[9] deleted -> active (restore)');
|
||||
r = await runW1(1, { status: 'active', oldRow: { Id: 1, username: 'alice', status: 'deleted' }, newRow: { Id: 1, username: 'alice', status: 'active' } });
|
||||
A('lifecycle active_provision', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user re-enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
A('scopes recomputed', JSON.stringify(roleScopes(rid1)) === JSON.stringify(['/Class/Health', '/Template/Music']), roleScopes(rid1));
|
||||
|
||||
// --- 10. sync_required flag forces reprocess ---
|
||||
console.log('\n[10] sync_required forces reprocess');
|
||||
r = await runW1(1, { status: 'active', syncRequired: true, oldRow: { Id: 1, username: 'alice', status: 'active' }, newRow: { Id: 1, username: 'alice', status: 'active', sync_required: true } });
|
||||
A('lifecycle active_provision (forced)', r.json.lifecycle === 'active_provision', r.json);
|
||||
A('user still enabled', userById(uid1).disabled === false, userById(uid1));
|
||||
|
||||
console.log(fail === 0 ? '\nALL PASSED' : `\n${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
|
||||
+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