diff --git a/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts new file mode 100644 index 000000000..d7d7a4081 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts @@ -0,0 +1,1240 @@ +import { randomUUID } from 'node:crypto'; + +import { + CommentChangeAction, + createCommentMutation, + createReplyMutation, + deleteCommentMutation, + deleteReplyMutation, + listCommentChangesQuery, + listCommentsQuery, + resolveCommentMutation, + updateCommentMutation, + updateReplyMutation, +} from '@affine/graphql'; + +import { DocRole } from '../../../models'; +import { Mockers } from '../../mocks'; +import { app, e2e } from '../test'; + +async function init() { + const other = await app.create(Mockers.User); + const member = await app.create(Mockers.User); + const owner = await app.create(Mockers.User); + + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + }); + + const teamWorkspace = await app.create(Mockers.Workspace, { + owner, + }); + await app.create(Mockers.TeamWorkspace, { + id: teamWorkspace.id, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: teamWorkspace.id, + userId: member.id, + }); + + return { + other, + member, + owner, + workspace, + teamWorkspace, + }; +} + +const { owner, workspace, member, other, teamWorkspace } = await init(); + +// #region comment + +e2e('should create comment work', async t => { + const docId = randomUUID(); + + await app.login(owner); + const result = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + t.truthy(result.createComment.id); + t.false(result.createComment.resolved); + t.is(result.createComment.replies.length, 0); + + await app.login(member); + const result2 = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + t.truthy(result2.createComment.id); + t.false(result2.createComment.resolved); + t.is(result2.createComment.replies.length, 0); +}); + +e2e('should create comment work when user is Commenter', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Commenter, + }); + + await app.login(member); + const result = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + t.truthy(result.createComment.id); + t.false(result.createComment.resolved); + t.is(result.createComment.replies.length, 0); +}); + +e2e('should create comment failed when user is not member', async t => { + const docId = randomUUID(); + + await app.login(other); + + await t.throwsAsync( + app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Create action on doc/, + } + ); +}); + +e2e('should create comment failed when user is Reader', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Reader, + }); + + await app.login(member); + + await t.throwsAsync( + app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Create action on doc/, + } + ); +}); + +e2e('should update comment work', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: updateCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test update' }], + }, + }, + }, + }); + + t.truthy(result.updateComment); +}); + +e2e('should update comment failed by another user', async t => { + const docId = randomUUID(); + + await app.login(owner); + + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + await app.login(member); + + await t.throwsAsync( + app.gql({ + query: updateCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test update' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Update action on doc/, + } + ); +}); + +e2e('should update comment failed when comment not found', async t => { + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: updateCommentMutation, + variables: { + input: { + id: 'not-found', + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: /Comment not found/, + } + ); +}); + +e2e('should resolve comment work', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + resolved: true, + }, + }, + }); + + t.truthy(result.resolveComment); + + // unresolved + const result2 = await app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + resolved: false, + }, + }, + }); + + t.truthy(result2.resolveComment); + + // resolve by doc editor + await app.login(member); + const result3 = await app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + resolved: true, + }, + }, + }); + + t.truthy(result3.resolveComment); +}); + +e2e('should resolve comment work by doc Commenter himself', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Commenter, + }); + + await app.login(member); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + resolved: true, + }, + }, + }); + + t.truthy(result.resolveComment); +}); + +e2e('should resolve comment failed by doc Reader user', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Reader, + }); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + await app.login(member); + await t.throwsAsync( + app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: createResult.createComment.id, + resolved: true, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Resolve action on doc/, + } + ); +}); + +e2e('should resolve comment failed when comment not found', async t => { + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: resolveCommentMutation, + variables: { + input: { + id: 'not-found', + resolved: true, + }, + }, + }), + { + message: /Comment not found/, + } + ); +}); + +e2e('should delete comment work', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: deleteCommentMutation, + variables: { + id: createResult.createComment.id, + }, + }); + + t.truthy(result.deleteComment); +}); + +// #endregion + +// #region reply + +e2e('should create reply work', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + t.truthy(result.createReply.id); + t.is(result.createReply.commentId, createResult.createComment.id); +}); + +e2e('should create reply work when user is Commenter', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Commenter, + }); + + await app.login(member); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + t.truthy(result.createReply.id); + t.is(result.createReply.commentId, createResult.createComment.id); +}); + +e2e('should create reply failed when comment not found', async t => { + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: 'not-found', + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: /Comment not found/, + } + ); +}); + +e2e('should create reply failed when user is not member', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + await app.login(other); + await t.throwsAsync( + app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Create action on doc/, + } + ); +}); + +e2e('should create reply failed when user is Reader', async t => { + const docId = randomUUID(); + + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Reader, + }); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + await app.login(member); + await t.throwsAsync( + app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Create action on doc/, + } + ); +}); + +e2e('should update reply work when user is reply owner', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const createReplyResult = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: updateReplyMutation, + variables: { + input: { + id: createReplyResult.createReply.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test update' }], + }, + }, + }, + }); + + t.truthy(result.updateReply); +}); + +e2e('should update reply failed when user is not reply owner', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const createReplyResult = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + await app.login(member); + await t.throwsAsync( + app.gql({ + query: updateReplyMutation, + variables: { + input: { + id: createReplyResult.createReply.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test update' }], + }, + }, + }, + }), + { + message: + /You do not have permission to perform Doc\.Comments\.Update action on doc/, + } + ); +}); + +e2e('should update reply failed when reply not found', async t => { + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: updateReplyMutation, + variables: { + input: { + id: 'not-found', + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }), + { + message: /Reply not found/, + } + ); +}); + +e2e('should delete reply work when user is reply owner', async t => { + const docId = randomUUID(); + + await app.login(owner); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const createReplyResult = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: deleteReplyMutation, + variables: { + id: createReplyResult.createReply.id, + }, + }); + + t.truthy(result.deleteReply); +}); + +e2e('should delete reply work when user is doc Editor', async t => { + const docId = randomUUID(); + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Editor, + }); + + await app.login(member); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const createReplyResult = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: deleteReplyMutation, + variables: { + id: createReplyResult.createReply.id, + }, + }); + + t.truthy(result.deleteReply); +}); + +e2e('should delete reply work when user is doc Manager', async t => { + const docId = randomUUID(); + + await app.create(Mockers.DocUser, { + workspaceId: teamWorkspace.id, + docId, + userId: member.id, + type: DocRole.Manager, + }); + + await app.login(member); + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: teamWorkspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const createReplyResult = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: deleteReplyMutation, + variables: { + id: createReplyResult.createReply.id, + }, + }); + + t.truthy(result.deleteReply); +}); + +e2e('should delete reply failed when reply not found', async t => { + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: deleteReplyMutation, + variables: { + id: 'not-found', + }, + }), + { + message: /Reply not found/, + } + ); +}); + +// #endregion + +// #region list comments and changes + +e2e('should list comments and changes work', async t => { + const docId = randomUUID(); + + // 3 comments and 2 replies + + await app.login(owner); + + const createResult = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 1' }], + }, + }, + }, + }); + + await app.login(member); + + const createResult2 = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 2' }], + }, + }, + }, + }); + + const createResult3 = await app.gql({ + query: createCommentMutation, + variables: { + input: { + workspaceId: workspace.id, + docId, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 3' }], + }, + }, + }, + }); + + const createReplyResult1 = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 1 reply 1' }], + }, + }, + }, + }); + + await app.login(owner); + + const createReplyResult2 = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 1 reply 2' }], + }, + }, + }, + }); + + const result = await app.gql({ + query: listCommentsQuery, + variables: { + workspaceId: workspace.id, + docId, + pagination: { + after: '', + }, + }, + }); + + // - comment-3 + member + // - comment-2 + member + // - comment-1 + owner + // - reply-1 + member + // - reply-2 + owner + t.is(result.workspace.comments.totalCount, 3); + t.is(result.workspace.comments.edges.length, 3); + + const comments = result.workspace.comments.edges.map(edge => edge.node); + t.is(comments[0].id, createResult3.createComment.id); + t.is(comments[0].user.id, member.id); + t.is(comments[0].replies.length, 0); + + t.is(comments[1].id, createResult2.createComment.id); + t.is(comments[1].user.id, member.id); + t.is(comments[1].replies.length, 0); + + t.is(comments[2].id, createResult.createComment.id); + t.is(comments[2].user.id, owner.id); + t.is(comments[2].replies.length, 2); + t.is(comments[2].replies[0].id, createReplyResult1.createReply.id); + t.is(comments[2].replies[0].user.id, member.id); + t.is(comments[2].replies[1].id, createReplyResult2.createReply.id); + t.is(comments[2].replies[1].user.id, owner.id); + + // use listComments.pageInfo.startCursor as listCommentChanges.pagination.after + let cursor = result.workspace.comments.pageInfo.startCursor; + + let result2 = await app.gql({ + query: listCommentChangesQuery, + variables: { + workspaceId: workspace.id, + docId, + pagination: { + after: cursor, + }, + }, + }); + + // no changes + t.is(result2.workspace.commentChanges.edges.length, 0); + // cursor is not changed + t.is(result2.workspace.commentChanges.pageInfo.endCursor, cursor); + cursor = result2.workspace.commentChanges.pageInfo.endCursor; + t.truthy(cursor); + + // new reply and delete comment1 + const createReplyResult3 = await app.gql({ + query: createReplyMutation, + variables: { + input: { + commentId: createResult.createComment.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test 1 reply 3' }], + }, + }, + }, + }); + + await app.gql({ + query: deleteCommentMutation, + variables: { + id: createResult.createComment.id, + }, + }); + + result2 = await app.gql({ + query: listCommentChangesQuery, + variables: { + workspaceId: workspace.id, + docId, + pagination: { + after: cursor, + }, + }, + }); + + t.is(result2.workspace.commentChanges.edges.length, 2); + t.is( + result2.workspace.commentChanges.edges[0].node.id, + createResult.createComment.id + ); + t.is( + result2.workspace.commentChanges.edges[0].node.action, + CommentChangeAction.delete + ); + t.is( + result2.workspace.commentChanges.edges[1].node.id, + createReplyResult3.createReply.id + ); + t.is( + result2.workspace.commentChanges.edges[1].node.commentId, + createReplyResult3.createReply.commentId + ); + t.is( + result2.workspace.commentChanges.edges[1].node.action, + CommentChangeAction.update + ); + + // cursor is changed + t.not(result2.workspace.commentChanges.pageInfo.endCursor, cursor); + cursor = result2.workspace.commentChanges.pageInfo.endCursor; + + // again, no changes + result2 = await app.gql({ + query: listCommentChangesQuery, + variables: { + workspaceId: workspace.id, + docId, + pagination: { + after: cursor, + }, + }, + }); + + t.is(result2.workspace.commentChanges.edges.length, 0); + t.is(result2.workspace.commentChanges.pageInfo.endCursor, cursor); +}); + +// #endregion + +// #region comment attachment + +e2e('should upload comment attachment work', async t => { + const docId = randomUUID(); + + await app.login(owner); + + const buffer = Buffer.from('test'); + + const res = await app + .POST('/graphql') + .field( + 'operations', + JSON.stringify({ + name: 'uploadCommentAttachment', + query: `mutation uploadCommentAttachment($attachment: Upload!) { + uploadCommentAttachment(workspaceId: "${workspace.id}", docId: "${docId}", attachment: $attachment) + }`, + variables: { attachment: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.attachment'] })) + .attach( + '0', + buffer, + `attachment-${Math.random().toString(16).substring(2, 10)}.txt` + ) + .expect(200); + + t.regex( + res.body.data.uploadCommentAttachment, + /^http:\/\/localhost:3010\/api\/workspaces\/[a-f0-9-]+\/docs\/[a-f0-9-]+\/comment-attachments\/[a-f0-9-]+$/ + ); +}); + +e2e( + 'should upload comment attachment failed when user has no permission', + async t => { + const docId = randomUUID(); + await app.login(other); + + const buffer = Buffer.from('test'); + + const res = await app + .POST('/graphql') + .field( + 'operations', + JSON.stringify({ + name: 'uploadCommentAttachment', + query: `mutation uploadCommentAttachment($attachment: Upload!) { + uploadCommentAttachment(workspaceId: "${workspace.id}", docId: "${docId}", attachment: $attachment) + }`, + variables: { attachment: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.attachment'] })) + .attach( + '0', + buffer, + `attachment-${Math.random().toString(16).substring(2, 10)}.txt` + ) + .expect(200); + + t.regex( + res.body.errors[0].message, + /You do not have permission to perform Doc\.Comments\.Create action on doc/ + ); + } +); + +e2e( + 'should upload comment attachment failed when attachment size exceeds the limit', + async t => { + const docId = randomUUID(); + await app.login(owner); + + const buffer = Buffer.alloc(10 * 1024 * 1024 + 1); + + const res = await app + .POST('/graphql') + .field( + 'operations', + JSON.stringify({ + name: 'uploadCommentAttachment', + query: `mutation uploadCommentAttachment($attachment: Upload!) { + uploadCommentAttachment(workspaceId: "${workspace.id}", docId: "${docId}", attachment: $attachment) + }`, + variables: { attachment: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.attachment'] })) + .attach( + '0', + buffer, + `attachment-${Math.random().toString(16).substring(2, 10)}.txt` + ) + .expect(200); + + t.regex( + res.body.errors[0].message, + /You have exceeded the comment attachment size quota/ + ); + } +); + +// #endregion diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 782ea7998..78bf5f467 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -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, }) ); diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index ffe00fe25..e2675a8c3 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -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) diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index d31c856a1..c763922aa 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -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; diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index e8394d76c..709a79743 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -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' diff --git a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md index b04e248d2..4768ca59f 100644 --- a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md +++ b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md @@ -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', + } diff --git a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap index 8cc73eeff..be5641955 100644 Binary files a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap and b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap differ diff --git a/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts b/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts index 911747300..7771c7dcb 100644 --- a/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts +++ b/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts @@ -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); +}); diff --git a/packages/backend/server/src/base/graphql/pagination.ts b/packages/backend/server/src/base/graphql/pagination.ts index 3f4681511..e16dd0bf8 100644 --- a/packages/backend/server/src/base/graphql/pagination.ts +++ b/packages/backend/server/src/base/graphql/pagination.ts @@ -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(base64String?: string | null): T | null { + const str = decode(base64String); + return str ? (JSON.parse(str) as T) : null; +} + export function paginate( list: T[], cursorField: keyof T, @@ -88,6 +97,31 @@ export function paginate( }; } +export function paginateWithCustomCursor( + list: T[], + total: number, + startCursor: unknown, + endCursor: unknown, + hasPreviousPage = false +): PaginatedType { + 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 { totalCount: number; edges: { diff --git a/packages/backend/server/src/base/utils/stream.ts b/packages/backend/server/src/base/utils/stream.ts index e7b71d47e..123996eb8 100644 --- a/packages/backend/server/src/base/utils/stream.ts +++ b/packages/backend/server/src/base/utils/stream.ts @@ -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); +} diff --git a/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.md b/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.md new file mode 100644 index 000000000..4526da137 --- /dev/null +++ b/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.md @@ -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', + } diff --git a/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.snap b/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.snap new file mode 100644 index 000000000..2cf8b47f1 Binary files /dev/null and b/packages/backend/server/src/core/comment/__tests__/__snapshots__/service.spec.ts.snap differ diff --git a/packages/backend/server/src/core/comment/__tests__/service.spec.ts b/packages/backend/server/src/core/comment/__tests__/service.spec.ts new file mode 100644 index 000000000..0816299d4 --- /dev/null +++ b/packages/backend/server/src/core/comment/__tests__/service.spec.ts @@ -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); +}); diff --git a/packages/backend/server/src/core/comment/index.ts b/packages/backend/server/src/core/comment/index.ts new file mode 100644 index 000000000..ce2671dc2 --- /dev/null +++ b/packages/backend/server/src/core/comment/index.ts @@ -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 {} diff --git a/packages/backend/server/src/core/comment/resolver.ts b/packages/backend/server/src/core/comment/resolver.ts new file mode 100644 index 000000000..e7668351f --- /dev/null +++ b/packages/backend/server/src/core/comment/resolver.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/packages/backend/server/src/core/comment/service.ts b/packages/backend/server/src/core/comment/service.ts new file mode 100644 index 000000000..1ab4a9359 --- /dev/null +++ b/packages/backend/server/src/core/comment/service.ts @@ -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(item: T) { + const user = await this.models.user.getPublicUser(item.userId); + return { + ...item, + user: user as PublicUserType, + }; + } +} diff --git a/packages/backend/server/src/core/comment/types.ts b/packages/backend/server/src/core/comment/types.ts new file mode 100644 index 000000000..03d70ece3 --- /dev/null +++ b/packages/backend/server/src/core/comment/types.ts @@ -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 { + @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 { + @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 { + @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 { + @Field(() => ID) + workspaceId!: string; + + @Field(() => ID) + docId!: string; + + @Field(() => GraphQLJSONObject) + content!: object; +} + +@InputType() +export class CommentUpdateInput implements Partial { + @Field(() => ID) + id!: string; + + @Field(() => GraphQLJSONObject) + content!: object; +} + +@InputType() +export class CommentResolveInput implements Partial { + @Field(() => ID) + id!: string; + + @Field(() => Boolean, { + description: 'Whether the comment is resolved', + }) + resolved!: boolean; +} + +@InputType() +export class ReplyCreateInput implements Partial { + @Field(() => ID) + commentId!: string; + + @Field(() => GraphQLJSONObject) + content!: object; +} + +@InputType() +export class ReplyUpdateInput implements Partial { + @Field(() => ID) + id!: string; + + @Field(() => GraphQLJSONObject) + content!: object; +} diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md index 88767c3c0..99948f14d 100644 --- a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md +++ b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md @@ -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', diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap index e319b6c8d..f956445cb 100644 Binary files a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap and b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap differ diff --git a/packages/backend/server/src/core/permission/types.ts b/packages/backend/server/src/core/permission/types.ts index f9a452d1d..18608b03a 100644 --- a/packages/backend/server/src/core/permission/types.ts +++ b/packages/backend/server/src/core/permission/types.ts @@ -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]() { diff --git a/packages/backend/server/src/models/common/role.ts b/packages/backend/server/src/models/common/role.ts index f2cb86870..336c7a1c4 100644 --- a/packages/backend/server/src/models/common/role.ts +++ b/packages/backend/server/src/models/common/role.ts @@ -5,6 +5,7 @@ export enum DocRole { None = -(1 << 15), External = 0, Reader = 10, + Commenter = 15, Editor = 20, Manager = 30, Owner = 99, diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index dd69600d7..afcbdbf27 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -85,13 +85,13 @@ export class UserModel extends BaseModel { async getPublicUsersMap( items: T[] ): Promise> { - const userIds: string[] = []; + const userIds = new Set(); 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])); } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 935fcf44a..446f3a099 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/common/graphql/src/graphql/comment-change-list.gql b/packages/common/graphql/src/graphql/comment-change-list.gql new file mode 100644 index 000000000..eebf75fca --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-change-list.gql @@ -0,0 +1,22 @@ +query listCommentChanges($workspaceId: String!, $docId: String!, $pagination: PaginationInput!) { + workspace(id: $workspaceId) { + commentChanges(docId: $docId, pagination: $pagination) { + totalCount + edges { + cursor + node { + action + id + commentId + item + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +} diff --git a/packages/common/graphql/src/graphql/comment-create.gql b/packages/common/graphql/src/graphql/comment-create.gql new file mode 100644 index 000000000..80b7aadba --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-create.gql @@ -0,0 +1,26 @@ +mutation createComment($input: CommentCreateInput!) { + createComment(input: $input) { + id + content + resolved + createdAt + updatedAt + user { + id + name + avatarUrl + } + replies { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } + } +} diff --git a/packages/common/graphql/src/graphql/comment-delete.gql b/packages/common/graphql/src/graphql/comment-delete.gql new file mode 100644 index 000000000..45d95261d --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-delete.gql @@ -0,0 +1,3 @@ +mutation deleteComment($id: String!) { + deleteComment(id: $id) +} diff --git a/packages/common/graphql/src/graphql/comment-list.gql b/packages/common/graphql/src/graphql/comment-list.gql new file mode 100644 index 000000000..5e2a1ed2f --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-list.gql @@ -0,0 +1,40 @@ +query listComments($workspaceId: String!, $docId: String!, $pagination: PaginationInput) { + workspace(id: $workspaceId) { + comments(docId: $docId, pagination: $pagination) { + totalCount + edges { + cursor + node { + id + content + resolved + createdAt + updatedAt + user { + id + name + avatarUrl + } + replies { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +} diff --git a/packages/common/graphql/src/graphql/comment-reply-create.gql b/packages/common/graphql/src/graphql/comment-reply-create.gql new file mode 100644 index 000000000..3acfdc4a2 --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-reply-create.gql @@ -0,0 +1,14 @@ +mutation createReply($input: ReplyCreateInput!) { + createReply(input: $input) { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } +} diff --git a/packages/common/graphql/src/graphql/comment-reply-delete.gql b/packages/common/graphql/src/graphql/comment-reply-delete.gql new file mode 100644 index 000000000..cfd80060b --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-reply-delete.gql @@ -0,0 +1,3 @@ +mutation deleteReply($id: String!) { + deleteReply(id: $id) +} diff --git a/packages/common/graphql/src/graphql/comment-reply-update.gql b/packages/common/graphql/src/graphql/comment-reply-update.gql new file mode 100644 index 000000000..9ab6a2bd1 --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-reply-update.gql @@ -0,0 +1,3 @@ +mutation updateReply($input: ReplyUpdateInput!) { + updateReply(input: $input) +} diff --git a/packages/common/graphql/src/graphql/comment-resolve.gql b/packages/common/graphql/src/graphql/comment-resolve.gql new file mode 100644 index 000000000..334486632 --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-resolve.gql @@ -0,0 +1,3 @@ +mutation resolveComment($input: CommentResolveInput!) { + resolveComment(input: $input) +} diff --git a/packages/common/graphql/src/graphql/comment-update.gql b/packages/common/graphql/src/graphql/comment-update.gql new file mode 100644 index 000000000..e7a310547 --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-update.gql @@ -0,0 +1,3 @@ +mutation updateComment($input: CommentUpdateInput!) { + updateComment(input: $input) +} diff --git a/packages/common/graphql/src/graphql/comment-upload-attachment.gql b/packages/common/graphql/src/graphql/comment-upload-attachment.gql new file mode 100644 index 000000000..2d1bf3cdf --- /dev/null +++ b/packages/common/graphql/src/graphql/comment-upload-attachment.gql @@ -0,0 +1,3 @@ +mutation uploadCommentAttachment($workspaceId: String!, $docId: String!, $attachment: Upload!) { + uploadCommentAttachment(workspaceId: $workspaceId, docId: $docId, attachment: $attachment) +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index ba7be4e6e..014badd11 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -334,6 +334,181 @@ export const changePasswordMutation = { }`, }; +export const listCommentChangesQuery = { + id: 'listCommentChangesQuery' as const, + op: 'listCommentChanges', + query: `query listCommentChanges($workspaceId: String!, $docId: String!, $pagination: PaginationInput!) { + workspace(id: $workspaceId) { + commentChanges(docId: $docId, pagination: $pagination) { + totalCount + edges { + cursor + node { + action + id + commentId + item + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +}`, +}; + +export const createCommentMutation = { + id: 'createCommentMutation' as const, + op: 'createComment', + query: `mutation createComment($input: CommentCreateInput!) { + createComment(input: $input) { + id + content + resolved + createdAt + updatedAt + user { + id + name + avatarUrl + } + replies { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } + } +}`, +}; + +export const deleteCommentMutation = { + id: 'deleteCommentMutation' as const, + op: 'deleteComment', + query: `mutation deleteComment($id: String!) { + deleteComment(id: $id) +}`, +}; + +export const listCommentsQuery = { + id: 'listCommentsQuery' as const, + op: 'listComments', + query: `query listComments($workspaceId: String!, $docId: String!, $pagination: PaginationInput) { + workspace(id: $workspaceId) { + comments(docId: $docId, pagination: $pagination) { + totalCount + edges { + cursor + node { + id + content + resolved + createdAt + updatedAt + user { + id + name + avatarUrl + } + replies { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +}`, +}; + +export const createReplyMutation = { + id: 'createReplyMutation' as const, + op: 'createReply', + query: `mutation createReply($input: ReplyCreateInput!) { + createReply(input: $input) { + commentId + id + content + createdAt + updatedAt + user { + id + name + avatarUrl + } + } +}`, +}; + +export const deleteReplyMutation = { + id: 'deleteReplyMutation' as const, + op: 'deleteReply', + query: `mutation deleteReply($id: String!) { + deleteReply(id: $id) +}`, +}; + +export const updateReplyMutation = { + id: 'updateReplyMutation' as const, + op: 'updateReply', + query: `mutation updateReply($input: ReplyUpdateInput!) { + updateReply(input: $input) +}`, +}; + +export const resolveCommentMutation = { + id: 'resolveCommentMutation' as const, + op: 'resolveComment', + query: `mutation resolveComment($input: CommentResolveInput!) { + resolveComment(input: $input) +}`, +}; + +export const updateCommentMutation = { + id: 'updateCommentMutation' as const, + op: 'updateComment', + query: `mutation updateComment($input: CommentUpdateInput!) { + updateComment(input: $input) +}`, +}; + +export const uploadCommentAttachmentMutation = { + id: 'uploadCommentAttachmentMutation' as const, + op: 'uploadCommentAttachment', + query: `mutation uploadCommentAttachment($workspaceId: String!, $docId: String!, $attachment: Upload!) { + uploadCommentAttachment( + workspaceId: $workspaceId + docId: $docId + attachment: $attachment + ) +}`, + file: true, +}; + export const addContextCategoryMutation = { id: 'addContextCategoryMutation' as const, op: 'addContextCategory', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 6da7294e8..87dda77de 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -140,6 +140,68 @@ export interface ChatMessage { streamObjects: Maybe>; } +/** Comment change action */ +export enum CommentChangeAction { + delete = 'delete', + update = 'update', +} + +export interface CommentChangeObjectType { + __typename?: 'CommentChangeObjectType'; + /** The action of the comment change */ + action: CommentChangeAction; + commentId: Maybe; + id: Scalars['ID']['output']; + /** The item of the comment or reply, different types have different fields, see UnionCommentObjectType */ + item: Scalars['JSONObject']['output']; +} + +export interface CommentChangeObjectTypeEdge { + __typename?: 'CommentChangeObjectTypeEdge'; + cursor: Scalars['String']['output']; + node: CommentChangeObjectType; +} + +export interface CommentCreateInput { + content: Scalars['JSONObject']['input']; + docId: Scalars['ID']['input']; + workspaceId: Scalars['ID']['input']; +} + +export interface CommentObjectType { + __typename?: 'CommentObjectType'; + /** The content of the comment */ + content: Scalars['JSONObject']['output']; + /** The created at time of the comment */ + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + /** The replies of the comment */ + replies: Array; + /** Whether the comment is resolved */ + resolved: Scalars['Boolean']['output']; + /** The updated at time of the comment */ + updatedAt: Scalars['DateTime']['output']; + /** The user who created the comment */ + user: PublicUserType; +} + +export interface CommentObjectTypeEdge { + __typename?: 'CommentObjectTypeEdge'; + cursor: Scalars['String']['output']; + node: CommentObjectType; +} + +export interface CommentResolveInput { + id: Scalars['ID']['input']; + /** Whether the comment is resolved */ + resolved: Scalars['Boolean']['input']; +} + +export interface CommentUpdateInput { + content: Scalars['JSONObject']['input']; + id: Scalars['ID']['input']; +} + export enum ContextCategories { Collection = 'Collection', Tag = 'Tag', @@ -565,6 +627,10 @@ export interface DocNotFoundDataType { export interface DocPermissions { __typename?: 'DocPermissions'; + Doc_Comments_Create: Scalars['Boolean']['output']; + Doc_Comments_Delete: Scalars['Boolean']['output']; + Doc_Comments_Read: Scalars['Boolean']['output']; + Doc_Comments_Resolve: Scalars['Boolean']['output']; Doc_Copy: Scalars['Boolean']['output']; Doc_Delete: Scalars['Boolean']['output']; Doc_Duplicate: Scalars['Boolean']['output']; @@ -582,6 +648,7 @@ export interface DocPermissions { /** User permission in doc */ export enum DocRole { + Commenter = 'Commenter', Editor = 'Editor', External = 'External', Manager = 'Manager', @@ -710,6 +777,7 @@ export enum ErrorNames { CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF', CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED', COMMENT_ATTACHMENT_NOT_FOUND = 'COMMENT_ATTACHMENT_NOT_FOUND', + COMMENT_ATTACHMENT_QUOTA_EXCEEDED = 'COMMENT_ATTACHMENT_QUOTA_EXCEEDED', COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED', @@ -1248,6 +1316,7 @@ export interface Mutation { createChangePasswordUrl: Scalars['String']['output']; /** Create a subscription checkout link of stripe */ createCheckoutSession: Scalars['String']['output']; + createComment: CommentObjectType; /** Create a context session */ createCopilotContext: Scalars['String']['output']; /** Create a chat message */ @@ -1259,6 +1328,7 @@ export interface Mutation { /** Create a stripe customer portal to manage payment methods */ createCustomerPortal: Scalars['String']['output']; createInviteLink: InviteLink; + createReply: ReplyObjectType; createSelfhostWorkspaceCustomerPortal: Scalars['String']['output']; /** Create a new user */ createUser: UserType; @@ -1267,6 +1337,10 @@ export interface Mutation { deactivateLicense: Scalars['Boolean']['output']; deleteAccount: DeleteAccount; deleteBlob: Scalars['Boolean']['output']; + /** Delete a comment */ + deleteComment: Scalars['Boolean']['output']; + /** Delete a reply */ + deleteReply: Scalars['Boolean']['output']; /** Delete a user account */ deleteUser: DeleteAccount; deleteWorkspace: Scalars['Boolean']['output']; @@ -1306,6 +1380,8 @@ export interface Mutation { /** Remove workspace embedding files */ removeWorkspaceEmbeddingFiles: Scalars['Boolean']['output']; removeWorkspaceFeature: Scalars['Boolean']['output']; + /** Resolve a comment or not */ + resolveComment: Scalars['Boolean']['output']; resumeSubscription: SubscriptionType; retryAudioTranscription: Maybe; /** @deprecated use [revokeMember] instead */ @@ -1326,6 +1402,8 @@ export interface Mutation { submitAudioTranscription: Maybe; /** update app configuration */ updateAppConfig: Scalars['JSONObject']['output']; + /** Update a comment content */ + updateComment: Scalars['Boolean']['output']; /** Update a copilot prompt */ updateCopilotPrompt: CopilotPromptType; /** Update a chat session */ @@ -1333,6 +1411,8 @@ export interface Mutation { updateDocDefaultRole: Scalars['Boolean']['output']; updateDocUserRole: Scalars['Boolean']['output']; updateProfile: UserType; + /** Update a reply content */ + updateReply: Scalars['Boolean']['output']; /** Update user settings */ updateSettings: Scalars['Boolean']['output']; updateSubscriptionRecurring: SubscriptionType; @@ -1346,6 +1426,8 @@ export interface Mutation { updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output']; /** Upload user avatar */ uploadAvatar: UserType; + /** Upload a comment attachment and return the access url */ + uploadCommentAttachment: Scalars['String']['output']; /** validate app configuration */ validateAppConfig: Array; verifyEmail: Scalars['Boolean']['output']; @@ -1428,6 +1510,10 @@ export interface MutationCreateCheckoutSessionArgs { input: CreateCheckoutSessionInput; } +export interface MutationCreateCommentArgs { + input: CommentCreateInput; +} + export interface MutationCreateCopilotContextArgs { sessionId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -1450,6 +1536,10 @@ export interface MutationCreateInviteLinkArgs { workspaceId: Scalars['String']['input']; } +export interface MutationCreateReplyArgs { + input: ReplyCreateInput; +} + export interface MutationCreateSelfhostWorkspaceCustomerPortalArgs { workspaceId: Scalars['String']['input']; } @@ -1473,6 +1563,14 @@ export interface MutationDeleteBlobArgs { workspaceId: Scalars['String']['input']; } +export interface MutationDeleteCommentArgs { + id: Scalars['String']['input']; +} + +export interface MutationDeleteReplyArgs { + id: Scalars['String']['input']; +} + export interface MutationDeleteUserArgs { id: Scalars['String']['input']; } @@ -1586,6 +1684,10 @@ export interface MutationRemoveWorkspaceFeatureArgs { workspaceId: Scalars['String']['input']; } +export interface MutationResolveCommentArgs { + input: CommentResolveInput; +} + export interface MutationResumeSubscriptionArgs { idempotencyKey?: InputMaybe; plan?: InputMaybe; @@ -1670,6 +1772,10 @@ export interface MutationUpdateAppConfigArgs { updates: Array; } +export interface MutationUpdateCommentArgs { + input: CommentUpdateInput; +} + export interface MutationUpdateCopilotPromptArgs { messages: Array; name: Scalars['String']['input']; @@ -1691,6 +1797,10 @@ export interface MutationUpdateProfileArgs { input: UpdateUserInput; } +export interface MutationUpdateReplyArgs { + input: ReplyUpdateInput; +} + export interface MutationUpdateSettingsArgs { input: UpdateUserSettingsInput; } @@ -1726,6 +1836,12 @@ export interface MutationUploadAvatarArgs { avatar: Scalars['Upload']['input']; } +export interface MutationUploadCommentAttachmentArgs { + attachment: Scalars['Upload']['input']; + docId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationValidateAppConfigArgs { updates: Array; } @@ -1814,6 +1930,20 @@ export interface PageInfo { startCursor: Maybe; } +export interface PaginatedCommentChangeObjectType { + __typename?: 'PaginatedCommentChangeObjectType'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +} + +export interface PaginatedCommentObjectType { + __typename?: 'PaginatedCommentObjectType'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +} + export interface PaginatedCopilotWorkspaceFileType { __typename?: 'PaginatedCopilotWorkspaceFileType'; edges: Array; @@ -2038,6 +2168,30 @@ export interface RemoveContextFileInput { fileId: Scalars['String']['input']; } +export interface ReplyCreateInput { + commentId: Scalars['ID']['input']; + content: Scalars['JSONObject']['input']; +} + +export interface ReplyObjectType { + __typename?: 'ReplyObjectType'; + commentId: Scalars['ID']['output']; + /** The content of the reply */ + content: Scalars['JSONObject']['output']; + /** The created at time of the reply */ + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + /** The updated at time of the reply */ + updatedAt: Scalars['DateTime']['output']; + /** The user who created the reply */ + user: PublicUserType; +} + +export interface ReplyUpdateInput { + content: Scalars['JSONObject']['input']; + id: Scalars['ID']['input']; +} + export interface RevokeDocUserRoleInput { docId: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -2605,6 +2759,10 @@ export interface WorkspaceType { blobs: Array; /** Blobs size of workspace */ blobsSize: Scalars['Int']['output']; + /** Get comment changes of a doc */ + commentChanges: PaginatedCommentChangeObjectType; + /** Get comments of a doc */ + comments: PaginatedCommentObjectType; /** Workspace created date */ createdAt: Scalars['DateTime']['output']; /** Get get with given id */ @@ -2672,6 +2830,16 @@ export interface WorkspaceTypeAggregateArgs { input: AggregateInput; } +export interface WorkspaceTypeCommentChangesArgs { + docId: Scalars['String']['input']; + pagination: PaginationInput; +} + +export interface WorkspaceTypeCommentsArgs { + docId: Scalars['String']['input']; + pagination?: InputMaybe; +} + export interface WorkspaceTypeDocArgs { docId: Scalars['String']['input']; } @@ -3069,6 +3237,211 @@ export type ChangePasswordMutation = { changePassword: boolean; }; +export type ListCommentChangesQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + pagination: PaginationInput; +}>; + +export type ListCommentChangesQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + commentChanges: { + __typename?: 'PaginatedCommentChangeObjectType'; + totalCount: number; + edges: Array<{ + __typename?: 'CommentChangeObjectTypeEdge'; + cursor: string; + node: { + __typename?: 'CommentChangeObjectType'; + action: CommentChangeAction; + id: string; + commentId: string | null; + item: any; + }; + }>; + pageInfo: { + __typename?: 'PageInfo'; + startCursor: string | null; + endCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }; + }; +}; + +export type CreateCommentMutationVariables = Exact<{ + input: CommentCreateInput; +}>; + +export type CreateCommentMutation = { + __typename?: 'Mutation'; + createComment: { + __typename?: 'CommentObjectType'; + id: string; + content: any; + resolved: boolean; + createdAt: string; + updatedAt: string; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + replies: Array<{ + __typename?: 'ReplyObjectType'; + commentId: string; + id: string; + content: any; + createdAt: string; + updatedAt: string; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + }>; + }; +}; + +export type DeleteCommentMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type DeleteCommentMutation = { + __typename?: 'Mutation'; + deleteComment: boolean; +}; + +export type ListCommentsQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + pagination?: InputMaybe; +}>; + +export type ListCommentsQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + comments: { + __typename?: 'PaginatedCommentObjectType'; + totalCount: number; + edges: Array<{ + __typename?: 'CommentObjectTypeEdge'; + cursor: string; + node: { + __typename?: 'CommentObjectType'; + id: string; + content: any; + resolved: boolean; + createdAt: string; + updatedAt: string; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + replies: Array<{ + __typename?: 'ReplyObjectType'; + commentId: string; + id: string; + content: any; + createdAt: string; + updatedAt: string; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + }>; + }; + }>; + pageInfo: { + __typename?: 'PageInfo'; + startCursor: string | null; + endCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }; + }; +}; + +export type CreateReplyMutationVariables = Exact<{ + input: ReplyCreateInput; +}>; + +export type CreateReplyMutation = { + __typename?: 'Mutation'; + createReply: { + __typename?: 'ReplyObjectType'; + commentId: string; + id: string; + content: any; + createdAt: string; + updatedAt: string; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + }; +}; + +export type DeleteReplyMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type DeleteReplyMutation = { + __typename?: 'Mutation'; + deleteReply: boolean; +}; + +export type UpdateReplyMutationVariables = Exact<{ + input: ReplyUpdateInput; +}>; + +export type UpdateReplyMutation = { + __typename?: 'Mutation'; + updateReply: boolean; +}; + +export type ResolveCommentMutationVariables = Exact<{ + input: CommentResolveInput; +}>; + +export type ResolveCommentMutation = { + __typename?: 'Mutation'; + resolveComment: boolean; +}; + +export type UpdateCommentMutationVariables = Exact<{ + input: CommentUpdateInput; +}>; + +export type UpdateCommentMutation = { + __typename?: 'Mutation'; + updateComment: boolean; +}; + +export type UploadCommentAttachmentMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + attachment: Scalars['Upload']['input']; +}>; + +export type UploadCommentAttachmentMutation = { + __typename?: 'Mutation'; + uploadCommentAttachment: string; +}; + export type AddContextCategoryMutationVariables = Exact<{ options: AddContextCategoryInput; }>; @@ -5340,6 +5713,16 @@ export type Queries = variables: ListBlobsQueryVariables; response: ListBlobsQuery; } + | { + name: 'listCommentChangesQuery'; + variables: ListCommentChangesQueryVariables; + response: ListCommentChangesQuery; + } + | { + name: 'listCommentsQuery'; + variables: ListCommentsQueryVariables; + response: ListCommentsQuery; + } | { name: 'listContextObjectQuery'; variables: ListContextObjectQueryVariables; @@ -5737,6 +6120,46 @@ export type Mutations = variables: ChangePasswordMutationVariables; response: ChangePasswordMutation; } + | { + name: 'createCommentMutation'; + variables: CreateCommentMutationVariables; + response: CreateCommentMutation; + } + | { + name: 'deleteCommentMutation'; + variables: DeleteCommentMutationVariables; + response: DeleteCommentMutation; + } + | { + name: 'createReplyMutation'; + variables: CreateReplyMutationVariables; + response: CreateReplyMutation; + } + | { + name: 'deleteReplyMutation'; + variables: DeleteReplyMutationVariables; + response: DeleteReplyMutation; + } + | { + name: 'updateReplyMutation'; + variables: UpdateReplyMutationVariables; + response: UpdateReplyMutation; + } + | { + name: 'resolveCommentMutation'; + variables: ResolveCommentMutationVariables; + response: ResolveCommentMutation; + } + | { + name: 'updateCommentMutation'; + variables: UpdateCommentMutationVariables; + response: UpdateCommentMutation; + } + | { + name: 'uploadCommentAttachmentMutation'; + variables: UploadCommentAttachmentMutationVariables; + response: UploadCommentAttachmentMutation; + } | { name: 'addContextCategoryMutation'; variables: AddContextCategoryMutationVariables; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index d5bedfc89..769d48977 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8895,6 +8895,10 @@ export function useAFFiNEI18N(): { * `Comment attachment not found.` */ ["error.COMMENT_ATTACHMENT_NOT_FOUND"](): string; + /** + * `You have exceeded the comment attachment size quota.` + */ + ["error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED"](): string; } { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); } function createComponent(i18nKey: string) { return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props }); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f47a182c5..659f75856 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2196,5 +2196,6 @@ "error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}", "error.COMMENT_NOT_FOUND": "Comment not found.", "error.REPLY_NOT_FOUND": "Reply not found.", - "error.COMMENT_ATTACHMENT_NOT_FOUND": "Comment attachment not found." + "error.COMMENT_ATTACHMENT_NOT_FOUND": "Comment attachment not found.", + "error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED": "You have exceeded the comment attachment size quota." }