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.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + View Comment␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> 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.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + View Comment␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + > 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 ( + + ); +} + +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 ( + + ); +} + +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 */