feat(server): send invitation review notification (#10418)

This commit is contained in:
fengmk2
2025-03-24 03:32:24 +00:00
parent dfd633b8b0
commit 28f8639aff
16 changed files with 629 additions and 137 deletions
@@ -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);
});
@@ -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({
@@ -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,
},
});
}
}
@@ -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);
}
@@ -100,31 +100,38 @@ export class MentionNotificationBodyType extends BaseNotificationBodyType {
}
@ObjectType()
export class InvitationNotificationBodyType
extends BaseNotificationBodyType
implements Partial<InvitationNotificationBody>
{
export abstract class InvitationBaseNotificationBodyType extends BaseNotificationBodyType {
@Field(() => ID)
inviteId!: string;
}
@ObjectType()
export class InvitationNotificationBodyType
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@ObjectType()
export class InvitationAcceptedNotificationBodyType
extends BaseNotificationBodyType
implements Partial<InvitationNotificationBody>
{
@Field(() => String)
inviteId!: string;
}
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@ObjectType()
export class InvitationBlockedNotificationBodyType
extends BaseNotificationBodyType
implements Partial<InvitationNotificationBody>
{
@Field(() => String)
inviteId!: string;
}
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@ObjectType()
export class InvitationReviewRequestNotificationBodyType
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@ObjectType()
export class InvitationReviewApprovedNotificationBodyType
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@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,
});
@@ -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,
@@ -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,
});
}
@@ -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 {
@@ -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 =