feat(server): send comment notifications

This commit is contained in:
fengmk2 2025-06-25 14:44:39 +08:00
parent 559aa849c8
commit c582ed6f35
No known key found for this signature in database
GPG Key ID: 8D8D804739EF5781
5 changed files with 376 additions and 1 deletions

View File

@ -6,6 +6,7 @@ import {
createReplyMutation,
deleteCommentMutation,
deleteReplyMutation,
DocMode,
listCommentChangesQuery,
listCommentsQuery,
resolveCommentMutation,
@ -64,6 +65,8 @@ e2e('should create comment work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -82,6 +85,8 @@ e2e('should create comment work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -94,6 +99,52 @@ e2e('should create comment work', async t => {
t.is(result2.createComment.replies.length, 0);
});
e2e('should create comment with mentions work', 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 count = app.queue.count('notification.sendComment');
const result = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
mentions: [
// send
owner.id,
// ignore doc owner himself
member.id,
// ignore not workspace member
other.id,
],
},
},
});
t.truthy(result.createComment.id);
t.false(result.createComment.resolved);
t.is(result.createComment.replies.length, 0);
// only send one notification to owner
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, owner.id);
});
e2e('should create comment work when user is Commenter', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
@ -110,6 +161,8 @@ e2e('should create comment work when user is Commenter', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -134,6 +187,8 @@ e2e('should create comment failed when user is not member', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -166,6 +221,8 @@ e2e('should create comment failed when user is Reader', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -190,6 +247,8 @@ e2e('should update comment work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -225,6 +284,8 @@ e2e('should update comment failed by another user', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -286,6 +347,8 @@ e2e('should resolve comment work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -350,6 +413,8 @@ e2e('should resolve comment work by doc Commenter himself', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -387,6 +452,8 @@ e2e('should resolve comment failed by doc Reader user', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -441,6 +508,8 @@ e2e('should delete comment work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -473,6 +542,8 @@ e2e('should create reply work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -486,6 +557,8 @@ e2e('should create reply work', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -498,6 +571,122 @@ e2e('should create reply work', async t => {
t.is(result.createReply.commentId, createResult.createComment.id);
});
e2e('should create reply with mentions work', 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' }],
},
},
},
});
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: [
// send
owner.id,
// ignore doc owner himself
member.id,
// ignore not workspace member
other.id,
],
},
},
});
t.truthy(result.createReply.id);
t.is(result.createReply.commentId, createResult.createComment.id);
// only send one notification to owner
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, owner.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 doc owner',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Owner,
});
await app.login(owner);
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' }],
},
},
},
});
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' }],
},
},
},
});
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, undefined);
}
);
e2e('should create reply work when user is Commenter', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
@ -514,6 +703,8 @@ e2e('should create reply work when user is Commenter', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -527,6 +718,8 @@ e2e('should create reply work when user is Commenter', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -547,6 +740,8 @@ e2e('should create reply failed when comment not found', async t => {
variables: {
input: {
commentId: 'not-found',
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -570,6 +765,8 @@ e2e('should create reply failed when user is not member', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -585,6 +782,8 @@ e2e('should create reply failed when user is not member', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -616,6 +815,8 @@ e2e('should create reply failed when user is Reader', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -631,6 +832,8 @@ e2e('should create reply failed when user is Reader', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -655,6 +858,8 @@ e2e('should update reply work when user is reply owner', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -668,6 +873,8 @@ e2e('should update reply work when user is reply owner', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -702,6 +909,8 @@ e2e('should update reply failed when user is not reply owner', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -715,6 +924,8 @@ e2e('should update reply failed when user is not reply owner', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -775,6 +986,8 @@ e2e('should delete reply work when user is reply owner', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -788,6 +1001,8 @@ e2e('should delete reply work when user is reply owner', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -822,6 +1037,8 @@ e2e('should delete reply work when user is doc Editor', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -835,6 +1052,8 @@ e2e('should delete reply work when user is doc Editor', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -870,6 +1089,8 @@ e2e('should delete reply work when user is doc Manager', async t => {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -883,6 +1104,8 @@ e2e('should delete reply work when user is doc Manager', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
@ -933,6 +1156,8 @@ e2e('should list comments and changes work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 1' }],
@ -949,6 +1174,8 @@ e2e('should list comments and changes work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 2' }],
@ -963,6 +1190,8 @@ e2e('should list comments and changes work', async t => {
input: {
workspaceId: workspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 3' }],
@ -976,6 +1205,8 @@ e2e('should list comments and changes work', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 1 reply 1' }],
@ -991,6 +1222,8 @@ e2e('should list comments and changes work', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 1 reply 2' }],
@ -1062,6 +1295,8 @@ e2e('should list comments and changes work', async t => {
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test 1 reply 3' }],

View File

@ -13,6 +13,7 @@ import {
CommentAttachmentQuotaExceeded,
CommentNotFound,
type FileUpload,
JobQueue,
readableToBuffer,
ReplyNotFound,
} from '../../base';
@ -21,6 +22,7 @@ import {
paginateWithCustomCursor,
PaginationInput,
} from '../../base/graphql';
import { Comment, DocMode, Models, Reply } from '../../models';
import { CurrentUser } from '../auth/session';
import { AccessController, DocAction } from '../permission';
import { CommentAttachmentStorage } from '../storage';
@ -50,7 +52,9 @@ export class CommentResolver {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly commentAttachmentStorage: CommentAttachmentStorage
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly queue: JobQueue,
private readonly models: Models
) {}
@Mutation(() => CommentObjectType)
@ -64,6 +68,15 @@ export class CommentResolver {
...input,
userId: me.id,
});
await this.sendCommentNotification(
me,
comment,
input.docTitle,
input.docMode,
input.mentions
);
return {
...comment,
user: {
@ -142,6 +155,16 @@ export class CommentResolver {
...input,
userId: me.id,
});
await this.sendCommentNotification(
me,
comment,
input.docTitle,
input.docMode,
input.mentions,
reply
);
return {
...reply,
user: {
@ -338,6 +361,74 @@ export class CommentResolver {
return this.commentAttachmentStorage.getUrl(workspaceId, docId, key);
}
private async sendCommentNotification(
sender: UserType,
comment: Comment,
docTitle: string,
docMode: DocMode,
mentions?: string[],
reply?: Reply
) {
// send comment notification to doc owners
const owner = await this.models.docUser.getOwner(
comment.workspaceId,
comment.docId
);
if (owner && owner.userId !== sender.id) {
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,
},
},
});
}
// send comment mention notification to mentioned users
if (mentions) {
for (const mentionUserId of mentions) {
// skip if the mention user is the doc owner
if (mentionUserId === owner?.userId || mentionUserId === sender.id) {
continue;
}
// check if the mention user has Doc.Comments.Read permission
const hasPermission = await this.ac
.user(mentionUserId)
.workspace(comment.workspaceId)
.doc(comment.docId)
.can('Doc.Comments.Read');
if (!hasPermission) {
continue;
}
await this.queue.add('notification.sendComment', {
isMention: true,
userId: mentionUserId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
commentId: comment.id,
replyId: reply?.id,
doc: {
id: comment.docId,
title: docTitle,
mode: docMode,
},
},
});
}
}
}
private async assertPermission(
me: UserType,
item: {

View File

@ -17,6 +17,7 @@ import {
CommentResolve,
CommentUpdate,
DeletedChangeItem,
DocMode,
Reply,
ReplyCreate,
ReplyUpdate,
@ -150,8 +151,21 @@ export class CommentCreateInput implements Partial<CommentCreate> {
@Field(() => ID)
docId!: string;
@Field(() => String)
docTitle!: string;
@Field(() => DocMode)
docMode!: DocMode;
@Field(() => GraphQLJSONObject)
content!: object;
@Field(() => [String], {
nullable: true,
description:
'The mention user ids, if not provided, the comment will not be mentioned',
})
mentions?: string[];
}
@InputType()
@ -181,6 +195,19 @@ export class ReplyCreateInput implements Partial<ReplyCreate> {
@Field(() => GraphQLJSONObject)
content!: object;
@Field(() => String)
docTitle!: string;
@Field(() => DocMode)
docMode!: DocMode;
@Field(() => [String], {
nullable: true,
description:
'The mention user ids, if not provided, the comment reply will not be mentioned',
})
mentions?: string[];
}
@InputType()

View File

@ -125,6 +125,13 @@ type CommentChangeObjectTypeEdge {
input CommentCreateInput {
content: JSONObject!
docId: ID!
docMode: DocMode!
docTitle: String!
"""
The mention user ids, if not provided, the comment will not be mentioned
"""
mentions: [String!]
workspaceId: ID!
}
@ -1583,6 +1590,13 @@ input RemoveContextFileInput {
input ReplyCreateInput {
commentId: ID!
content: JSONObject!
docMode: DocMode!
docTitle: String!
"""
The mention user ids, if not provided, the comment reply will not be mentioned
"""
mentions: [String!]
}
type ReplyObjectType {

View File

@ -165,6 +165,10 @@ export interface CommentChangeObjectTypeEdge {
export interface CommentCreateInput {
content: Scalars['JSONObject']['input'];
docId: Scalars['ID']['input'];
docMode: DocMode;
docTitle: Scalars['String']['input'];
/** The mention user ids, if not provided, the comment will not be mentioned */
mentions?: InputMaybe<Array<Scalars['String']['input']>>;
workspaceId: Scalars['ID']['input'];
}
@ -2172,6 +2176,10 @@ export interface RemoveContextFileInput {
export interface ReplyCreateInput {
commentId: Scalars['ID']['input'];
content: Scalars['JSONObject']['input'];
docMode: DocMode;
docTitle: Scalars['String']['input'];
/** The mention user ids, if not provided, the comment reply will not be mentioned */
mentions?: InputMaybe<Array<Scalars['String']['input']>>;
}
export interface ReplyObjectType {