mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
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:
1240
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
1240
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -231,7 +231,7 @@ export async function createApp(
|
||||
app.useBodyParser('raw', { limit: 1 * OneMB });
|
||||
app.use(
|
||||
graphqlUploadExpress({
|
||||
maxFileSize: 10 * OneMB,
|
||||
maxFileSize: 100 * OneMB,
|
||||
maxFiles: 5,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { StorageProviderModule } from './base/storage';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { CommentModule } from './core/comment';
|
||||
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
|
||||
import { DocStorageModule } from './core/doc';
|
||||
import { DocRendererModule } from './core/doc-renderer';
|
||||
@@ -186,7 +187,8 @@ export function buildAppModule(env: Env) {
|
||||
CopilotModule,
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CustomerIoModule
|
||||
CustomerIoModule,
|
||||
CommentModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
|
||||
@@ -921,4 +921,8 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'resource_not_found',
|
||||
message: 'Comment attachment not found.',
|
||||
},
|
||||
comment_attachment_quota_exceeded: {
|
||||
type: 'quota_exceeded',
|
||||
message: 'You have exceeded the comment attachment size quota.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
||||
@@ -1085,6 +1085,12 @@ export class CommentAttachmentNotFound extends UserFriendlyError {
|
||||
super('resource_not_found', 'comment_attachment_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentAttachmentQuotaExceeded extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('quota_exceeded', 'comment_attachment_quota_exceeded', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NETWORK_ERROR,
|
||||
@@ -1223,7 +1229,8 @@ export enum ErrorNames {
|
||||
INVALID_INDEXER_INPUT,
|
||||
COMMENT_NOT_FOUND,
|
||||
REPLY_NOT_FOUND,
|
||||
COMMENT_ATTACHMENT_NOT_FOUND
|
||||
COMMENT_ATTACHMENT_NOT_FOUND,
|
||||
COMMENT_ATTACHMENT_QUOTA_EXCEEDED
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
||||
@@ -79,3 +79,88 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should return encode pageInfo with custom cursor
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
edges: [
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 13,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 17,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 18,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: 'eyJpZCI6MjAsIm5hbWUiOiJ0ZXN0MiJ9',
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0=',
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should decode with json
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
id: 10,
|
||||
name: 'test',
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -4,7 +4,13 @@ import Sinon from 'sinon';
|
||||
|
||||
import { createTestingApp } from '../../../__tests__/utils';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { paginate, Paginated, PaginationInput } from '../pagination';
|
||||
import {
|
||||
decodeWithJson,
|
||||
paginate,
|
||||
Paginated,
|
||||
paginateWithCustomCursor,
|
||||
PaginationInput,
|
||||
} from '../pagination';
|
||||
|
||||
const TOTAL_COUNT = 105;
|
||||
const ITEMS = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1 }));
|
||||
@@ -104,3 +110,24 @@ test('should return encode pageInfo', async t => {
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should return encode pageInfo with custom cursor', async t => {
|
||||
const result = paginateWithCustomCursor(
|
||||
ITEMS.slice(10, 20),
|
||||
TOTAL_COUNT,
|
||||
{ id: 10, name: 'test' },
|
||||
{ id: 20, name: 'test2' }
|
||||
);
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should decode with json', async t => {
|
||||
const result = decodeWithJson<{ id: number; name: string }>(
|
||||
'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0='
|
||||
);
|
||||
t.snapshot(result);
|
||||
|
||||
const result2 = decodeWithJson<{ id: number; name: string }>('');
|
||||
t.is(result2, null);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,15 @@ const encode = (input: unknown) => {
|
||||
const decode = (base64String?: string | null) =>
|
||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||
|
||||
function encodeWithJson(input: unknown) {
|
||||
return encode(JSON.stringify(input ?? null));
|
||||
}
|
||||
|
||||
export function decodeWithJson<T>(base64String?: string | null): T | null {
|
||||
const str = decode(base64String);
|
||||
return str ? (JSON.parse(str) as T) : null;
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
list: T[],
|
||||
cursorField: keyof T,
|
||||
@@ -88,6 +97,31 @@ export function paginate<T>(
|
||||
};
|
||||
}
|
||||
|
||||
export function paginateWithCustomCursor<T>(
|
||||
list: T[],
|
||||
total: number,
|
||||
startCursor: unknown,
|
||||
endCursor: unknown,
|
||||
hasPreviousPage = false
|
||||
): PaginatedType<T> {
|
||||
const edges = list.map(item => ({
|
||||
node: item,
|
||||
// set cursor to empty string for ignore it
|
||||
cursor: '',
|
||||
}));
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasNextPage: list.length > 0,
|
||||
hasPreviousPage,
|
||||
endCursor: encodeWithJson(endCursor),
|
||||
startCursor: encodeWithJson(startCursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedType<T> {
|
||||
totalCount: number;
|
||||
edges: {
|
||||
|
||||
@@ -60,3 +60,11 @@ export async function readBufferWithLimit(
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function readableToBuffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
});
|
||||
13
packages/backend/server/src/core/comment/index.ts
Normal file
13
packages/backend/server/src/core/comment/index.ts
Normal 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 {}
|
||||
361
packages/backend/server/src/core/comment/resolver.ts
Normal file
361
packages/backend/server/src/core/comment/resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
131
packages/backend/server/src/core/comment/service.ts
Normal file
131
packages/backend/server/src/core/comment/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
193
packages/backend/server/src/core/comment/types.ts
Normal file
193
packages/backend/server/src/core/comment/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Binary file not shown.
@@ -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]() {
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum DocRole {
|
||||
None = -(1 << 15),
|
||||
External = 0,
|
||||
Reader = 10,
|
||||
Commenter = 15,
|
||||
Editor = 20,
|
||||
Manager = 30,
|
||||
Owner = 99,
|
||||
|
||||
@@ -85,13 +85,13 @@ export class UserModel extends BaseModel {
|
||||
async getPublicUsersMap<T extends ItemWithUserId>(
|
||||
items: T[]
|
||||
): Promise<Map<string, PublicUser>> {
|
||||
const userIds: string[] = [];
|
||||
const userIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.userId) {
|
||||
userIds.push(item.userId);
|
||||
userIds.add(item.userId);
|
||||
}
|
||||
}
|
||||
const users = await this.getPublicUsers(userIds);
|
||||
const users = await this.getPublicUsers(Array.from(userIds));
|
||||
return new Map(users.map(user => [user.id, user]));
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,73 @@ type ChatMessage {
|
||||
streamObjects: [StreamObject!]
|
||||
}
|
||||
|
||||
"""Comment change action"""
|
||||
enum CommentChangeAction {
|
||||
delete
|
||||
update
|
||||
}
|
||||
|
||||
type CommentChangeObjectType {
|
||||
"""The action of the comment change"""
|
||||
action: CommentChangeAction!
|
||||
commentId: ID
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
The item of the comment or reply, different types have different fields, see UnionCommentObjectType
|
||||
"""
|
||||
item: JSONObject!
|
||||
}
|
||||
|
||||
type CommentChangeObjectTypeEdge {
|
||||
cursor: String!
|
||||
node: CommentChangeObjectType!
|
||||
}
|
||||
|
||||
input CommentCreateInput {
|
||||
content: JSONObject!
|
||||
docId: ID!
|
||||
workspaceId: ID!
|
||||
}
|
||||
|
||||
type CommentObjectType {
|
||||
"""The content of the comment"""
|
||||
content: JSONObject!
|
||||
|
||||
"""The created at time of the comment"""
|
||||
createdAt: DateTime!
|
||||
id: ID!
|
||||
|
||||
"""The replies of the comment"""
|
||||
replies: [ReplyObjectType!]!
|
||||
|
||||
"""Whether the comment is resolved"""
|
||||
resolved: Boolean!
|
||||
|
||||
"""The updated at time of the comment"""
|
||||
updatedAt: DateTime!
|
||||
|
||||
"""The user who created the comment"""
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
type CommentObjectTypeEdge {
|
||||
cursor: String!
|
||||
node: CommentObjectType!
|
||||
}
|
||||
|
||||
input CommentResolveInput {
|
||||
id: ID!
|
||||
|
||||
"""Whether the comment is resolved"""
|
||||
resolved: Boolean!
|
||||
}
|
||||
|
||||
input CommentUpdateInput {
|
||||
content: JSONObject!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
enum ContextCategories {
|
||||
Collection
|
||||
Tag
|
||||
@@ -456,6 +523,10 @@ type DocNotFoundDataType {
|
||||
}
|
||||
|
||||
type DocPermissions {
|
||||
Doc_Comments_Create: Boolean!
|
||||
Doc_Comments_Delete: Boolean!
|
||||
Doc_Comments_Read: Boolean!
|
||||
Doc_Comments_Resolve: Boolean!
|
||||
Doc_Copy: Boolean!
|
||||
Doc_Delete: Boolean!
|
||||
Doc_Duplicate: Boolean!
|
||||
@@ -473,6 +544,7 @@ type DocPermissions {
|
||||
|
||||
"""User permission in doc"""
|
||||
enum DocRole {
|
||||
Commenter
|
||||
Editor
|
||||
External
|
||||
Manager
|
||||
@@ -541,6 +613,7 @@ enum ErrorNames {
|
||||
CAN_NOT_REVOKE_YOURSELF
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
COMMENT_ATTACHMENT_NOT_FOUND
|
||||
COMMENT_ATTACHMENT_QUOTA_EXCEEDED
|
||||
COMMENT_NOT_FOUND
|
||||
COPILOT_ACTION_TAKEN
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
||||
@@ -1090,6 +1163,7 @@ type Mutation {
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||
createComment(input: CommentCreateInput!): CommentObjectType!
|
||||
|
||||
"""Create a context session"""
|
||||
createCopilotContext(sessionId: String!, workspaceId: String!): String!
|
||||
@@ -1106,6 +1180,7 @@ type Mutation {
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
|
||||
createReply(input: ReplyCreateInput!): ReplyObjectType!
|
||||
createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String!
|
||||
|
||||
"""Create a new user"""
|
||||
@@ -1117,6 +1192,12 @@ type Mutation {
|
||||
deleteAccount: DeleteAccount!
|
||||
deleteBlob(hash: String @deprecated(reason: "use parameter [key]"), key: String, permanently: Boolean! = false, workspaceId: String!): Boolean!
|
||||
|
||||
"""Delete a comment"""
|
||||
deleteComment(id: String!): Boolean!
|
||||
|
||||
"""Delete a reply"""
|
||||
deleteReply(id: String!): Boolean!
|
||||
|
||||
"""Delete a user account"""
|
||||
deleteUser(id: String!): DeleteAccount!
|
||||
deleteWorkspace(id: String!): Boolean!
|
||||
@@ -1165,6 +1246,9 @@ type Mutation {
|
||||
"""Remove workspace embedding files"""
|
||||
removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||
|
||||
"""Resolve a comment or not"""
|
||||
resolveComment(input: CommentResolveInput!): Boolean!
|
||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||
retryAudioTranscription(jobId: String!, workspaceId: String!): TranscriptionResultType
|
||||
revoke(userId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use [revokeMember] instead")
|
||||
@@ -1185,6 +1269,9 @@ type Mutation {
|
||||
"""update app configuration"""
|
||||
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
|
||||
|
||||
"""Update a comment content"""
|
||||
updateComment(input: CommentUpdateInput!): Boolean!
|
||||
|
||||
"""Update a copilot prompt"""
|
||||
updateCopilotPrompt(messages: [CopilotPromptMessageInput!]!, name: String!): CopilotPromptType!
|
||||
|
||||
@@ -1194,6 +1281,9 @@ type Mutation {
|
||||
updateDocUserRole(input: UpdateDocUserRoleInput!): Boolean!
|
||||
updateProfile(input: UpdateUserInput!): UserType!
|
||||
|
||||
"""Update a reply content"""
|
||||
updateReply(input: ReplyUpdateInput!): Boolean!
|
||||
|
||||
"""Update user settings"""
|
||||
updateSettings(input: UpdateUserSettingsInput!): Boolean!
|
||||
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType!
|
||||
@@ -1213,6 +1303,9 @@ type Mutation {
|
||||
"""Upload user avatar"""
|
||||
uploadAvatar(avatar: Upload!): UserType!
|
||||
|
||||
"""Upload a comment attachment and return the access url"""
|
||||
uploadCommentAttachment(attachment: Upload!, docId: String!, workspaceId: String!): String!
|
||||
|
||||
"""validate app configuration"""
|
||||
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]!
|
||||
verifyEmail(token: String!): Boolean!
|
||||
@@ -1301,6 +1394,18 @@ type PageInfo {
|
||||
startCursor: String
|
||||
}
|
||||
|
||||
type PaginatedCommentChangeObjectType {
|
||||
edges: [CommentChangeObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedCommentObjectType {
|
||||
edges: [CommentObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedCopilotWorkspaceFileType {
|
||||
edges: [CopilotWorkspaceFileTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -1474,6 +1579,33 @@ input RemoveContextFileInput {
|
||||
fileId: String!
|
||||
}
|
||||
|
||||
input ReplyCreateInput {
|
||||
commentId: ID!
|
||||
content: JSONObject!
|
||||
}
|
||||
|
||||
type ReplyObjectType {
|
||||
commentId: ID!
|
||||
|
||||
"""The content of the reply"""
|
||||
content: JSONObject!
|
||||
|
||||
"""The created at time of the reply"""
|
||||
createdAt: DateTime!
|
||||
id: ID!
|
||||
|
||||
"""The updated at time of the reply"""
|
||||
updatedAt: DateTime!
|
||||
|
||||
"""The user who created the reply"""
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
input ReplyUpdateInput {
|
||||
content: JSONObject!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input RevokeDocUserRoleInput {
|
||||
docId: String!
|
||||
userId: String!
|
||||
@@ -2017,6 +2149,12 @@ type WorkspaceType {
|
||||
"""Blobs size of workspace"""
|
||||
blobsSize: Int!
|
||||
|
||||
"""Get comment changes of a doc"""
|
||||
commentChanges(docId: String!, pagination: PaginationInput!): PaginatedCommentChangeObjectType!
|
||||
|
||||
"""Get comments of a doc"""
|
||||
comments(docId: String!, pagination: PaginationInput): PaginatedCommentObjectType!
|
||||
|
||||
"""Workspace created date"""
|
||||
createdAt: DateTime!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user