From 28f8639aff45e23f511ad074594291762ae7cae6 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 24 Mar 2025 03:32:24 +0000 Subject: [PATCH] feat(server): send invitation review notification (#10418) --- .../migration.sql | 11 ++ packages/backend/server/schema.prisma | 3 + .../backend/server/src/__tests__/team.e2e.ts | 49 +++++-- .../core/notification/__tests__/job.spec.ts | 80 +++++++++++ .../notification/__tests__/service.spec.ts | 133 ++++++++++++++++++ .../server/src/core/notification/job.ts | 66 +++++++++ .../server/src/core/notification/service.ts | 121 ++++++++++++++++ .../server/src/core/notification/types.ts | 42 +++--- .../server/src/core/workspaces/event.ts | 32 ----- .../src/core/workspaces/resolvers/service.ts | 64 +++------ .../src/core/workspaces/resolvers/team.ts | 11 +- .../core/workspaces/resolvers/workspace.ts | 15 +- .../backend/server/src/models/notification.ts | 46 +++++- .../server/src/models/workspace-user.ts | 10 -- packages/backend/server/src/schema.gql | 44 +++++- packages/common/graphql/src/schema.ts | 39 ++++- 16 files changed, 629 insertions(+), 137 deletions(-) create mode 100644 packages/backend/server/migrations/20250307123308_add_more_notification_types/migration.sql diff --git a/packages/backend/server/migrations/20250307123308_add_more_notification_types/migration.sql b/packages/backend/server/migrations/20250307123308_add_more_notification_types/migration.sql new file mode 100644 index 0000000000..712f5e7df1 --- /dev/null +++ b/packages/backend/server/migrations/20250307123308_add_more_notification_types/migration.sql @@ -0,0 +1,11 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "NotificationType" ADD VALUE 'InvitationReviewRequest'; +ALTER TYPE "NotificationType" ADD VALUE 'InvitationReviewApproved'; +ALTER TYPE "NotificationType" ADD VALUE 'InvitationReviewDeclined'; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 9a9dd4cec6..7c68fcdc4d 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -714,6 +714,9 @@ enum NotificationType { InvitationAccepted InvitationBlocked InvitationRejected + InvitationReviewRequest + InvitationReviewApproved + InvitationReviewDeclined } enum NotificationLevel { diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index 80dcd909c8..42226820d1 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -494,6 +494,18 @@ test('should be able to approve team member', async t => { t.is(memberInvite.status, 'UnderReview', 'should be under review'); t.true(await approveMember(app, tws.id, member.id)); + const requestApprovedNotification = app.queue.last( + 'notification.sendInvitationReviewApproved' + ); + t.truthy(requestApprovedNotification); + t.deepEqual( + requestApprovedNotification.payload, + { + inviteId: memberInvite.inviteId, + reviewerId: owner.id, + }, + 'should send review approved notification' + ); } { @@ -627,7 +639,7 @@ test('should be able to invite batch and send notifications', async t => { t.truthy(job.payload.inviterId); }); -test('should be able to emit events', async t => { +test('should be able to emit events and send notifications', async t => { const { app, event } = t.context; { @@ -654,24 +666,35 @@ test('should be able to emit events', async t => { app.switchUser(owner); const { members } = await getWorkspace(app, tws.id); const memberInvite = members.find(m => m.id === user.id)!; + const requestRequestNotification = app.queue.last( + 'notification.sendInvitationReviewRequest' + ); + t.truthy(requestRequestNotification); + // find admin + const admins = await t.context.models.workspaceUser.getAdmins(tws.id); t.deepEqual( - event.emit.lastCall.args, - [ - 'workspace.members.reviewRequested', - { inviteId: memberInvite.inviteId }, - ], - 'should emit review requested event' + requestRequestNotification.payload, + { + inviteId: memberInvite.inviteId, + reviewerId: admins[0].id, + }, + 'should send review request notification' ); app.switchUser(owner); await revokeUser(app, tws.id, user.id); + const requestDeclinedNotification = app.queue.last( + 'notification.sendInvitationReviewDeclined' + ); + t.truthy(requestDeclinedNotification); t.deepEqual( - event.emit.lastCall.args, - [ - 'workspace.members.requestDeclined', - { userId: user.id, workspaceId: tws.id }, - ], - 'should emit review requested event' + requestDeclinedNotification.payload, + { + userId: user.id, + workspaceId: tws.id, + reviewerId: owner.id, + }, + 'should send review declined notification' ); } 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 08eab9f971..e1d015e3fa 100644 --- a/packages/backend/server/src/core/notification/__tests__/job.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/job.spec.ts @@ -114,3 +114,83 @@ test('should ignore send invitation accepted notification when inviteId not exis }); t.is(spy.callCount, 0); }); + +test('should create invitation review request notification', async t => { + const { notificationJob, notificationService, models } = t.context; + const invite = await models.workspaceUser.set( + workspace.id, + member.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Pending + ); + const spy = Sinon.spy(notificationService, 'createInvitationReviewRequest'); + await notificationJob.sendInvitationReviewRequest({ + reviewerId: 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); + t.is(spy.firstCall.args[0].body.inviteId, invite.id); +}); + +test('should ignore send invitation review request notification when inviteId not exists', async t => { + const { notificationJob, notificationService } = t.context; + const spy = Sinon.spy(notificationService, 'createInvitationReviewRequest'); + await notificationJob.sendInvitationReviewRequest({ + reviewerId: owner.id, + inviteId: `not-exists-${randomUUID()}`, + }); + t.is(spy.callCount, 0); +}); + +test('should create invitation review approved notification', async t => { + const { notificationJob, notificationService, models } = t.context; + const invite = await models.workspaceUser.set( + workspace.id, + member.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Pending + ); + const spy = Sinon.spy(notificationService, 'createInvitationReviewApproved'); + await notificationJob.sendInvitationReviewApproved({ + reviewerId: owner.id, + inviteId: invite.id, + }); + t.is(spy.callCount, 1); + t.is(spy.firstCall.args[0].userId, member.id); + t.is(spy.firstCall.args[0].body.workspaceId, workspace.id); + t.is(spy.firstCall.args[0].body.createdByUserId, owner.id); + t.is(spy.firstCall.args[0].body.inviteId, invite.id); +}); + +test('should ignore send invitation review approved notification when inviteId not exists', async t => { + const { notificationJob, notificationService } = t.context; + const spy = Sinon.spy(notificationService, 'createInvitationReviewApproved'); + await notificationJob.sendInvitationReviewApproved({ + reviewerId: owner.id, + inviteId: `not-exists-${randomUUID()}`, + }); + t.is(spy.callCount, 0); +}); + +test('should create invitation review declined notification', async t => { + const { notificationJob, notificationService, models } = t.context; + await models.workspaceUser.set( + workspace.id, + member.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Pending + ); + const spy = Sinon.spy(notificationService, 'createInvitationReviewDeclined'); + await notificationJob.sendInvitationReviewDeclined({ + reviewerId: owner.id, + userId: member.id, + workspaceId: workspace.id, + }); + t.is(spy.callCount, 1); + t.is(spy.firstCall.args[0].userId, member.id); + t.is(spy.firstCall.args[0].body.workspaceId, workspace.id); + t.is(spy.firstCall.args[0].body.createdByUserId, owner.id); +}); 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 c91893e60a..54ba242d6a 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -224,6 +224,139 @@ test('should create invitation rejected notification', async t => { t.is(notification!.body.inviteId, inviteId); }); +test('should create invitation review request 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.createInvitationReviewRequest({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationReviewRequest); + t.is(notification!.userId, owner.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, member.id); + t.is(notification!.body.inviteId, inviteId); + + // should send email + const invitationReviewRequestMail = t.context.module.mails.last( + 'LinkInvitationReviewRequest' + ); + t.is(invitationReviewRequestMail.to, owner.email); +}); + +test('should not create invitation review request notification if user is an active member', async t => { + const { notificationService, models } = t.context; + const inviteId = randomUUID(); + mock.method(models.workspaceUser, 'getActive', async () => ({ + id: inviteId, + })); + const notification = await notificationService.createInvitationReviewRequest({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.is(notification, undefined); +}); + +test('should create invitation review approved notification if user is an active member', async t => { + const { notificationService, models } = t.context; + const inviteId = randomUUID(); + mock.method(models.workspaceUser, 'getActive', async () => ({ + id: inviteId, + })); + const notification = await notificationService.createInvitationReviewApproved( + { + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId, + }, + } + ); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationReviewApproved); + t.is(notification!.userId, member.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, owner.id); + t.is(notification!.body.inviteId, inviteId); + + // should send email + const invitationReviewApprovedMail = t.context.module.mails.last( + 'LinkInvitationApprove' + ); + t.is(invitationReviewApprovedMail.to, member.email); +}); + +test('should not create invitation review approved 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.createInvitationReviewApproved( + { + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + } + ); + t.is(notification, undefined); +}); + +test('should create invitation review declined notification if user is not an active member', async t => { + const { notificationService, models } = t.context; + mock.method(models.workspaceUser, 'getActive', async () => null); + const notification = await notificationService.createInvitationReviewDeclined( + { + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + }, + } + ); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationReviewDeclined); + t.is(notification!.userId, member.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, owner.id); + + // should send email + const invitationReviewDeclinedMail = t.context.module.mails.last( + 'LinkInvitationDecline' + ); + t.is(invitationReviewDeclinedMail.to, member.email); +}); + +test('should not create invitation review declined notification if user is an active member', async t => { + const { notificationService, models } = t.context; + const inviteId = randomUUID(); + mock.method(models.workspaceUser, 'getActive', async () => ({ + id: inviteId, + })); + const notification = await notificationService.createInvitationReviewDeclined( + { + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + }, + } + ); + t.is(notification, undefined); +}); + test('should clean expired notifications', async t => { const { notificationService } = t.context; await notificationService.createInvitation({ diff --git a/packages/backend/server/src/core/notification/job.ts b/packages/backend/server/src/core/notification/job.ts index 72a22ed437..b4dcc11dae 100644 --- a/packages/backend/server/src/core/notification/job.ts +++ b/packages/backend/server/src/core/notification/job.ts @@ -16,6 +16,19 @@ declare global { inviterId: string; inviteId: string; }; + 'notification.sendInvitationReviewRequest': { + reviewerId: string; + inviteId: string; + }; + 'notification.sendInvitationReviewApproved': { + reviewerId: string; + inviteId: string; + }; + 'notification.sendInvitationReviewDeclined': { + reviewerId: string; + userId: string; + workspaceId: string; + }; } } @@ -80,4 +93,57 @@ export class NotificationJob { }, }); } + + @OnJob('notification.sendInvitationReviewRequest') + async sendInvitationReviewRequest({ + reviewerId, + inviteId, + }: Jobs['notification.sendInvitationReviewRequest']) { + const invite = await this.models.workspaceUser.getById(inviteId); + if (!invite) { + return; + } + await this.service.createInvitationReviewRequest({ + userId: reviewerId, + body: { + workspaceId: invite.workspaceId, + createdByUserId: invite.userId, + inviteId, + }, + }); + } + + @OnJob('notification.sendInvitationReviewApproved') + async sendInvitationReviewApproved({ + reviewerId, + inviteId, + }: Jobs['notification.sendInvitationReviewApproved']) { + const invite = await this.models.workspaceUser.getById(inviteId); + if (!invite) { + return; + } + await this.service.createInvitationReviewApproved({ + userId: invite.userId, + body: { + workspaceId: invite.workspaceId, + createdByUserId: reviewerId, + inviteId, + }, + }); + } + + @OnJob('notification.sendInvitationReviewDeclined') + async sendInvitationReviewDeclined({ + reviewerId, + userId, + workspaceId, + }: Jobs['notification.sendInvitationReviewDeclined']) { + await this.service.createInvitationReviewDeclined({ + userId, + body: { + workspaceId, + createdByUserId: reviewerId, + }, + }); + } } diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts index f1b95aa858..5715086142 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -10,6 +10,7 @@ import { import { DEFAULT_WORKSPACE_NAME, InvitationNotificationCreate, + InvitationReviewDeclinedNotificationCreate, MentionNotification, MentionNotificationCreate, Models, @@ -191,6 +192,126 @@ export class NotificationService { ); } + async createInvitationReviewRequest(input: InvitationNotificationCreate) { + 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.InvitationReviewRequest + ); + await this.sendInvitationReviewRequestEmail(input); + return notification; + } + + private async sendInvitationReviewRequestEmail( + input: InvitationNotificationCreate + ) { + const inviteeUserId = input.body.createdByUserId; + const reviewerUserId = input.userId; + const workspaceId = input.body.workspaceId; + const reviewer = await this.models.user.getWorkspaceUser(reviewerUserId); + if (!reviewer) { + return; + } + await this.mailer.send({ + name: 'LinkInvitationReviewRequest', + to: reviewer.email, + props: { + user: { + $$userId: inviteeUserId, + }, + workspace: { + $$workspaceId: workspaceId, + }, + url: this.url.link(`/workspace/${workspaceId}`), + }, + }); + this.logger.log( + `Invitation review request email sent to user ${reviewer.id} for workspace ${workspaceId}` + ); + } + + async createInvitationReviewApproved(input: InvitationNotificationCreate) { + 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.InvitationReviewApproved + ); + await this.sendInvitationReviewApprovedEmail(input); + return notification; + } + + private async sendInvitationReviewApprovedEmail( + input: InvitationNotificationCreate + ) { + const workspaceId = input.body.workspaceId; + const receiverUserId = input.userId; + const receiver = await this.models.user.getWorkspaceUser(receiverUserId); + if (!receiver) { + return; + } + await this.mailer.send({ + name: 'LinkInvitationApprove', + to: receiver.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + url: this.url.link(`/workspace/${workspaceId}`), + }, + }); + this.logger.log( + `Invitation review approved email sent to user ${receiver.id} for workspace ${workspaceId}` + ); + } + + async createInvitationReviewDeclined( + input: InvitationReviewDeclinedNotificationCreate + ) { + 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.createInvitationReviewDeclined(input); + await this.sendInvitationReviewDeclinedEmail(input); + return notification; + } + + private async sendInvitationReviewDeclinedEmail( + input: InvitationReviewDeclinedNotificationCreate + ) { + const workspaceId = input.body.workspaceId; + const receiverUserId = input.userId; + const receiver = await this.models.user.getWorkspaceUser(receiverUserId); + if (!receiver) { + return; + } + await this.mailer.send({ + name: 'LinkInvitationDecline', + to: receiver.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + }, + }); + this.logger.log( + `Invitation review declined email sent to user ${receiver.id} for workspace ${workspaceId}` + ); + } + private async ensureWorkspaceContentExists(workspaceId: string) { await this.docReader.getWorkspaceContent(workspaceId); } diff --git a/packages/backend/server/src/core/notification/types.ts b/packages/backend/server/src/core/notification/types.ts index 12bf7ed2a6..a9e51e66bf 100644 --- a/packages/backend/server/src/core/notification/types.ts +++ b/packages/backend/server/src/core/notification/types.ts @@ -100,31 +100,38 @@ export class MentionNotificationBodyType extends BaseNotificationBodyType { } @ObjectType() -export class InvitationNotificationBodyType - extends BaseNotificationBodyType - implements Partial -{ +export abstract class InvitationBaseNotificationBodyType extends BaseNotificationBodyType { @Field(() => ID) inviteId!: string; } +@ObjectType() +export class InvitationNotificationBodyType + extends InvitationBaseNotificationBodyType + implements Partial {} + @ObjectType() export class InvitationAcceptedNotificationBodyType - extends BaseNotificationBodyType - implements Partial -{ - @Field(() => String) - inviteId!: string; -} + extends InvitationBaseNotificationBodyType + implements Partial {} @ObjectType() export class InvitationBlockedNotificationBodyType - extends BaseNotificationBodyType - implements Partial -{ - @Field(() => String) - inviteId!: string; -} + extends InvitationBaseNotificationBodyType + implements Partial {} + +@ObjectType() +export class InvitationReviewRequestNotificationBodyType + extends InvitationBaseNotificationBodyType + implements Partial {} + +@ObjectType() +export class InvitationReviewApprovedNotificationBodyType + extends InvitationBaseNotificationBodyType + implements Partial {} + +@ObjectType() +export class InvitationReviewDeclinedNotificationBodyType extends BaseNotificationBodyType {} export const UnionNotificationBodyType = createUnionType({ name: 'UnionNotificationBodyType', @@ -134,6 +141,9 @@ export const UnionNotificationBodyType = createUnionType({ InvitationNotificationBodyType, InvitationAcceptedNotificationBodyType, InvitationBlockedNotificationBodyType, + InvitationReviewRequestNotificationBodyType, + InvitationReviewApprovedNotificationBodyType, + InvitationReviewDeclinedNotificationBodyType, ] as const, }); diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index ff9930b1a6..0514074736 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -11,38 +11,6 @@ export class WorkspaceEvents { private readonly models: Models ) {} - @OnEvent('workspace.members.reviewRequested') - async onReviewRequested({ - inviteId, - }: Events['workspace.members.reviewRequested']) { - // send review request mail to owner and admin - await this.workspaceService.sendReviewRequestedEmail(inviteId); - } - - @OnEvent('workspace.members.requestApproved') - async onApproveRequest({ - inviteId, - }: Events['workspace.members.requestApproved']) { - // send approve mail - await this.workspaceService.sendReviewApproveEmail(inviteId); - } - - @OnEvent('workspace.members.requestDeclined') - async onDeclineRequest({ - userId, - workspaceId, - }: Events['workspace.members.requestDeclined']) { - const user = await this.models.user.getWorkspaceUser(userId); - if (!user) { - return; - } - // send decline mail - await this.workspaceService.sendReviewDeclinedEmail( - user.email, - workspaceId - ); - } - @OnEvent('workspace.members.roleChanged') async onRoleChanged({ userId, diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index cdf9b22181..e89b60fec9 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -148,7 +148,7 @@ export class WorkspaceService { ); } - async sendReviewRequestedEmail(inviteId: string) { + async sendReviewRequestNotification(inviteId: string) { const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); if (!inviteeUserId) { this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); @@ -159,59 +159,31 @@ export class WorkspaceService { const admins = await this.models.workspaceUser.getAdmins(workspaceId); await Promise.allSettled( - [owner, ...admins].map(async receiver => { - await this.mailer.send({ - name: 'LinkInvitationReviewRequest', - to: receiver.email, - props: { - user: { - $$userId: inviteeUserId, - }, - workspace: { - $$workspaceId: workspaceId, - }, - url: this.url.link(`/workspace/${workspaceId}`), - }, + [owner, ...admins].map(async reviewer => { + await this.queue.add('notification.sendInvitationReviewRequest', { + reviewerId: reviewer.id, + inviteId, }); }) ); } - async sendReviewApproveEmail(inviteId: string) { - const invitation = await this.models.workspaceUser.getById(inviteId); - if (!invitation) { - this.logger.warn(`Invitation not found for inviteId: ${inviteId}`); - return; - } - - const user = await this.models.user.getWorkspaceUser(invitation.userId); - - if (!user) { - this.logger.warn(`Invitee user not found for inviteId: ${inviteId}`); - return; - } - - await this.mailer.send({ - name: 'LinkInvitationApprove', - to: user.email, - props: { - workspace: { - $$workspaceId: invitation.workspaceId, - }, - url: this.url.link(`/workspace/${invitation.workspaceId}`), - }, + async sendReviewApprovedNotification(inviteId: string, reviewerId: string) { + await this.queue.add('notification.sendInvitationReviewApproved', { + reviewerId, + inviteId, }); } - async sendReviewDeclinedEmail(email: string, workspaceId: string) { - await this.mailer.send({ - name: 'LinkInvitationDecline', - to: email, - props: { - workspace: { - $$workspaceId: workspaceId, - }, - }, + async sendReviewDeclinedNotification( + userId: string, + workspaceId: string, + reviewerId: string + ) { + await this.queue.add('notification.sendInvitationReviewDeclined', { + reviewerId, + userId, + workspaceId, }); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 8d8bc67032..798a8417d7 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -223,12 +223,12 @@ export class TeamWorkspaceResolver { @Mutation(() => Boolean) async approveMember( - @CurrentUser() user: CurrentUser, + @CurrentUser() me: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { await this.ac - .user(user.id) + .user(me.id) .workspace(workspaceId) .assert('Workspace.Users.Manage'); @@ -242,9 +242,10 @@ export class TeamWorkspaceResolver { WorkspaceMemberStatus.Accepted ); - this.event.emit('workspace.members.requestApproved', { - inviteId: result.id, - }); + await this.workspaceService.sendReviewApprovedNotification( + result.id, + me.id + ); } return true; } else { diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 34707e39c6..b9e8371e43 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -529,11 +529,11 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async revoke( - @CurrentUser() user: CurrentUser, + @CurrentUser() me: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - if (userId === user.id) { + if (userId === me.id) { throw new CanNotRevokeYourself(); } @@ -544,7 +544,7 @@ export class WorkspaceResolver { } await this.ac - .user(user.id) + .user(me.id) .workspace(workspaceId) .assert( role.type === WorkspaceRole.Admin @@ -562,10 +562,11 @@ export class WorkspaceResolver { }); if (role.status === WorkspaceMemberStatus.UnderReview) { - this.event.emit('workspace.members.requestDeclined', { + await this.workspaceService.sendReviewDeclinedNotification( userId, workspaceId, - }); + me.id + ); } else if (role.status === WorkspaceMemberStatus.Accepted) { this.event.emit('workspace.members.removed', { userId, @@ -617,9 +618,7 @@ export class WorkspaceResolver { WorkspaceRole.Collaborator, WorkspaceMemberStatus.UnderReview ); - this.event.emit('workspace.members.reviewRequested', { - inviteId: invite.id, - }); + await this.workspaceService.sendReviewRequestNotification(invite.id); return true; } else { const isTeam = diff --git a/packages/backend/server/src/models/notification.ts b/packages/backend/server/src/models/notification.ts index 64aaaca4ad..0a86662576 100644 --- a/packages/backend/server/src/models/notification.ts +++ b/packages/backend/server/src/models/notification.ts @@ -78,9 +78,28 @@ export type InvitationNotificationCreate = z.input< typeof InvitationNotificationCreateSchema >; +const InvitationReviewDeclinedNotificationBodySchema = z.object({ + workspaceId: IdSchema, + createdByUserId: IdSchema, +}); + +export type InvitationReviewDeclinedNotificationBody = z.infer< + typeof InvitationReviewDeclinedNotificationBodySchema +>; + +export const InvitationReviewDeclinedNotificationCreateSchema = + BaseNotificationCreateSchema.extend({ + body: InvitationReviewDeclinedNotificationBodySchema, + }); + +export type InvitationReviewDeclinedNotificationCreate = z.input< + typeof InvitationReviewDeclinedNotificationCreateSchema +>; + export type UnionNotificationBody = | MentionNotificationBody - | InvitationNotificationBody; + | InvitationNotificationBody + | InvitationReviewDeclinedNotificationBody; // #endregion @@ -92,7 +111,13 @@ export type MentionNotification = Notification & export type InvitationNotification = Notification & z.infer; -export type UnionNotification = MentionNotification | InvitationNotification; +export type InvitationReviewDeclinedNotification = Notification & + z.infer; + +export type UnionNotification = + | MentionNotification + | InvitationNotification + | InvitationReviewDeclinedNotification; // #endregion @@ -135,6 +160,23 @@ export class NotificationModel extends BaseModel { return row as InvitationNotification; } + async createInvitationReviewDeclined( + input: InvitationReviewDeclinedNotificationCreate + ) { + const data = InvitationReviewDeclinedNotificationCreateSchema.parse(input); + const type = NotificationType.InvitationReviewDeclined; + const row = await this.create({ + userId: data.userId, + level: data.level, + type, + body: data.body, + }); + this.logger.log( + `Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}` + ); + return row as InvitationReviewDeclinedNotification; + } + // #endregion // #region common diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 474f377e8e..5de6fbd89e 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -26,16 +26,6 @@ declare global { workspaceId: string; count: number; }; - 'workspace.members.reviewRequested': { - inviteId: string; - }; - 'workspace.members.requestApproved': { - inviteId: string; - }; - 'workspace.members.requestDeclined': { - userId: string; - workspaceId: string; - }; 'workspace.members.removed': { userId: string; workspaceId: string; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 8fb07e6406..b7d624b4fb 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -624,7 +624,7 @@ type InvitationAcceptedNotificationBodyType { The user who created the notification, maybe null when user is deleted or sent by system """ createdByUser: PublicUserType - inviteId: String! + inviteId: ID! """The type of the notification""" type: NotificationType! @@ -636,7 +636,7 @@ type InvitationBlockedNotificationBodyType { The user who created the notification, maybe null when user is deleted or sent by system """ createdByUser: PublicUserType - inviteId: String! + inviteId: ID! """The type of the notification""" type: NotificationType! @@ -655,6 +655,41 @@ type InvitationNotificationBodyType { workspace: NotificationWorkspaceType } +type InvitationReviewApprovedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + inviteId: ID! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + +type InvitationReviewDeclinedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + +type InvitationReviewRequestNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + inviteId: ID! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + type InvitationType { """Invitee information""" invitee: UserType! @@ -1047,6 +1082,9 @@ enum NotificationType { InvitationAccepted InvitationBlocked InvitationRejected + InvitationReviewApproved + InvitationReviewDeclined + InvitationReviewRequest Mention } @@ -1435,7 +1473,7 @@ type TranscriptionResultType { transcription: [TranscriptionItemType!] } -union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | MentionNotificationBodyType +union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | InvitationReviewApprovedNotificationBodyType | InvitationReviewDeclinedNotificationBodyType | InvitationReviewRequestNotificationBodyType | MentionNotificationBodyType type UnknownOauthProviderDataType { name: String! diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index f6345629eb..c651c28354 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -776,7 +776,7 @@ export interface InvitationAcceptedNotificationBodyType { __typename?: 'InvitationAcceptedNotificationBodyType'; /** The user who created the notification, maybe null when user is deleted or sent by system */ createdByUser: Maybe; - inviteId: Scalars['String']['output']; + inviteId: Scalars['ID']['output']; /** The type of the notification */ type: NotificationType; workspace: Maybe; @@ -786,7 +786,7 @@ export interface InvitationBlockedNotificationBodyType { __typename?: 'InvitationBlockedNotificationBodyType'; /** The user who created the notification, maybe null when user is deleted or sent by system */ createdByUser: Maybe; - inviteId: Scalars['String']['output']; + inviteId: Scalars['ID']['output']; /** The type of the notification */ type: NotificationType; workspace: Maybe; @@ -802,6 +802,35 @@ export interface InvitationNotificationBodyType { workspace: Maybe; } +export interface InvitationReviewApprovedNotificationBodyType { + __typename?: 'InvitationReviewApprovedNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + inviteId: Scalars['ID']['output']; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + +export interface InvitationReviewDeclinedNotificationBodyType { + __typename?: 'InvitationReviewDeclinedNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + +export interface InvitationReviewRequestNotificationBodyType { + __typename?: 'InvitationReviewRequestNotificationBodyType'; + /** The user who created the notification, maybe null when user is deleted or sent by system */ + createdByUser: Maybe; + inviteId: Scalars['ID']['output']; + /** The type of the notification */ + type: NotificationType; + workspace: Maybe; +} + export interface InvitationType { __typename?: 'InvitationType'; /** Invitee information */ @@ -1501,6 +1530,9 @@ export enum NotificationType { InvitationAccepted = 'InvitationAccepted', InvitationBlocked = 'InvitationBlocked', InvitationRejected = 'InvitationRejected', + InvitationReviewApproved = 'InvitationReviewApproved', + InvitationReviewDeclined = 'InvitationReviewDeclined', + InvitationReviewRequest = 'InvitationReviewRequest', Mention = 'Mention', } @@ -1945,6 +1977,9 @@ export type UnionNotificationBodyType = | InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType + | InvitationReviewApprovedNotificationBodyType + | InvitationReviewDeclinedNotificationBodyType + | InvitationReviewRequestNotificationBodyType | MentionNotificationBodyType; export interface UnknownOauthProviderDataType {