feat(server): support comment notification type (#12924)

#### PR Dependency Tree


* **PR #12924** 👈
  * **PR #12925**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced comment and comment mention notifications, including email
notifications when users are mentioned or receive comments on documents.
* Added new email templates for comment and comment mention
notifications.
* Users can now control whether they receive comment-related emails via
a new user setting.

* **Bug Fixes**
  * None.

* **Documentation**
* Updated GraphQL schema documentation to reflect new notification types
and user settings.

* **Refactor**
* Streamlined and enhanced test coverage for notification and user
settings, including comment notifications.

* **Chores**
* Improved test setup and snapshot coverage for user settings and
notifications.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-01 21:48:06 +08:00
committed by GitHub
parent 2aa5c13082
commit 7ed72ed1d0
23 changed files with 916 additions and 255 deletions

View File

@@ -44,7 +44,8 @@ export class MailSender {
}
get configured() {
return this.smtp !== null;
// NOTE: testing environment will use mock queue, so we need to return true
return this.smtp !== null || env.testing;
}
@OnEvent('config.init')

View File

@@ -8,6 +8,7 @@ import {
type TestingModule,
} from '../../../__tests__/utils';
import {
DocMode,
Models,
User,
Workspace,
@@ -204,3 +205,24 @@ test('should create invitation review declined notification', async t => {
t.is(spy.firstCall.args[0].body.workspaceId, workspace.id);
t.is(spy.firstCall.args[0].body.createdByUserId, owner.id);
});
test('should create comment notification', async t => {
const { notificationJob, notificationService } = t.context;
const spy = Sinon.spy(notificationService, 'createComment');
await notificationJob.sendComment({
userId: member.id,
body: {
workspaceId: workspace.id,
createdByUserId: owner.id,
doc: {
id: randomUUID(),
title: 'doc-title-1',
mode: DocMode.page,
},
commentId: randomUUID(),
},
});
t.is(spy.callCount, 1);
});

View File

@@ -1,14 +1,11 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import ava, { TestFn } from 'ava';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { NotificationNotFound } from '../../../base';
import { Due, NotificationNotFound } from '../../../base';
import {
DocMode,
MentionNotificationBody,
@@ -18,33 +15,33 @@ import {
Workspace,
WorkspaceMemberStatus,
} from '../../../models';
import { DocReader } from '../../doc';
import { DocStorageModule } from '../../doc';
import { FeatureModule } from '../../features';
import { MailModule } from '../../mail';
import { PermissionModule } from '../../permission';
import { StorageModule } from '../../storage';
import { NotificationModule } from '..';
import { NotificationService } from '../service';
interface Context {
module: TestingModule;
notificationService: NotificationService;
models: Models;
docReader: DocReader;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule();
t.context.module = module;
t.context.notificationService = module.get(NotificationService);
t.context.models = module.get(Models);
t.context.docReader = module.get(DocReader);
const module = await createModule({
imports: [
FeatureModule,
PermissionModule,
DocStorageModule,
StorageModule,
MailModule,
NotificationModule,
],
providers: [NotificationService],
});
const notificationService = module.get(NotificationService);
const models = module.get(Models);
let owner: User;
let member: User;
let workspace: Workspace;
test.beforeEach(async t => {
const { module } = t.context;
await module.initTestingDB();
test.beforeEach(async () => {
owner = await module.create(Mockers.User);
member = await module.create(Mockers.User);
workspace = await module.create(Mockers.Workspace, {
@@ -61,13 +58,13 @@ test.afterEach.always(() => {
mock.timers.reset();
});
test.after.always(async t => {
await t.context.module.close();
test.after.always(async () => {
await module.close();
});
test('should create invitation notification and email', async t => {
const { notificationService } = t.context;
const inviteId = randomUUID();
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
@@ -76,25 +73,28 @@ test('should create invitation notification and email', async t => {
inviteId,
},
});
t.truthy(notification);
t.is(notification!.type, NotificationType.Invitation);
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 invitation email
const invitationMail = t.context.module.mails.last('MemberInvitation');
t.is(invitationMail.to, member.email);
const invitationMail = module.queue.last('notification.sendMail');
t.is(invitationMail.payload.to, member.email);
t.is(invitationMail.payload.name, 'MemberInvitation');
});
test('should not send invitation email if user setting is not to receive invitation email', async t => {
const { notificationService, module } = t.context;
const inviteId = randomUUID();
await module.create(Mockers.UserSettings, {
userId: member.id,
receiveInvitationEmail: false,
});
const invitationMailCount = module.mails.count('MemberInvitation');
const invitationMailCount = module.queue.count('notification.sendMail');
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
@@ -103,17 +103,19 @@ test('should not send invitation email if user setting is not to receive invitat
inviteId,
},
});
t.truthy(notification);
// no new invitation email should be sent
t.is(t.context.module.mails.count('MemberInvitation'), invitationMailCount);
t.is(module.queue.count('notification.sendMail'), invitationMailCount);
});
test('should not create invitation notification if user is already a member', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
@@ -122,15 +124,16 @@ test('should not create invitation notification if user is already a member', as
inviteId,
},
});
t.is(notification, undefined);
});
test('should create invitation accepted notification and email', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
const notification = await notificationService.createInvitationAccepted({
userId: owner.id,
body: {
@@ -139,6 +142,7 @@ test('should create invitation accepted notification and email', async t => {
inviteId,
},
});
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationAccepted);
t.is(notification!.userId, owner.id);
@@ -147,12 +151,12 @@ test('should create invitation accepted notification and email', async t => {
t.is(notification!.body.inviteId, inviteId);
// should send email
const invitationAcceptedMail = module.mails.last('MemberAccepted');
t.is(invitationAcceptedMail.to, owner.email);
const invitationAcceptedMail = module.queue.last('notification.sendMail');
t.is(invitationAcceptedMail.payload.to, owner.email);
t.is(invitationAcceptedMail.payload.name, 'MemberAccepted');
});
test('should not send invitation accepted email if user settings is not receive invitation email', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
@@ -162,8 +166,10 @@ test('should not send invitation accepted email if user settings is not receive
userId: owner.id,
receiveInvitationEmail: false,
});
const invitationAcceptedMailCount =
t.context.module.mails.count('MemberAccepted');
const invitationAcceptedMailCount = module.queue.count(
'notification.sendMail'
);
const notification = await notificationService.createInvitationAccepted({
userId: owner.id,
body: {
@@ -172,17 +178,19 @@ test('should not send invitation accepted email if user settings is not receive
inviteId,
},
});
t.truthy(notification);
// no new invitation accepted email should be sent
t.is(
t.context.module.mails.count('MemberAccepted'),
module.queue.count('notification.sendMail'),
invitationAcceptedMailCount
);
});
test('should not create invitation accepted notification if user is not an active member', async t => {
const { notificationService } = t.context;
const inviteId = randomUUID();
const notification = await notificationService.createInvitationAccepted({
userId: owner.id,
body: {
@@ -191,12 +199,13 @@ test('should not create invitation accepted notification if user is not an activ
inviteId,
},
});
t.is(notification, undefined);
});
test('should create invitation blocked notification', async t => {
const { notificationService } = t.context;
const inviteId = randomUUID();
const notification = await notificationService.createInvitationBlocked({
userId: owner.id,
body: {
@@ -205,6 +214,7 @@ test('should create invitation blocked notification', async t => {
inviteId,
},
});
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationBlocked);
t.is(notification!.userId, owner.id);
@@ -214,8 +224,8 @@ test('should create invitation blocked notification', async t => {
});
test('should create invitation rejected notification', async t => {
const { notificationService } = t.context;
const inviteId = randomUUID();
const notification = await notificationService.createInvitationRejected({
userId: owner.id,
body: {
@@ -224,6 +234,7 @@ test('should create invitation rejected notification', async t => {
inviteId,
},
});
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationRejected);
t.is(notification!.userId, owner.id);
@@ -233,8 +244,8 @@ test('should create invitation rejected notification', async t => {
});
test('should create invitation review request notification if user is not an active member', async t => {
const { notificationService, module } = t.context;
const inviteId = randomUUID();
const notification = await notificationService.createInvitationReviewRequest({
userId: owner.id,
body: {
@@ -243,6 +254,7 @@ test('should create invitation review request notification if user is not an act
inviteId,
},
});
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationReviewRequest);
t.is(notification!.userId, owner.id);
@@ -251,18 +263,19 @@ test('should create invitation review request notification if user is not an act
t.is(notification!.body.inviteId, inviteId);
// should send email
const invitationReviewRequestMail = module.mails.last(
'LinkInvitationReviewRequest'
const invitationReviewRequestMail = module.queue.last(
'notification.sendMail'
);
t.is(invitationReviewRequestMail.to, owner.email);
t.is(invitationReviewRequestMail.payload.to, owner.email);
t.is(invitationReviewRequestMail.payload.name, 'LinkInvitationReviewRequest');
});
test('should not create invitation review request notification if user is an active member', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
const notification = await notificationService.createInvitationReviewRequest({
userId: owner.id,
body: {
@@ -271,15 +284,16 @@ test('should not create invitation review request notification if user is an act
inviteId,
},
});
t.is(notification, undefined);
});
test('should create invitation review approved notification if user is an active member', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
const notification = await notificationService.createInvitationReviewApproved(
{
userId: member.id,
@@ -290,6 +304,7 @@ test('should create invitation review approved notification if user is an active
},
}
);
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationReviewApproved);
t.is(notification!.userId, member.id);
@@ -298,19 +313,20 @@ test('should create invitation review approved notification if user is an active
t.is(notification!.body.inviteId, inviteId);
// should send email
const invitationReviewApprovedMail = t.context.module.mails.last(
'LinkInvitationApprove'
const invitationReviewApprovedMail = module.queue.last(
'notification.sendMail'
);
t.is(invitationReviewApprovedMail.to, member.email);
t.is(invitationReviewApprovedMail.payload.to, member.email);
t.is(invitationReviewApprovedMail.payload.name, 'LinkInvitationApprove');
});
test('should not create invitation review approved notification if user is not an active member', async t => {
const { notificationService, module } = t.context;
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
status: WorkspaceMemberStatus.Pending,
});
const notification = await notificationService.createInvitationReviewApproved(
{
userId: member.id,
@@ -321,11 +337,11 @@ test('should not create invitation review approved notification if user is not a
},
}
);
t.is(notification, undefined);
});
test('should create invitation review declined notification if user is not an active member', async t => {
const { notificationService, module } = t.context;
const notification = await notificationService.createInvitationReviewDeclined(
{
userId: member.id,
@@ -335,6 +351,7 @@ test('should create invitation review declined notification if user is not an ac
},
}
);
t.truthy(notification);
t.is(notification!.type, NotificationType.InvitationReviewDeclined);
t.is(notification!.userId, member.id);
@@ -342,18 +359,19 @@ test('should create invitation review declined notification if user is not an ac
t.is(notification!.body.createdByUserId, owner.id);
// should send email
const invitationReviewDeclinedMail = module.mails.last(
'LinkInvitationDecline'
const invitationReviewDeclinedMail = module.queue.last(
'notification.sendMail'
);
t.is(invitationReviewDeclinedMail.to, member.email);
t.is(invitationReviewDeclinedMail.payload.to, member.email);
t.is(invitationReviewDeclinedMail.payload.name, 'LinkInvitationDecline');
});
test('should not create invitation review declined notification if user is an active member', async t => {
const { notificationService, module } = t.context;
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
const notification = await notificationService.createInvitationReviewDeclined(
{
userId: owner.id,
@@ -363,11 +381,11 @@ test('should not create invitation review declined notification if user is an ac
},
}
);
t.is(notification, undefined);
});
test('should clean expired notifications', async t => {
const { notificationService } = t.context;
await notificationService.createInvitation({
userId: member.id,
body: {
@@ -376,29 +394,35 @@ test('should clean expired notifications', async t => {
inviteId: randomUUID(),
},
});
let count = await notificationService.countByUserId(member.id);
t.is(count, 1);
// wait for 100 days
mock.timers.enable({
apis: ['Date'],
now: Date.now() + 1000 * 60 * 60 * 24 * 100,
now: Due.after('100d'),
});
await t.context.models.notification.cleanExpiredNotifications();
await models.notification.cleanExpiredNotifications();
count = await notificationService.countByUserId(member.id);
t.is(count, 1);
mock.timers.reset();
// wait for 1 year
mock.timers.enable({
apis: ['Date'],
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
now: Due.after('1y'),
});
await t.context.models.notification.cleanExpiredNotifications();
await models.notification.cleanExpiredNotifications();
count = await notificationService.countByUserId(member.id);
t.is(count, 0);
});
test('should mark notification as read', async t => {
const { notificationService } = t.context;
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
@@ -407,22 +431,20 @@ test('should mark notification as read', async t => {
inviteId: randomUUID(),
},
});
await notificationService.markAsRead(member.id, notification!.id);
const updatedNotification = await t.context.models.notification.get(
notification!.id
);
const updatedNotification = await models.notification.get(notification!.id);
t.is(updatedNotification!.read, true);
});
test('should throw error on mark notification as read if notification is not found', async t => {
const { notificationService } = t.context;
await t.throwsAsync(notificationService.markAsRead(member.id, randomUUID()), {
instanceOf: NotificationNotFound,
});
});
test('should throw error on mark notification as read if notification user is not the same', async t => {
const { notificationService, module } = t.context;
const notification = await notificationService.createInvitation({
userId: member.id,
body: {
@@ -431,7 +453,9 @@ test('should throw error on mark notification as read if notification user is no
inviteId: randomUUID(),
},
});
const otherUser = await module.create(Mockers.User);
await t.throwsAsync(
notificationService.markAsRead(otherUser.id, notification!.id),
{
@@ -441,8 +465,8 @@ test('should throw error on mark notification as read if notification user is no
});
test('should use latest doc title in mention notification', async t => {
const { notificationService, models } = t.context;
const docId = randomUUID();
await notificationService.createMention({
userId: member.id,
body: {
@@ -456,6 +480,7 @@ test('should use latest doc title in mention notification', async t => {
},
},
});
const mentionNotification = await notificationService.createMention({
userId: member.id,
body: {
@@ -469,7 +494,9 @@ test('should use latest doc title in mention notification', async t => {
},
},
});
t.truthy(mentionNotification);
mock.method(models.doc, 'findMetas', async () => [
{
title: 'doc-title-2-updated',
@@ -478,7 +505,9 @@ test('should use latest doc title in mention notification', async t => {
title: 'doc-title-1-updated',
},
]);
const notifications = await notificationService.findManyByUserId(member.id);
t.is(notifications.length, 2);
const mention = notifications[0];
t.is(mention.body.workspace!.id, workspace.id);
@@ -498,8 +527,8 @@ test('should use latest doc title in mention notification', async t => {
});
test('should raw doc title in mention notification if no doc found', async t => {
const { notificationService, models } = t.context;
const docId = randomUUID();
await notificationService.createMention({
userId: member.id,
body: {
@@ -526,7 +555,9 @@ test('should raw doc title in mention notification if no doc found', async t =>
},
},
});
mock.method(models.doc, 'findMetas', async () => [null, null]);
const notifications = await notificationService.findManyByUserId(member.id);
t.is(notifications.length, 2);
const mention = notifications[0];
@@ -545,8 +576,8 @@ test('should raw doc title in mention notification if no doc found', async t =>
});
test('should send mention email by user setting', async t => {
const { notificationService, module } = t.context;
const docId = randomUUID();
const notification = await notificationService.createMention({
userId: member.id,
body: {
@@ -560,17 +591,21 @@ test('should send mention email by user setting', async t => {
},
},
});
t.truthy(notification);
// should send mention email
const mentionMail = module.mails.last('Mention');
t.is(mentionMail.to, member.email);
const mentionMail = module.queue.last('notification.sendMail');
t.is(mentionMail.payload.to, member.email);
t.is(mentionMail.payload.name, 'Mention');
// update user setting to not receive mention email
const mentionMailCount = module.mails.count('Mention');
const mentionMailCount = module.queue.count('notification.sendMail');
await module.create(Mockers.UserSettings, {
userId: member.id,
receiveMentionEmail: false,
});
await notificationService.createMention({
userId: member.id,
body: {
@@ -584,12 +619,12 @@ test('should send mention email by user setting', async t => {
},
},
});
// should not send mention email
t.is(module.mails.count('Mention'), mentionMailCount);
t.is(module.queue.count('notification.sendMail'), mentionMailCount);
});
test('should send mention email with use client doc title if server doc title is empty', async t => {
const { notificationService, module } = t.context;
const docId = randomUUID();
await module.create(Mockers.DocMeta, {
workspaceId: workspace.id,
@@ -597,6 +632,7 @@ test('should send mention email with use client doc title if server doc title is
// mock empty title
title: '',
});
const notification = await notificationService.createMention({
userId: member.id,
body: {
@@ -610,8 +646,115 @@ test('should send mention email with use client doc title if server doc title is
},
},
});
t.truthy(notification);
const mentionMail = module.mails.last('Mention');
t.is(mentionMail.to, member.email);
t.is(mentionMail.props.doc.title, 'doc-title-1');
const mentionMail = module.queue.last('notification.sendMail');
t.is(mentionMail.payload.to, member.email);
t.is(mentionMail.payload.name, 'Mention');
// @ts-expect-error - payload is not typed
t.is(mentionMail.payload.props.doc.title, 'doc-title-1');
});
test('should send comment notification and email', async t => {
const docId = randomUUID();
const commentId = randomUUID();
const notification = await notificationService.createComment({
userId: member.id,
body: {
workspaceId: workspace.id,
createdByUserId: owner.id,
doc: {
id: docId,
title: 'doc-title-1',
mode: DocMode.page,
},
commentId,
},
});
t.truthy(notification);
const commentMail = module.queue.last('notification.sendMail');
t.is(commentMail.payload.to, member.email);
t.is(commentMail.payload.name, 'Comment');
});
test('should send comment mention notification and email', async t => {
const docId = randomUUID();
const commentId = randomUUID();
const replyId = randomUUID();
const notification = await notificationService.createComment(
{
userId: member.id,
body: {
workspaceId: workspace.id,
createdByUserId: owner.id,
doc: {
id: docId,
title: 'doc-title-1',
mode: DocMode.page,
},
commentId,
replyId,
},
},
true
);
t.truthy(notification);
const commentMentionMail = module.queue.last('notification.sendMail');
t.is(commentMentionMail.payload.to, member.email);
t.is(commentMentionMail.payload.name, 'CommentMention');
});
test('should send comment email by user setting', async t => {
const docId = randomUUID();
const notification = await notificationService.createComment({
userId: member.id,
body: {
workspaceId: workspace.id,
createdByUserId: owner.id,
doc: {
id: docId,
title: 'doc-title-1',
mode: DocMode.page,
},
commentId: randomUUID(),
},
});
t.truthy(notification);
const commentMail = module.queue.last('notification.sendMail');
t.is(commentMail.payload.to, member.email);
t.is(commentMail.payload.name, 'Comment');
// update user setting to not receive comment email
const commentMailCount = module.queue.count('notification.sendMail');
await module.create(Mockers.UserSettings, {
userId: member.id,
receiveCommentEmail: false,
});
await notificationService.createComment({
userId: member.id,
body: {
workspaceId: workspace.id,
createdByUserId: owner.id,
doc: {
id: docId,
title: 'doc-title-2',
mode: DocMode.page,
},
commentId: randomUUID(),
},
});
// should not send comment email
t.is(module.queue.count('notification.sendMail'), commentMailCount);
});

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OnJob } from '../../base';
import { Models } from '../../models';
import { CommentNotificationBody, Models } from '../../models';
import { NotificationService } from './service';
declare global {
@@ -29,6 +29,11 @@ declare global {
userId: string;
workspaceId: string;
};
'notification.sendComment': {
userId: string;
isMention?: boolean;
body: CommentNotificationBody;
};
}
}
@@ -146,4 +151,19 @@ export class NotificationJob {
},
});
}
@OnJob('notification.sendComment')
async sendComment({
userId,
isMention,
body,
}: Jobs['notification.sendComment']) {
await this.service.createComment(
{
userId,
body,
},
isMention
);
}
}

View File

@@ -3,6 +3,8 @@ import { Prisma } from '@prisma/client';
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
import {
CommentNotification,
CommentNotificationCreate,
DEFAULT_WORKSPACE_NAME,
InvitationNotificationCreate,
InvitationReviewDeclinedNotificationCreate,
@@ -36,6 +38,58 @@ export class NotificationService {
return await this.models.notification.cleanExpiredNotifications();
}
async createComment(input: CommentNotificationCreate, isMention?: boolean) {
const notification = isMention
? await this.models.notification.createCommentMention(input)
: await this.models.notification.createComment(input);
await this.sendCommentEmail(input, isMention);
return notification;
}
private async sendCommentEmail(
input: CommentNotificationCreate,
isMention?: boolean
) {
const userSetting = await this.models.userSettings.get(input.userId);
if (!userSetting.receiveCommentEmail) {
return;
}
const receiver = await this.models.user.getWorkspaceUser(input.userId);
if (!receiver) {
return;
}
const doc = await this.models.doc.getMeta(
input.body.workspaceId,
input.body.doc.id
);
const title = doc?.title || input.body.doc.title;
const url = this.url.link(
generateDocPath({
workspaceId: input.body.workspaceId,
docId: input.body.doc.id,
mode: input.body.doc.mode,
blockId: input.body.doc.blockId,
elementId: input.body.doc.elementId,
commentId: input.body.commentId,
replyId: input.body.replyId,
})
);
await this.mailer.trySend({
name: isMention ? 'CommentMention' : 'Comment',
to: receiver.email,
props: {
user: {
$$userId: input.body.createdByUserId,
},
doc: {
title,
url,
},
},
});
this.logger.debug(`Comment email sent to user ${receiver.id}`);
}
async createMention(input: MentionNotificationCreate) {
const notification = await this.models.notification.createMention(input);
await this.sendMentionEmail(input);
@@ -370,8 +424,11 @@ export class NotificationService {
// fill latest doc title
const mentions = notifications.filter(
n => n.type === NotificationType.Mention
) as MentionNotification[];
n =>
n.type === NotificationType.Mention ||
n.type === NotificationType.CommentMention ||
n.type === NotificationType.Comment
) as (MentionNotification | CommentNotification)[];
const mentionDocs = await this.models.doc.findMetas(
mentions.map(m => ({
workspaceId: m.body.workspaceId,

View File

@@ -121,6 +121,9 @@ export class UserSettingsType implements UserSettings {
@Field({ description: 'Receive mention email' })
receiveMentionEmail!: boolean;
@Field({ description: 'Receive comment email' })
receiveCommentEmail!: boolean;
}
@InputType()
@@ -145,4 +148,7 @@ export class UpdateUserSettingsInput implements UserSettingsInput {
@Field({ description: 'Receive mention email', nullable: true })
receiveMentionEmail?: boolean;
@Field({ description: 'Receive comment email', nullable: true })
receiveCommentEmail?: boolean;
}

View File

@@ -128,12 +128,14 @@ type DocPathParams = {
mode: DocMode;
blockId?: string;
elementId?: string;
commentId?: string;
replyId?: string;
};
/**
* To generate a doc url path like
*
* /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId}
* /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId}&commentId={commentId}&replyId={replyId}
*/
export function generateDocPath(params: DocPathParams) {
const search = new URLSearchParams({
@@ -145,5 +147,11 @@ export function generateDocPath(params: DocPathParams) {
if (params.blockId) {
search.set('blockIds', params.blockId);
}
if (params.commentId) {
search.set('commentId', params.commentId);
}
if (params.replyId) {
search.set('replyId', params.replyId);
}
return `/workspace/${params.workspaceId}/${params.docId}?${search.toString()}`;
}