Skip to content

Commit a6b7ae4

Browse files
committed
fix(admin): report member operation failures accurately
1 parent a4d8eb1 commit a6b7ae4

2 files changed

Lines changed: 104 additions & 6 deletions

File tree

apps/sim/lib/admin/member-operation.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function payload(workspaceIds: string[]) {
8686
memberId: null,
8787
transferredFromOrganizationId: null,
8888
nextWorkspaceIndex: 0,
89+
currentWorkspaceId: null,
8990
},
9091
}
9192
}
@@ -187,6 +188,7 @@ describe('durable admin member operation', () => {
187188
memberId: 'member-new',
188189
transferredFromOrganizationId: 'org-old',
189190
nextWorkspaceIndex: 2,
191+
currentWorkspaceId: null,
190192
},
191193
})
192194
})
@@ -214,6 +216,92 @@ describe('durable admin member operation', () => {
214216
memberId: 'member-new',
215217
transferredFromOrganizationId: 'org-old',
216218
nextWorkspaceIndex: 10,
219+
currentWorkspaceId: null,
220+
},
221+
})
222+
})
223+
224+
it('checkpoints the active workspace before attempting its move', async () => {
225+
queueTableRows(member, [{ id: 'member-new', role: 'member', organizationId: 'org-new' }])
226+
mocks.moveWorkspace.mockRejectedValueOnce(new Error('Move failed'))
227+
const checkpointPayload = vi.fn()
228+
229+
await expect(
230+
processAdminMemberOperation(payload(['workspace-1']), {
231+
eventId: 'operation-1',
232+
eventType: 'admin.organization-member-operation',
233+
attempts: 0,
234+
checkpointPayload,
235+
})
236+
).rejects.toThrow('Move failed')
237+
238+
expect(checkpointPayload).toHaveBeenLastCalledWith({
239+
progress: {
240+
memberId: 'member-new',
241+
transferredFromOrganizationId: 'org-old',
242+
nextWorkspaceIndex: 0,
243+
currentWorkspaceId: 'workspace-1',
244+
},
245+
})
246+
})
247+
248+
it('does not mislabel a membership failure as a workspace failure', async () => {
249+
queueTableRows(outboxEvent, [
250+
{
251+
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
252+
eventType: 'admin.organization-member-operation',
253+
payload: payload(['workspace-1', 'workspace-2']),
254+
status: 'dead_letter',
255+
lastError: 'Seat limit reached',
256+
createdAt: new Date('2026-08-20T00:00:00.000Z'),
257+
},
258+
])
259+
queueTableRows(outboxEvent, [{ selected: 0, completed: 0, failed: 0 }])
260+
261+
await expect(
262+
getAdminMemberOperation('org-new', '1c38ca61-79d5-4d24-8094-c29cb52132ba')
263+
).resolves.toMatchObject({
264+
error: 'Seat limit reached',
265+
workspaceMoves: {
266+
selected: 2,
267+
moved: 0,
268+
pending: 2,
269+
failedCount: 0,
270+
failed: [],
271+
},
272+
})
273+
})
274+
275+
it('separates the active failed workspace from still-pending workspaces', async () => {
276+
queueTableRows(outboxEvent, [
277+
{
278+
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
279+
eventType: 'admin.organization-member-operation',
280+
payload: {
281+
...payload(['workspace-1', 'workspace-2', 'workspace-3']),
282+
progress: {
283+
memberId: 'member-new',
284+
transferredFromOrganizationId: 'org-old',
285+
nextWorkspaceIndex: 1,
286+
currentWorkspaceId: 'workspace-2',
287+
},
288+
},
289+
status: 'dead_letter',
290+
lastError: 'Move failed',
291+
createdAt: new Date('2026-08-20T00:00:00.000Z'),
292+
},
293+
])
294+
queueTableRows(outboxEvent, [{ selected: 0, completed: 0, failed: 0 }])
295+
296+
await expect(
297+
getAdminMemberOperation('org-new', '1c38ca61-79d5-4d24-8094-c29cb52132ba')
298+
).resolves.toMatchObject({
299+
workspaceMoves: {
300+
selected: 3,
301+
moved: 1,
302+
pending: 1,
303+
failedCount: 1,
304+
failed: [{ workspaceId: 'workspace-2', error: 'Move failed' }],
217305
},
218306
})
219307
})
@@ -229,6 +317,7 @@ describe('durable admin member operation', () => {
229317
memberId: 'member-new',
230318
transferredFromOrganizationId: 'org-old',
231319
nextWorkspaceIndex: 1,
320+
currentWorkspaceId: null,
232321
},
233322
},
234323
status: 'completed',

apps/sim/lib/admin/member-operation.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const memberOperationProgressSchema = z
5555
memberId: z.string().min(1).nullable().default(null),
5656
transferredFromOrganizationId: z.string().min(1).nullable().default(null),
5757
nextWorkspaceIndex: z.number().int().min(0).default(0),
58+
currentWorkspaceId: z.string().min(1).nullable().default(null),
5859
})
5960
.strict()
6061

@@ -65,6 +66,7 @@ const memberOperationPayloadSchema = z
6566
memberId: null,
6667
transferredFromOrganizationId: null,
6768
nextWorkspaceIndex: 0,
69+
currentWorkspaceId: null,
6870
}),
6971
})
7072
.strict()
@@ -198,11 +200,10 @@ async function toMemberOperationView(
198200
const payload = parseMemberOperationPayload(row.payload)
199201
const followUpJobs = await getMemberOperationFollowUpJobs(row.id, executor)
200202
const failed =
201-
row.status === 'dead_letter' &&
202-
payload.progress.nextWorkspaceIndex < payload.request.workspaceIds.length
203+
row.status === 'dead_letter' && payload.progress.currentWorkspaceId
203204
? [
204205
{
205-
workspaceId: payload.request.workspaceIds[payload.progress.nextWorkspaceIndex],
206+
workspaceId: payload.progress.currentWorkspaceId,
206207
error: row.lastError ?? 'Workspace move failed',
207208
},
208209
]
@@ -220,7 +221,10 @@ async function toMemberOperationView(
220221
workspaceMoves: {
221222
selected: payload.request.workspaceIds.length,
222223
moved: payload.progress.nextWorkspaceIndex,
223-
pending: payload.request.workspaceIds.length - payload.progress.nextWorkspaceIndex,
224+
pending: Math.max(
225+
0,
226+
payload.request.workspaceIds.length - payload.progress.nextWorkspaceIndex - failed.length
227+
),
224228
failedCount: failed.length,
225229
failed,
226230
},
@@ -378,6 +382,7 @@ export async function startAdminMemberOperation(
378382
memberId: null,
379383
transferredFromOrganizationId: null,
380384
nextWorkspaceIndex: 0,
385+
currentWorkspaceId: null,
381386
},
382387
}
383388
await enqueueOutboxEvent(tx, ADMIN_MEMBER_OPERATION_EVENT_TYPE, payload, {
@@ -643,8 +648,12 @@ export const processAdminMemberOperation: OutboxHandler<unknown> = async (rawPay
643648
nextWorkspaceIndex + MEMBER_OPERATION_WORKSPACE_BATCH_SIZE
644649
)
645650
while (nextWorkspaceIndex < batchEnd) {
651+
const currentWorkspaceId = payload.request.workspaceIds[nextWorkspaceIndex]
652+
await context.checkpointPayload({
653+
progress: { ...progress, nextWorkspaceIndex, currentWorkspaceId },
654+
})
646655
await moveWorkspaceToOrganization({
647-
workspaceId: payload.request.workspaceIds[nextWorkspaceIndex],
656+
workspaceId: currentWorkspaceId,
648657
destinationOrganizationId: payload.request.organizationId,
649658
adminEmail: payload.request.actor.email ?? ADMIN_API_AUDIT_EMAIL,
650659
auditActor: payload.request.actor,
@@ -654,7 +663,7 @@ export const processAdminMemberOperation: OutboxHandler<unknown> = async (rawPay
654663
})
655664
nextWorkspaceIndex += 1
656665
await context.checkpointPayload({
657-
progress: { ...progress, nextWorkspaceIndex },
666+
progress: { ...progress, nextWorkspaceIndex, currentWorkspaceId: null },
658667
})
659668
}
660669

0 commit comments

Comments
 (0)