feat(server): comment service and resolver (#12761)

close CLOUD-227
close CLOUD-230

































#### PR Dependency Tree


* **PR #12761** 👈
  * **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 a comprehensive commenting system, enabling users to
create, update, resolve, and delete comments and replies on documents.
* Added support for uploading attachments to comments, with clear error
messaging if size limits are exceeded.
* Implemented role-based permissions for comment actions, including a
new "Commenter" role.
* Enabled paginated listing and change tracking of comments and replies
via GraphQL queries.
* Provided full localization and error handling for comment-related
actions.

* **Bug Fixes**
* Improved uniqueness handling when fetching user data for comments and
replies.

* **Documentation**
* Extended GraphQL schema and frontend localization to document and
support new comment features.

* **Tests**
* Added extensive backend test suites covering all comment and reply
functionalities, permissions, and attachment uploads.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-01 21:12:28 +08:00
committed by GitHub
parent 6a04fbe335
commit 2aa5c13082
37 changed files with 3504 additions and 9 deletions

View File

@@ -0,0 +1,33 @@
# Snapshot report for `src/core/comment/__tests__/service.spec.ts`
The actual snapshot is saved in `service.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should update a comment
> Snapshot 1
{
content: [
{
text: 'test2',
type: 'text',
},
],
type: 'paragraph',
}
## should update a reply
> Snapshot 1
{
content: [
{
text: 'test2',
type: 'text',
},
],
type: 'paragraph',
}

View File

@@ -0,0 +1,411 @@
import { randomUUID } from 'node:crypto';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Comment, CommentChangeAction } from '../../../models';
import { CommentModule } from '..';
import { CommentService } from '../service';
const module = await createModule({
imports: [CommentModule],
});
const commentService = module.get(CommentService);
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
const member = await module.create(Mockers.User);
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
test.after.always(async () => {
await module.close();
});
test('should create a comment', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
t.truthy(comment);
});
test('should update a comment', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const updatedComment = await commentService.updateComment({
id: comment.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
});
t.snapshot(updatedComment.content);
});
test('should delete a comment', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
await commentService.deleteComment(comment.id);
const deletedComment = await commentService.getComment(comment.id);
t.is(deletedComment, null);
});
test('should resolve a comment', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const resolvedComment = await commentService.resolveComment({
id: comment.id,
resolved: true,
});
t.is(resolvedComment.resolved, true);
// unresolved
const unresolvedComment = await commentService.resolveComment({
id: comment.id,
resolved: false,
});
t.is(unresolvedComment.resolved, false);
});
test('should create a reply', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const reply = await commentService.createReply({
commentId: comment.id,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
t.truthy(reply);
});
test('should update a reply', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const reply = await commentService.createReply({
commentId: comment.id,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const updatedReply = await commentService.updateReply({
id: reply.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
});
t.snapshot(updatedReply.content);
});
test('should delete a reply', async t => {
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId: randomUUID(),
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const reply = await commentService.createReply({
commentId: comment.id,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
await commentService.deleteReply(reply.id);
const deletedReply = await commentService.getReply(reply.id);
t.is(deletedReply, null);
});
test('should list comments', async t => {
const docId = randomUUID();
// empty comments
let comments = await commentService.listComments(workspace.id, docId, {
take: 2,
});
t.is(comments.length, 0);
const comment0 = await commentService.createComment({
workspaceId: workspace.id,
docId,
userId: member.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test0' }],
},
});
const comment1 = await commentService.createComment({
workspaceId: workspace.id,
docId,
userId: member.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
const comment2 = await commentService.createComment({
workspaceId: workspace.id,
docId,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
});
const reply1 = await commentService.createReply({
commentId: comment2.id,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply' }],
},
});
const reply2 = await commentService.createReply({
commentId: comment2.id,
userId: member.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
});
const reply3 = await commentService.createReply({
commentId: comment0.id,
userId: member.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply3' }],
},
});
// order by sid desc
comments = await commentService.listComments(workspace.id, docId, {
take: 2,
});
t.is(comments.length, 2);
t.is(comments[0].id, comment2.id);
t.is(comments[0].user.id, owner.id);
// replies order by sid asc
t.is(comments[0].replies.length, 2);
t.is(comments[0].replies[0].id, reply1.id);
t.is(comments[0].replies[0].user.id, owner.id);
t.is(comments[0].replies[1].id, reply2.id);
t.is(comments[0].replies[1].user.id, member.id);
t.is(comments[1].id, comment1.id);
t.is(comments[1].user.id, member.id);
t.is(comments[1].replies.length, 0);
// next page
const comments2 = await commentService.listComments(workspace.id, docId, {
take: 2,
sid: comments[1].sid,
});
t.is(comments2.length, 1);
t.is(comments2[0].id, comment0.id);
t.is(comments2[0].user.id, member.id);
t.is(comments2[0].replies.length, 1);
t.is(comments2[0].replies[0].id, reply3.id);
t.is(comments2[0].replies[0].user.id, member.id);
// no more comments
const comments3 = await commentService.listComments(workspace.id, docId, {
take: 2,
sid: comments2[0].sid,
});
t.is(comments3.length, 0);
});
test('should list comment changes from scratch', async t => {
const docId = randomUUID();
let changes = await commentService.listCommentChanges(workspace.id, docId, {
take: 2,
});
t.is(changes.length, 0);
let commentUpdatedAt: Date | undefined;
let replyUpdatedAt: Date | undefined;
const comment = await commentService.createComment({
workspaceId: workspace.id,
docId,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
});
changes = await commentService.listCommentChanges(workspace.id, docId, {
commentUpdatedAt,
replyUpdatedAt,
});
t.is(changes.length, 1);
t.is(changes[0].action, CommentChangeAction.update);
t.is(changes[0].id, comment.id);
t.deepEqual(changes[0].item, comment);
commentUpdatedAt = changes[0].item.updatedAt;
// 2 new replies, 1 new comment and update it, 3 changes
const reply1 = await commentService.createReply({
commentId: comment.id,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply1' }],
},
});
const reply2 = await commentService.createReply({
commentId: comment.id,
userId: member.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
});
const comment2 = await commentService.createComment({
workspaceId: workspace.id,
docId,
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test comment2' }],
},
});
const updateContent = {
type: 'paragraph',
content: [{ type: 'text', text: 'test comment2 update' }],
};
await commentService.updateComment({
id: comment2.id,
content: updateContent,
});
changes = await commentService.listCommentChanges(workspace.id, docId, {
commentUpdatedAt,
replyUpdatedAt,
});
t.is(changes.length, 3);
t.is(changes[0].action, CommentChangeAction.update);
t.is(changes[0].id, comment2.id);
t.deepEqual((changes[0].item as Comment).content, updateContent);
t.is(changes[1].action, CommentChangeAction.update);
t.is(changes[1].id, reply1.id);
t.is(changes[1].commentId, comment.id);
t.deepEqual(changes[1].item, reply1);
t.is(changes[2].action, CommentChangeAction.update);
t.is(changes[2].id, reply2.id);
t.is(changes[2].commentId, comment.id);
t.deepEqual(changes[2].item, reply2);
commentUpdatedAt = changes[0].item.updatedAt;
replyUpdatedAt = changes[2].item.updatedAt;
// delete comment2 and reply1, 2 changes
await commentService.deleteComment(comment2.id);
await commentService.deleteReply(reply1.id);
changes = await commentService.listCommentChanges(workspace.id, docId, {
commentUpdatedAt,
replyUpdatedAt,
});
t.is(changes.length, 2);
t.is(changes[0].action, CommentChangeAction.delete);
t.is(changes[0].id, comment2.id);
t.is(changes[1].action, CommentChangeAction.delete);
t.is(changes[1].id, reply1.id);
commentUpdatedAt = changes[0].item.updatedAt;
replyUpdatedAt = changes[1].item.updatedAt;
// no changes
changes = await commentService.listCommentChanges(workspace.id, docId, {
commentUpdatedAt,
replyUpdatedAt,
});
t.is(changes.length, 0);
});

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { CommentResolver } from './resolver';
import { CommentService } from './service';
@Module({
imports: [PermissionModule, StorageModule],
providers: [CommentResolver, CommentService],
exports: [CommentService],
})
export class CommentModule {}

View File

@@ -0,0 +1,361 @@
import { randomUUID } from 'node:crypto';
import {
Args,
Mutation,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
CommentAttachmentQuotaExceeded,
CommentNotFound,
type FileUpload,
readableToBuffer,
ReplyNotFound,
} from '../../base';
import {
decodeWithJson,
paginateWithCustomCursor,
PaginationInput,
} from '../../base/graphql';
import { CurrentUser } from '../auth/session';
import { AccessController, DocAction } from '../permission';
import { CommentAttachmentStorage } from '../storage';
import { UserType } from '../user';
import { WorkspaceType } from '../workspaces';
import { CommentService } from './service';
import {
CommentCreateInput,
CommentObjectType,
CommentResolveInput,
CommentUpdateInput,
PaginatedCommentChangeObjectType,
PaginatedCommentObjectType,
ReplyCreateInput,
ReplyObjectType,
ReplyUpdateInput,
} from './types';
export interface CommentCursor {
sid?: number;
commentUpdatedAt?: Date;
replyUpdatedAt?: Date;
}
@Resolver(() => WorkspaceType)
export class CommentResolver {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
private readonly commentAttachmentStorage: CommentAttachmentStorage
) {}
@Mutation(() => CommentObjectType)
async createComment(
@CurrentUser() me: UserType,
@Args('input') input: CommentCreateInput
): Promise<CommentObjectType> {
await this.assertPermission(me, input, 'Doc.Comments.Create');
const comment = await this.service.createComment({
...input,
userId: me.id,
});
return {
...comment,
user: {
id: me.id,
name: me.name,
avatarUrl: me.avatarUrl,
},
replies: [],
};
}
@Mutation(() => Boolean, {
description: 'Update a comment content',
})
async updateComment(
@CurrentUser() me: UserType,
@Args('input') input: CommentUpdateInput
) {
const comment = await this.service.getComment(input.id);
if (!comment) {
throw new CommentNotFound();
}
await this.assertPermission(me, comment, 'Doc.Comments.Update');
await this.service.updateComment(input);
return true;
}
@Mutation(() => Boolean, {
description: 'Resolve a comment or not',
})
async resolveComment(
@CurrentUser() me: UserType,
@Args('input') input: CommentResolveInput
) {
const comment = await this.service.getComment(input.id);
if (!comment) {
throw new CommentNotFound();
}
await this.assertPermission(me, comment, 'Doc.Comments.Resolve');
await this.service.resolveComment(input);
return true;
}
@Mutation(() => Boolean, {
description: 'Delete a comment',
})
async deleteComment(@CurrentUser() me: UserType, @Args('id') id: string) {
const comment = await this.service.getComment(id);
if (!comment) {
throw new CommentNotFound();
}
await this.assertPermission(me, comment, 'Doc.Comments.Delete');
await this.service.deleteComment(id);
return true;
}
@Mutation(() => ReplyObjectType)
async createReply(
@CurrentUser() me: UserType,
@Args('input') input: ReplyCreateInput
): Promise<ReplyObjectType> {
const comment = await this.service.getComment(input.commentId);
if (!comment) {
throw new CommentNotFound();
}
await this.assertPermission(me, comment, 'Doc.Comments.Create');
const reply = await this.service.createReply({
...input,
userId: me.id,
});
return {
...reply,
user: {
id: me.id,
name: me.name,
avatarUrl: me.avatarUrl,
},
};
}
@Mutation(() => Boolean, {
description: 'Update a reply content',
})
async updateReply(
@CurrentUser() me: UserType,
@Args('input') input: ReplyUpdateInput
) {
const reply = await this.service.getReply(input.id);
if (!reply) {
throw new ReplyNotFound();
}
await this.assertPermission(me, reply, 'Doc.Comments.Update');
await this.service.updateReply(input);
return true;
}
@Mutation(() => Boolean, {
description: 'Delete a reply',
})
async deleteReply(@CurrentUser() me: UserType, @Args('id') id: string) {
const reply = await this.service.getReply(id);
if (!reply) {
throw new ReplyNotFound();
}
await this.assertPermission(me, reply, 'Doc.Comments.Delete');
await this.service.deleteReply(id);
return true;
}
@ResolveField(() => PaginatedCommentObjectType, {
description: 'Get comments of a doc',
})
async comments(
@CurrentUser() me: UserType,
@Parent() workspace: WorkspaceType,
@Args('docId') docId: string,
@Args({
name: 'pagination',
nullable: true,
})
pagination?: PaginationInput
): Promise<PaginatedCommentObjectType> {
await this.assertPermission(
me,
{
workspaceId: workspace.id,
docId,
},
'Doc.Comments.Read'
);
const cursor: CommentCursor = decodeWithJson(pagination?.after) ?? {};
const [totalCount, comments] = await Promise.all([
this.service.getCommentCount(workspace.id, docId),
this.service.listComments(workspace.id, docId, {
sid: cursor.sid,
take: pagination?.first,
}),
]);
const endCursor: CommentCursor = {};
const startCursor: CommentCursor = {};
if (comments.length > 0) {
const lastComment = comments[comments.length - 1];
// next page cursor
endCursor.sid = lastComment.sid;
const firstComment = comments[0];
startCursor.sid = firstComment.sid;
startCursor.commentUpdatedAt = firstComment.updatedAt;
let replyUpdatedAt: Date | undefined;
// find latest reply
for (const comment of comments) {
for (const reply of comment.replies) {
if (
!replyUpdatedAt ||
reply.updatedAt.getTime() > replyUpdatedAt.getTime()
) {
replyUpdatedAt = reply.updatedAt;
}
}
}
if (!replyUpdatedAt) {
// if no reply, use comment updated at as reply updated at
replyUpdatedAt = startCursor.commentUpdatedAt;
}
startCursor.replyUpdatedAt = replyUpdatedAt;
}
return paginateWithCustomCursor(
comments,
totalCount,
startCursor,
endCursor,
// not support to get previous page
false
);
}
@ResolveField(() => PaginatedCommentChangeObjectType, {
description: 'Get comment changes of a doc',
})
async commentChanges(
@CurrentUser() me: UserType,
@Parent() workspace: WorkspaceType,
@Args('docId') docId: string,
@Args({
name: 'pagination',
})
pagination: PaginationInput
): Promise<PaginatedCommentChangeObjectType> {
await this.assertPermission(
me,
{
workspaceId: workspace.id,
docId,
},
'Doc.Comments.Read'
);
const cursor: CommentCursor = decodeWithJson(pagination.after) ?? {};
const changes = await this.service.listCommentChanges(workspace.id, docId, {
commentUpdatedAt: cursor.commentUpdatedAt,
replyUpdatedAt: cursor.replyUpdatedAt,
take: pagination.first,
});
const endCursor = cursor;
for (const c of changes) {
if (c.commentId) {
// is reply change
endCursor.replyUpdatedAt = c.item.updatedAt;
} else {
// is comment change
endCursor.commentUpdatedAt = c.item.updatedAt;
}
}
return paginateWithCustomCursor(
changes,
changes.length,
// not support to get start cursor
null,
endCursor,
// not support to get previous page
false
);
}
@Mutation(() => String, {
description: 'Upload a comment attachment and return the access url',
})
async uploadCommentAttachment(
@CurrentUser() me: UserType,
@Args('workspaceId') workspaceId: string,
@Args('docId') docId: string,
@Args({ name: 'attachment', type: () => GraphQLUpload })
attachment: FileUpload
) {
await this.assertPermission(
me,
{ workspaceId, docId },
'Doc.Comments.Create'
);
// TODO(@fengmk2): should check total attachment quota in the future version
const buffer = await readableToBuffer(attachment.createReadStream());
// max attachment size is 10MB
if (buffer.length > 10 * 1024 * 1024) {
throw new CommentAttachmentQuotaExceeded();
}
const key = randomUUID();
await this.commentAttachmentStorage.put(
workspaceId,
docId,
key,
attachment.filename ?? key,
buffer
);
return this.commentAttachmentStorage.getUrl(workspaceId, docId, key);
}
private async assertPermission(
me: UserType,
item: {
workspaceId: string;
docId: string;
userId?: string;
},
action: DocAction
) {
// the owner of the comment/reply can update, delete, resolve it
if (item.userId === me.id) {
return;
}
await this.ac
.user(me.id)
.workspace(item.workspaceId)
.doc(item.docId)
.assert(action);
}
}

View File

@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import {
CommentCreate,
CommentResolve,
CommentUpdate,
ItemWithUserId,
Models,
ReplyCreate,
ReplyUpdate,
} from '../../models';
import { PublicUserType } from '../user';
@Injectable()
export class CommentService {
constructor(private readonly models: Models) {}
async createComment(input: CommentCreate) {
const comment = await this.models.comment.create(input);
return await this.fillUser(comment);
}
async getComment(id: string) {
const comment = await this.models.comment.get(id);
return comment ? await this.fillUser(comment) : null;
}
async updateComment(input: CommentUpdate) {
return await this.models.comment.update(input);
}
async resolveComment(input: CommentResolve) {
return await this.models.comment.resolve(input);
}
async deleteComment(id: string) {
return await this.models.comment.delete(id);
}
async createReply(input: ReplyCreate) {
const reply = await this.models.comment.createReply(input);
return await this.fillUser(reply);
}
async getReply(id: string) {
const reply = await this.models.comment.getReply(id);
return reply ? await this.fillUser(reply) : null;
}
async updateReply(input: ReplyUpdate) {
return await this.models.comment.updateReply(input);
}
async deleteReply(id: string) {
return await this.models.comment.deleteReply(id);
}
async getCommentCount(workspaceId: string, docId: string) {
return await this.models.comment.count(workspaceId, docId);
}
async listComments(
workspaceId: string,
docId: string,
options?: {
sid?: number;
take?: number;
}
) {
const comments = await this.models.comment.list(
workspaceId,
docId,
options
);
// fill user info
const userMap = await this.models.user.getPublicUsersMap([
...comments,
...comments.flatMap(c => c.replies),
]);
return comments.map(c => ({
...c,
user: userMap.get(c.userId) as PublicUserType,
replies: c.replies.map(r => ({
...r,
user: userMap.get(r.userId) as PublicUserType,
})),
}));
}
async listCommentChanges(
workspaceId: string,
docId: string,
options: {
commentUpdatedAt?: Date;
replyUpdatedAt?: Date;
take?: number;
}
) {
const changes = await this.models.comment.listChanges(
workspaceId,
docId,
options
);
// fill user info
const userMap = await this.models.user.getPublicUsersMap(
changes.map(c => c.item as ItemWithUserId)
);
return changes.map(c => ({
...c,
item:
'userId' in c.item
? {
...c.item,
user: userMap.get(c.item.userId) as PublicUserType,
}
: c.item,
}));
}
private async fillUser<T extends { userId: string }>(item: T) {
const user = await this.models.user.getPublicUser(item.userId);
return {
...item,
user: user as PublicUserType,
};
}
}

View File

@@ -0,0 +1,193 @@
import {
createUnionType,
Field,
ID,
InputType,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { GraphQLJSONObject } from 'graphql-scalars';
import { Paginated } from '../../base';
import {
Comment,
CommentChange,
CommentChangeAction,
CommentCreate,
CommentResolve,
CommentUpdate,
DeletedChangeItem,
Reply,
ReplyCreate,
ReplyUpdate,
} from '../../models';
import { PublicUserType } from '../user';
@ObjectType()
export class CommentObjectType implements Partial<Comment> {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSONObject, {
description: 'The content of the comment',
})
content!: object;
@Field(() => Boolean, {
description: 'Whether the comment is resolved',
})
resolved!: boolean;
@Field(() => PublicUserType, {
description: 'The user who created the comment',
})
user!: PublicUserType;
@Field(() => Date, {
description: 'The created at time of the comment',
})
createdAt!: Date;
@Field(() => Date, {
description: 'The updated at time of the comment',
})
updatedAt!: Date;
@Field(() => [ReplyObjectType], {
description: 'The replies of the comment',
})
replies!: ReplyObjectType[];
}
@ObjectType()
export class ReplyObjectType implements Partial<Reply> {
@Field(() => ID)
commentId!: string;
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSONObject, {
description: 'The content of the reply',
})
content!: object;
@Field(() => PublicUserType, {
description: 'The user who created the reply',
})
user!: PublicUserType;
@Field(() => Date, {
description: 'The created at time of the reply',
})
createdAt!: Date;
@Field(() => Date, {
description: 'The updated at time of the reply',
})
updatedAt!: Date;
}
@ObjectType()
export class DeletedCommentObjectType implements DeletedChangeItem {
@Field(() => Date, {
description: 'The deleted at time of the comment or reply',
})
deletedAt!: Date;
@Field(() => Date, {
description: 'The updated at time of the comment or reply',
})
updatedAt!: Date;
}
export const UnionCommentObjectType = createUnionType({
name: 'UnionCommentObjectType',
types: () =>
[CommentObjectType, ReplyObjectType, DeletedCommentObjectType] as const,
});
registerEnumType(CommentChangeAction, {
name: 'CommentChangeAction',
description: 'Comment change action',
});
@ObjectType()
export class CommentChangeObjectType implements Omit<CommentChange, 'item'> {
@Field(() => CommentChangeAction, {
description: 'The action of the comment change',
})
action!: CommentChangeAction;
@Field(() => ID)
id!: string;
@Field(() => ID, {
nullable: true,
})
commentId?: string;
@Field(() => GraphQLJSONObject, {
description:
'The item of the comment or reply, different types have different fields, see UnionCommentObjectType',
})
item!: object;
}
@ObjectType()
export class PaginatedCommentObjectType extends Paginated(CommentObjectType) {}
@ObjectType()
export class PaginatedCommentChangeObjectType extends Paginated(
CommentChangeObjectType
) {}
@InputType()
export class CommentCreateInput implements Partial<CommentCreate> {
@Field(() => ID)
workspaceId!: string;
@Field(() => ID)
docId!: string;
@Field(() => GraphQLJSONObject)
content!: object;
}
@InputType()
export class CommentUpdateInput implements Partial<CommentUpdate> {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSONObject)
content!: object;
}
@InputType()
export class CommentResolveInput implements Partial<CommentResolve> {
@Field(() => ID)
id!: string;
@Field(() => Boolean, {
description: 'Whether the comment is resolved',
})
resolved!: boolean;
}
@InputType()
export class ReplyCreateInput implements Partial<ReplyCreate> {
@Field(() => ID)
commentId!: string;
@Field(() => GraphQLJSONObject)
content!: object;
}
@InputType()
export class ReplyUpdateInput implements Partial<ReplyUpdate> {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSONObject)
content!: object;
}

View File

@@ -18,6 +18,10 @@ Generated by [AVA](https://avajs.dev).
'Reader'
> WorkspaceRole: External, DocRole: Commenter
'Commenter'
> WorkspaceRole: External, DocRole: Editor
'Editor'
@@ -42,6 +46,10 @@ Generated by [AVA](https://avajs.dev).
'Reader'
> WorkspaceRole: Collaborator, DocRole: Commenter
'Commenter'
> WorkspaceRole: Collaborator, DocRole: Editor
'Editor'
@@ -66,6 +74,10 @@ Generated by [AVA](https://avajs.dev).
'Manager'
> WorkspaceRole: Admin, DocRole: Commenter
'Manager'
> WorkspaceRole: Admin, DocRole: Editor
'Manager'
@@ -90,6 +102,10 @@ Generated by [AVA](https://avajs.dev).
'Owner'
> WorkspaceRole: Owner, DocRole: Commenter
'Owner'
> WorkspaceRole: Owner, DocRole: Editor
'Owner'
@@ -209,6 +225,10 @@ Generated by [AVA](https://avajs.dev).
> DocRole: None
{
'Doc.Comments.Create': false,
'Doc.Comments.Delete': false,
'Doc.Comments.Read': false,
'Doc.Comments.Resolve': false,
'Doc.Copy': false,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -227,6 +247,10 @@ Generated by [AVA](https://avajs.dev).
> DocRole: External
{
'Doc.Comments.Create': false,
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': false,
@@ -245,6 +269,32 @@ Generated by [AVA](https://avajs.dev).
> DocRole: Reader
{
'Doc.Comments.Create': false,
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
'Doc.Properties.Read': true,
'Doc.Properties.Update': false,
'Doc.Publish': false,
'Doc.Read': true,
'Doc.Restore': false,
'Doc.TransferOwner': false,
'Doc.Trash': false,
'Doc.Update': false,
'Doc.Users.Manage': false,
'Doc.Users.Read': true,
}
> DocRole: Commenter
{
'Doc.Comments.Create': true,
'Doc.Comments.Delete': false,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': false,
'Doc.Copy': true,
'Doc.Delete': false,
'Doc.Duplicate': true,
@@ -263,6 +313,10 @@ Generated by [AVA](https://avajs.dev).
> DocRole: Editor
{
'Doc.Comments.Create': true,
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -281,6 +335,10 @@ Generated by [AVA](https://avajs.dev).
> DocRole: Manager
{
'Doc.Comments.Create': true,
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -299,6 +357,10 @@ Generated by [AVA](https://avajs.dev).
> DocRole: Owner
{
'Doc.Comments.Create': true,
'Doc.Comments.Delete': true,
'Doc.Comments.Read': true,
'Doc.Comments.Resolve': true,
'Doc.Copy': true,
'Doc.Delete': true,
'Doc.Duplicate': true,
@@ -346,6 +408,10 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
{
'Doc.Comments.Create': 'Commenter',
'Doc.Comments.Delete': 'Editor',
'Doc.Comments.Read': 'External',
'Doc.Comments.Resolve': 'Editor',
'Doc.Copy': 'External',
'Doc.Delete': 'Editor',
'Doc.Duplicate': 'Reader',

View File

@@ -65,6 +65,13 @@ export const Actions = {
Read: '',
Manage: '',
},
Comments: {
Read: '',
Create: '',
Update: '',
Delete: '',
Resolve: '',
},
},
} as const;
@@ -112,7 +119,12 @@ export const RoleActionsMap = {
},
DocRole: {
get [DocRole.External]() {
return [Action.Doc.Read, Action.Doc.Copy, Action.Doc.Properties.Read];
return [
Action.Doc.Read,
Action.Doc.Copy,
Action.Doc.Properties.Read,
Action.Doc.Comments.Read,
];
},
get [DocRole.Reader]() {
return [
@@ -121,14 +133,20 @@ export const RoleActionsMap = {
Action.Doc.Duplicate,
];
},
get [DocRole.Commenter]() {
return [...this[DocRole.Reader], Action.Doc.Comments.Create];
},
get [DocRole.Editor]() {
return [
...this[DocRole.Reader],
...this[DocRole.Commenter],
Action.Doc.Trash,
Action.Doc.Restore,
Action.Doc.Delete,
Action.Doc.Properties.Update,
Action.Doc.Update,
Action.Doc.Comments.Resolve,
Action.Doc.Comments.Delete,
];
},
get [DocRole.Manager]() {