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
@@ -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)
})
})