feat(server): send comment notifications (#12925)

close CLOUD-228















#### PR Dependency Tree


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

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
fengmk2
2025-07-01 22:25:31 +08:00
committed by GitHub
parent 7ed72ed1d0
commit a2810f3f61
5 changed files with 376 additions and 1 deletions
@@ -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: {
@@ -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()