diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 8fd712891..2db3a383f 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -155,7 +155,7 @@ import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/use import { getServerInfoFactory } from '@/modules/core/repositories/server' import { asOperation, commandFactory } from '@/modules/shared/command' import { throwIfRateLimitedFactory } from '@/modules/core/utils/ratelimiter' -import { getRegionDb } from '@/modules/multiregion/utils/dbSelector' +import { getProjectDbClient, getRegionDb } from '@/modules/multiregion/utils/dbSelector' import { listUserExpiredSsoSessionsFactory, listWorkspaceSsoMembershipsByUserEmailFactory @@ -226,6 +226,7 @@ import { validateProjectInviteBeforeFinalizationFactory } from '@/modules/serverinvites/services/coreFinalization' import { WorkspaceInvitesLimit } from '@/modules/workspaces/domain/constants' +import { copyWorkspaceFactory } from '@/modules/workspaces/repositories/projectRegions' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -1539,6 +1540,8 @@ export = FF_WORKSPACES_MODULE_ENABLED moveToWorkspace: async (_parent, args, context) => { const { projectId, workspaceId } = args + const projectDb = await getProjectDbClient({ projectId }) + const logger = context.log.child({ projectId, streamId: projectId, //legacy @@ -1562,9 +1565,13 @@ export = FF_WORKSPACES_MODULE_ENABLED operationFactory: ({ db, emit }) => moveProjectToWorkspaceFactory({ getProject: getProjectFactory({ db }), - updateProject: updateProjectFactory({ db }), + updateProject: updateProjectFactory({ db: projectDb }), updateProjectRole: updateStreamRoleAndNotify, getProjectCollaborators: getStreamCollaboratorsFactory({ db }), + copyWorkspace: copyWorkspaceFactory({ + sourceDb: db, + targetDb: projectDb + }), getWorkspaceRolesAndSeats: getWorkspaceRolesAndSeatsFactory({ db }), updateWorkspaceRole: addOrUpdateWorkspaceRoleFactory({ getWorkspaceRoles: getWorkspaceRolesFactory({ db }), diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index 27feaabc4..f6e8e18dd 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -7,7 +7,8 @@ import { IntersectProjectCollaboratorsAndWorkspaceCollaborators, QueryAllWorkspaceProjects, AddOrUpdateWorkspaceRole, - ValidateWorkspaceMemberProjectRole + ValidateWorkspaceMemberProjectRole, + CopyWorkspace } from '@/modules/workspaces/domain/operations' import { WorkspaceInvalidProjectError, @@ -99,6 +100,7 @@ export const moveProjectToWorkspaceFactory = updateProject, updateProjectRole, getProjectCollaborators, + copyWorkspace, getWorkspaceDomains, getWorkspaceRolesAndSeats, updateWorkspaceRole, @@ -110,6 +112,7 @@ export const moveProjectToWorkspaceFactory = updateProject: UpdateProject updateProjectRole: UpdateStreamRole getProjectCollaborators: GetStreamCollaborators + copyWorkspace: CopyWorkspace getWorkspaceDomains: GetWorkspaceDomains getWorkspaceRolesAndSeats: GetWorkspaceRolesAndSeats updateWorkspaceRole: AddOrUpdateWorkspaceRole @@ -139,6 +142,9 @@ export const moveProjectToWorkspaceFactory = ]) if (!workspace) throw new WorkspaceNotFoundError() + // Ensure workspace record exists in source region + await copyWorkspace({ workspaceId: workspace.id }) + for (const projectMembers of chunk(projectTeam, 5)) { await Promise.all( projectMembers.map(async ({ id: userId, streamRole: currentProjectRole }) => { diff --git a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts index 37a069ce7..ea74c0da7 100644 --- a/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/projects.graph.spec.ts @@ -30,6 +30,7 @@ import { MoveProjectToWorkspaceDocument, ProjectUpdateRoleInput, ProjectVisibility, + UpdateProjectDocument, UpdateProjectRoleDocument, UpdateWorkspaceProjectRoleDocument } from '@/test/graphql/generated/graphql' @@ -40,6 +41,7 @@ import { } from '@/test/graphqlHelper' import { beforeEachContext } from '@/test/hooks' import { mockAdminOverride } from '@/test/mocks/global' +import { isMultiRegionTestMode } from '@/test/speckle-helpers/regions' import { addToStream, BasicTestStream, @@ -860,7 +862,8 @@ describe('Workspace project GQL CRUD', () => { id: '', ownerId: '', name: 'Test Project', - visibility: ProjectRecordVisibility.Private + visibility: ProjectRecordVisibility.Private, + regionKey: isMultiRegionTestMode() ? 'region1' : undefined } const targetWorkspace: BasicTestWorkspace = { @@ -982,6 +985,27 @@ describe('Workspace project GQL CRUD', () => { expect(resB).to.not.haveGraphQLErrors() expect(adminWorkspaceRole?.role).to.equal(Roles.Workspace.Admin) }) + + it('should respect project region during move mutations @multiregion', async () => { + const resA = await apollo.execute(MoveProjectToWorkspaceDocument, { + projectId: testProject.id, + workspaceId: targetWorkspace.id + }) + const resB = await apollo.execute(UpdateProjectDocument, { + input: { + id: testProject.id, + name: 'Foo' + } + }) + const resC = await apollo.execute(GetProjectDocument, { + id: testProject.id + }) + + expect(resA).to.not.haveGraphQLErrors() + expect(resB).to.not.haveGraphQLErrors() + expect(resC).to.not.haveGraphQLErrors() + expect(resC.data?.project?.workspaceId).to.equal(targetWorkspace.id) + }) }) // moved over Alessandro's tests from core to here, since they are all related to workspaces diff --git a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts index b3f2144f7..7e0896778 100644 --- a/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/projects.spec.ts @@ -245,6 +245,7 @@ describe('Project management services', () => { getProjectCollaborators: async () => { expect.fail() }, + copyWorkspace: async () => '', getWorkspaceRolesAndSeats: async () => { expect.fail() }, @@ -290,6 +291,7 @@ describe('Project management services', () => { getProjectCollaborators: async () => { expect.fail() }, + copyWorkspace: async () => '', getWorkspaceRolesAndSeats: async () => { expect.fail() }, @@ -344,6 +346,7 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, + copyWorkspace: async () => '', getWorkspaceRolesAndSeats: async () => { return { [userId]: { @@ -413,6 +416,7 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, + copyWorkspace: async () => '', getWorkspaceRolesAndSeats: async () => { return {} }, @@ -486,6 +490,7 @@ describe('Project management services', () => { } as unknown as ProjectTeamMember ] }, + copyWorkspace: async () => '', getWorkspaceRolesAndSeats: async () => { return workspaceRole && workspaceSeatType ? { diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index a091275a3..3e7bc5901 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -6082,6 +6082,13 @@ export type BatchDeleteProjectsMutationVariables = Exact<{ export type BatchDeleteProjectsMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', batchDelete: boolean } }; +export type UpdateProjectMutationVariables = Exact<{ + input: ProjectUpdateInput; +}>; + + +export type UpdateProjectMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', update: { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: ProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string } } }; + export type UpdateProjectRoleMutationVariables = Exact<{ input: ProjectUpdateRoleInput; }>; @@ -6635,6 +6642,7 @@ export const GetProjectObjectDocument = {"kind":"Document","definitions":[{"kind export const GetProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const BatchDeleteProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchDeleteProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}]}]}}]}}]} as unknown as DocumentNode; +export const UpdateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"update"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const UpdateProjectRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProjectRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectUpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicProjectFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicProjectFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const GetProjectCollaboratorsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectCollaborators"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProjectVersionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectVersions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/test/graphql/projects.ts b/packages/server/test/graphql/projects.ts index 614d5d8bb..785e0eb3b 100644 --- a/packages/server/test/graphql/projects.ts +++ b/packages/server/test/graphql/projects.ts @@ -86,6 +86,18 @@ export const batchDeleteProjectsMutation = gql` } ` +export const updateProjectMutation = gql` + mutation UpdateProject($input: ProjectUpdateInput!) { + projectMutations { + update(update: $input) { + ...BasicProjectFields + } + } + } + + ${basicProjectFieldsFragment} +` + export const updateProjectRoleMutation = gql` mutation UpdateProjectRole($input: ProjectUpdateRoleInput!) { projectMutations { diff --git a/packages/server/test/speckle-helpers/streamHelper.ts b/packages/server/test/speckle-helpers/streamHelper.ts index 3891570ed..a14967179 100644 --- a/packages/server/test/speckle-helpers/streamHelper.ts +++ b/packages/server/test/speckle-helpers/streamHelper.ts @@ -1,8 +1,11 @@ import { db } from '@/db/knex' import { StreamAcl } from '@/modules/core/dbSchema' +import { RegionalProjectCreationError } from '@/modules/core/errors/projects' +import { StreamNotFoundError } from '@/modules/core/errors/stream' import { mapDbToGqlProjectVisibility } from '@/modules/core/helpers/project' import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' import { createBranchFactory } from '@/modules/core/repositories/branches' +import { getProjectFactory } from '@/modules/core/repositories/projects' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { createStreamFactory, @@ -32,6 +35,7 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { getRegionDb } from '@/modules/multiregion/utils/dbSelector' import { deleteInvitesByTargetFactory, deleteServerOnlyInvitesFactory, @@ -53,6 +57,7 @@ import { } from '@/modules/serverinvites/services/processing' import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { authorizeResolver } from '@/modules/shared' +import { isTestEnv } from '@/modules/shared/helpers/envHelper' import { Nullable } from '@/modules/shared/helpers/typeHelper' import { getEventBus } from '@/modules/shared/services/eventBus' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' @@ -60,7 +65,8 @@ import { createWorkspaceProjectFactory } from '@/modules/workspaces/services/pro import { BasicTestUser } from '@/test/authHelper' import { ProjectVisibility } from '@/test/graphql/generated/graphql' import { faker } from '@faker-js/faker' -import { ensureError, Roles, StreamRoles } from '@speckle/shared' +import { retry } from '@lifeomic/attempt' +import { ensureError, Roles, StreamRoles, TIME_MS } from '@speckle/shared' import { omit } from 'lodash' const getServerInfo = getServerInfoFactory({ db }) @@ -215,11 +221,45 @@ export async function createTestStream( }) id = newProject.id } else { - id = await createStream({ - ...omit(streamObj, ['id', 'ownerId', 'visibility']), - isPublic: visibility === ProjectVisibility.Public, - ownerId: owner.id - }) + // Create personal project + if (streamObj.regionKey) { + const regionDb = await getRegionDb({ regionKey: streamObj.regionKey }) + const project = await createStreamFactory({ db: regionDb })({ + ...omit(streamObj, ['id', 'ownerId', 'visibility']), + isPublic: visibility === ProjectVisibility.Public + }) + try { + await retry( + async () => { + const replicatedProject = await getProjectFactory({ + db + })({ projectId: project.id }) + if (!replicatedProject) throw new StreamNotFoundError() + }, + { maxAttempts: 10, delay: isTestEnv() ? TIME_MS.second : undefined } + ) + } catch (err) { + if (err instanceof StreamNotFoundError) { + throw new RegionalProjectCreationError(undefined, { + info: { projectId: project.id, regionKey: streamObj.regionKey } + }) + } + // else throw as is + throw err + } + await grantStreamPermissionsFactory({ db })({ + streamId: project.id, + userId: owner.id, + role: Roles.Stream.Owner + }) + id = project.id + } else { + id = await createStream({ + ...omit(streamObj, ['id', 'ownerId', 'visibility']), + isPublic: visibility === ProjectVisibility.Public, + ownerId: owner.id + }) + } } streamObj.id = id