fix(regions): respect region during project move to workspace (#4985)

This commit is contained in:
Chuck Driesler
2025-06-25 12:54:24 +01:00
committed by GitHub
parent 1a0342ad25
commit 2ef38a3962
7 changed files with 112 additions and 10 deletions
@@ -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 }),
@@ -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 }) => {
@@ -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
@@ -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
? {
@@ -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<GetProjectQuery, GetProjectQueryVariables>;
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<CreateProjectMutation, CreateProjectMutationVariables>;
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<BatchDeleteProjectsMutation, BatchDeleteProjectsMutationVariables>;
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<UpdateProjectMutation, UpdateProjectMutationVariables>;
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<UpdateProjectRoleMutation, UpdateProjectRoleMutationVariables>;
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<GetProjectCollaboratorsQuery, GetProjectCollaboratorsQueryVariables>;
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<GetProjectVersionsQuery, GetProjectVersionsQueryVariables>;
+12
View File
@@ -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 {
@@ -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