From 35e99d6ee7bc05b76693bfea353eec6f1cee3346 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Mon, 7 Apr 2025 10:27:08 +0100 Subject: [PATCH] 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 --- .../lib/common/generated/gql/graphql.ts | 25 ++++ .../typedefs/workspaces.graphql | 9 ++ packages/server/codegen.yml | 1 + .../core/domain/projects/operations.ts | 5 - .../modules/core/graph/generated/graphql.ts | 30 ++++- .../modules/core/repositories/streams.ts | 7 - .../graph/generated/graphql.ts | 18 +++ .../modules/workspaces/domain/operations.ts | 6 + .../workspaces/graph/resolvers/projects.ts | 21 +++ .../workspaces/graph/resolvers/workspaces.ts | 8 +- .../workspaces/repositories/projects.ts | 27 ++++ .../modules/workspaces/services/projects.ts | 21 ++- .../integration/repositories/projects.spec.ts | 126 ++++++++++++++++++ .../workspacesCore/helpers/graphTypes.ts | 2 + .../server/test/graphql/generated/graphql.ts | 18 +++ 15 files changed, 304 insertions(+), 20 deletions(-) create mode 100644 packages/server/modules/workspaces/repositories/projects.ts create mode 100644 packages/server/modules/workspaces/tests/integration/repositories/projects.spec.ts diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index bf18560be..b2fd260f5 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -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; /** 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; + addedToWorkspaceTotalCount: Scalars['Int']['output']; +}; + + +export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = { + limit?: InputMaybe; +}; + 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, diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index f8ec283b0..ea728f711 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -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 { diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index ab0469ecd..da5d2a495 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -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' diff --git a/packages/server/modules/core/domain/projects/operations.ts b/packages/server/modules/core/domain/projects/operations.ts index 156f365b0..11167950b 100644 --- a/packages/server/modules/core/domain/projects/operations.ts +++ b/packages/server/modules/core/domain/projects/operations.ts @@ -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 -export type GetProjectCollaborators = (args: { - projectId: string -}) => Promise - export type UpdateProject = (args: { projectUpdate: Pick }) => Promise diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 4ad08f360..f5b62ec81 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -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; /** 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; + addedToWorkspaceTotalCount: Scalars['Int']['output']; +}; + + +export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = { + limit?: InputMaybe; +}; + export type ProjectMutations = { __typename?: 'ProjectMutations'; /** Access request related mutations */ @@ -5226,6 +5244,7 @@ export type ResolversTypes = { ProjectModelsTreeFilter: ProjectModelsTreeFilter; ProjectModelsUpdatedMessage: ResolverTypeWrapper & { model?: Maybe }>; ProjectModelsUpdatedMessageType: ProjectModelsUpdatedMessageType; + ProjectMoveToWorkspaceDryRun: ResolverTypeWrapper; ProjectMutations: ResolverTypeWrapper; ProjectPendingModelsUpdatedMessage: ResolverTypeWrapper & { model: ResolversTypes['FileUpload'] }>; ProjectPendingModelsUpdatedMessageType: ProjectPendingModelsUpdatedMessageType; @@ -5545,6 +5564,7 @@ export type ResolversParentTypes = { ProjectModelsFilter: ProjectModelsFilter; ProjectModelsTreeFilter: ProjectModelsTreeFilter; ProjectModelsUpdatedMessage: Omit & { model?: Maybe }; + ProjectMoveToWorkspaceDryRun: ProjectMoveToWorkspaceDryRunGraphQLReturn; ProjectMutations: MutationsObjectGraphQLReturn; ProjectPendingModelsUpdatedMessage: Omit & { model: ResolversParentTypes['FileUpload'] }; ProjectPendingVersionsUpdatedMessage: Omit & { version: ResolversParentTypes['FileUpload'] }; @@ -6458,6 +6478,7 @@ export type ProjectResolvers, ParentType, ContextType, RequireFields>; models?: Resolver>; modelsTree?: Resolver>; + moveToWorkspaceDryRun?: Resolver>; name?: Resolver; object?: Resolver, ParentType, ContextType, RequireFields>; pendingAccessRequests?: Resolver>, ParentType, ContextType>; @@ -6564,6 +6585,12 @@ export type ProjectModelsUpdatedMessageResolvers; }; +export type ProjectMoveToWorkspaceDryRunResolvers = { + addedToWorkspace?: Resolver, ParentType, ContextType, Partial>; + addedToWorkspaceTotalCount?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ProjectMutationsResolvers = { accessRequestMutations?: Resolver; automationMutations?: Resolver>; @@ -7492,6 +7519,7 @@ export type Resolvers = { ProjectFileImportUpdatedMessage?: ProjectFileImportUpdatedMessageResolvers; ProjectInviteMutations?: ProjectInviteMutationsResolvers; ProjectModelsUpdatedMessage?: ProjectModelsUpdatedMessageResolvers; + ProjectMoveToWorkspaceDryRun?: ProjectMoveToWorkspaceDryRunResolvers; ProjectMutations?: ProjectMutationsResolvers; ProjectPendingModelsUpdatedMessage?: ProjectPendingModelsUpdatedMessageResolvers; ProjectPendingVersionsUpdatedMessage?: ProjectPendingVersionsUpdatedMessageResolvers; diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 328017f0a..b6f443b06 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -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 */ diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 74f55bde6..1c96ae36b 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -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; /** 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; + addedToWorkspaceTotalCount: Scalars['Int']['output']; +}; + + +export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = { + limit?: InputMaybe; +}; + export type ProjectMutations = { __typename?: 'ProjectMutations'; /** Access request related mutations */ diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index cc07c2f28..9cd530186 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -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 + +export type IntersectProjectCollaboratorsAndWorkspaceCollaborators = (params: { + projectId: string + workspaceId: string +}) => Promise diff --git a/packages/server/modules/workspaces/graph/resolvers/projects.ts b/packages/server/modules/workspaces/graph/resolvers/projects.ts index 73d396c9f..0961e6f18 100644 --- a/packages/server/modules/workspaces/graph/resolvers/projects.ts +++ b/packages/server/modules/workspaces/graph/resolvers/projects.ts @@ -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) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index b256ef6a3..217e72024 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -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 diff --git a/packages/server/modules/workspaces/repositories/projects.ts b/packages/server/modules/workspaces/repositories/projects.ts new file mode 100644 index 000000000..482b60d0d --- /dev/null +++ b/packages/server/modules/workspaces/repositories/projects.ts @@ -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('stream_acl') +} + +export const intersectProjectCollaboratorsAndWorkspaceCollaboratorsFactory = + (deps: { db: Knex }): IntersectProjectCollaboratorsAndWorkspaceCollaborators => + async ({ projectId, workspaceId }) => { + return await tables + .streamAcl(deps.db) + .select(...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) + }) + } diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index 0d2d5fde9..014bb485d 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -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 } + } diff --git a/packages/server/modules/workspaces/tests/integration/repositories/projects.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories/projects.spec.ts new file mode 100644 index 000000000..a4a98c2d4 --- /dev/null +++ b/packages/server/modules/workspaces/tests/integration/repositories/projects.spec.ts @@ -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) + }) +}) diff --git a/packages/server/modules/workspacesCore/helpers/graphTypes.ts b/packages/server/modules/workspacesCore/helpers/graphTypes.ts index 46d94773e..66b4199bb 100644 --- a/packages/server/modules/workspacesCore/helpers/graphTypes.ts +++ b/packages/server/modules/workspacesCore/helpers/graphTypes.ts @@ -40,3 +40,5 @@ export type WorkspaceCollaboratorGraphQLReturn = WorkspaceTeamMember export type WorkspacePermissionChecksGraphQLReturn = { workspaceId: string } + +export type ProjectMoveToWorkspaceDryRunGraphQLReturn = LimitedUserRecord[] diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index baf2ab940..ace2bb9d7 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -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; /** 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; + addedToWorkspaceTotalCount: Scalars['Int']['output']; +}; + + +export type ProjectMoveToWorkspaceDryRunAddedToWorkspaceArgs = { + limit?: InputMaybe; +}; + export type ProjectMutations = { __typename?: 'ProjectMutations'; /** Access request related mutations */