From 8cd060f869b54c0fbf1a1ea87cbf991d6698221b Mon Sep 17 00:00:00 2001 From: Alessandro Magionami Date: Thu, 16 Jan 2025 14:30:45 +0100 Subject: [PATCH] chore(workspaces): use commandFactory in deny workspace join request --- .../typedefs/workspaceJoinRequests.graphql | 9 ++ .../modules/core/graph/generated/graphql.ts | 14 ++ .../graph/generated/graphql.ts | 11 ++ .../modules/workspaces/domain/operations.ts | 9 ++ .../graph/resolvers/workspaceJoinRequests.ts | 44 ++++++- .../workspaceJoinRequestEmails/denied.ts | 88 +++++++++++++ .../services/workspaceJoinRequests.ts | 50 ++++++++ .../integration/workspaceJoinRequests.spec.ts | 120 ++++++++++++++++++ .../server/test/graphql/generated/graphql.ts | 11 ++ 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 packages/server/modules/workspaces/services/workspaceJoinRequestEmails/denied.ts diff --git a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql index 024a44c76..370ab50a1 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaceJoinRequests.graphql @@ -8,9 +8,18 @@ input ApproveWorkspaceJoinRequestInput { userId: String! } +input DenyWorkspaceJoinRequestInput { + workspaceId: String! + userId: String! +} + type WorkspaceJoinRequestMutations { approve(input: ApproveWorkspaceJoinRequestInput!): Boolean! @hasServerRole(role: SERVER_USER) @hasScope(scope: "workspace:update") @hasWorkspaceRole(role: ADMIN) + deny(input: DenyWorkspaceJoinRequestInput!): Boolean! + @hasServerRole(role: SERVER_USER) + @hasScope(scope: "workspace:update") + @hasWorkspaceRole(role: ADMIN) } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index e38052d2a..e53af7888 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -945,6 +945,11 @@ export type DeleteVersionsInput = { versionIds: Array; }; +export type DenyWorkspaceJoinRequestInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export enum DiscoverableStreamsSortType { CreatedDate = 'CREATED_DATE', FavoritesCount = 'FAVORITES_COUNT' @@ -4433,6 +4438,7 @@ export type WorkspaceJoinRequestCollection = { export type WorkspaceJoinRequestMutations = { __typename?: 'WorkspaceJoinRequestMutations'; approve: Scalars['Boolean']['output']; + deny: Scalars['Boolean']['output']; }; @@ -4440,6 +4446,11 @@ export type WorkspaceJoinRequestMutationsApproveArgs = { input: ApproveWorkspaceJoinRequestInput; }; + +export type WorkspaceJoinRequestMutationsDenyArgs = { + input: DenyWorkspaceJoinRequestInput; +}; + export enum WorkspaceJoinRequestStatus { Approved = 'approved', Denied = 'denied', @@ -4876,6 +4887,7 @@ export type ResolversTypes = { DeleteModelInput: DeleteModelInput; DeleteUserEmailInput: DeleteUserEmailInput; DeleteVersionsInput: DeleteVersionsInput; + DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput; DiscoverableStreamsSortType: DiscoverableStreamsSortType; DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EditCommentInput: EditCommentInput; @@ -5176,6 +5188,7 @@ export type ResolversParentTypes = { DeleteModelInput: DeleteModelInput; DeleteUserEmailInput: DeleteUserEmailInput; DeleteVersionsInput: DeleteVersionsInput; + DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput; DiscoverableStreamsSortingInput: DiscoverableStreamsSortingInput; EditCommentInput: EditCommentInput; EmailVerificationRequestInput: EmailVerificationRequestInput; @@ -6880,6 +6893,7 @@ export type WorkspaceJoinRequestCollectionResolvers = { approve?: Resolver>; + deny?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; 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 b69b023c0..54445c530 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -926,6 +926,11 @@ export type DeleteVersionsInput = { versionIds: Array; }; +export type DenyWorkspaceJoinRequestInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export enum DiscoverableStreamsSortType { CreatedDate = 'CREATED_DATE', FavoritesCount = 'FAVORITES_COUNT' @@ -4414,6 +4419,7 @@ export type WorkspaceJoinRequestCollection = { export type WorkspaceJoinRequestMutations = { __typename?: 'WorkspaceJoinRequestMutations'; approve: Scalars['Boolean']['output']; + deny: Scalars['Boolean']['output']; }; @@ -4421,6 +4427,11 @@ export type WorkspaceJoinRequestMutationsApproveArgs = { input: ApproveWorkspaceJoinRequestInput; }; + +export type WorkspaceJoinRequestMutationsDenyArgs = { + input: DenyWorkspaceJoinRequestInput; +}; + export enum WorkspaceJoinRequestStatus { Approved = 'approved', Denied = 'denied', diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 57105dc3e..6d1ca3819 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -331,6 +331,11 @@ export type SendWorkspaceJoinRequestApprovedEmail = (params: { requester: { id: string; name: string; email: string } }) => Promise +export type SendWorkspaceJoinRequestDeniedEmail = (params: { + workspace: Pick + requester: { id: string; name: string; email: string } +}) => Promise + export type GetWorkspaceJoinRequest = ( params: Pick & Partial> @@ -339,3 +344,7 @@ export type GetWorkspaceJoinRequest = ( export type ApproveWorkspaceJoinRequest = ( params: Pick ) => Promise + +export type DenyWorkspaceJoinRequest = ( + params: Pick +) => Promise diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts index c60d6715e..c9a7464b1 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaceJoinRequests.ts @@ -7,7 +7,10 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' import { commandFactory } from '@/modules/shared/command' import { getPaginatedItemsFactory } from '@/modules/shared/services/paginatedItems' -import { ApproveWorkspaceJoinRequest } from '@/modules/workspaces/domain/operations' +import { + ApproveWorkspaceJoinRequest, + DenyWorkspaceJoinRequest +} from '@/modules/workspaces/domain/operations' import { countAdminWorkspaceJoinRequestsFactory, getAdminWorkspaceJoinRequestsFactory, @@ -16,7 +19,11 @@ import { } from '@/modules/workspaces/repositories/workspaceJoinRequests' import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { sendWorkspaceJoinRequestApprovedEmailFactory } from '@/modules/workspaces/services/workspaceJoinRequestEmails/approved' -import { approveWorkspaceJoinRequestFactory } from '@/modules/workspaces/services/workspaceJoinRequests' +import { sendWorkspaceJoinRequestDeniedEmailFactory } from '@/modules/workspaces/services/workspaceJoinRequestEmails/denied' +import { + approveWorkspaceJoinRequestFactory, + denyWorkspaceJoinRequestFactory +} from '@/modules/workspaces/services/workspaceJoinRequests' import { WorkspaceJoinRequestStatus } from '@/modules/workspacesCore/domain/types' import { WorkspaceJoinRequestGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes' @@ -92,6 +99,39 @@ export default { userId: args.input.userId, workspaceId: args.input.workspaceId }) + }, + deny: async (_parent, args) => { + const denyWorkspaceJoinRequest = commandFactory({ + db, + operationFactory: ({ db }) => { + const updateWorkspaceJoinRequestStatus = + updateWorkspaceJoinRequestStatusFactory({ + db + }) + const sendWorkspaceJoinRequestDeniedEmail = + sendWorkspaceJoinRequestDeniedEmailFactory({ + renderEmail, + sendEmail, + getServerInfo: getServerInfoFactory({ db }), + getUserEmails: findEmailsByUserIdFactory({ db }) + }) + + return denyWorkspaceJoinRequestFactory({ + updateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail, + getUserById: getUserFactory({ db }), + getWorkspace: getWorkspaceFactory({ db }), + getWorkspaceJoinRequest: getWorkspaceJoinRequestFactory({ + db + }) + }) + } + }) + + return await denyWorkspaceJoinRequest({ + userId: args.input.userId, + workspaceId: args.input.workspaceId + }) } } } as Resolvers diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/denied.ts b/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/denied.ts new file mode 100644 index 000000000..f4f9841f1 --- /dev/null +++ b/packages/server/modules/workspaces/services/workspaceJoinRequestEmails/denied.ts @@ -0,0 +1,88 @@ +import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { FindEmailsByUserId } from '@/modules/core/domain/userEmails/operations' +import { RenderEmail, SendEmail } from '@/modules/emails/domain/operations' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' +import { SendWorkspaceJoinRequestApprovedEmail } from '@/modules/workspaces/domain/operations' + +type WorkspaceJoinRequestDeniedEmailArgs = { + workspace: { id: string; name: string; slug: string } + requester: { id: string; name: string } +} + +const buildMjmlBody = ({ + workspace, + requester +}: WorkspaceJoinRequestDeniedEmailArgs) => { + const bodyStart = ` +Hi ${requester.name}! +
+
+Your request to join the workspace ${workspace.name} was denied by the workspace admin.. +
+
+ +
+ ` + 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 +}: WorkspaceJoinRequestDeniedEmailArgs) => { + const bodyStart = ` +Hi ${requester.name}! +\r\n\r\n +Your request to join the workspace ${workspace.name} was denied by the workspace admin. +\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: WorkspaceJoinRequestDeniedEmailArgs) => { + const url = new URL(getFrontendOrigin()).toString() + return { + mjml: buildMjmlBody(args), + text: buildTextBody(args), + cta: { + title: 'Open Speckle', + url + } + } +} + +export const sendWorkspaceJoinRequestDeniedEmailFactory = + ({ + renderEmail, + sendEmail, + getServerInfo, + getUserEmails + }: { + renderEmail: RenderEmail + sendEmail: SendEmail + getServerInfo: GetServerInfo + getUserEmails: FindEmailsByUserId + }): SendWorkspaceJoinRequestApprovedEmail => + async (args) => { + const { requester, workspace } = args + const serverInfo = await getServerInfo() + + const userEmails = await getUserEmails({ userId: requester.id }) + const emailTemplateParams = buildEmailTemplateParams({ + requester, + workspace + }) + const { html, text } = await renderEmail(emailTemplateParams, serverInfo, null) + const subject = 'Request to join workspace denied' + const sendEmailParams = { + html, + text, + subject, + to: userEmails.map((e) => e.email) + } + await sendEmail(sendEmailParams) + } diff --git a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts index d0b7d4ac8..004709269 100644 --- a/packages/server/modules/workspaces/services/workspaceJoinRequests.ts +++ b/packages/server/modules/workspaces/services/workspaceJoinRequests.ts @@ -3,9 +3,11 @@ import { GetUser } from '@/modules/core/domain/users/operations' import { NotFoundError } from '@/modules/shared/errors' import { CreateWorkspaceJoinRequest, + DenyWorkspaceJoinRequest, GetWorkspace, GetWorkspaceJoinRequest, SendWorkspaceJoinRequestApprovedEmail, + SendWorkspaceJoinRequestDeniedEmail, SendWorkspaceJoinRequestReceivedEmail, UpdateWorkspaceJoinRequestStatus } from '@/modules/workspaces/domain/operations' @@ -117,3 +119,51 @@ export const approveWorkspaceJoinRequestFactory = return true } + +export const denyWorkspaceJoinRequestFactory = + ({ + updateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail, + getUserById, + getWorkspace, + getWorkspaceJoinRequest + }: { + updateWorkspaceJoinRequestStatus: UpdateWorkspaceJoinRequestStatus + sendWorkspaceJoinRequestDeniedEmail: SendWorkspaceJoinRequestDeniedEmail + getUserById: GetUser + getWorkspace: GetWorkspace + getWorkspaceJoinRequest: GetWorkspaceJoinRequest + }): DenyWorkspaceJoinRequest => + async ({ userId, workspaceId }) => { + const requester = await getUserById(userId) + if (!requester) { + throw new NotFoundError('User not found') + } + + const workspace = await getWorkspace({ workspaceId }) + if (!workspace) { + throw new WorkspaceNotFoundError('Workspace not found') + } + + const request = await getWorkspaceJoinRequest({ + userId, + workspaceId, + status: 'pending' + }) + if (!request) { + throw new NotFoundError('Workspace join request not found') + } + + await updateWorkspaceJoinRequestStatus({ + userId, + workspaceId, + status: 'denied' + }) + + await sendWorkspaceJoinRequestDeniedEmail({ + 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 d22782e66..f9b5e9a41 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaceJoinRequests.spec.ts @@ -10,10 +10,12 @@ import { UserWithOptionalRole } from '@/modules/core/repositories/users' import { CreateWorkspaceJoinRequest, SendWorkspaceJoinRequestApprovedEmail, + SendWorkspaceJoinRequestDeniedEmail, SendWorkspaceJoinRequestReceivedEmail, UpdateWorkspaceJoinRequestStatus } from '@/modules/workspaces/domain/operations' import { + denyWorkspaceJoinRequestFactory, approveWorkspaceJoinRequestFactory, dismissWorkspaceJoinRequestFactory, requestToJoinWorkspaceFactory @@ -291,5 +293,123 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() expect(sendWorkspaceJoinRequestApprovedEmailCalls[0].requester).to.equal(user) }) }) + describe('denyWorkspaceJoinRequestFactory, returns a function that ', () => { + it('throws a NotFoundError if the user does not exists', async () => { + const err = await expectToThrow(() => + denyWorkspaceJoinRequestFactory({ + updateWorkspaceJoinRequestStatus: (async () => + Promise.resolve()) as unknown as UpdateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail: async () => Promise.resolve(), + getUserById: async () => null, + getWorkspace: async () => null, + getWorkspaceJoinRequest: async () => undefined + })({ 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(() => + denyWorkspaceJoinRequestFactory({ + updateWorkspaceJoinRequestStatus: (async () => + Promise.resolve()) as unknown as UpdateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail: async () => Promise.resolve(), + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspace: async () => null, + getWorkspaceJoinRequest: async () => undefined + })({ workspaceId: createRandomString(), userId: createRandomString() }) + ) + + expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage) + }) + it('throws a NotFoundError if the request does not exists in the pending status', async () => { + const user = await createTestUser({}) + const workspace: BasicTestWorkspace = { + id: '', + slug: '', + ownerId: '', + name: cryptoRandomString({ length: 6 }), + description: cryptoRandomString({ length: 12 }) + } + await createTestWorkspace(workspace, user) + const err = await expectToThrow(() => + denyWorkspaceJoinRequestFactory({ + updateWorkspaceJoinRequestStatus: (async () => + Promise.resolve()) as unknown as UpdateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail: async () => Promise.resolve(), + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspace: async () => workspace as unknown as Workspace, + getWorkspaceJoinRequest: async () => undefined + })({ workspaceId: createRandomString(), userId: createRandomString() }) + ) + + expect(err.message).to.equal('Workspace join request not found') + }) + it('marks the request as denied and send an email to the requester', async () => { + const sendWorkspaceJoinRequestDeniedEmailCalls: Parameters[number][] = + [] + const sendWorkspaceJoinRequestDeniedEmail = async ( + args: Parameters[number] + ) => sendWorkspaceJoinRequestDeniedEmailCalls.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) + + const request = await createWorkspaceJoinRequestFactory({ db })({ + workspaceJoinRequest: { + workspaceId: workspace.id, + userId: user.id, + status: 'pending' + } + }) + + const updateWorkspaceJoinRequestStatus = + updateWorkspaceJoinRequestStatusFactory({ db }) + + expect( + await denyWorkspaceJoinRequestFactory({ + updateWorkspaceJoinRequestStatus, + sendWorkspaceJoinRequestDeniedEmail: + sendWorkspaceJoinRequestDeniedEmail as unknown as SendWorkspaceJoinRequestApprovedEmail, + getUserById: async () => user as unknown as UserWithOptionalRole, + getWorkspace: async () => workspace as unknown as Workspace, + getWorkspaceJoinRequest: async () => request + })({ 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('denied') + + expect(sendWorkspaceJoinRequestDeniedEmailCalls).to.have.length(1) + expect(sendWorkspaceJoinRequestDeniedEmailCalls[0].workspace).to.equal( + workspace + ) + expect(sendWorkspaceJoinRequestDeniedEmailCalls[0].requester).to.equal(user) + }) + }) } ) diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 7a0b418b7..2a4231cb4 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -927,6 +927,11 @@ export type DeleteVersionsInput = { versionIds: Array; }; +export type DenyWorkspaceJoinRequestInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + export enum DiscoverableStreamsSortType { CreatedDate = 'CREATED_DATE', FavoritesCount = 'FAVORITES_COUNT' @@ -4415,6 +4420,7 @@ export type WorkspaceJoinRequestCollection = { export type WorkspaceJoinRequestMutations = { __typename?: 'WorkspaceJoinRequestMutations'; approve: Scalars['Boolean']['output']; + deny: Scalars['Boolean']['output']; }; @@ -4422,6 +4428,11 @@ export type WorkspaceJoinRequestMutationsApproveArgs = { input: ApproveWorkspaceJoinRequestInput; }; + +export type WorkspaceJoinRequestMutationsDenyArgs = { + input: DenyWorkspaceJoinRequestInput; +}; + export enum WorkspaceJoinRequestStatus { Approved = 'approved', Denied = 'denied',