diff --git a/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts index d7d7a4081a..423625f7b6 100644 --- a/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts @@ -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' }], diff --git a/packages/backend/server/src/core/comment/resolver.ts b/packages/backend/server/src/core/comment/resolver.ts index e7668351f8..a36749acf0 100644 --- a/packages/backend/server/src/core/comment/resolver.ts +++ b/packages/backend/server/src/core/comment/resolver.ts @@ -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: { diff --git a/packages/backend/server/src/core/comment/types.ts b/packages/backend/server/src/core/comment/types.ts index 03d70ece36..823b6b5096 100644 --- a/packages/backend/server/src/core/comment/types.ts +++ b/packages/backend/server/src/core/comment/types.ts @@ -17,6 +17,7 @@ import { CommentResolve, CommentUpdate, DeletedChangeItem, + DocMode, Reply, ReplyCreate, ReplyUpdate, @@ -150,8 +151,21 @@ export class CommentCreateInput implements Partial { @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 { @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() diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 4a2af2c20c..8736a4d502 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! } @@ -1584,6 +1591,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 { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 48b63a9804..2e0157eb82 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -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>; workspaceId: Scalars['ID']['input']; } @@ -2173,6 +2177,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>; } export interface ReplyObjectType {