From 37ede3b1b2749aabcd2387e194498236214b0379 Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Tue, 14 Jan 2025 12:49:21 +0100 Subject: [PATCH] Alessandro/web 2360 request to join workspace (#3799) * feat(workspaces): request to join workspace mutation * feat(workspaces): random email in test * feat(workspaces): update email * feat(workspaces): code review changes * chore(workspaces): fix tests --- .../typedefs/workspaces.graphql | 6 + .../modules/core/graph/generated/graphql.ts | 14 ++ .../graph/generated/graphql.ts | 11 ++ .../modules/workspaces/domain/operations.ts | 11 +- .../workspaces/graph/resolvers/workspaces.ts | 40 ++++- .../repositories/workspaceJoinRequests.ts | 16 +- .../services/workspaceJoinRequests.ts | 154 +++++++++++++++++- .../integration/workspaceJoinRequests.spec.ts | 105 +++++++++++- .../server/test/graphql/generated/graphql.ts | 11 ++ 9 files changed, 357 insertions(+), 11 deletions(-) diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 8114359bec..97d360cb60 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -155,12 +155,18 @@ type WorkspaceMutations { Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" """ dismiss(input: WorkspaceDismissInput!): Boolean! @hasServerRole(role: SERVER_USER) + requestToJoin(input: WorkspaceRequestToJoinInput!): Boolean! + @hasServerRole(role: SERVER_USER) } input WorkspaceDismissInput { workspaceId: ID! } +input WorkspaceRequestToJoinInput { + workspaceId: ID! +} + input WorkspaceCreationStateInput { workspaceId: ID! completed: Boolean! diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 2019d21b13..fb5c4c9079 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -4404,11 +4404,13 @@ export type WorkspaceMutations = { delete: Scalars['Boolean']['output']; deleteDomain: Workspace; deleteSsoProvider: Scalars['Boolean']['output']; + /** Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" */ dismiss: Scalars['Boolean']['output']; invites: WorkspaceInviteMutations; join: Workspace; leave: Scalars['Boolean']['output']; projects: WorkspaceProjectMutations; + requestToJoin: Scalars['Boolean']['output']; /** Set the default region where project data will be stored. Only available to admins. */ setDefaultRegion: Workspace; update: Workspace; @@ -4457,6 +4459,11 @@ export type WorkspaceMutationsLeaveArgs = { }; +export type WorkspaceMutationsRequestToJoinArgs = { + input: WorkspaceRequestToJoinInput; +}; + + export type WorkspaceMutationsSetDefaultRegionArgs = { regionKey: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -4566,6 +4573,10 @@ export enum WorkspaceProjectsUpdatedMessageType { Removed = 'REMOVED' } +export type WorkspaceRequestToJoinInput = { + workspaceId: Scalars['ID']['input']; +}; + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -5001,6 +5012,7 @@ export type ResolversTypes = { WorkspaceProjectsFilter: WorkspaceProjectsFilter; WorkspaceProjectsUpdatedMessage: ResolverTypeWrapper & { project?: Maybe }>; WorkspaceProjectsUpdatedMessageType: WorkspaceProjectsUpdatedMessageType; + WorkspaceRequestToJoinInput: WorkspaceRequestToJoinInput; WorkspaceRole: WorkspaceRole; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; @@ -5270,6 +5282,7 @@ export type ResolversParentTypes = { WorkspaceProjectMutations: WorkspaceProjectMutationsGraphQLReturn; WorkspaceProjectsFilter: WorkspaceProjectsFilter; WorkspaceProjectsUpdatedMessage: Omit & { project?: Maybe }; + WorkspaceRequestToJoinInput: WorkspaceRequestToJoinInput; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; WorkspaceSso: WorkspaceSsoGraphQLReturn; @@ -6796,6 +6809,7 @@ export type WorkspaceMutationsResolvers>; leave?: Resolver>; projects?: Resolver; + requestToJoin?: Resolver>; setDefaultRegion?: Resolver>; update?: Resolver>; updateCreationState?: 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 eb11728b8b..804fe3d907 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -4385,11 +4385,13 @@ export type WorkspaceMutations = { delete: Scalars['Boolean']['output']; deleteDomain: Workspace; deleteSsoProvider: Scalars['Boolean']['output']; + /** Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" */ dismiss: Scalars['Boolean']['output']; invites: WorkspaceInviteMutations; join: Workspace; leave: Scalars['Boolean']['output']; projects: WorkspaceProjectMutations; + requestToJoin: Scalars['Boolean']['output']; /** Set the default region where project data will be stored. Only available to admins. */ setDefaultRegion: Workspace; update: Workspace; @@ -4438,6 +4440,11 @@ export type WorkspaceMutationsLeaveArgs = { }; +export type WorkspaceMutationsRequestToJoinArgs = { + input: WorkspaceRequestToJoinInput; +}; + + export type WorkspaceMutationsSetDefaultRegionArgs = { regionKey: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -4547,6 +4554,10 @@ export enum WorkspaceProjectsUpdatedMessageType { Removed = 'REMOVED' } +export type WorkspaceRequestToJoinInput = { + workspaceId: Scalars['ID']['input']; +}; + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 8ce7e2c21c..f96afa8419 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -315,4 +315,13 @@ export type UpdateWorkspaceJoinRequestStatus = (params: { workspaceId: string userId: string status: WorkspaceJoinRequestStatus -}) => Promise | undefined> +}) => Promise + +export type CreateWorkspaceJoinRequest = (params: { + workspaceJoinRequest: Omit +}) => Promise + +export type SendWorkspaceJoinRequestReceivedEmail = (params: { + workspace: Pick + requester: { id: string; name: string; email: string } +}) => Promise diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 64825bbe45..b392a16c97 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -203,8 +203,15 @@ import { Knex } from 'knex' import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems' import { InvalidWorkspacePlanStatus } from '@/modules/gatekeeper/errors/billing' import { BadRequestError } from '@/modules/shared/errors' -import { dismissWorkspaceJoinRequestFactory } from '@/modules/workspaces/services/workspaceJoinRequests' -import { updateWorkspaceJoinRequestStatusFactory } from '@/modules/workspaces/repositories/workspaceJoinRequests' +import { + dismissWorkspaceJoinRequestFactory, + requestToJoinWorkspaceFactory, + sendWorkspaceJoinRequestReceivedEmailFactory +} from '@/modules/workspaces/services/workspaceJoinRequests' +import { + createWorkspaceJoinRequestFactory, + updateWorkspaceJoinRequestStatusFactory +} from '@/modules/workspaces/repositories/workspaceJoinRequests' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -784,6 +791,35 @@ export = FF_WORKSPACES_MODULE_ENABLED db }) })({ userId: ctx.userId!, workspaceId: args.input.workspaceId }) + }, + requestToJoin: async (_parent, args, ctx) => { + const transaction = await db.transaction() + const createWorkspaceJoinRequest = createWorkspaceJoinRequestFactory({ + db: transaction + }) + 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/repositories/workspaceJoinRequests.ts b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts index a9ab04ca50..8c3ff44a60 100644 --- a/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/repositories/workspaceJoinRequests.ts @@ -1,4 +1,7 @@ -import { UpdateWorkspaceJoinRequestStatus } from '@/modules/workspaces/domain/operations' +import { + CreateWorkspaceJoinRequest, + UpdateWorkspaceJoinRequestStatus +} from '@/modules/workspaces/domain/operations' import { WorkspaceJoinRequest } from '@/modules/workspacesCore/domain/types' import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db' import { Knex } from 'knex' @@ -8,14 +11,19 @@ const tables = { db(WorkspaceJoinRequests.name) } +export const createWorkspaceJoinRequestFactory = + ({ db }: { db: Knex }): CreateWorkspaceJoinRequest => + async ({ workspaceJoinRequest }) => { + const res = await tables.workspaceJoinRequests(db).insert(workspaceJoinRequest, '*') + return res[0] + } + export const updateWorkspaceJoinRequestStatusFactory = ({ db }: { db: Knex }): UpdateWorkspaceJoinRequestStatus => async ({ workspaceId, userId, status }) => { - const [request] = await tables + return await tables .workspaceJoinRequests(db) .insert({ workspaceId, userId, status }) .onConflict(['workspaceId', 'userId']) .merge(['status']) - .returning('*') - return request } diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index f758bc0919..43b431f607 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -1,8 +1,18 @@ +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' +import { GetUser } from '@/modules/core/domain/users/operations' +import { RenderEmail, SendEmail } from '@/modules/emails/domain/operations' +import { NotFoundError } from '@/modules/shared/errors' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import { + CreateWorkspaceJoinRequest, GetWorkspace, + GetWorkspaceCollaborators, + SendWorkspaceJoinRequestReceivedEmail, UpdateWorkspaceJoinRequestStatus } from '@/modules/workspaces/domain/operations' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { Roles } from '@speckle/shared' export const dismissWorkspaceJoinRequestFactory = ({ @@ -24,3 +34,145 @@ export const dismissWorkspaceJoinRequestFactory = }) return true } + +type WorkspaceJoinRequestReceivedEmailArgs = { + workspace: { id: string; name: string; slug: string } + requester: { name: string } + workspaceAdmin: { id: string; name: string } +} + +const buildMjmlBody = ({ + workspace, + requester, + workspaceAdmin +}: WorkspaceJoinRequestReceivedEmailArgs) => { + const bodyStart = ` +Hi ${workspaceAdmin.name}! +
+
+${requester.name} is requesting to join your workspace ${workspace.name}. +
+
+ +
+ ` + const bodyEnd = ` +Have questions or feedback? Please write us at hello@speckle.systems and we'd be more than happy to talk. + ` + return { bodyStart, bodyEnd } +} + +const buildTextBody = ({ + workspace, + requester, + workspaceAdmin +}: WorkspaceJoinRequestReceivedEmailArgs) => { + const bodyStart = ` +Hi ${workspaceAdmin.name}! +\r\n\r\n +${requester.name} is requesting to join your workspace ${workspace.name}. +\r\n\r\n + ` + const bodyEnd = `Have questions or feedback? Please write us at hello@speckle.systems and we'd be more than happy to talk.` + return { bodyStart, bodyEnd } +} + +const buildEmailTemplateParams = (args: WorkspaceJoinRequestReceivedEmailArgs) => { + const url = new URL( + `workspaces/${args.workspace.slug}`, + getFrontendOrigin() + ).toString() + return { + mjml: buildMjmlBody(args), + text: buildTextBody(args), + cta: { + title: 'Manage Members', + url + } + } +} + +export const sendWorkspaceJoinRequestReceivedEmailFactory = + ({ + renderEmail, + sendEmail, + getServerInfo, + getWorkspaceCollaborators, + getUserEmails + }: { + renderEmail: RenderEmail + sendEmail: SendEmail + getServerInfo: GetServerInfo + getWorkspaceCollaborators: GetWorkspaceCollaborators + getUserEmails: FindEmailsByUserId + }) => + async (args: Omit) => { + const { requester, workspace } = args + const [serverInfo, workspaceAdmins] = await Promise.all([ + getServerInfo(), + getWorkspaceCollaborators({ + workspaceId: workspace.id, + limit: 100, + filter: { roles: [Roles.Workspace.Admin] } + }) + ]) + const sendEmailParams = await Promise.all( + workspaceAdmins.map(async (admin) => { + const userEmails = await getUserEmails({ userId: admin.id }) + const emailTemplateParams = buildEmailTemplateParams({ + requester, + workspace, + workspaceAdmin: admin + }) + const { html, text } = await renderEmail(emailTemplateParams, serverInfo, null) + const subject = `${requester.name} wants to join your workspace` + const sendEmailParams = { + html, + text, + subject, + to: userEmails.map((e) => e.email) + } + return sendEmailParams + }) + ) + await Promise.all(sendEmailParams.map((params) => sendEmail(params))) + } + +export const requestToJoinWorkspaceFactory = + ({ + createWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail, + getUserById, + getWorkspace + }: { + createWorkspaceJoinRequest: CreateWorkspaceJoinRequest + sendWorkspaceJoinRequestReceivedEmail: SendWorkspaceJoinRequestReceivedEmail + getUserById: GetUser + getWorkspace: GetWorkspace + }) => + async ({ userId, workspaceId }: { userId: string; workspaceId: string }) => { + await createWorkspaceJoinRequest({ + workspaceJoinRequest: { + userId, + workspaceId, + status: 'pending' + } + }) + + const requester = await getUserById(userId) + if (!requester) { + throw new NotFoundError('User not found') + } + + const workspace = await getWorkspace({ workspaceId }) + if (!workspace) { + throw new NotFoundError('Workspace not found') + } + + await sendWorkspaceJoinRequestReceivedEmail({ + workspace, + requester + }) + + return true + } diff --git a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts index 2f8b3c598d..c6a717863a 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -1,20 +1,35 @@ import { db } from '@/db/knex' -import { createRandomString } from '@/modules/core/helpers/testHelpers' +import { + createRandomEmail, + createRandomString +} from '@/modules/core/helpers/testHelpers' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' -import { updateWorkspaceJoinRequestStatusFactory } from '@/modules/workspaces/repositories/workspaceJoinRequests' import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' -import { dismissWorkspaceJoinRequestFactory } from '@/modules/workspaces/services/workspaceJoinRequests' +import { UserWithOptionalRole } from '@/modules/core/repositories/users' +import { + CreateWorkspaceJoinRequest, + SendWorkspaceJoinRequestReceivedEmail +} from '@/modules/workspaces/domain/operations' +import { + dismissWorkspaceJoinRequestFactory, + requestToJoinWorkspaceFactory +} from '@/modules/workspaces/services/workspaceJoinRequests' import { BasicTestWorkspace, createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import { Workspace, WorkspaceJoinRequest } from '@/modules/workspacesCore/domain/types' import { WorkspaceJoinRequests } from '@/modules/workspacesCore/helpers/db' import { expectToThrow } from '@/test/assertionHelper' import { BasicTestUser, createTestUser } from '@/test/authHelper' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { + createWorkspaceJoinRequestFactory, + updateWorkspaceJoinRequestStatusFactory +} from '@/modules/workspaces/repositories/workspaceJoinRequests' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -70,5 +85,89 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() ).to.deep.equal({ status: 'dismissed' }) }) }) + + describe('requestToJoinWorkspaceFactory, returns a function that ', () => { + it('throws a NotFoundError if the user does not exists', async () => { + const err = await expectToThrow(() => + requestToJoinWorkspaceFactory({ + createWorkspaceJoinRequest: (async () => + Promise.resolve()) as unknown as CreateWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail: async () => Promise.resolve(), + getUserById: async () => null, + getWorkspace: async () => null + })({ workspaceId: createRandomString(), userId: createRandomString() }) + ) + + expect(err.message).to.equal('User not found') + }) + it('throws a WorkspaceNotFoundError if the workspace does not exists', async () => { + const user = await createTestUser({}) + const err = await expectToThrow(() => + requestToJoinWorkspaceFactory({ + createWorkspaceJoinRequest: (async () => + Promise.resolve()) as unknown as CreateWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail: async () => Promise.resolve(), + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspace: async () => null + })({ 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) + + const user: BasicTestUser = { + id: '', + name: 'John Speckle', + email: createRandomEmail(), + role: Roles.Server.Admin, + verified: true + } + + await createTestUser(user) + + const workspace: BasicTestWorkspace = { + id: '', + slug: '', + ownerId: '', + name: cryptoRandomString({ length: 6 }), + description: cryptoRandomString({ length: 12 }) + } + await createTestWorkspace(workspace, user) + + expect( + await requestToJoinWorkspaceFactory({ + createWorkspaceJoinRequest, + sendWorkspaceJoinRequestReceivedEmail: + sendWorkspaceJoinRequestReceivedEmail as unknown as SendWorkspaceJoinRequestReceivedEmail, + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspace: async () => workspace as unknown as Workspace + })({ workspaceId: workspace.id, userId: user.id }) + ).to.equal(true) + + expect( + (await db(WorkspaceJoinRequests.name) + .where({ + workspaceId: workspace.id, + userId: user.id + }) + .select('status') + .first())!.status + ).to.equal('pending') + + expect(sendWorkspaceJoinRequestReceivedEmailCalls).to.have.length(1) + expect(sendWorkspaceJoinRequestReceivedEmailCalls[0].workspace).to.equal( + workspace + ) + expect(sendWorkspaceJoinRequestReceivedEmailCalls[0].requester).to.equal(user) + }) + }) } ) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 1e328b4326..c31fe7bdfe 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4386,11 +4386,13 @@ export type WorkspaceMutations = { delete: Scalars['Boolean']['output']; deleteDomain: Workspace; deleteSsoProvider: Scalars['Boolean']['output']; + /** Dismiss a workspace from the discoverable list, behind the scene a join request is created with the status "dismissed" */ dismiss: Scalars['Boolean']['output']; invites: WorkspaceInviteMutations; join: Workspace; leave: Scalars['Boolean']['output']; projects: WorkspaceProjectMutations; + requestToJoin: Scalars['Boolean']['output']; /** Set the default region where project data will be stored. Only available to admins. */ setDefaultRegion: Workspace; update: Workspace; @@ -4439,6 +4441,11 @@ export type WorkspaceMutationsLeaveArgs = { }; +export type WorkspaceMutationsRequestToJoinArgs = { + input: WorkspaceRequestToJoinInput; +}; + + export type WorkspaceMutationsSetDefaultRegionArgs = { regionKey: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -4548,6 +4555,10 @@ export enum WorkspaceProjectsUpdatedMessageType { Removed = 'REMOVED' } +export type WorkspaceRequestToJoinInput = { + workspaceId: Scalars['ID']['input']; +}; + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST',