diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts index 1368760c0ee..e235536af8b 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.test.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts @@ -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: { diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 0629fb63c55..bbdd9479cb1 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -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,