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

close CLOUD-227
close CLOUD-230

































#### PR Dependency Tree


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

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

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

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

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

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-01 21:12:28 +08:00
committed by GitHub
parent 6a04fbe335
commit 2aa5c13082
37 changed files with 3504 additions and 9 deletions
@@ -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',
}
@@ -4,7 +4,13 @@ import Sinon from 'sinon';
import { createTestingApp } from '../../../__tests__/utils';
import { Public } from '../../../core/auth';
import { paginate, Paginated, PaginationInput } from '../pagination';
import {
decodeWithJson,
paginate,
Paginated,
paginateWithCustomCursor,
PaginationInput,
} from '../pagination';
const TOTAL_COUNT = 105;
const ITEMS = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1 }));
@@ -104,3 +110,24 @@ test('should return encode pageInfo', async t => {
t.snapshot(result);
});
test('should return encode pageInfo with custom cursor', async t => {
const result = paginateWithCustomCursor(
ITEMS.slice(10, 20),
TOTAL_COUNT,
{ id: 10, name: 'test' },
{ id: 20, name: 'test2' }
);
t.snapshot(result);
});
test('should decode with json', async t => {
const result = decodeWithJson<{ id: number; name: string }>(
'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0='
);
t.snapshot(result);
const result2 = decodeWithJson<{ id: number; name: string }>('');
t.is(result2, null);
});
@@ -65,6 +65,15 @@ const encode = (input: unknown) => {
const decode = (base64String?: string | null) =>
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
function encodeWithJson(input: unknown) {
return encode(JSON.stringify(input ?? null));
}
export function decodeWithJson<T>(base64String?: string | null): T | null {
const str = decode(base64String);
return str ? (JSON.parse(str) as T) : null;
}
export function paginate<T>(
list: T[],
cursorField: keyof T,
@@ -88,6 +97,31 @@ export function paginate<T>(
};
}
export function paginateWithCustomCursor<T>(
list: T[],
total: number,
startCursor: unknown,
endCursor: unknown,
hasPreviousPage = false
): PaginatedType<T> {
const edges = list.map(item => ({
node: item,
// set cursor to empty string for ignore it
cursor: '',
}));
return {
totalCount: total,
edges,
pageInfo: {
hasNextPage: list.length > 0,
hasPreviousPage,
endCursor: encodeWithJson(endCursor),
startCursor: encodeWithJson(startCursor),
},
};
}
export interface PaginatedType<T> {
totalCount: number;
edges: {