diff --git a/packages/backend/server/migrations/20250625012447_add_comment_type_to_notification/migration.sql b/packages/backend/server/migrations/20250625012447_add_comment_type_to_notification/migration.sql
new file mode 100644
index 0000000000..de617bebb6
--- /dev/null
+++ b/packages/backend/server/migrations/20250625012447_add_comment_type_to_notification/migration.sql
@@ -0,0 +1,10 @@
+-- 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 'Comment';
+ALTER TYPE "NotificationType" ADD VALUE 'CommentMention';
diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma
index 2e16209a5f..b409c0662a 100644
--- a/packages/backend/server/schema.prisma
+++ b/packages/backend/server/schema.prisma
@@ -822,6 +822,8 @@ enum NotificationType {
InvitationReviewRequest
InvitationReviewApproved
InvitationReviewDeclined
+ Comment
+ CommentMention
}
enum NotificationLevel {
diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md
index 87e70cc833..470c2beedf 100644
--- a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md
+++ b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md
@@ -1513,6 +1513,179 @@ Generated by [AVA](https://avajs.dev).
␊
`
+> test@test.com commented on Test Doc
+
+ `␊
+ ␊
+
␊
+ ␊
+ ␊
+ ␊
+ ␊
+ You have a new comment␊
+
␊
+ ␊
+ ␊
+ ␊
+
␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ test@test.com commented on␊
+ Test Doc .␊
+
␊
+ ␊
+ ␊
+
␊
+ ␊
+ ␊
+ ␊
+ ␊
+
␊
+ ␊
+ `
+
+> test@test.com mentioned you in a comment on Test Doc
+
+ `␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ You are mentioned in a comment␊
+
␊
+ ␊
+ ␊
+ ␊
+
␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ test@test.com mentioned you␊
+ in a comment on␊
+ Test Doc .␊
+
␊
+ ␊
+ ␊
+
␊
+ ␊
+ ␊
+ ␊
+ ␊
+
␊
+ ␊
+ `
+
> Your workspace has been upgraded to team workspace! 🎉
`␊
diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap
index 83e581cfa8..4511420b84 100644
Binary files a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap and b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap differ
diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts
index 5567851e4b..7b0c45cf69 100644
--- a/packages/backend/server/src/core/mail/sender.ts
+++ b/packages/backend/server/src/core/mail/sender.ts
@@ -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')
diff --git a/packages/backend/server/src/core/notification/__tests__/job.spec.ts b/packages/backend/server/src/core/notification/__tests__/job.spec.ts
index 199555e809..669a55f51c 100644
--- a/packages/backend/server/src/core/notification/__tests__/job.spec.ts
+++ b/packages/backend/server/src/core/notification/__tests__/job.spec.ts
@@ -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);
+});
diff --git a/packages/backend/server/src/core/notification/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts
index ff0849ee21..3aa70896d6 100644
--- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts
+++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts
@@ -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;
-
-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);
});
diff --git a/packages/backend/server/src/core/notification/job.ts b/packages/backend/server/src/core/notification/job.ts
index b4dcc11dae..edf65adc47 100644
--- a/packages/backend/server/src/core/notification/job.ts
+++ b/packages/backend/server/src/core/notification/job.ts
@@ -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
+ );
+ }
}
diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts
index 1ce0f23bff..9a9759d4ed 100644
--- a/packages/backend/server/src/core/notification/service.ts
+++ b/packages/backend/server/src/core/notification/service.ts
@@ -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,
diff --git a/packages/backend/server/src/core/user/types.ts b/packages/backend/server/src/core/user/types.ts
index 651e21adce..22626ff0f1 100644
--- a/packages/backend/server/src/core/user/types.ts
+++ b/packages/backend/server/src/core/user/types.ts
@@ -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;
}
diff --git a/packages/backend/server/src/core/utils/doc.ts b/packages/backend/server/src/core/utils/doc.ts
index dd5b788242..df8f9f2055 100644
--- a/packages/backend/server/src/core/utils/doc.ts
+++ b/packages/backend/server/src/core/utils/doc.ts
@@ -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()}`;
}
diff --git a/packages/backend/server/src/mails/docs/comment-mention.tsx b/packages/backend/server/src/mails/docs/comment-mention.tsx
new file mode 100644
index 0000000000..7d2deb3184
--- /dev/null
+++ b/packages/backend/server/src/mails/docs/comment-mention.tsx
@@ -0,0 +1,37 @@
+import { TEST_DOC, TEST_USER } from '../common';
+import {
+ Button,
+ Content,
+ Doc,
+ type DocProps,
+ P,
+ Template,
+ Title,
+ User,
+ type UserProps,
+} from '../components';
+
+export type CommentMentionProps = {
+ user: UserProps;
+ doc: DocProps;
+};
+
+export function CommentMention(props: CommentMentionProps) {
+ const { user, doc } = props;
+ return (
+
+ You are mentioned in a comment
+
+
+ mentioned you in a comment on .
+
+ View Comment
+
+
+ );
+}
+
+CommentMention.PreviewProps = {
+ user: TEST_USER,
+ doc: TEST_DOC,
+};
diff --git a/packages/backend/server/src/mails/docs/comment.tsx b/packages/backend/server/src/mails/docs/comment.tsx
new file mode 100644
index 0000000000..5e676c4dd8
--- /dev/null
+++ b/packages/backend/server/src/mails/docs/comment.tsx
@@ -0,0 +1,37 @@
+import { TEST_DOC, TEST_USER } from '../common';
+import {
+ Button,
+ Content,
+ Doc,
+ type DocProps,
+ P,
+ Template,
+ Title,
+ User,
+ type UserProps,
+} from '../components';
+
+export type CommentProps = {
+ user: UserProps;
+ doc: DocProps;
+};
+
+export function Comment(props: CommentProps) {
+ const { user, doc } = props;
+ return (
+
+ You have a new comment
+
+
+ commented on .
+
+ View Comment
+
+
+ );
+}
+
+Comment.PreviewProps = {
+ user: TEST_USER,
+ doc: TEST_DOC,
+};
diff --git a/packages/backend/server/src/mails/docs/index.ts b/packages/backend/server/src/mails/docs/index.ts
index d672a68cb8..4e3c5b925f 100644
--- a/packages/backend/server/src/mails/docs/index.ts
+++ b/packages/backend/server/src/mails/docs/index.ts
@@ -1 +1,3 @@
+export * from './comment';
+export * from './comment-mention';
export * from './mention';
diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx
index d6e36240d0..1eb985053f 100644
--- a/packages/backend/server/src/mails/index.tsx
+++ b/packages/backend/server/src/mails/index.tsx
@@ -1,6 +1,6 @@
import { render as rawRender } from '@react-email/components';
-import { Mention } from './docs';
+import { Comment, CommentMention, Mention } from './docs';
import {
TeamBecomeAdmin,
TeamBecomeCollaborator,
@@ -125,6 +125,15 @@ export const Renderers = {
Mention,
props => `${props.user.email} mentioned you in ${props.doc.title}`
),
+ Comment: make(
+ Comment,
+ props => `${props.user.email} commented on ${props.doc.title}`
+ ),
+ CommentMention: make(
+ CommentMention,
+ props =>
+ `${props.user.email} mentioned you in a comment on ${props.doc.title}`
+ ),
//#endregion
//#region Team
diff --git a/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.md b/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.md
new file mode 100644
index 0000000000..1c0c71486e
--- /dev/null
+++ b/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.md
@@ -0,0 +1,51 @@
+# Snapshot report for `src/models/__tests__/user-settings.spec.ts`
+
+The actual snapshot is saved in `user-settings.spec.ts.snap`.
+
+Generated by [AVA](https://avajs.dev).
+
+## should get a user settings with default value
+
+> Snapshot 1
+
+ {
+ receiveCommentEmail: true,
+ receiveInvitationEmail: true,
+ receiveMentionEmail: true,
+ }
+
+## should update a user settings
+
+> Snapshot 1
+
+ {
+ receiveCommentEmail: true,
+ receiveInvitationEmail: false,
+ receiveMentionEmail: true,
+ }
+
+> Snapshot 2
+
+ {
+ receiveCommentEmail: true,
+ receiveInvitationEmail: true,
+ receiveMentionEmail: true,
+ }
+
+> Snapshot 3
+
+ {
+ receiveCommentEmail: true,
+ receiveInvitationEmail: false,
+ receiveMentionEmail: false,
+ }
+
+## should set receiveCommentEmail to false
+
+> Snapshot 1
+
+ {
+ receiveCommentEmail: false,
+ receiveInvitationEmail: true,
+ receiveMentionEmail: true,
+ }
diff --git a/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.snap b/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.snap
new file mode 100644
index 0000000000..a1ad720c37
Binary files /dev/null and b/packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.snap differ
diff --git a/packages/backend/server/src/models/__tests__/notification.spec.ts b/packages/backend/server/src/models/__tests__/notification.spec.ts
index 47602f2a29..cc5e101537 100644
--- a/packages/backend/server/src/models/__tests__/notification.spec.ts
+++ b/packages/backend/server/src/models/__tests__/notification.spec.ts
@@ -1,10 +1,11 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
-import ava, { TestFn } from 'ava';
+import test from 'ava';
-import { createTestingModule, type TestingModule } from '../../__tests__/utils';
-import { Config } from '../../base/config';
+import { createModule } from '../../__tests__/create-module';
+import { Mockers } from '../../__tests__/mocks';
+import { Due } from '../../base';
import {
DocMode,
Models,
@@ -13,38 +14,20 @@ import {
User,
Workspace,
} from '../../models';
-interface Context {
- config: Config;
- module: TestingModule;
- models: Models;
-}
-
-const test = ava as TestFn;
-
-test.before(async t => {
- const module = await createTestingModule();
-
- t.context.models = module.get(Models);
- t.context.config = module.get(Config);
- t.context.module = module;
-});
+const module = await createModule();
+const models = module.get(Models);
let user: User;
let createdBy: User;
let workspace: Workspace;
let docId: string;
-test.beforeEach(async t => {
- await t.context.module.initTestingDB();
- user = await t.context.models.user.create({
- email: 'test@affine.pro',
- });
- createdBy = await t.context.models.user.create({
- email: 'createdBy@affine.pro',
- });
- workspace = await t.context.models.workspace.create(user.id);
+test.beforeEach(async () => {
+ user = await module.create(Mockers.User);
+ createdBy = await module.create(Mockers.User);
+ workspace = await module.create(Mockers.Workspace);
docId = randomUUID();
- await t.context.models.doc.upsert({
+ await models.doc.upsert({
spaceId: user.id,
docId,
blob: Buffer.from('hello'),
@@ -58,12 +41,12 @@ test.afterEach.always(() => {
mock.timers.reset();
});
-test.after(async t => {
- await t.context.module.close();
+test.after.always(async () => {
+ await module.close();
});
test('should create a mention notification with default level', async t => {
- const notification = await t.context.models.notification.createMention({
+ const notification = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -87,7 +70,7 @@ test('should create a mention notification with default level', async t => {
});
test('should create a mention notification with custom level', async t => {
- const notification = await t.context.models.notification.createMention({
+ const notification = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -112,7 +95,7 @@ test('should create a mention notification with custom level', async t => {
});
test('should mark a mention notification as read', async t => {
- const notification = await t.context.models.notification.createMention({
+ const notification = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -126,16 +109,14 @@ test('should mark a mention notification as read', async t => {
},
});
t.is(notification.read, false);
- await t.context.models.notification.markAsRead(notification.id, user.id);
- const updatedNotification = await t.context.models.notification.get(
- notification.id
- );
+ await models.notification.markAsRead(notification.id, user.id);
+ const updatedNotification = await models.notification.get(notification.id);
t.is(updatedNotification!.read, true);
});
test('should create an invite notification', async t => {
const inviteId = randomUUID();
- const notification = await t.context.models.notification.createInvitation({
+ const notification = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -152,7 +133,7 @@ test('should create an invite notification', async t => {
test('should mark an invite notification as read', async t => {
const inviteId = randomUUID();
- const notification = await t.context.models.notification.createInvitation({
+ const notification = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -161,15 +142,13 @@ test('should mark an invite notification as read', async t => {
},
});
t.is(notification.read, false);
- await t.context.models.notification.markAsRead(notification.id, user.id);
- const updatedNotification = await t.context.models.notification.get(
- notification.id
- );
+ await models.notification.markAsRead(notification.id, user.id);
+ const updatedNotification = await models.notification.get(notification.id);
t.is(updatedNotification!.read, true);
});
test('should find many notifications by user id, order by createdAt descending', async t => {
- const notification1 = await t.context.models.notification.createMention({
+ const notification1 = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -183,7 +162,7 @@ test('should find many notifications by user id, order by createdAt descending',
},
});
const inviteId = randomUUID();
- const notification2 = await t.context.models.notification.createInvitation({
+ const notification2 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -191,16 +170,14 @@ test('should find many notifications by user id, order by createdAt descending',
inviteId,
},
});
- const notifications = await t.context.models.notification.findManyByUserId(
- user.id
- );
+ const notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 2);
t.is(notifications[0].id, notification2.id);
t.is(notifications[1].id, notification1.id);
});
test('should find many notifications by user id, filter read notifications', async t => {
- const notification1 = await t.context.models.notification.createMention({
+ const notification1 = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -214,7 +191,7 @@ test('should find many notifications by user id, filter read notifications', asy
},
});
const inviteId = randomUUID();
- const notification2 = await t.context.models.notification.createInvitation({
+ const notification2 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -222,16 +199,14 @@ test('should find many notifications by user id, filter read notifications', asy
inviteId,
},
});
- await t.context.models.notification.markAsRead(notification2.id, user.id);
- const notifications = await t.context.models.notification.findManyByUserId(
- user.id
- );
+ await models.notification.markAsRead(notification2.id, user.id);
+ const notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 1);
t.is(notifications[0].id, notification1.id);
});
test('should clean expired notifications', async t => {
- const notification = await t.context.models.notification.createMention({
+ const notification = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -245,30 +220,28 @@ test('should clean expired notifications', async t => {
},
});
t.truthy(notification);
- let notifications = await t.context.models.notification.findManyByUserId(
- user.id
- );
+ let notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 1);
- let count = await t.context.models.notification.cleanExpiredNotifications();
+ let count = await models.notification.cleanExpiredNotifications();
t.is(count, 0);
- notifications = await t.context.models.notification.findManyByUserId(user.id);
+ notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 1);
t.is(notifications[0].id, notification.id);
- await t.context.models.notification.markAsRead(notification.id, user.id);
+ await models.notification.markAsRead(notification.id, user.id);
// wait for 1 year
mock.timers.enable({
apis: ['Date'],
- now: Date.now() + 1000 * 60 * 60 * 24 * 365,
+ now: Due.after('1y'),
});
- count = await t.context.models.notification.cleanExpiredNotifications();
- t.is(count, 1);
- notifications = await t.context.models.notification.findManyByUserId(user.id);
+ count = await models.notification.cleanExpiredNotifications();
+ t.true(count > 0);
+ notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 0);
});
test('should not clean unexpired notifications', async t => {
- const notification = await t.context.models.notification.createMention({
+ const notification = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -281,15 +254,15 @@ test('should not clean unexpired notifications', async t => {
createdByUserId: createdBy.id,
},
});
- let count = await t.context.models.notification.cleanExpiredNotifications();
+ let count = await models.notification.cleanExpiredNotifications();
t.is(count, 0);
- await t.context.models.notification.markAsRead(notification.id, user.id);
- count = await t.context.models.notification.cleanExpiredNotifications();
+ await models.notification.markAsRead(notification.id, user.id);
+ count = await models.notification.cleanExpiredNotifications();
t.is(count, 0);
});
test('should find many notifications by user id, order by createdAt descending, with pagination', async t => {
- const notification1 = await t.context.models.notification.createMention({
+ const notification1 = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -302,7 +275,7 @@ test('should find many notifications by user id, order by createdAt descending,
createdByUserId: createdBy.id,
},
});
- const notification2 = await t.context.models.notification.createInvitation({
+ const notification2 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -310,7 +283,7 @@ test('should find many notifications by user id, order by createdAt descending,
inviteId: randomUUID(),
},
});
- const notification3 = await t.context.models.notification.createInvitation({
+ const notification3 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -318,7 +291,7 @@ test('should find many notifications by user id, order by createdAt descending,
inviteId: randomUUID(),
},
});
- const notification4 = await t.context.models.notification.createInvitation({
+ const notification4 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -326,38 +299,29 @@ test('should find many notifications by user id, order by createdAt descending,
inviteId: randomUUID(),
},
});
- const notifications = await t.context.models.notification.findManyByUserId(
- user.id,
- {
- offset: 0,
- first: 2,
- }
- );
+ const notifications = await models.notification.findManyByUserId(user.id, {
+ offset: 0,
+ first: 2,
+ });
t.is(notifications.length, 2);
t.is(notifications[0].id, notification4.id);
t.is(notifications[1].id, notification3.id);
- const notifications2 = await t.context.models.notification.findManyByUserId(
- user.id,
- {
- offset: 2,
- first: 2,
- }
- );
+ const notifications2 = await models.notification.findManyByUserId(user.id, {
+ offset: 2,
+ first: 2,
+ });
t.is(notifications2.length, 2);
t.is(notifications2[0].id, notification2.id);
t.is(notifications2[1].id, notification1.id);
- const notifications3 = await t.context.models.notification.findManyByUserId(
- user.id,
- {
- offset: 4,
- first: 2,
- }
- );
+ const notifications3 = await models.notification.findManyByUserId(user.id, {
+ offset: 4,
+ first: 2,
+ });
t.is(notifications3.length, 0);
});
test('should count notifications by user id, exclude read notifications', async t => {
- const notification1 = await t.context.models.notification.createMention({
+ const notification1 = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -371,7 +335,7 @@ test('should count notifications by user id, exclude read notifications', async
},
});
t.truthy(notification1);
- const notification2 = await t.context.models.notification.createInvitation({
+ const notification2 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -380,13 +344,13 @@ test('should count notifications by user id, exclude read notifications', async
},
});
t.truthy(notification2);
- await t.context.models.notification.markAsRead(notification2.id, user.id);
- const count = await t.context.models.notification.countByUserId(user.id);
+ await models.notification.markAsRead(notification2.id, user.id);
+ const count = await models.notification.countByUserId(user.id);
t.is(count, 1);
});
test('should count notifications by user id, include read notifications', async t => {
- const notification1 = await t.context.models.notification.createMention({
+ const notification1 = await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -400,7 +364,7 @@ test('should count notifications by user id, include read notifications', async
},
});
t.truthy(notification1);
- const notification2 = await t.context.models.notification.createInvitation({
+ const notification2 = await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
@@ -409,9 +373,60 @@ test('should count notifications by user id, include read notifications', async
},
});
t.truthy(notification2);
- await t.context.models.notification.markAsRead(notification2.id, user.id);
- const count = await t.context.models.notification.countByUserId(user.id, {
+ await models.notification.markAsRead(notification2.id, user.id);
+ const count = await models.notification.countByUserId(user.id, {
includeRead: true,
});
t.is(count, 2);
});
+
+test('should create a comment notification', async t => {
+ const commentId = randomUUID();
+
+ const notification = await models.notification.createComment({
+ userId: user.id,
+ body: {
+ workspaceId: workspace.id,
+ doc: {
+ id: docId,
+ title: 'doc-title',
+ mode: DocMode.page,
+ },
+ createdByUserId: createdBy.id,
+ commentId,
+ },
+ });
+
+ t.is(notification.type, NotificationType.Comment);
+ t.is(notification.body.workspaceId, workspace.id);
+ t.is(notification.body.doc.id, docId);
+ t.is(notification.body.doc.title, 'doc-title');
+ t.is(notification.body.commentId, commentId);
+});
+
+test('should create a comment mention notification', async t => {
+ const commentId = randomUUID();
+ const replyId = randomUUID();
+
+ const notification = await models.notification.createCommentMention({
+ userId: user.id,
+ body: {
+ workspaceId: workspace.id,
+ doc: {
+ id: docId,
+ title: 'doc-title',
+ mode: DocMode.page,
+ },
+ createdByUserId: createdBy.id,
+ commentId,
+ replyId,
+ },
+ });
+
+ t.is(notification.type, NotificationType.CommentMention);
+ t.is(notification.body.workspaceId, workspace.id);
+ t.is(notification.body.doc.id, docId);
+ t.is(notification.body.doc.title, 'doc-title');
+ t.is(notification.body.commentId, commentId);
+ t.is(notification.body.replyId, replyId);
+});
diff --git a/packages/backend/server/src/models/__tests__/user-settings.spec.ts b/packages/backend/server/src/models/__tests__/user-settings.spec.ts
index a575708ba6..27e2d8e4c3 100644
--- a/packages/backend/server/src/models/__tests__/user-settings.spec.ts
+++ b/packages/backend/server/src/models/__tests__/user-settings.spec.ts
@@ -1,92 +1,80 @@
-import { randomUUID } from 'node:crypto';
-import { mock } from 'node:test';
-
-import ava, { TestFn } from 'ava';
+import test from 'ava';
import { ZodError } from 'zod';
-import { createTestingModule, type TestingModule } from '../../__tests__/utils';
-import { Config } from '../../base/config';
-import { Models, User } from '..';
+import { createModule } from '../../__tests__/create-module';
+import { Mockers } from '../../__tests__/mocks';
+import { Models } from '..';
-interface Context {
- config: Config;
- module: TestingModule;
- models: Models;
-}
+const module = await createModule();
+const models = module.get(Models);
-const test = ava as TestFn;
-
-test.before(async t => {
- const module = await createTestingModule();
-
- t.context.models = module.get(Models);
- t.context.config = module.get(Config);
- t.context.module = module;
- await t.context.module.initTestingDB();
-});
-
-let user: User;
-
-test.beforeEach(async t => {
- user = await t.context.models.user.create({
- email: `test-${randomUUID()}@affine.pro`,
- });
-});
-
-test.afterEach.always(() => {
- mock.reset();
- mock.timers.reset();
-});
-
-test.after(async t => {
- await t.context.module.close();
+test.after.always(async () => {
+ await module.close();
});
test('should get a user settings with default value', async t => {
- const settings = await t.context.models.userSettings.get(user.id);
- t.deepEqual(settings, {
- receiveInvitationEmail: true,
- receiveMentionEmail: true,
- });
+ const user = await module.create(Mockers.User);
+
+ const settings = await models.userSettings.get(user.id);
+
+ t.snapshot(settings);
});
test('should update a user settings', async t => {
- const settings = await t.context.models.userSettings.set(user.id, {
+ const user = await module.create(Mockers.User);
+
+ const settings = await models.userSettings.set(user.id, {
receiveInvitationEmail: false,
});
- t.deepEqual(settings, {
- receiveInvitationEmail: false,
- receiveMentionEmail: true,
- });
- const settings2 = await t.context.models.userSettings.get(user.id);
+
+ t.snapshot(settings);
+
+ const settings2 = await models.userSettings.get(user.id);
+
t.deepEqual(settings2, settings);
// update existing setting
- const setting3 = await t.context.models.userSettings.set(user.id, {
+ const setting3 = await models.userSettings.set(user.id, {
receiveInvitationEmail: true,
});
- t.deepEqual(setting3, {
- receiveInvitationEmail: true,
- receiveMentionEmail: true,
- });
- const setting4 = await t.context.models.userSettings.get(user.id);
+
+ t.snapshot(setting3);
+
+ const setting4 = await models.userSettings.get(user.id);
+
t.deepEqual(setting4, setting3);
- const setting5 = await t.context.models.userSettings.set(user.id, {
+ const setting5 = await models.userSettings.set(user.id, {
receiveMentionEmail: false,
receiveInvitationEmail: false,
});
- t.deepEqual(setting5, {
- receiveInvitationEmail: false,
- receiveMentionEmail: false,
- });
- const setting6 = await t.context.models.userSettings.get(user.id);
+
+ t.snapshot(setting5);
+
+ const setting6 = await models.userSettings.get(user.id);
+
t.deepEqual(setting6, setting5);
});
+test('should set receiveCommentEmail to false', async t => {
+ const user = await module.create(Mockers.User);
+
+ const settings = await models.userSettings.set(user.id, {
+ receiveCommentEmail: false,
+ });
+
+ t.snapshot(settings);
+
+ const settings2 = await models.userSettings.get(user.id);
+
+ t.deepEqual(settings2, settings);
+});
+
test('should throw error when update settings with invalid payload', async t => {
+ const user = await module.create(Mockers.User);
+
await t.throwsAsync(
- t.context.models.userSettings.set(user.id, {
+ models.userSettings.set(user.id, {
// @ts-expect-error invalid setting input types
receiveInvitationEmail: 1,
}),
diff --git a/packages/backend/server/src/models/notification.ts b/packages/backend/server/src/models/notification.ts
index a180c4befa..279b9a6bd8 100644
--- a/packages/backend/server/src/models/notification.ts
+++ b/packages/backend/server/src/models/notification.ts
@@ -7,7 +7,7 @@ import {
} from '@prisma/client';
import { z } from 'zod';
-import { PaginationInput } from '../base';
+import { Due, PaginationInput } from '../base';
import { BaseModel } from './base';
import { DocMode } from './common';
@@ -16,7 +16,7 @@ export type { Notification };
// #region input
-export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
+export const ONE_YEAR = Due.ms('1y');
const IdSchema = z.string().trim().min(1).max(100);
export const BaseNotificationCreateSchema = z.object({
@@ -96,10 +96,37 @@ export type InvitationReviewDeclinedNotificationCreate = z.input<
typeof InvitationReviewDeclinedNotificationCreateSchema
>;
+export const CommentNotificationBodySchema = z.object({
+ workspaceId: IdSchema,
+ createdByUserId: IdSchema,
+ commentId: IdSchema,
+ replyId: IdSchema.optional(),
+ doc: MentionDocSchema,
+});
+
+export type CommentNotificationBody = z.infer<
+ typeof CommentNotificationBodySchema
+>;
+
+export const CommentNotificationCreateSchema =
+ BaseNotificationCreateSchema.extend({
+ body: CommentNotificationBodySchema,
+ });
+
+export type CommentNotificationCreate = z.input<
+ typeof CommentNotificationCreateSchema
+>;
+
+export const CommentMentionNotificationCreateSchema =
+ BaseNotificationCreateSchema.extend({
+ body: CommentNotificationBodySchema,
+ });
+
export type UnionNotificationBody =
| MentionNotificationBody
| InvitationNotificationBody
- | InvitationReviewDeclinedNotificationBody;
+ | InvitationReviewDeclinedNotificationBody
+ | CommentNotificationBody;
// #endregion
@@ -114,10 +141,14 @@ export type InvitationNotification = Notification &
export type InvitationReviewDeclinedNotification = Notification &
z.infer;
+export type CommentNotification = Notification &
+ z.infer;
+
export type UnionNotification =
| MentionNotification
| InvitationNotification
- | InvitationReviewDeclinedNotification;
+ | InvitationReviewDeclinedNotification
+ | CommentNotification;
// #endregion
@@ -179,6 +210,40 @@ export class NotificationModel extends BaseModel {
// #endregion
+ // #region comment
+
+ async createComment(input: CommentNotificationCreate) {
+ const data = CommentNotificationCreateSchema.parse(input);
+ const type = NotificationType.Comment;
+ const row = await this.create({
+ userId: data.userId,
+ level: data.level,
+ type,
+ body: data.body,
+ });
+ this.logger.debug(
+ `Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}`
+ );
+ return row as CommentNotification;
+ }
+
+ async createCommentMention(input: CommentNotificationCreate) {
+ const data = CommentMentionNotificationCreateSchema.parse(input);
+ const type = NotificationType.CommentMention;
+ const row = await this.create({
+ userId: data.userId,
+ level: data.level,
+ type,
+ body: data.body,
+ });
+ this.logger.debug(
+ `Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}`
+ );
+ return row as CommentNotification;
+ }
+
+ // #endregion
+
// #region common
private async create(data: Prisma.NotificationUncheckedCreateInput) {
diff --git a/packages/backend/server/src/models/user-settings.ts b/packages/backend/server/src/models/user-settings.ts
index 7be7ad412f..8cdf332b07 100644
--- a/packages/backend/server/src/models/user-settings.ts
+++ b/packages/backend/server/src/models/user-settings.ts
@@ -7,6 +7,7 @@ import { BaseModel } from './base';
export const UserSettingsSchema = z.object({
receiveInvitationEmail: z.boolean().default(true),
receiveMentionEmail: z.boolean().default(true),
+ receiveCommentEmail: z.boolean().default(true),
});
export type UserSettingsInput = z.input;
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 446f3a0992..4a2af2c20c 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -1361,6 +1361,8 @@ type NotificationObjectTypeEdge {
"""Notification type"""
enum NotificationType {
+ Comment
+ CommentMention
Invitation
InvitationAccepted
InvitationBlocked
@@ -1933,6 +1935,9 @@ input UpdateUserInput {
}
input UpdateUserSettingsInput {
+ """Receive comment email"""
+ receiveCommentEmail: Boolean
+
"""Receive invitation email"""
receiveInvitationEmail: Boolean
@@ -1993,6 +1998,9 @@ type UserQuotaUsageType {
}
type UserSettingsType {
+ """Receive comment email"""
+ receiveCommentEmail: Boolean!
+
"""Receive invitation email"""
receiveInvitationEmail: Boolean!
diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts
index 87dda77de2..48b63a9804 100644
--- a/packages/common/graphql/src/schema.ts
+++ b/packages/common/graphql/src/schema.ts
@@ -1896,6 +1896,8 @@ export interface NotificationObjectTypeEdge {
/** Notification type */
export enum NotificationType {
+ Comment = 'Comment',
+ CommentMention = 'CommentMention',
Invitation = 'Invitation',
InvitationAccepted = 'InvitationAccepted',
InvitationBlocked = 'InvitationBlocked',
@@ -2530,6 +2532,8 @@ export interface UpdateUserInput {
}
export interface UpdateUserSettingsInput {
+ /** Receive comment email */
+ receiveCommentEmail?: InputMaybe;
/** Receive invitation email */
receiveInvitationEmail?: InputMaybe;
/** Receive mention email */
@@ -2589,6 +2593,8 @@ export interface UserQuotaUsageType {
export interface UserSettingsType {
__typename?: 'UserSettingsType';
+ /** Receive comment email */
+ receiveCommentEmail: Scalars['Boolean']['output'];
/** Receive invitation email */
receiveInvitationEmail: Scalars['Boolean']['output'];
/** Receive mention email */