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

View File

@@ -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';

View File

@@ -714,6 +714,9 @@ enum NotificationType {
InvitationAccepted InvitationAccepted
InvitationBlocked InvitationBlocked
InvitationRejected InvitationRejected
InvitationReviewRequest
InvitationReviewApproved
InvitationReviewDeclined
} }
enum NotificationLevel { enum NotificationLevel {

View File

@@ -494,6 +494,18 @@ test('should be able to approve team member', async t => {
t.is(memberInvite.status, 'UnderReview', 'should be under review'); t.is(memberInvite.status, 'UnderReview', 'should be under review');
t.true(await approveMember(app, tws.id, member.id)); 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); 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; const { app, event } = t.context;
{ {
@@ -654,24 +666,35 @@ test('should be able to emit events', async t => {
app.switchUser(owner); app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id); const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === user.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( t.deepEqual(
event.emit.lastCall.args, requestRequestNotification.payload,
[ {
'workspace.members.reviewRequested', inviteId: memberInvite.inviteId,
{ inviteId: memberInvite.inviteId }, reviewerId: admins[0].id,
], },
'should emit review requested event' 'should send review request notification'
); );
app.switchUser(owner); app.switchUser(owner);
await revokeUser(app, tws.id, user.id); await revokeUser(app, tws.id, user.id);
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual( t.deepEqual(
event.emit.lastCall.args, requestDeclinedNotification.payload,
[ {
'workspace.members.requestDeclined', userId: user.id,
{ userId: user.id, workspaceId: tws.id }, workspaceId: tws.id,
], reviewerId: owner.id,
'should emit review requested event' },
'should send review declined notification'
); );
} }

View File

@@ -114,3 +114,83 @@ test('should ignore send invitation accepted notification when inviteId not exis
}); });
t.is(spy.callCount, 0); 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);
});

View File

@@ -224,6 +224,139 @@ test('should create invitation rejected notification', async t => {
t.is(notification!.body.inviteId, inviteId); 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 => { test('should clean expired notifications', async t => {
const { notificationService } = t.context; const { notificationService } = t.context;
await notificationService.createInvitation({ await notificationService.createInvitation({

View File

@@ -16,6 +16,19 @@ declare global {
inviterId: string; inviterId: string;
inviteId: 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,
},
});
}
} }

View File

@@ -10,6 +10,7 @@ import {
import { import {
DEFAULT_WORKSPACE_NAME, DEFAULT_WORKSPACE_NAME,
InvitationNotificationCreate, InvitationNotificationCreate,
InvitationReviewDeclinedNotificationCreate,
MentionNotification, MentionNotification,
MentionNotificationCreate, MentionNotificationCreate,
Models, 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) { private async ensureWorkspaceContentExists(workspaceId: string) {
await this.docReader.getWorkspaceContent(workspaceId); await this.docReader.getWorkspaceContent(workspaceId);
} }

View File

@@ -100,31 +100,38 @@ export class MentionNotificationBodyType extends BaseNotificationBodyType {
} }
@ObjectType() @ObjectType()
export class InvitationNotificationBodyType export abstract class InvitationBaseNotificationBodyType extends BaseNotificationBodyType {
extends BaseNotificationBodyType
implements Partial<InvitationNotificationBody>
{
@Field(() => ID) @Field(() => ID)
inviteId!: string; inviteId!: string;
} }
@ObjectType()
export class InvitationNotificationBodyType
extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> {}
@ObjectType() @ObjectType()
export class InvitationAcceptedNotificationBodyType export class InvitationAcceptedNotificationBodyType
extends BaseNotificationBodyType extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> implements Partial<InvitationNotificationBody> {}
{
@Field(() => String)
inviteId!: string;
}
@ObjectType() @ObjectType()
export class InvitationBlockedNotificationBodyType export class InvitationBlockedNotificationBodyType
extends BaseNotificationBodyType extends InvitationBaseNotificationBodyType
implements Partial<InvitationNotificationBody> implements Partial<InvitationNotificationBody> {}
{
@Field(() => String) @ObjectType()
inviteId!: string; 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({ export const UnionNotificationBodyType = createUnionType({
name: 'UnionNotificationBodyType', name: 'UnionNotificationBodyType',
@@ -134,6 +141,9 @@ export const UnionNotificationBodyType = createUnionType({
InvitationNotificationBodyType, InvitationNotificationBodyType,
InvitationAcceptedNotificationBodyType, InvitationAcceptedNotificationBodyType,
InvitationBlockedNotificationBodyType, InvitationBlockedNotificationBodyType,
InvitationReviewRequestNotificationBodyType,
InvitationReviewApprovedNotificationBodyType,
InvitationReviewDeclinedNotificationBodyType,
] as const, ] as const,
}); });

View File

@@ -11,38 +11,6 @@ export class WorkspaceEvents {
private readonly models: Models 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') @OnEvent('workspace.members.roleChanged')
async onRoleChanged({ async onRoleChanged({
userId, userId,

View File

@@ -148,7 +148,7 @@ export class WorkspaceService {
); );
} }
async sendReviewRequestedEmail(inviteId: string) { async sendReviewRequestNotification(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) { if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); 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); const admins = await this.models.workspaceUser.getAdmins(workspaceId);
await Promise.allSettled( await Promise.allSettled(
[owner, ...admins].map(async receiver => { [owner, ...admins].map(async reviewer => {
await this.mailer.send({ await this.queue.add('notification.sendInvitationReviewRequest', {
name: 'LinkInvitationReviewRequest', reviewerId: reviewer.id,
to: receiver.email, inviteId,
props: {
user: {
$$userId: inviteeUserId,
},
workspace: {
$$workspaceId: workspaceId,
},
url: this.url.link(`/workspace/${workspaceId}`),
},
}); });
}) })
); );
} }
async sendReviewApproveEmail(inviteId: string) { async sendReviewApprovedNotification(inviteId: string, reviewerId: string) {
const invitation = await this.models.workspaceUser.getById(inviteId); await this.queue.add('notification.sendInvitationReviewApproved', {
if (!invitation) { reviewerId,
this.logger.warn(`Invitation not found for inviteId: ${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 sendReviewDeclinedEmail(email: string, workspaceId: string) { async sendReviewDeclinedNotification(
await this.mailer.send({ userId: string,
name: 'LinkInvitationDecline', workspaceId: string,
to: email, reviewerId: string
props: { ) {
workspace: { await this.queue.add('notification.sendInvitationReviewDeclined', {
$$workspaceId: workspaceId, reviewerId,
}, userId,
}, workspaceId,
}); });
} }

View File

@@ -223,12 +223,12 @@ export class TeamWorkspaceResolver {
@Mutation(() => Boolean) @Mutation(() => Boolean)
async approveMember( async approveMember(
@CurrentUser() user: CurrentUser, @CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string @Args('userId') userId: string
) { ) {
await this.ac await this.ac
.user(user.id) .user(me.id)
.workspace(workspaceId) .workspace(workspaceId)
.assert('Workspace.Users.Manage'); .assert('Workspace.Users.Manage');
@@ -242,9 +242,10 @@ export class TeamWorkspaceResolver {
WorkspaceMemberStatus.Accepted WorkspaceMemberStatus.Accepted
); );
this.event.emit('workspace.members.requestApproved', { await this.workspaceService.sendReviewApprovedNotification(
inviteId: result.id, result.id,
}); me.id
);
} }
return true; return true;
} else { } else {

View File

@@ -529,11 +529,11 @@ export class WorkspaceResolver {
@Mutation(() => Boolean) @Mutation(() => Boolean)
async revoke( async revoke(
@CurrentUser() user: CurrentUser, @CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string @Args('userId') userId: string
) { ) {
if (userId === user.id) { if (userId === me.id) {
throw new CanNotRevokeYourself(); throw new CanNotRevokeYourself();
} }
@@ -544,7 +544,7 @@ export class WorkspaceResolver {
} }
await this.ac await this.ac
.user(user.id) .user(me.id)
.workspace(workspaceId) .workspace(workspaceId)
.assert( .assert(
role.type === WorkspaceRole.Admin role.type === WorkspaceRole.Admin
@@ -562,10 +562,11 @@ export class WorkspaceResolver {
}); });
if (role.status === WorkspaceMemberStatus.UnderReview) { if (role.status === WorkspaceMemberStatus.UnderReview) {
this.event.emit('workspace.members.requestDeclined', { await this.workspaceService.sendReviewDeclinedNotification(
userId, userId,
workspaceId, workspaceId,
}); me.id
);
} else if (role.status === WorkspaceMemberStatus.Accepted) { } else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', { this.event.emit('workspace.members.removed', {
userId, userId,
@@ -617,9 +618,7 @@ export class WorkspaceResolver {
WorkspaceRole.Collaborator, WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview WorkspaceMemberStatus.UnderReview
); );
this.event.emit('workspace.members.reviewRequested', { await this.workspaceService.sendReviewRequestNotification(invite.id);
inviteId: invite.id,
});
return true; return true;
} else { } else {
const isTeam = const isTeam =

View File

@@ -78,9 +78,28 @@ export type InvitationNotificationCreate = z.input<
typeof InvitationNotificationCreateSchema 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 = export type UnionNotificationBody =
| MentionNotificationBody | MentionNotificationBody
| InvitationNotificationBody; | InvitationNotificationBody
| InvitationReviewDeclinedNotificationBody;
// #endregion // #endregion
@@ -92,7 +111,13 @@ export type MentionNotification = Notification &
export type InvitationNotification = Notification & export type InvitationNotification = Notification &
z.infer<typeof InvitationNotificationCreateSchema>; z.infer<typeof InvitationNotificationCreateSchema>;
export type UnionNotification = MentionNotification | InvitationNotification; export type InvitationReviewDeclinedNotification = Notification &
z.infer<typeof InvitationReviewDeclinedNotificationCreateSchema>;
export type UnionNotification =
| MentionNotification
| InvitationNotification
| InvitationReviewDeclinedNotification;
// #endregion // #endregion
@@ -135,6 +160,23 @@ export class NotificationModel extends BaseModel {
return row as InvitationNotification; 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 // #endregion
// #region common // #region common

View File

@@ -26,16 +26,6 @@ declare global {
workspaceId: string; workspaceId: string;
count: number; count: number;
}; };
'workspace.members.reviewRequested': {
inviteId: string;
};
'workspace.members.requestApproved': {
inviteId: string;
};
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.removed': { 'workspace.members.removed': {
userId: string; userId: string;
workspaceId: string; workspaceId: string;

View File

@@ -624,7 +624,7 @@ type InvitationAcceptedNotificationBodyType {
The user who created the notification, maybe null when user is deleted or sent by system The user who created the notification, maybe null when user is deleted or sent by system
""" """
createdByUser: PublicUserType createdByUser: PublicUserType
inviteId: String! inviteId: ID!
"""The type of the notification""" """The type of the notification"""
type: NotificationType! type: NotificationType!
@@ -636,7 +636,7 @@ type InvitationBlockedNotificationBodyType {
The user who created the notification, maybe null when user is deleted or sent by system The user who created the notification, maybe null when user is deleted or sent by system
""" """
createdByUser: PublicUserType createdByUser: PublicUserType
inviteId: String! inviteId: ID!
"""The type of the notification""" """The type of the notification"""
type: NotificationType! type: NotificationType!
@@ -655,6 +655,41 @@ type InvitationNotificationBodyType {
workspace: NotificationWorkspaceType 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 { type InvitationType {
"""Invitee information""" """Invitee information"""
invitee: UserType! invitee: UserType!
@@ -1047,6 +1082,9 @@ enum NotificationType {
InvitationAccepted InvitationAccepted
InvitationBlocked InvitationBlocked
InvitationRejected InvitationRejected
InvitationReviewApproved
InvitationReviewDeclined
InvitationReviewRequest
Mention Mention
} }
@@ -1435,7 +1473,7 @@ type TranscriptionResultType {
transcription: [TranscriptionItemType!] transcription: [TranscriptionItemType!]
} }
union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | MentionNotificationBodyType union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | InvitationReviewApprovedNotificationBodyType | InvitationReviewDeclinedNotificationBodyType | InvitationReviewRequestNotificationBodyType | MentionNotificationBodyType
type UnknownOauthProviderDataType { type UnknownOauthProviderDataType {
name: String! name: String!

View File

@@ -776,7 +776,7 @@ export interface InvitationAcceptedNotificationBodyType {
__typename?: 'InvitationAcceptedNotificationBodyType'; __typename?: 'InvitationAcceptedNotificationBodyType';
/** The user who created the notification, maybe null when user is deleted or sent by system */ /** The user who created the notification, maybe null when user is deleted or sent by system */
createdByUser: Maybe<PublicUserType>; createdByUser: Maybe<PublicUserType>;
inviteId: Scalars['String']['output']; inviteId: Scalars['ID']['output'];
/** The type of the notification */ /** The type of the notification */
type: NotificationType; type: NotificationType;
workspace: Maybe<NotificationWorkspaceType>; workspace: Maybe<NotificationWorkspaceType>;
@@ -786,7 +786,7 @@ export interface InvitationBlockedNotificationBodyType {
__typename?: 'InvitationBlockedNotificationBodyType'; __typename?: 'InvitationBlockedNotificationBodyType';
/** The user who created the notification, maybe null when user is deleted or sent by system */ /** The user who created the notification, maybe null when user is deleted or sent by system */
createdByUser: Maybe<PublicUserType>; createdByUser: Maybe<PublicUserType>;
inviteId: Scalars['String']['output']; inviteId: Scalars['ID']['output'];
/** The type of the notification */ /** The type of the notification */
type: NotificationType; type: NotificationType;
workspace: Maybe<NotificationWorkspaceType>; workspace: Maybe<NotificationWorkspaceType>;
@@ -802,6 +802,35 @@ export interface InvitationNotificationBodyType {
workspace: Maybe<NotificationWorkspaceType>; workspace: Maybe<NotificationWorkspaceType>;
} }
export interface InvitationReviewApprovedNotificationBodyType {
__typename?: 'InvitationReviewApprovedNotificationBodyType';
/** The user who created the notification, maybe null when user is deleted or sent by system */
createdByUser: Maybe<PublicUserType>;
inviteId: Scalars['ID']['output'];
/** The type of the notification */
type: NotificationType;
workspace: Maybe<NotificationWorkspaceType>;
}
export interface InvitationReviewDeclinedNotificationBodyType {
__typename?: 'InvitationReviewDeclinedNotificationBodyType';
/** The user who created the notification, maybe null when user is deleted or sent by system */
createdByUser: Maybe<PublicUserType>;
/** The type of the notification */
type: NotificationType;
workspace: Maybe<NotificationWorkspaceType>;
}
export interface InvitationReviewRequestNotificationBodyType {
__typename?: 'InvitationReviewRequestNotificationBodyType';
/** The user who created the notification, maybe null when user is deleted or sent by system */
createdByUser: Maybe<PublicUserType>;
inviteId: Scalars['ID']['output'];
/** The type of the notification */
type: NotificationType;
workspace: Maybe<NotificationWorkspaceType>;
}
export interface InvitationType { export interface InvitationType {
__typename?: 'InvitationType'; __typename?: 'InvitationType';
/** Invitee information */ /** Invitee information */
@@ -1501,6 +1530,9 @@ export enum NotificationType {
InvitationAccepted = 'InvitationAccepted', InvitationAccepted = 'InvitationAccepted',
InvitationBlocked = 'InvitationBlocked', InvitationBlocked = 'InvitationBlocked',
InvitationRejected = 'InvitationRejected', InvitationRejected = 'InvitationRejected',
InvitationReviewApproved = 'InvitationReviewApproved',
InvitationReviewDeclined = 'InvitationReviewDeclined',
InvitationReviewRequest = 'InvitationReviewRequest',
Mention = 'Mention', Mention = 'Mention',
} }
@@ -1945,6 +1977,9 @@ export type UnionNotificationBodyType =
| InvitationAcceptedNotificationBodyType | InvitationAcceptedNotificationBodyType
| InvitationBlockedNotificationBodyType | InvitationBlockedNotificationBodyType
| InvitationNotificationBodyType | InvitationNotificationBodyType
| InvitationReviewApprovedNotificationBodyType
| InvitationReviewDeclinedNotificationBodyType
| InvitationReviewRequestNotificationBodyType
| MentionNotificationBodyType; | MentionNotificationBodyType;
export interface UnknownOauthProviderDataType { export interface UnknownOauthProviderDataType {