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

#### PR Dependency Tree


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

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

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

## Summary by CodeRabbit

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

* **Bug Fixes**
  * None.

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

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

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

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