diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index fc71d2b96..c20a1c09f 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -468,6 +468,7 @@ type WorkspaceJoinRequestCollection { } type WorkspaceJoinRequest { + id: String! workspace: Workspace! user: LimitedUser! status: WorkspaceJoinRequestStatus! diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index e53626aca..13f7486d0 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4423,6 +4423,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; workspace: Workspace; @@ -6886,6 +6887,7 @@ export type WorkspaceInviteMutationsResolvers = { createdAt?: Resolver; + id?: Resolver; status?: Resolver; user?: Resolver; workspace?: Resolver; 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 d01b5ffe7..62107b8d0 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4404,6 +4404,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; workspace: Workspace; diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index c9a7464b1..0956e5369 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -17,7 +17,10 @@ import { getWorkspaceJoinRequestFactory, updateWorkspaceJoinRequestStatusFactory } from '@/modules/workspaces/repositories/workspaceJoinRequests' -import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' +import { + getWorkspaceFactory, + upsertWorkspaceRoleFactory +} from '@/modules/workspaces/repositories/workspaces' import { sendWorkspaceJoinRequestApprovedEmailFactory } from '@/modules/workspaces/services/workspaceJoinRequestEmails/approved' import { sendWorkspaceJoinRequestDeniedEmailFactory } from '@/modules/workspaces/services/workspaceJoinRequestEmails/denied' import { @@ -58,6 +61,9 @@ export default { } }, WorkspaceJoinRequest: { + id: async (parent) => { + return parent.userId + parent.workspaceId + }, user: async (parent, _args, ctx) => { return await ctx.loaders.users.getUser.load(parent.userId) }, @@ -91,7 +97,8 @@ export default { getWorkspace: getWorkspaceFactory({ db }), getWorkspaceJoinRequest: getWorkspaceJoinRequestFactory({ db - }) + }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }) }) } }) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 9b7eabf08..e6d5bc75c 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -799,33 +799,35 @@ export = FF_WORKSPACES_MODULE_ENABLED })({ userId: ctx.userId!, workspaceId: args.input.workspaceId }) }, requestToJoin: async (_parent, args, ctx) => { - const transaction = await db.transaction() - const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({ - db: transaction + const requestToJoin = commandFactory({ + db, + operationFactory: ({ db }) => { + const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({ + db + }) + const sendWorkspaceJoinRequestReceivedEmail = + sendWorkspaceJoinRequestReceivedEmailFactory({ + renderEmail, + sendEmail, + getServerInfo, + getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ + db + }), + getUserEmails: findEmailsByUserIdFactory({ db }) + }) + return requestToJoinWorkspaceFactory({ + createWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail, + getUserById: getUserFactory({ db }), + getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({ db }), + getUserEmails: findEmailsByUserIdFactory({ db }) + }) + } + }) + return await requestToJoin({ + userId: ctx.userId!, + workspaceId: args.input.workspaceId }) - const sendWorkspaceJoinRequestReceivedEmail = - sendWorkspaceJoinRequestReceivedEmailFactory({ - renderEmail, - sendEmail, - getServerInfo, - getWorkspaceCollaborators: getWorkspaceCollaboratorsFactory({ - db: transaction - }), - getUserEmails: findEmailsByUserIdFactory({ db: transaction }) - }) - - return await withTransaction( - requestToJoinWorkspaceFactory({ - createWorkspaceJoinRequest, - sendWorkspaceJoinRequestReceivedEmail, - getUserById: getUserFactory({ db: transaction }), - getWorkspace: getWorkspaceFactory({ db: transaction }) - })({ - userId: ctx.userId!, - workspaceId: args.input.workspaceId - }), - transaction - ) } }, WorkspaceInviteMutations: { diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index 004709269..807cd1c2e 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -1,4 +1,9 @@ -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { + WorkspaceNotDiscoverableError, + WorkspaceNotFoundError, + WorkspaceNotJoinableError, + WorkspaceProtectedError +} from '@/modules/workspaces/errors/workspace' import { GetUser } from '@/modules/core/domain/users/operations' import { NotFoundError } from '@/modules/shared/errors' import { @@ -6,11 +11,16 @@ import { DenyWorkspaceJoinRequest, GetWorkspace, GetWorkspaceJoinRequest, + GetWorkspaceWithDomains, SendWorkspaceJoinRequestApprovedEmail, SendWorkspaceJoinRequestDeniedEmail, SendWorkspaceJoinRequestReceivedEmail, - UpdateWorkspaceJoinRequestStatus + UpdateWorkspaceJoinRequestStatus, + UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' +import { Roles } from '@speckle/shared' +import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' +import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic' export const dismissWorkspaceJoinRequestFactory = ({ @@ -38,12 +48,14 @@ export const requestToJoinWorkspaceFactory = createWorkspaceJoinRequest, sendWorkspaceJoinRequestReceivedEmail, getUserById, - getWorkspace + getWorkspaceWithDomains, + getUserEmails }: { createWorkspaceJoinRequest: CreateWorkspaceJoinRequest sendWorkspaceJoinRequestReceivedEmail: SendWorkspaceJoinRequestReceivedEmail getUserById: GetUser - getWorkspace: GetWorkspace + getWorkspaceWithDomains: GetWorkspaceWithDomains + getUserEmails: FindEmailsByUserId }) => async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { const requester = await getUserById(userId) @@ -51,10 +63,23 @@ export const requestToJoinWorkspaceFactory = throw new NotFoundError('User not found') } - const workspace = await getWorkspace({ workspaceId }) + const workspace = await getWorkspaceWithDomains({ id: workspaceId }) if (!workspace) { throw new WorkspaceNotFoundError('Workspace not found') } + if (!workspace?.discoverabilityEnabled) throw new WorkspaceNotDiscoverableError() + const workspaceDomains = workspace.domains.filter((domain) => domain.verified) + if (!workspaceDomains.length) throw new WorkspaceNotJoinableError() + + const userEmails = await getUserEmails({ userId }) + + const canJoinWorkspace = userEmailsCompliantWithWorkspaceDomains({ + workspaceDomains: workspace.domains, + userEmails + }) + if (!canJoinWorkspace) { + throw new WorkspaceProtectedError() + } await createWorkspaceJoinRequest({ workspaceJoinRequest: { @@ -78,13 +103,15 @@ export const approveWorkspaceJoinRequestFactory = sendWorkspaceJoinRequestApprovedEmail, getUserById, getWorkspace, - getWorkspaceJoinRequest + getWorkspaceJoinRequest, + upsertWorkspaceRole }: { updateWorkspaceJoinRequestStatus: UpdateWorkspaceJoinRequestStatus sendWorkspaceJoinRequestApprovedEmail: SendWorkspaceJoinRequestApprovedEmail getUserById: GetUser getWorkspace: GetWorkspace getWorkspaceJoinRequest: GetWorkspaceJoinRequest + upsertWorkspaceRole: UpsertWorkspaceRole }) => async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { const requester = await getUserById(userId) @@ -112,6 +139,9 @@ export const approveWorkspaceJoinRequestFactory = status: 'approved' }) + const role = Roles.Workspace.Member + await upsertWorkspaceRole({ userId, workspaceId, role, createdAt: new Date() }) + await sendWorkspaceJoinRequestApprovedEmail({ workspace, requester diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts index ec10b513b..2fcaba4de 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.graph.spec.ts @@ -38,43 +38,59 @@ describe('WorkspaceJoinRequests GQL', () => { it('should return the workspace join requests for the admin', async () => { const admin = await createTestUser({ name: 'admin user', - role: Roles.Server.User + role: Roles.Server.User, + email: `${createRandomString()}@example.org`, + verified: true }) - const user1 = await createTestUser({ name: 'user 1', role: Roles.Server.User }) - const user2 = await createTestUser({ name: 'user 2', role: Roles.Server.User }) + const user1 = await createTestUser({ + name: 'user 1', + role: Roles.Server.User, + email: `${createRandomString()}@example.org`, + verified: true + }) + const user2 = await createTestUser({ + name: 'user 2', + role: Roles.Server.User, + email: `${createRandomString()}@example.org`, + verified: true + }) const workspace1 = { id: createRandomString(), name: 'Workspace 1', ownerId: admin.id, - description: '' + description: '', + discoverabilityEnabled: true } - await createTestWorkspace(workspace1, admin) + await createTestWorkspace(workspace1, admin, { domain: 'example.org' }) const workspace2 = { id: createRandomString(), name: 'Workspace 2', ownerId: admin.id, - description: '' + description: '', + discoverabilityEnabled: true } - await createTestWorkspace(workspace2, admin) + await createTestWorkspace(workspace2, admin, { domain: 'example.org' }) const nobodyWorkspace = { id: createRandomString(), name: 'nobody', ownerId: admin.id, - description: '' + description: '', + discoverabilityEnabled: true } - await createTestWorkspace(nobodyWorkspace, admin) + await createTestWorkspace(nobodyWorkspace, admin, { domain: 'example.org' }) const nonAdminWorkspace = { id: createRandomString(), name: 'nonadmin', ownerId: admin.id, - description: '' + description: '', + discoverabilityEnabled: true } - await createTestWorkspace(nonAdminWorkspace, admin) + await createTestWorkspace(nonAdminWorkspace, admin, { domain: 'example.org' }) await upsertWorkspaceRoleFactory({ db })({ userId: admin.id, workspaceId: nonAdminWorkspace.id, diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts index f9b5e9a41..44d1cabd5 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -4,7 +4,10 @@ import { createRandomString } from '@/modules/core/helpers/testHelpers' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { + WorkspaceNotDiscoverableError, + WorkspaceNotFoundError +} from '@/modules/workspaces/errors/workspace' import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { UserWithOptionalRole } from '@/modules/core/repositories/users' import { @@ -12,7 +15,8 @@ import { SendWorkspaceJoinRequestApprovedEmail, SendWorkspaceJoinRequestDeniedEmail, SendWorkspaceJoinRequestReceivedEmail, - UpdateWorkspaceJoinRequestStatus + UpdateWorkspaceJoinRequestStatus, + UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' import { denyWorkspaceJoinRequestFactory, @@ -24,7 +28,11 @@ import { BasicTestWorkspace, createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' -import { Workspace, WorkspaceJoinRequest } from '@/modules/workspacesCore/domain/types' +import { + Workspace, + WorkspaceJoinRequest, + WorkspaceWithDomains +} from '@/modules/workspacesCore/domain/types' import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db' import { expectToThrow } from '@/test/assertionHelper' import { BasicTestUser, createTestUser } from '@/test/authHelper' @@ -35,6 +43,7 @@ import { createWorkspaceJoinRequestFactory, updateWorkspaceJoinRequestStatusFactory } from '@/modules/workspaces/repositories/workspaceJoinRequests' +import { UserEmail } from '@/modules/core/domain/userEmails/types' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -99,7 +108,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() Promise.resolve()) as unknown as CreateWorkspaceJoinRequest, sendWorkspaceJoinRequestReceivedEmail: async () => Promise.resolve(), getUserById: async () => null, - getWorkspace: async () => null + getWorkspaceWithDomains: async () => null, + getUserEmails: async () => [] })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -113,21 +123,14 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() Promise.resolve()) as unknown as CreateWorkspaceJoinRequest, sendWorkspaceJoinRequestReceivedEmail: async () => Promise.resolve(), getUserById: async () => user as unknown as UserWithOptionalRole, - getWorkspace: async () => null + getWorkspaceWithDomains: async () => null, + getUserEmails: async () => [] })({ workspaceId: createRandomString(), userId: createRandomString() }) ) expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage) }) - it('creates a join request and sends an email to all admins', async () => { - const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({ db }) - - const sendWorkspaceJoinRequestReceivedEmailCalls: Parameters[number][] = - [] - const sendWorkspaceJoinRequestReceivedEmail = async ( - args: Parameters[number] - ) => sendWorkspaceJoinRequestReceivedEmailCalls.push(args) - + it('throws a WorkspaceNotDiscoverable if the workspace has no domain', async () => { const user: BasicTestUser = { id: '', name: 'John Speckle', @@ -146,6 +149,57 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() description: cryptoRandomString({ length: 12 }) } await createTestWorkspace(workspace, user) + const err = await expectToThrow(() => + requestToJoinWorkspaceFactory({ + createWorkspaceJoinRequest: (async () => + Promise.resolve()) as unknown as CreateWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail: async () => Promise.resolve(), + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspaceWithDomains: async () => + workspace as unknown as WorkspaceWithDomains, + getUserEmails: async () => [] + })({ workspaceId: createRandomString(), userId: createRandomString() }) + ) + + expect(err.message).to.equal(WorkspaceNotDiscoverableError.defaultMessage) + }) + it('creates a join request and sends an email to all admins', async () => { + const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({ db }) + + const sendWorkspaceJoinRequestReceivedEmailCalls: Parameters[number][] = + [] + const sendWorkspaceJoinRequestReceivedEmail = async ( + args: Parameters[number] + ) => sendWorkspaceJoinRequestReceivedEmailCalls.push(args) + + const user: BasicTestUser = { + id: '', + name: 'John Speckle', + email: `${createRandomString()}@example.org`, + role: Roles.Server.Admin, + verified: true + } + + await createTestUser(user) + + const workspace: BasicTestWorkspace = { + id: '', + slug: '', + ownerId: '', + name: cryptoRandomString({ length: 6 }), + description: cryptoRandomString({ length: 12 }), + discoverabilityEnabled: true + } + await createTestWorkspace(workspace, user, { domain: 'example.org' }) + const domain = { + id: createRandomString(), + workspaceId: workspace.id, + domain: 'example.org', + verified: true, + createdAt: new Date(), + createdByUserId: user.id, + updatedAt: new Date() + } expect( await requestToJoinWorkspaceFactory({ @@ -153,7 +207,13 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() sendWorkspaceJoinRequestReceivedEmail: sendWorkspaceJoinRequestReceivedEmail as unknown as SendWorkspaceJoinRequestReceivedEmail, getUserById: async () => user as unknown as UserWithOptionalRole, - getWorkspace: async () => workspace as unknown as Workspace + getWorkspaceWithDomains: async () => + ({ + ...workspace, + domains: [domain] + } as unknown as WorkspaceWithDomains), + getUserEmails: async () => + [{ email: user.email, verified: true }] as unknown as UserEmail[] })({ workspaceId: workspace.id, userId: user.id }) ).to.equal(true) @@ -168,8 +228,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() ).to.equal('pending') expect(sendWorkspaceJoinRequestReceivedEmailCalls).to.have.length(1) - expect(sendWorkspaceJoinRequestReceivedEmailCalls[0].workspace).to.equal( - workspace + expect(sendWorkspaceJoinRequestReceivedEmailCalls[0].workspace.id).to.equal( + workspace.id ) expect(sendWorkspaceJoinRequestReceivedEmailCalls[0].requester).to.equal(user) }) @@ -184,7 +244,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() sendWorkspaceJoinRequestApprovedEmail: async () => Promise.resolve(), getUserById: async () => null, getWorkspace: async () => null, - getWorkspaceJoinRequest: async () => undefined + getWorkspaceJoinRequest: async () => undefined, + upsertWorkspaceRole: async () => Promise.resolve() })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -199,7 +260,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() sendWorkspaceJoinRequestApprovedEmail: async () => Promise.resolve(), getUserById: async () => user as unknown as UserWithOptionalRole, getWorkspace: async () => null, - getWorkspaceJoinRequest: async () => undefined + getWorkspaceJoinRequest: async () => undefined, + upsertWorkspaceRole: async () => Promise.resolve() })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -222,7 +284,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() sendWorkspaceJoinRequestApprovedEmail: async () => Promise.resolve(), getUserById: async () => user as unknown as UserWithOptionalRole, getWorkspace: async () => workspace as unknown as Workspace, - getWorkspaceJoinRequest: async () => undefined + getWorkspaceJoinRequest: async () => undefined, + upsertWorkspaceRole: async () => Promise.resolve() })({ workspaceId: createRandomString(), userId: createRandomString() }) ) @@ -235,6 +298,13 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() args: Parameters[number] ) => sendWorkspaceJoinRequestApprovedEmailCalls.push(args) + const upsertWorkspaceRoleCalls: Parameters[number][] = [] + const upsertWorkspaceRole = async ( + args: Parameters[number] + ) => { + upsertWorkspaceRoleCalls.push(args) + } + const user: BasicTestUser = { id: '', name: 'John Speckle', @@ -272,7 +342,8 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() sendWorkspaceJoinRequestApprovedEmail as unknown as SendWorkspaceJoinRequestApprovedEmail, getUserById: async () => user as unknown as UserWithOptionalRole, getWorkspace: async () => workspace as unknown as Workspace, - getWorkspaceJoinRequest: async () => request + getWorkspaceJoinRequest: async () => request, + upsertWorkspaceRole })({ workspaceId: workspace.id, userId: user.id }) ).to.equal(true) @@ -286,6 +357,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() .first())!.status ).to.equal('approved') + expect(upsertWorkspaceRoleCalls).to.have.length(1) + expect(upsertWorkspaceRoleCalls[0].workspaceId).to.equal(workspace.id) + expect(upsertWorkspaceRoleCalls[0].userId).to.equal(user.id) + expect(upsertWorkspaceRoleCalls[0].role).to.equal(Roles.Workspace.Member) + expect(sendWorkspaceJoinRequestApprovedEmailCalls).to.have.length(1) expect(sendWorkspaceJoinRequestApprovedEmailCalls[0].workspace).to.equal( workspace diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 091cdd433..1195f0a5a 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4405,6 +4405,7 @@ export type WorkspaceInviteUseInput = { export type WorkspaceJoinRequest = { __typename?: 'WorkspaceJoinRequest'; createdAt: Scalars['DateTime']['output']; + id: Scalars['String']['output']; status: WorkspaceJoinRequestStatus; user: LimitedUser; workspace: Workspace;