Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/sim/lib/billing/enterprise-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,56 @@ describe('Enterprise creation invitations', () => {
}
)

it('reads applied access on an archived selected workspace, which the selection keeps', async () => {
const payload = operationPayload({
request: { ...operationPayload().request, workspaceIds: ['workspace-1'] },
applicationResult: { appliedAt: '2026-08-13T00:00:00.000Z', subscriptionId: 'sub-1' },
})
queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }])
queueTableRows(schemaMock.user, [
{ userId: 'invitee-1', workspaceId: 'workspace-1', role: 'admin', permission: 'admin' },
])
queueTableRows(schemaMock.invitation, [])

await expect(
inviteEnterprisePeople(
{
provisioningOperationId: 'operation-1',
organizationId: 'org-1',
ownerUserId: 'owner-1',
email: 'new@example.com',
role: 'member',
permission: 'write',
sequence: 0,
},
{
eventId: 'invite-1',
eventType: 'enterprise.invite-people',
attempts: 0,
checkpointPayload: vi.fn(),
}
)
).resolves.toBeUndefined()

/**
* The applied-state join scopes workspaces to the organization and the selection only.
* An archived filter here would turn already-applied access on an archived selected
* workspace into a missing one, scheduling an invitation the archived workspace refuses.
*/
const joinConditions = dbChainMockFns.innerJoin.mock.calls.map(([, condition]) =>
JSON.stringify(condition)
)
const workspaceJoin = joinConditions.find((condition) =>
condition.includes('workspace.organizationId')
)
expect(workspaceJoin).toBeDefined()
expect(workspaceJoin).toContain('"workspace-1"')
expect(workspaceJoin).not.toContain('workspace.archivedAt')
expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled()
})

it('waits without consuming attempts until every selected workspace move completes', async () => {
const payload = operationPayload({
request: {
Expand Down
12 changes: 7 additions & 5 deletions apps/sim/lib/billing/enterprise-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2758,13 +2758,15 @@ async function resolveEnterpriseInvitationApplicationState(
member,
and(eq(member.userId, user.id), eq(member.organizationId, payload.organizationId))
)
/**
* Archived workspaces stay in the join: the selection keeps them on purpose (the move
* carries them into the organization so they can be unarchived later), so a recipient
* whose access on one is already in place must read as applied, or the sweep would
* schedule an invitation the archived workspace cannot accept and never converge.
*/
.innerJoin(
workspace,
and(
eq(workspace.organizationId, member.organizationId),
inArray(workspace.id, workspaceIds),
isNull(workspace.archivedAt)
)
and(eq(workspace.organizationId, member.organizationId), inArray(workspace.id, workspaceIds))
)
.leftJoin(
permissions,
Expand Down
Loading