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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user