feat(server): send invitation review notification (#10418)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user