feat(server): support comment notification type

This commit is contained in:
fengmk2 2025-06-25 10:56:37 +08:00
parent 6cb63ddc48
commit 559aa849c8
No known key found for this signature in database
GPG Key ID: 8D8D804739EF5781
23 changed files with 916 additions and 255 deletions

View File

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

View File

@ -821,6 +821,8 @@ enum NotificationType {
InvitationReviewRequest
InvitationReviewApproved
InvitationReviewDeclined
Comment
CommentMention
}
enum NotificationLevel {

View File

@ -1513,6 +1513,179 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->
`
> test@test.com commented on Test Doc
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>
<tr>
<td>
<p
style="font-size:20px;line-height:28px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
You have a new comment␊
</p>
</td>
</tr>
</tbody>
</table>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>
<tr>
<td>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">
<tr style="width:100%">
<p
style="font-size:15px;line-height:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
<span style="font-weight:600">test@test.com</span> commented on␊
<a
href="https://app.affine.pro"␊
style="color:#067df7;text-decoration-line:none"␊
target="_blank"␊
><span style="font-weight:600">Test Doc</span></a␊
>.␊
</p>
</tr>
</tbody>
</table>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">
<tr style="width:100%">
<a
href="https://app.affine.pro"␊
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
target="_blank"␊
><span
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>&#8202;&#8202;</i><![endif]--></span␊
><span
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
>View Comment</span␊
><span
><!--[if mso]><i style="mso-font-width:450%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span␊
></a␊
>␊
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--/$-->
`
> test@test.com mentioned you in a comment on Test Doc
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>
<tr>
<td>
<p
style="font-size:20px;line-height:28px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
You are mentioned in a comment␊
</p>
</td>
</tr>
</tbody>
</table>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>
<tr>
<td>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">
<tr style="width:100%">
<p
style="font-size:15px;line-height:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
<span style="font-weight:600">test@test.com</span> mentioned you␊
in a comment on␊
<a
href="https://app.affine.pro"␊
style="color:#067df7;text-decoration-line:none"␊
target="_blank"␊
><span style="font-weight:600">Test Doc</span></a␊
>.␊
</p>
</tr>
</tbody>
</table>
<table
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">
<tr style="width:100%">
<a
href="https://app.affine.pro"␊
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
target="_blank"␊
><span
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>&#8202;&#8202;</i><![endif]--></span␊
><span
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
>View Comment</span␊
><span
><!--[if mso]><i style="mso-font-width:450%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span␊
></a␊
>␊
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--/$-->
`
> Your workspace has been upgraded to team workspace! 🎉
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<Template>
<Title>You are mentioned in a comment</Title>
<Content>
<P>
<User {...user} /> mentioned you in a comment on <Doc {...doc} />.
</P>
<Button href={doc.url}>View Comment</Button>
</Content>
</Template>
);
}
CommentMention.PreviewProps = {
user: TEST_USER,
doc: TEST_DOC,
};

View File

@ -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 (
<Template>
<Title>You have a new comment</Title>
<Content>
<P>
<User {...user} /> commented on <Doc {...doc} />.
</P>
<Button href={doc.url}>View Comment</Button>
</Content>
</Template>
);
}
Comment.PreviewProps = {
user: TEST_USER,
doc: TEST_DOC,
};

View File

@ -1 +1,3 @@
export * from './comment';
export * from './comment-mention';
export * from './mention';

View File

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

View File

@ -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,
}

View File

@ -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<Context>;
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);
});

View File

@ -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<Context>;
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,
}),

View File

@ -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<typeof InvitationReviewDeclinedNotificationCreateSchema>;
export type CommentNotification = Notification &
z.infer<typeof CommentNotificationCreateSchema>;
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) {

View File

@ -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<typeof UserSettingsSchema>;

View File

@ -1360,6 +1360,8 @@ type NotificationObjectTypeEdge {
"""Notification type"""
enum NotificationType {
Comment
CommentMention
Invitation
InvitationAccepted
InvitationBlocked
@ -1929,6 +1931,9 @@ input UpdateUserInput {
}
input UpdateUserSettingsInput {
"""Receive comment email"""
receiveCommentEmail: Boolean
"""Receive invitation email"""
receiveInvitationEmail: Boolean
@ -1989,6 +1994,9 @@ type UserQuotaUsageType {
}
type UserSettingsType {
"""Receive comment email"""
receiveCommentEmail: Boolean!
"""Receive invitation email"""
receiveInvitationEmail: Boolean!

View File

@ -1895,6 +1895,8 @@ export interface NotificationObjectTypeEdge {
/** Notification type */
export enum NotificationType {
Comment = 'Comment',
CommentMention = 'CommentMention',
Invitation = 'Invitation',
InvitationAccepted = 'InvitationAccepted',
InvitationBlocked = 'InvitationBlocked',
@ -2527,6 +2529,8 @@ export interface UpdateUserInput {
}
export interface UpdateUserSettingsInput {
/** Receive comment email */
receiveCommentEmail?: InputMaybe<Scalars['Boolean']['input']>;
/** Receive invitation email */
receiveInvitationEmail?: InputMaybe<Scalars['Boolean']['input']>;
/** Receive mention email */
@ -2586,6 +2590,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 */