chore(server): send comment notification to all repliers (#13063)

close AF-2714



#### PR Dependency Tree


* **PR #13063** 👈

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**
  * Added the ability to list all replies for a specific comment.

* **Bug Fixes**
* Improved notification delivery for comment replies, ensuring all
relevant users (comment author, document owner, and all repliers) are
notified appropriately.

* **Tests**
* Added and updated tests to verify correct notification behavior and
the new reply listing functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-07 15:03:36 +08:00
committed by GitHub
parent 563a14d0b3
commit 3b8ae496dc
4 changed files with 219 additions and 27 deletions

View File

@@ -688,7 +688,7 @@ e2e(
);
e2e(
'should create reply and send comment mention notification to comment author',
'should create reply and send comment notification to comment author',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
@@ -740,12 +740,12 @@ e2e(
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, member.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, true);
t.is(notification.payload.isMention, undefined);
}
);
e2e(
'should create reply and send comment mention notification to comment author only when author is doc owner',
'should create reply and send comment notification to comment author only when author is doc owner',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
@@ -796,7 +796,140 @@ e2e(
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, member.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, true);
t.is(notification.payload.isMention, undefined);
}
);
e2e('should send comment mention notification is high priority', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Owner,
});
await app.login(member);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(owner);
const count = app.queue.count('notification.sendComment');
const result = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
mentions: [member.id],
},
},
});
t.truthy(result.createReply.id);
t.is(result.createReply.commentId, createResult.createComment.id);
t.is(app.queue.count('notification.sendComment'), count + 1);
const notification = app.queue.last('notification.sendComment');
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, member.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, true);
});
e2e(
'should create reply and send comment notification to all repliers',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Owner,
});
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: other.id,
type: DocRole.Commenter,
});
await app.login(member);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(owner);
await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
// notify to all repliers: member and owner
const count = app.queue.count('notification.sendComment');
await app.login(other);
const result = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
t.truthy(result.createReply.id);
t.is(result.createReply.commentId, createResult.createComment.id);
t.is(app.queue.count('notification.sendComment'), count + 2);
const notification = app.queue.last('notification.sendComment');
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, owner.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, undefined);
}
);

View File

@@ -375,10 +375,7 @@ export class CommentResolver {
reply?: Reply
) {
const mentionUserIds = new Set(mentions);
// send comment mention notification to comment author on reply
if (reply) {
mentionUserIds.add(comment.userId);
}
const notifyUserIds = new Set<string>();
// send comment mention notification to mentioned users
for (const mentionUserId of mentionUserIds) {
@@ -420,26 +417,41 @@ export class CommentResolver {
comment.workspaceId,
comment.docId
);
// if the owner is not in the mention user ids, send comment notification to the owner
if (
owner &&
owner.userId !== sender.id &&
!mentionUserIds.has(owner.userId)
) {
await this.queue.add('notification.sendComment', {
userId: owner.userId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
commentId: comment.id,
replyId: reply?.id,
doc: {
id: comment.docId,
title: docTitle,
mode: docMode,
if (owner) {
notifyUserIds.add(owner.userId);
}
// send comment notification to all repliers and comment author
if (reply) {
notifyUserIds.add(comment.userId);
const replies = await this.models.comment.listReplies(
comment.workspaceId,
comment.docId,
comment.id
);
for (const reply of replies) {
notifyUserIds.add(reply.userId);
}
}
for (const userId of notifyUserIds) {
// skip if the user is the sender or mentioned
if (userId !== sender.id && !mentionUserIds.has(userId)) {
await this.queue.add('notification.sendComment', {
userId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
commentId: comment.id,
replyId: reply?.id,
doc: {
id: comment.docId,
title: docTitle,
mode: docMode,
},
},
},
});
});
}
}
}

View File

@@ -524,3 +524,43 @@ test('should list changes', async t => {
});
t.is(changes5.length, 0);
});
test('should list replies', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply1 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply1' }],
},
commentId: comment.id,
});
const reply2 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
commentId: comment.id,
});
const replies = await models.comment.listReplies(
workspace.id,
docId,
comment.id
);
t.is(replies.length, 2);
t.is(replies[0].id, reply1.id);
t.is(replies[1].id, reply2.id);
});

View File

@@ -300,6 +300,13 @@ export class CommentModel extends BaseModel {
})) as Reply | null;
}
async listReplies(workspaceId: string, docId: string, commentId: string) {
return (await this.db.reply.findMany({
where: { workspaceId, docId, commentId, deletedAt: null },
orderBy: { sid: 'asc' },
})) as Reply[];
}
/**
* Update a reply content
* @param input - The reply update input