From ed888d65070a16e47aab62da8256f81cbd9c3897 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 24 Mar 2025 02:27:23 +0000 Subject: [PATCH] feat(server): send invitation accepted notification (#10421) --- .../core/notification/__tests__/job.spec.ts | 29 ++++++++++++ .../notification/__tests__/service.spec.ts | 46 ++++++++++++++++++- .../server/src/core/notification/job.ts | 23 ++++++++++ .../server/src/core/notification/service.ts | 42 ++++++++++++++++- .../src/core/workspaces/resolvers/service.ts | 28 ++++++----- .../core/workspaces/resolvers/workspace.ts | 12 ++--- packages/backend/server/src/schema.gql | 2 +- 7 files changed, 157 insertions(+), 25 deletions(-) diff --git a/packages/backend/server/src/core/notification/__tests__/job.spec.ts b/packages/backend/server/src/core/notification/__tests__/job.spec.ts index 12f1c41860..08eab9f971 100644 --- a/packages/backend/server/src/core/notification/__tests__/job.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/job.spec.ts @@ -85,3 +85,32 @@ test('should create invitation notification', async t => { t.is(spy.firstCall.args[0].body.workspaceId, workspace.id); t.is(spy.firstCall.args[0].body.createdByUserId, owner.id); }); + +test('should create invitation accepted notification when user accepts the invitation', async t => { + const { notificationJob, notificationService, models } = t.context; + const invite = await models.workspaceUser.set( + workspace.id, + member.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Accepted + ); + const spy = Sinon.spy(notificationService, 'createInvitationAccepted'); + await notificationJob.sendInvitationAccepted({ + inviterId: owner.id, + inviteId: invite.id, + }); + t.is(spy.callCount, 1); + t.is(spy.firstCall.args[0].userId, owner.id); + t.is(spy.firstCall.args[0].body.workspaceId, workspace.id); + t.is(spy.firstCall.args[0].body.createdByUserId, member.id); +}); + +test('should ignore send invitation accepted notification when inviteId not exists', async t => { + const { notificationJob, notificationService } = t.context; + const spy = Sinon.spy(notificationService, 'createInvitationAccepted'); + await notificationJob.sendInvitationAccepted({ + inviterId: owner.id, + inviteId: `not-exists-${randomUUID()}`, + }); + t.is(spy.callCount, 0); +}); diff --git a/packages/backend/server/src/core/notification/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts index 6070825647..c91893e60a 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -123,7 +123,7 @@ test('should not create invitation notification if user is already a member', as t.is(notification, undefined); }); -test('should create invitation accepted notification', async t => { +test('should create invitation accepted notification and email', async t => { const { notificationService } = t.context; const inviteId = randomUUID(); const notification = await notificationService.createInvitationAccepted({ @@ -140,6 +140,50 @@ test('should create invitation accepted notification', async t => { t.is(notification!.body.workspaceId, workspace.id); t.is(notification!.body.createdByUserId, member.id); t.is(notification!.body.inviteId, inviteId); + + // should send email + const invitationAcceptedMail = t.context.module.mails.last('MemberAccepted'); + t.is(invitationAcceptedMail.to, owner.email); +}); + +test('should not send invitation accepted email if user settings is not receive invitation email', async t => { + const { notificationService } = t.context; + const inviteId = randomUUID(); + // should not send email if user settings is not receive invitation email + await t.context.models.settings.set(owner.id, { + receiveInvitationEmail: false, + }); + const invitationAcceptedMailCount = + t.context.module.mails.count('MemberAccepted'); + const notification = await notificationService.createInvitationAccepted({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.truthy(notification); + // no new invitation accepted email should be sent + t.is( + t.context.module.mails.count('MemberAccepted'), + invitationAcceptedMailCount + ); +}); + +test('should not create invitation accepted notification if user is not an active member', async t => { + const { notificationService, models } = t.context; + const inviteId = randomUUID(); + mock.method(models.workspaceUser, 'getActive', async () => null); + const notification = await notificationService.createInvitationAccepted({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.is(notification, undefined); }); test('should create invitation blocked notification', async t => { diff --git a/packages/backend/server/src/core/notification/job.ts b/packages/backend/server/src/core/notification/job.ts index 89c9f0810a..72a22ed437 100644 --- a/packages/backend/server/src/core/notification/job.ts +++ b/packages/backend/server/src/core/notification/job.ts @@ -12,6 +12,10 @@ declare global { inviterId: string; inviteId: string; }; + 'notification.sendInvitationAccepted': { + inviterId: string; + inviteId: string; + }; } } @@ -57,4 +61,23 @@ export class NotificationJob { }, }); } + + @OnJob('notification.sendInvitationAccepted') + async sendInvitationAccepted({ + inviterId, + inviteId, + }: Jobs['notification.sendInvitationAccepted']) { + const invite = await this.models.workspaceUser.getById(inviteId); + if (!invite) { + return; + } + await this.service.createInvitationAccepted({ + userId: inviterId, + body: { + workspaceId: invite.workspaceId, + createdByUserId: invite.userId, + inviteId, + }, + }); + } } diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts index 6bda186f17..f1b95aa858 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -130,11 +130,49 @@ export class NotificationService { } async createInvitationAccepted(input: InvitationNotificationCreate) { - await this.ensureWorkspaceContentExists(input.body.workspaceId); - return await this.models.notification.createInvitation( + const workspaceId = input.body.workspaceId; + const userId = input.userId; + if (!(await this.isActiveWorkspaceUser(workspaceId, userId))) { + return; + } + await this.ensureWorkspaceContentExists(workspaceId); + const notification = await this.models.notification.createInvitation( input, NotificationType.InvitationAccepted ); + await this.sendInvitationAcceptedEmail(input); + return notification; + } + + private async sendInvitationAcceptedEmail( + input: InvitationNotificationCreate + ) { + const inviterUserId = input.userId; + const inviteeUserId = input.body.createdByUserId; + const workspaceId = input.body.workspaceId; + const userSetting = await this.models.settings.get(inviterUserId); + if (!userSetting.receiveInvitationEmail) { + return; + } + const inviter = await this.models.user.getWorkspaceUser(inviterUserId); + if (!inviter) { + return; + } + await this.mailer.send({ + name: 'MemberAccepted', + to: inviter.email, + props: { + user: { + $$userId: inviteeUserId, + }, + workspace: { + $$workspaceId: workspaceId, + }, + }, + }); + this.logger.log( + `Invitation accepted email sent to user ${inviter.id} for workspace ${workspaceId}` + ); } async createInvitationBlocked(input: InvitationNotificationCreate) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 83307cbb6b..cdf9b22181 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -1,7 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { getStreamAsBuffer } from 'get-stream'; -import { Cache, JobQueue, NotFound, OnEvent, URLHelper } from '../../../base'; +import { + Cache, + JobQueue, + NotFound, + OnEvent, + URLHelper, + UserNotFound, +} from '../../../base'; import { DEFAULT_WORKSPACE_AVATAR, DEFAULT_WORKSPACE_NAME, @@ -75,10 +82,9 @@ export class WorkspaceService { }; } - async sendAcceptedEmail(inviteId: string) { + async sendInvitationAcceptedNotification(inviteId: string) { const { workspaceId, inviterUserId, inviteeUserId } = await this.getInviteInfo(inviteId); - const inviter = inviterUserId ? await this.models.user.getWorkspaceUser(inviterUserId) : await this.models.workspaceUser.getOwner(workspaceId); @@ -87,20 +93,12 @@ export class WorkspaceService { this.logger.warn( `Inviter or invitee user not found for inviteId: ${inviteId}` ); - return false; + throw new UserNotFound(); } - return await this.mailer.send({ - name: 'MemberAccepted', - to: inviter.email, - props: { - user: { - $$userId: inviteeUserId, - }, - workspace: { - $$workspaceId: workspaceId, - }, - }, + await this.queue.add('notification.sendInvitationAccepted', { + inviterId: inviter.id, + inviteId, }); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index fb8a8919cd..34707e39c6 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -582,7 +582,11 @@ export class WorkspaceResolver { @CurrentUser() user: CurrentUser | undefined, @Args('workspaceId') workspaceId: string, @Args('inviteId') inviteId: string, - @Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean + @Args('sendAcceptMail', { + nullable: true, + deprecationReason: 'never used', + }) + _sendAcceptMail: boolean ) { const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); @@ -642,12 +646,8 @@ export class WorkspaceResolver { } } - if (sendAcceptMail) { - const success = await this.workspaceService.sendAcceptedEmail(inviteId); - if (!success) throw new UserNotFound(); - } - await this.models.workspaceUser.accept(inviteId); + await this.workspaceService.sendInvitationAcceptedNotification(inviteId); return true; } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 47ddd56371..8fb07e6406 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -850,7 +850,7 @@ type MissingOauthQueryParameterDataType { } type Mutation { - acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! + acceptInviteById(inviteId: String!, sendAcceptMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): Boolean! activateLicense(license: String!, workspaceId: String!): License! """add a category to context"""