feat(workspaces): emit who will be added to workspace for a given project move (#4332)

* wip

* feat(workspaces): preflight service wip

* feat(workspaces): move project to workspace dry run

* fix(workspaces): add tests and refine query

* chore(workspaces): gqlgen
This commit is contained in:
Chuck Driesler
2025-04-07 10:27:08 +01:00
committed by GitHub
parent ac9fc794b7
commit 35e99d6ee7
15 changed files with 304 additions and 20 deletions
@@ -2008,6 +2008,8 @@ export type Project = {
* real or fake (e.g., with a foo/bar model, it will be nested under foo even if such a model doesn't actually exist)
*/
modelsTree: ModelsTreeItemCollection;
/** Returns information about the potential effects of moving a project to a given workspace. */
moveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRun;
name: Scalars['String']['output'];
object?: Maybe<Object>;
/** Pending project access requests */
@@ -2106,6 +2108,11 @@ export type ProjectModelsTreeArgs = {
};
export type ProjectMoveToWorkspaceDryRunArgs = {
workspaceId: Scalars['String']['input'];
};
export type ProjectObjectArgs = {
id: Scalars['String']['input'];
};
@@ -2426,6 +2433,17 @@ export const ProjectModelsUpdatedMessageType = {
} as const;
export type ProjectModelsUpdatedMessageType = typeof ProjectModelsUpdatedMessageType[keyof typeof ProjectModelsUpdatedMessageType];
export type ProjectMoveToWorkspaceDryRun = {
__typename?: 'ProjectMoveToWorkspaceDryRun';
addedToWorkspace: Array<LimitedUser>;
addedToWorkspaceTotalCount: Scalars['Int']['output'];
};
export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectMutations = {
__typename?: 'ProjectMutations';
/** Access request related mutations */
@@ -7364,6 +7382,7 @@ export type AllObjectTypes = {
ProjectFileImportUpdatedMessage: ProjectFileImportUpdatedMessage,
ProjectInviteMutations: ProjectInviteMutations,
ProjectModelsUpdatedMessage: ProjectModelsUpdatedMessage,
ProjectMoveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRun,
ProjectMutations: ProjectMutations,
ProjectPendingModelsUpdatedMessage: ProjectPendingModelsUpdatedMessage,
ProjectPendingVersionsUpdatedMessage: ProjectPendingVersionsUpdatedMessage,
@@ -8039,6 +8058,7 @@ export type ProjectFieldArgs = {
modelChildrenTree: ProjectModelChildrenTreeArgs,
models: ProjectModelsArgs,
modelsTree: ProjectModelsTreeArgs,
moveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRunArgs,
name: {},
object: ProjectObjectArgs,
pendingAccessRequests: {},
@@ -8121,6 +8141,10 @@ export type ProjectModelsUpdatedMessageFieldArgs = {
model: {},
type: {},
}
export type ProjectMoveToWorkspaceDryRunFieldArgs = {
addedToWorkspace: ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs,
addedToWorkspaceTotalCount: {},
}
export type ProjectMutationsFieldArgs = {
accessRequestMutations: {},
automationMutations: ProjectMutationsAutomationMutationsArgs,
@@ -8868,6 +8892,7 @@ export type AllObjectFieldArgTypes = {
ProjectFileImportUpdatedMessage: ProjectFileImportUpdatedMessageFieldArgs,
ProjectInviteMutations: ProjectInviteMutationsFieldArgs,
ProjectModelsUpdatedMessage: ProjectModelsUpdatedMessageFieldArgs,
ProjectMoveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRunFieldArgs,
ProjectMutations: ProjectMutationsFieldArgs,
ProjectPendingModelsUpdatedMessage: ProjectPendingModelsUpdatedMessageFieldArgs,
ProjectPendingVersionsUpdatedMessage: ProjectPendingVersionsUpdatedMessageFieldArgs,
@@ -550,6 +550,15 @@ extend type User {
extend type Project {
workspace: Workspace
"""
Returns information about the potential effects of moving a project to a given workspace.
"""
moveToWorkspaceDryRun(workspaceId: String!): ProjectMoveToWorkspaceDryRun!
}
type ProjectMoveToWorkspaceDryRun {
addedToWorkspace(limit: Int): [LimitedUser!]!
addedToWorkspaceTotalCount: Int!
}
type ServerWorkspacesInfo {
+1
View File
@@ -72,6 +72,7 @@ generates:
WorkspaceSubscriptionSeats: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceSubscriptionSeatsGraphQLReturn'
WorkspaceJoinRequest: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceJoinRequestGraphQLReturn'
LimitedWorkspaceJoinRequest: '@/modules/workspacesCore/helpers/graphTypes#LimitedWorkspaceJoinRequestGraphQLReturn'
ProjectMoveToWorkspaceDryRun: '@/modules/workspacesCore/helpers/graphTypes#ProjectMoveToWorkspaceDryRunGraphQLReturn'
Webhook: '@/modules/webhooks/helpers/graphTypes#WebhookGraphQLReturn'
SmartTextEditorValue: '@/modules/core/services/richTextEditorService#SmartTextEditorValueGraphQLReturn'
BlobMetadata: '@/modules/blobstorage/domain/types#BlobStorageItem'
@@ -1,14 +1,9 @@
import { ProjectTeamMember } from '@/modules/core/domain/projects/types'
import { Project } from '@/modules/core/domain/streams/types'
import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types'
import { MaybeNullOrUndefined, StreamRoles } from '@speckle/shared'
export type GetProject = (args: { projectId: string }) => Promise<Project | null>
export type GetProjectCollaborators = (args: {
projectId: string
}) => Promise<ProjectTeamMember[]>
export type UpdateProject = (args: {
projectUpdate: Pick<StreamRecord, 'id' | 'workspaceId'>
}) => Promise<StreamRecord>
@@ -5,7 +5,7 @@ import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from
import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes';
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceJoinRequestMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, WorkspaceJoinRequestGraphQLReturn, LimitedWorkspaceJoinRequestGraphQLReturn, ProjectMoveToWorkspaceDryRunGraphQLReturn, ProjectRoleGraphQLReturn, WorkspacePermissionChecksGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspacePlanGraphQLReturn, WorkspacePlanUsageGraphQLReturn, PriceGraphQLReturn } from '@/modules/gatekeeperCore/helpers/graphTypes';
import { WorkspaceBillingMutationsGraphQLReturn, WorkspaceSubscriptionSeatsGraphQLReturn, WorkspaceSubscriptionGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes';
@@ -2031,6 +2031,8 @@ export type Project = {
* real or fake (e.g., with a foo/bar model, it will be nested under foo even if such a model doesn't actually exist)
*/
modelsTree: ModelsTreeItemCollection;
/** Returns information about the potential effects of moving a project to a given workspace. */
moveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRun;
name: Scalars['String']['output'];
object?: Maybe<Object>;
/** Pending project access requests */
@@ -2129,6 +2131,11 @@ export type ProjectModelsTreeArgs = {
};
export type ProjectMoveToWorkspaceDryRunArgs = {
workspaceId: Scalars['String']['input'];
};
export type ProjectObjectArgs = {
id: Scalars['String']['input'];
};
@@ -2449,6 +2456,17 @@ export const ProjectModelsUpdatedMessageType = {
} as const;
export type ProjectModelsUpdatedMessageType = typeof ProjectModelsUpdatedMessageType[keyof typeof ProjectModelsUpdatedMessageType];
export type ProjectMoveToWorkspaceDryRun = {
__typename?: 'ProjectMoveToWorkspaceDryRun';
addedToWorkspace: Array<LimitedUser>;
addedToWorkspaceTotalCount: Scalars['Int']['output'];
};
export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectMutations = {
__typename?: 'ProjectMutations';
/** Access request related mutations */
@@ -5226,6 +5244,7 @@ export type ResolversTypes = {
ProjectModelsTreeFilter: ProjectModelsTreeFilter;
ProjectModelsUpdatedMessage: ResolverTypeWrapper<Omit<ProjectModelsUpdatedMessage, 'model'> & { model?: Maybe<ResolversTypes['Model']> }>;
ProjectModelsUpdatedMessageType: ProjectModelsUpdatedMessageType;
ProjectMoveToWorkspaceDryRun: ResolverTypeWrapper<ProjectMoveToWorkspaceDryRunGraphQLReturn>;
ProjectMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
ProjectPendingModelsUpdatedMessage: ResolverTypeWrapper<Omit<ProjectPendingModelsUpdatedMessage, 'model'> & { model: ResolversTypes['FileUpload'] }>;
ProjectPendingModelsUpdatedMessageType: ProjectPendingModelsUpdatedMessageType;
@@ -5545,6 +5564,7 @@ export type ResolversParentTypes = {
ProjectModelsFilter: ProjectModelsFilter;
ProjectModelsTreeFilter: ProjectModelsTreeFilter;
ProjectModelsUpdatedMessage: Omit<ProjectModelsUpdatedMessage, 'model'> & { model?: Maybe<ResolversParentTypes['Model']> };
ProjectMoveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRunGraphQLReturn;
ProjectMutations: MutationsObjectGraphQLReturn;
ProjectPendingModelsUpdatedMessage: Omit<ProjectPendingModelsUpdatedMessage, 'model'> & { model: ResolversParentTypes['FileUpload'] };
ProjectPendingVersionsUpdatedMessage: Omit<ProjectPendingVersionsUpdatedMessage, 'version'> & { version: ResolversParentTypes['FileUpload'] };
@@ -6458,6 +6478,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
modelChildrenTree?: Resolver<Array<ResolversTypes['ModelsTreeItem']>, ParentType, ContextType, RequireFields<ProjectModelChildrenTreeArgs, 'fullName'>>;
models?: Resolver<ResolversTypes['ModelCollection'], ParentType, ContextType, RequireFields<ProjectModelsArgs, 'limit'>>;
modelsTree?: Resolver<ResolversTypes['ModelsTreeItemCollection'], ParentType, ContextType, RequireFields<ProjectModelsTreeArgs, 'limit'>>;
moveToWorkspaceDryRun?: Resolver<ResolversTypes['ProjectMoveToWorkspaceDryRun'], ParentType, ContextType, RequireFields<ProjectMoveToWorkspaceDryRunArgs, 'workspaceId'>>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
object?: Resolver<Maybe<ResolversTypes['Object']>, ParentType, ContextType, RequireFields<ProjectObjectArgs, 'id'>>;
pendingAccessRequests?: Resolver<Maybe<Array<ResolversTypes['ProjectAccessRequest']>>, ParentType, ContextType>;
@@ -6564,6 +6585,12 @@ export type ProjectModelsUpdatedMessageResolvers<ContextType = GraphQLContext, P
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectMoveToWorkspaceDryRunResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectMoveToWorkspaceDryRun'] = ResolversParentTypes['ProjectMoveToWorkspaceDryRun']> = {
addedToWorkspace?: Resolver<Array<ResolversTypes['LimitedUser']>, ParentType, ContextType, Partial<ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs>>;
addedToWorkspaceTotalCount?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ProjectMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectMutations'] = ResolversParentTypes['ProjectMutations']> = {
accessRequestMutations?: Resolver<ResolversTypes['ProjectAccessRequestMutations'], ParentType, ContextType>;
automationMutations?: Resolver<ResolversTypes['ProjectAutomationMutations'], ParentType, ContextType, RequireFields<ProjectMutationsAutomationMutationsArgs, 'projectId'>>;
@@ -7492,6 +7519,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
ProjectFileImportUpdatedMessage?: ProjectFileImportUpdatedMessageResolvers<ContextType>;
ProjectInviteMutations?: ProjectInviteMutationsResolvers<ContextType>;
ProjectModelsUpdatedMessage?: ProjectModelsUpdatedMessageResolvers<ContextType>;
ProjectMoveToWorkspaceDryRun?: ProjectMoveToWorkspaceDryRunResolvers<ContextType>;
ProjectMutations?: ProjectMutationsResolvers<ContextType>;
ProjectPendingModelsUpdatedMessage?: ProjectPendingModelsUpdatedMessageResolvers<ContextType>;
ProjectPendingVersionsUpdatedMessage?: ProjectPendingVersionsUpdatedMessageResolvers<ContextType>;
@@ -57,7 +57,6 @@ import { metaHelpers } from '@/modules/core/helpers/meta'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
DeleteProjectRole,
GetProjectCollaborators,
UpdateProject,
GetRolesByUserId,
UpsertProjectRole,
@@ -686,12 +685,6 @@ export const getStreamCollaboratorsFactory =
return items
}
export const getProjectCollaboratorsFactory =
(deps: { db: Knex }): GetProjectCollaborators =>
async ({ projectId }) => {
return await getStreamCollaboratorsFactory(deps)(projectId)
}
/**
* Get base query for finding or counting user streams
*/
@@ -2011,6 +2011,8 @@ export type Project = {
* real or fake (e.g., with a foo/bar model, it will be nested under foo even if such a model doesn't actually exist)
*/
modelsTree: ModelsTreeItemCollection;
/** Returns information about the potential effects of moving a project to a given workspace. */
moveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRun;
name: Scalars['String']['output'];
object?: Maybe<Object>;
/** Pending project access requests */
@@ -2109,6 +2111,11 @@ export type ProjectModelsTreeArgs = {
};
export type ProjectMoveToWorkspaceDryRunArgs = {
workspaceId: Scalars['String']['input'];
};
export type ProjectObjectArgs = {
id: Scalars['String']['input'];
};
@@ -2429,6 +2436,17 @@ export const ProjectModelsUpdatedMessageType = {
} as const;
export type ProjectModelsUpdatedMessageType = typeof ProjectModelsUpdatedMessageType[keyof typeof ProjectModelsUpdatedMessageType];
export type ProjectMoveToWorkspaceDryRun = {
__typename?: 'ProjectMoveToWorkspaceDryRun';
addedToWorkspace: Array<LimitedUser>;
addedToWorkspaceTotalCount: Scalars['Int']['output'];
};
export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectMutations = {
__typename?: 'ProjectMutations';
/** Access request related mutations */
@@ -26,6 +26,7 @@ import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types'
import { ServerRegion } from '@/modules/multiregion/domain/types'
import { SetOptional } from 'type-fest'
import { WorkspaceSeat, WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import { UserRecord } from '@/modules/core/helpers/userHelper'
/** Workspace */
@@ -445,3 +446,8 @@ export type SetUserActiveWorkspace = (args: {
/** Is the user in a "personal project" outside of a workspace? */
isProjectsActive?: boolean
}) => Promise<void>
export type IntersectProjectCollaboratorsAndWorkspaceCollaborators = (params: {
projectId: string
workspaceId: string
}) => Promise<UserRecord[]>
@@ -3,10 +3,12 @@ import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems'
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
import { intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory } from '@/modules/workspaces/repositories/projects'
import {
countInvitableCollaboratorsByProjectIdFactory,
getInvitableCollaboratorsByProjectIdFactory
} from '@/modules/workspaces/repositories/users'
import { getMoveProjectToWorkspaceDryRunFactory } from '@/modules/workspaces/services/projects'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -46,6 +48,25 @@ export default FF_WORKSPACES_MODULE_ENABLED
cursor: args.cursor ?? undefined,
limit: args.limit
})
},
moveToWorkspaceDryRun: async (parent, args) => {
const { id: projectId } = parent
const { workspaceId } = args
const { addedToWorkspace } = await getMoveProjectToWorkspaceDryRunFactory({
intersectProjectCollaboratorsAndWorkspaceCollaborators:
intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory({ db })
})({ projectId, workspaceId })
return addedToWorkspace
}
},
ProjectMoveToWorkspaceDryRun: {
addedToWorkspace: async (parent, args) => {
return args.limit ? parent.slice(0, args.limit) : parent
},
addedToWorkspaceTotalCount: async (parent) => {
return parent.length
}
}
} as Resolvers)
@@ -2,7 +2,6 @@ import { db } from '@/db/knex'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
getProjectCollaboratorsFactory,
updateProjectFactory,
upsertProjectRoleFactory,
getRolesByUserIdFactory,
@@ -12,7 +11,8 @@ import {
grantStreamPermissionsFactory,
legacyGetStreamsFactory,
getUserStreamsPageFactory,
getUserStreamsCountFactory
getUserStreamsCountFactory,
getStreamCollaboratorsFactory
} from '@/modules/core/repositories/streams'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import {
@@ -1027,7 +1027,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
getProject: getProjectFactory({ db }),
updateProject: updateProjectFactory({ db }),
upsertProjectRole: upsertProjectRoleFactory({ db }),
getProjectCollaborators: getProjectCollaboratorsFactory({ db }),
getProjectCollaborators: getStreamCollaboratorsFactory({ db }),
getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }),
updateWorkspaceRole: updateWorkspaceRoleFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ db }),
@@ -1519,7 +1519,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
getTotalCount: getWorkspaceCollaboratorsTotalCountFactory({ db })
})({
workspaceId: parent.id,
limit: args.limit,
limit: args.limit ?? 100,
cursor: args.cursor ?? undefined
})
return team
@@ -0,0 +1,27 @@
import { StreamAcl, Users } from '@/modules/core/dbSchema'
import { StreamAclRecord } from '@/modules/core/helpers/types'
import { UserRecord } from '@/modules/core/helpers/userHelper'
import { IntersectProjectCollaboratorsAndWorkspaceCollaborators } from '@/modules/workspaces/domain/operations'
import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db'
import { Knex } from 'knex'
const tables = {
streamAcl: (db: Knex) => db.table<StreamAclRecord>('stream_acl')
}
export const intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory =
(deps: { db: Knex }): IntersectProjectCollaboratorsAndWorkspaceCollaborators =>
async ({ projectId, workspaceId }) => {
return await tables
.streamAcl(deps.db)
.select<UserRecord[]>(...Users.cols)
.join(Users.name, Users.col.id, StreamAcl.col.userId)
.where(StreamAcl.col.resourceId, projectId)
.except((builder) => {
return builder
.select(...Users.cols)
.from(WorkspaceAcl.name)
.join(Users.name, Users.col.id, WorkspaceAcl.col.userId)
.where(WorkspaceAcl.col.workspaceId, workspaceId)
})
}
@@ -3,6 +3,7 @@ import {
GetDefaultRegion,
GetWorkspaceRoleToDefaultProjectRoleMapping,
GetWorkspaceSeatTypeToProjectRoleMapping,
IntersectProjectCollaboratorsAndWorkspaceCollaborators,
QueryAllWorkspaceProjects,
UpdateWorkspaceRole
} from '@/modules/workspaces/domain/operations'
@@ -13,7 +14,6 @@ import {
} from '@/modules/workspaces/errors/workspace'
import {
GetProject,
GetProjectCollaborators,
UpdateProject,
UpsertProjectRole
} from '@/modules/core/domain/projects/operations'
@@ -22,6 +22,7 @@ import { Roles, StreamRoles } from '@speckle/shared'
import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic'
import coreUserRoles from '@/modules/core/roles'
import {
GetStreamCollaborators,
GetUserStreamsPage,
LegacyGetStreams
} from '@/modules/core/domain/streams/operations'
@@ -144,7 +145,7 @@ export const moveProjectToWorkspaceFactory =
getProject: GetProject
updateProject: UpdateProject
upsertProjectRole: UpsertProjectRole
getProjectCollaborators: GetProjectCollaborators
getProjectCollaborators: GetStreamCollaborators
getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats
getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping
updateWorkspaceRole: UpdateWorkspaceRole
@@ -168,7 +169,7 @@ export const moveProjectToWorkspaceFactory =
// Update roles for current project members
const [workspace, projectTeam, workspaceTeam] = await Promise.all([
getWorkspaceWithPlan({ workspaceId }),
getProjectCollaborators({ projectId }),
getProjectCollaborators(projectId),
getWorkspaceRolesAndSeats({ workspaceId })
])
if (!workspace) throw new WorkspaceNotFoundError()
@@ -357,3 +358,17 @@ export const createWorkspaceProjectFactory =
return project
}
export const getMoveProjectToWorkspaceDryRunFactory =
(deps: {
intersectProjectCollaboratorsAndWorkspaceCollaborators: IntersectProjectCollaboratorsAndWorkspaceCollaborators
}) =>
async (args: { projectId: string; workspaceId: string }) => {
const addedToWorkspace =
await deps.intersectProjectCollaboratorsAndWorkspaceCollaborators({
projectId: args.projectId,
workspaceId: args.workspaceId
})
return { addedToWorkspace }
}
@@ -0,0 +1,126 @@
import { createRandomEmail } from '@/modules/core/helpers/testHelpers'
import { intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory } from '@/modules/workspaces/repositories/projects'
import {
assignToWorkspaces,
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper'
import {
addAllToStream,
BasicTestStream,
createTestStream
} from '@/test/speckle-helpers/streamHelper'
import cryptoRandomString from 'crypto-random-string'
import { db } from '@/db/knex'
import { expect } from 'chai'
describe('intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory returns a function, that', () => {
const adminUser: BasicTestUser = {
id: '',
email: createRandomEmail(),
name: 'Mr. Workspace'
}
const projectUsers: BasicTestUser[] = [
{
id: '',
email: createRandomEmail(),
name: 'John A. Speckle'
},
{
id: '',
email: createRandomEmail(),
name: 'John B. Speckle'
},
{
id: '',
email: createRandomEmail(),
name: 'John C. Speckle'
}
]
const workspaceUsers: BasicTestUser[] = [
{
id: '',
email: createRandomEmail(),
name: 'John X. Speckle'
},
{
id: '',
email: createRandomEmail(),
name: 'John Y. Speckle'
},
{
id: '',
email: createRandomEmail(),
name: 'John Z. Speckle'
}
]
const project: BasicTestStream = {
id: '',
ownerId: '',
name: cryptoRandomString({ length: 9 }),
isPublic: true
}
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
name: cryptoRandomString({ length: 9 }),
slug: ''
}
before(async () => {
await createTestUser(adminUser)
await createTestUsers([...projectUsers, ...workspaceUsers])
await createTestStream(project, adminUser)
await addAllToStream(project, projectUsers)
await createTestWorkspace(workspace, adminUser)
await assignToWorkspaces(workspaceUsers.map((user) => [workspace, user, null]))
})
it('returns users that are project members but not members of the target workspace', async () => {
const result = await intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory({
db
})({
projectId: project.id,
workspaceId: workspace.id
})
expect(result.length).to.equal(3)
expect(
result.every((resultUser) =>
projectUsers.some((projectUser) => projectUser.id === resultUser.id)
)
).to.equal(true)
})
it('does not return project users that are already members of the workspace', async () => {
const result = await intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory({
db
})({
projectId: project.id,
workspaceId: workspace.id
})
expect(
result.some((resultUser) =>
workspaceUsers.some((workspaceUser) => workspaceUser.id === resultUser.id)
)
).to.equal(false)
})
it('does not return workspace admin or project owner', async () => {
const result = await intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory({
db
})({
projectId: project.id,
workspaceId: workspace.id
})
expect(result.some((user) => user.id === adminUser.id)).to.equal(false)
})
})
@@ -40,3 +40,5 @@ export type WorkspaceCollaboratorGraphQLReturn = WorkspaceTeamMember
export type WorkspacePermissionChecksGraphQLReturn = {
workspaceId: string
}
export type ProjectMoveToWorkspaceDryRunGraphQLReturn = LimitedUserRecord[]
@@ -2012,6 +2012,8 @@ export type Project = {
* real or fake (e.g., with a foo/bar model, it will be nested under foo even if such a model doesn't actually exist)
*/
modelsTree: ModelsTreeItemCollection;
/** Returns information about the potential effects of moving a project to a given workspace. */
moveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRun;
name: Scalars['String']['output'];
object?: Maybe<Object>;
/** Pending project access requests */
@@ -2110,6 +2112,11 @@ export type ProjectModelsTreeArgs = {
};
export type ProjectMoveToWorkspaceDryRunArgs = {
workspaceId: Scalars['String']['input'];
};
export type ProjectObjectArgs = {
id: Scalars['String']['input'];
};
@@ -2430,6 +2437,17 @@ export const ProjectModelsUpdatedMessageType = {
} as const;
export type ProjectModelsUpdatedMessageType = typeof ProjectModelsUpdatedMessageType[keyof typeof ProjectModelsUpdatedMessageType];
export type ProjectMoveToWorkspaceDryRun = {
__typename?: 'ProjectMoveToWorkspaceDryRun';
addedToWorkspace: Array<LimitedUser>;
addedToWorkspaceTotalCount: Scalars['Int']['output'];
};
export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
export type ProjectMutations = {
__typename?: 'ProjectMutations';
/** Access request related mutations */