From 7ed72ed1d0ee2eb8c0f5c5c6094643750b9ce8db Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Tue, 1 Jul 2025 21:48:06 +0800 Subject: [PATCH] feat(server): support comment notification type (#12924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #12924** πŸ‘ˆ * **PR #12925** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Introduced comment and comment mention notifications, including email notifications when users are mentioned or receive comments on documents. * Added new email templates for comment and comment mention notifications. * Users can now control whether they receive comment-related emails via a new user setting. * **Bug Fixes** * None. * **Documentation** * Updated GraphQL schema documentation to reflect new notification types and user settings. * **Refactor** * Streamlined and enhanced test coverage for notification and user settings, including comment notifications. * **Chores** * Improved test setup and snapshot coverage for user settings and notifications. --- .../migration.sql | 10 + packages/backend/server/schema.prisma | 2 + .../__tests__/__snapshots__/mails.spec.ts.md | 173 ++++++++++ .../__snapshots__/mails.spec.ts.snap | Bin 4511 -> 4636 bytes .../backend/server/src/core/mail/sender.ts | 3 +- .../core/notification/__tests__/job.spec.ts | 22 ++ .../notification/__tests__/service.spec.ts | 305 +++++++++++++----- .../server/src/core/notification/job.ts | 22 +- .../server/src/core/notification/service.ts | 61 +++- .../backend/server/src/core/user/types.ts | 6 + packages/backend/server/src/core/utils/doc.ts | 10 +- .../server/src/mails/docs/comment-mention.tsx | 37 +++ .../backend/server/src/mails/docs/comment.tsx | 37 +++ .../backend/server/src/mails/docs/index.ts | 2 + packages/backend/server/src/mails/index.tsx | 11 +- .../__snapshots__/user-settings.spec.ts.md | 51 +++ .../__snapshots__/user-settings.spec.ts.snap | Bin 0 -> 319 bytes .../src/models/__tests__/notification.spec.ts | 219 +++++++------ .../models/__tests__/user-settings.spec.ts | 112 +++---- .../backend/server/src/models/notification.ts | 73 ++++- .../server/src/models/user-settings.ts | 1 + packages/backend/server/src/schema.gql | 8 + packages/common/graphql/src/schema.ts | 6 + 23 files changed, 916 insertions(+), 255 deletions(-) create mode 100644 packages/backend/server/migrations/20250625012447_add_comment_type_to_notification/migration.sql create mode 100644 packages/backend/server/src/mails/docs/comment-mention.tsx create mode 100644 packages/backend/server/src/mails/docs/comment.tsx create mode 100644 packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.md create mode 100644 packages/backend/server/src/models/__tests__/__snapshots__/user-settings.spec.ts.snap 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 83e581cfa899acd309d2662c993f90dacf064d52..4511420b84d4848ba96ede86f0719e51023b2085 100644 GIT binary patch literal 4636 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 S|D8Fflm9;?Lzof?Q2_ulQQWNn literal 4511 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 { 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 0000000000000000000000000000000000000000..a1ad720c37bcb5adaabf9359f6f52b27d507af47 GIT binary patch literal 319 zcmV-F0l@x2RzVa^j0vQgDHr4(VvnA!^YPyV7MM17A; - -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 */