Compare commits

...

4 Commits

Author SHA1 Message Date
fengmk2
6cb63ddc48
feat(server): comment service and resolver 2025-06-26 13:36:26 +08:00
fengmk2
39f34e2a2a
feat(server): add comment-attachment storage 2025-06-26 13:01:11 +08:00
fengmk2
9612f8ff74
feat(server): add comment-attachment model 2025-06-26 13:01:11 +08:00
fengmk2
03391670d9
feat(server): comment model 2025-06-26 13:01:10 +08:00
53 changed files with 5221 additions and 14 deletions

View File

@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "comments" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"content" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(3),
"resolved" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "replies" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"comment_id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"content" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "replies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "comments_sid_key" ON "comments"("sid");
-- CreateIndex
CREATE INDEX "comments_workspace_id_doc_id_sid_idx" ON "comments"("workspace_id", "doc_id", "sid");
-- CreateIndex
CREATE INDEX "comments_workspace_id_doc_id_updated_at_idx" ON "comments"("workspace_id", "doc_id", "updated_at");
-- CreateIndex
CREATE INDEX "comments_user_id_idx" ON "comments"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "replies_sid_key" ON "replies"("sid");
-- CreateIndex
CREATE INDEX "replies_comment_id_sid_idx" ON "replies"("comment_id", "sid");
-- CreateIndex
CREATE INDEX "replies_workspace_id_doc_id_updated_at_idx" ON "replies"("workspace_id", "doc_id", "updated_at");
-- CreateIndex
CREATE INDEX "replies_user_id_idx" ON "replies"("user_id");
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "replies" ADD CONSTRAINT "replies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "replies" ADD CONSTRAINT "replies_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "comment_attachments" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"key" VARCHAR NOT NULL,
"size" INTEGER NOT NULL,
"mime" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" VARCHAR,
CONSTRAINT "comment_attachments_pkey" PRIMARY KEY ("workspace_id","doc_id","key")
);
-- CreateIndex
CREATE UNIQUE INDEX "comment_attachments_sid_key" ON "comment_attachments"("sid");
-- AddForeignKey
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -46,6 +46,9 @@ model User {
// receive notifications
notifications Notification[] @relation("user_notifications")
settings UserSettings?
comments Comment[]
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
@@index([email])
@@map("users")
@ -126,6 +129,8 @@ model Workspace {
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
@@map("workspaces")
}
@ -856,3 +861,69 @@ model UserSettings {
@@map("user_settings")
}
model Comment {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement()) @db.Integer
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
userId String @map("user_id") @db.VarChar
content Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
// whether the comment is resolved
resolved Boolean @default(false) @map("resolved")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
replies Reply[]
@@index([workspaceId, docId, sid])
@@index([workspaceId, docId, updatedAt])
@@index([userId])
@@map("comments")
}
model Reply {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement()) @db.Integer
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
commentId String @map("comment_id") @db.VarChar
// query new replies by workspaceId and docId
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
content Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([commentId, sid])
@@index([workspaceId, docId, updatedAt])
@@index([userId])
@@map("replies")
}
model CommentAttachment {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
name String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// will delete creator record if creator's account is deleted
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
@@id([workspaceId, docId, key])
@@map("comment_attachments")
}

File diff suppressed because it is too large Load Diff

View File

@ -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,
})
);

View File

@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner,
});
return {
owner,
workspace,
};
}
e2e.afterEach.always(() => {
mock.reset();
});
// #region comment attachment
e2e(
'should get comment attachment not found when key is not exists',
async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/not-exists`
);
t.is(res.status, 404);
t.is(res.body.message, 'Comment attachment not found.');
}
);
e2e(
'should get comment attachment no permission when user is not member',
async t => {
const { workspace } = await createWorkspace();
// signup a new user
await app.signup();
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/some-key`
);
t.is(res.status, 403);
t.regex(
res.body.message,
/You do not have permission to perform Doc.Read action on doc /
);
}
);
e2e('should get comment attachment body', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test')
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
});
e2e('should get comment attachment redirect url', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
mock.method(attachment, 'get', async () => {
return {
body: null,
metadata: null,
redirectUrl: `https://foo.com/${key}`,
};
});
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 302);
t.is(res.headers['location'], `https://foo.com/${key}`);
});
// #endregion

View File

@ -28,6 +28,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';
@ -180,7 +181,8 @@ export function buildAppModule(env: Env) {
CopilotModule,
CaptchaModule,
OAuthModule,
CustomerIoModule
CustomerIoModule,
CommentModule
)
// doc service only
.useIf(() => env.flavors.doc, DocServiceModule)

View File

@ -907,4 +907,22 @@ export const USER_FRIENDLY_ERRORS = {
args: { reason: 'string' },
message: ({ reason }) => `Invalid indexer input: ${reason}`,
},
// comment and reply errors
comment_not_found: {
type: 'resource_not_found',
message: 'Comment not found.',
},
reply_not_found: {
type: 'resource_not_found',
message: 'Reply not found.',
},
comment_attachment_not_found: {
type: 'resource_not_found',
message: 'Comment attachment not found.',
},
comment_attachment_quota_exceeded: {
type: 'quota_exceeded',
message: 'You have exceeded the comment attachment size quota.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;

View File

@ -1067,6 +1067,30 @@ export class InvalidIndexerInput extends UserFriendlyError {
super('invalid_input', 'invalid_indexer_input', message, args);
}
}
export class CommentNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'comment_not_found', message);
}
}
export class ReplyNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'reply_not_found', message);
}
}
export class CommentAttachmentNotFound extends UserFriendlyError {
constructor(message?: string) {
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,
@ -1202,7 +1226,11 @@ export enum ErrorNames {
INVALID_APP_CONFIG_INPUT,
SEARCH_PROVIDER_NOT_FOUND,
INVALID_SEARCH_PROVIDER_REQUEST,
INVALID_INDEXER_INPUT
INVALID_INDEXER_INPUT,
COMMENT_NOT_FOUND,
REPLY_NOT_FOUND,
COMMENT_ATTACHMENT_NOT_FOUND,
COMMENT_ATTACHMENT_QUOTA_EXCEEDED
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'

View File

@ -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',
}

View File

@ -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);
});

View File

@ -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: {

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { CommentAttachmentStorage, StorageModule } from '..';
const module = await createModule({
imports: [StorageModule],
});
const storage = module.get(CommentAttachmentStorage);
const models = module.get(Models);
test.before(async () => {
await storage.onConfigInit();
});
test.after.always(async () => {
await module.close();
});
test('should put comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.workspaceId, workspace.id);
t.is(item?.docId, docId);
t.is(item?.key, key);
t.is(item?.mime, 'text/plain');
t.is(item?.size, blob.length);
t.is(item?.name, 'test.txt');
});
test('should get comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await storage.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.metadata?.contentType, 'text/plain');
t.is(item?.metadata?.contentLength, blob.length);
// body is readable stream
t.truthy(item?.body);
const bytes = await readableToBytes(item?.body as Readable);
t.is(bytes.toString(), 'test');
});
test('should get comment attachment with access url', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const url = storage.getUrl(workspace.id, docId, key);
t.truthy(url);
t.is(
url,
`http://localhost:3010/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
});
test('should delete comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.delete(workspace.id, docId, key);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle comment.attachment.delete event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.onCommentAttachmentDelete({
workspaceId: workspace.id,
docId,
key,
});
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle workspace.deleted event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key1 = randomUUID();
const key2 = randomUUID();
const blob1 = Buffer.from('test');
const blob2 = Buffer.from('test2');
await storage.put(workspace.id, docId, key1, 'test.txt', blob1);
await storage.put(workspace.id, docId, key2, 'test.txt', blob2);
const count = module.event.count('comment.attachment.delete');
await storage.onWorkspaceDeleted({
id: workspace.id,
});
t.is(module.event.count('comment.attachment.delete'), count + 2);
});
async function readableToBytes(stream: Readable) {
const chunks: Buffer[] = [];
let chunk: Buffer;
for await (chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

View File

@ -2,12 +2,16 @@ import './config';
import { Module } from '@nestjs/common';
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
import {
AvatarStorage,
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from './wrappers';
@Module({
providers: [WorkspaceBlobStorage, AvatarStorage],
exports: [WorkspaceBlobStorage, AvatarStorage],
providers: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
exports: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
})
export class StorageModule {}
export { AvatarStorage, WorkspaceBlobStorage };
export { AvatarStorage, CommentAttachmentStorage, WorkspaceBlobStorage };

View File

@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import {
autoMetadata,
Config,
EventBus,
OnEvent,
type StorageProvider,
StorageProviderFactory,
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
declare global {
interface Events {
'comment.attachment.delete': {
workspaceId: string;
docId: string;
key: string;
};
}
}
@Injectable()
export class CommentAttachmentStorage {
private readonly logger = new Logger(CommentAttachmentStorage.name);
private provider!: StorageProvider;
get config() {
return this.AFFiNEConfig.storages.blob;
}
constructor(
private readonly AFFiNEConfig: Config,
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly models: Models,
private readonly url: URLHelper
) {}
@OnEvent('config.init')
async onConfigInit() {
this.provider = this.storageFactory.create(this.config.storage);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates.storages?.blob?.storage) {
this.provider = this.storageFactory.create(this.config.storage);
}
}
private storageKey(workspaceId: string, docId: string, key: string) {
return `comment-attachments/${workspaceId}/${docId}/${key}`;
}
async put(
workspaceId: string,
docId: string,
key: string,
name: string,
blob: Buffer
) {
const meta = autoMetadata(blob);
await this.provider.put(
this.storageKey(workspaceId, docId, key),
blob,
meta
);
await this.models.commentAttachment.upsert({
workspaceId,
docId,
key,
name,
mime: meta.contentType ?? 'application/octet-stream',
size: blob.length,
});
}
async get(
workspaceId: string,
docId: string,
key: string,
signedUrl?: boolean
) {
return await this.provider.get(
this.storageKey(workspaceId, docId, key),
signedUrl
);
}
async delete(workspaceId: string, docId: string, key: string) {
await this.provider.delete(this.storageKey(workspaceId, docId, key));
await this.models.commentAttachment.delete(workspaceId, docId, key);
this.logger.log(
`deleted comment attachment ${workspaceId}/${docId}/${key}`
);
}
getUrl(workspaceId: string, docId: string, key: string) {
return this.url.link(
`/api/workspaces/${workspaceId}/docs/${docId}/comment-attachments/${key}`
);
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
const attachments = await this.models.commentAttachment.list(id);
attachments.forEach(attachment => {
this.event.emit('comment.attachment.delete', {
workspaceId: id,
docId: attachment.docId,
key: attachment.key,
});
});
}
@OnEvent('comment.attachment.delete')
async onCommentAttachmentDelete({
workspaceId,
docId,
key,
}: Events['comment.attachment.delete']) {
await this.delete(workspaceId, docId, key);
}
}

View File

@ -1,2 +1,3 @@
export { AvatarStorage } from './avatar';
export { WorkspaceBlobStorage } from './blob';
export { CommentAttachmentStorage } from './comment-attachment';

View File

@ -4,6 +4,7 @@ import type { Response } from 'express';
import {
BlobNotFound,
CallMetric,
CommentAttachmentNotFound,
DocHistoryNotFound,
DocNotFound,
InvalidHistoryTimestamp,
@ -13,7 +14,7 @@ import { CurrentUser, Public } from '../auth';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import { DocReader } from '../doc/reader';
import { AccessController } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
import { CommentAttachmentStorage, WorkspaceBlobStorage } from '../storage';
import { DocID } from '../utils/doc';
@Controller('/api/workspaces')
@ -21,6 +22,7 @@ export class WorkspacesController {
logger = new Logger(WorkspacesController.name);
constructor(
private readonly storage: WorkspaceBlobStorage,
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly ac: AccessController,
private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly docReader: DocReader,
@ -180,4 +182,41 @@ export class WorkspacesController {
});
}
}
@Get('/:id/docs/:docId/comment-attachments/:key')
@CallMetric('controllers', 'workspace_get_comment_attachment')
async commentAttachment(
@CurrentUser() user: CurrentUser,
@Param('id') workspaceId: string,
@Param('docId') docId: string,
@Param('key') key: string,
@Res() res: Response
) {
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Read');
const { body, metadata, redirectUrl } =
await this.commentAttachmentStorage.get(workspaceId, docId, key);
if (redirectUrl) {
return res.redirect(redirectUrl);
}
if (!body) {
throw new CommentAttachmentNotFound();
}
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toUTCString());
res.setHeader('content-length', metadata.contentLength);
} else {
this.logger.warn(
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
);
}
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
body.pipe(res);
}
}

View File

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

View File

@ -0,0 +1,125 @@
import test from 'ava';
import { createModule } from '../../__tests__/create-module';
import { Mockers } from '../../__tests__/mocks';
import { Models } from '..';
const module = await createModule();
const models = module.get(Models);
test.after.always(async () => {
await module.close();
});
test('should upsert comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
// add
const item = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id',
key: 'test-key',
name: 'test-name',
mime: 'text/plain',
size: 100,
});
t.is(item.workspaceId, workspace.id);
t.is(item.docId, 'test-doc-id');
t.is(item.key, 'test-key');
t.is(item.mime, 'text/plain');
t.is(item.size, 100);
t.truthy(item.createdAt);
// update
const item2 = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id',
name: 'test-name',
key: 'test-key',
mime: 'text/html',
size: 200,
});
t.is(item2.workspaceId, workspace.id);
t.is(item2.docId, 'test-doc-id');
t.is(item2.key, 'test-key');
t.is(item2.mime, 'text/html');
t.is(item2.size, 200);
// make sure only one blob is created
const items = await models.commentAttachment.list(workspace.id);
t.is(items.length, 1);
t.deepEqual(items[0], item2);
});
test('should delete comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const item = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id',
key: 'test-key',
name: 'test-name',
mime: 'text/plain',
size: 100,
});
await models.commentAttachment.delete(workspace.id, item.docId, item.key);
const item2 = await models.commentAttachment.get(
workspace.id,
item.docId,
item.key
);
t.is(item2, null);
});
test('should list comment attachments', async t => {
const workspace = await module.create(Mockers.Workspace);
const item1 = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id',
name: 'test-name',
key: 'test-key',
mime: 'text/plain',
size: 100,
});
const item2 = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id2',
name: 'test-name2',
key: 'test-key2',
mime: 'text/plain',
size: 200,
});
const items = await models.commentAttachment.list(workspace.id);
t.is(items.length, 2);
items.sort((a, b) => a.key.localeCompare(b.key));
t.is(items[0].key, item1.key);
t.is(items[1].key, item2.key);
});
test('should get comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const item = await models.commentAttachment.upsert({
workspaceId: workspace.id,
docId: 'test-doc-id',
name: 'test-name',
key: 'test-key',
mime: 'text/plain',
size: 100,
});
const item2 = await models.commentAttachment.get(
workspace.id,
item.docId,
item.key
);
t.truthy(item2);
t.is(item2?.key, item.key);
});

View File

@ -0,0 +1,494 @@
import { randomUUID } from 'node:crypto';
import test from 'ava';
import { createModule } from '../../__tests__/create-module';
import { Mockers } from '../../__tests__/mocks';
import { Models } from '..';
import { CommentChangeAction, Reply } from '../comment';
const module = await createModule({});
const models = module.get(Models);
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
test.after.always(async () => {
await module.close();
});
test('should throw error when content is null', async t => {
const docId = randomUUID();
await t.throwsAsync(
models.comment.create({
// @ts-expect-error test null content
content: null,
workspaceId: workspace.id,
docId,
userId: owner.id,
}),
{
message: /Expected object, received null/,
}
);
await t.throwsAsync(
models.comment.createReply({
// @ts-expect-error test null content
content: null,
commentId: randomUUID(),
}),
{
message: /Expected object, received null/,
}
);
});
test('should create a comment', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
t.is(comment.createdAt.getTime(), comment.updatedAt.getTime());
t.is(comment.deletedAt, null);
t.is(comment.resolved, false);
t.deepEqual(comment.content, {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
});
});
test('should get a comment', async t => {
const docId = randomUUID();
const comment1 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment2 = await models.comment.get(comment1.id);
t.deepEqual(comment2, comment1);
t.deepEqual(comment2?.content, {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
});
});
test('should update a comment', async t => {
const docId = randomUUID();
const comment1 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment2 = await models.comment.update({
id: comment1.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
});
t.deepEqual(comment2.content, {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
});
// updatedAt should be changed
t.true(comment2.updatedAt.getTime() > comment2.createdAt.getTime());
const comment3 = await models.comment.get(comment1.id);
t.deepEqual(comment3, comment2);
});
test('should delete a comment', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
await models.comment.delete(comment.id);
const comment2 = await models.comment.get(comment.id);
t.is(comment2, null);
});
test('should resolve a comment', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment2 = await models.comment.resolve({
id: comment.id,
resolved: true,
});
t.is(comment2.resolved, true);
const comment3 = await models.comment.get(comment.id);
t.is(comment3!.resolved, true);
// updatedAt should be changed
t.true(comment3!.updatedAt.getTime() > comment3!.createdAt.getTime());
const comment4 = await models.comment.resolve({
id: comment.id,
resolved: false,
});
t.is(comment4.resolved, false);
const comment5 = await models.comment.get(comment.id);
t.is(comment5!.resolved, false);
// updatedAt should be changed
t.true(comment5!.updatedAt.getTime() > comment3!.updatedAt.getTime());
});
test('should count comments', async t => {
const docId = randomUUID();
const comment1 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const count = await models.comment.count(workspace.id, docId);
t.is(count, 1);
await models.comment.delete(comment1.id);
const count2 = await models.comment.count(workspace.id, docId);
t.is(count2, 0);
});
test('should create and get a reply', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply' }],
},
commentId: comment.id,
});
t.snapshot(reply.content);
t.is(reply.commentId, comment.id);
t.is(reply.userId, owner.id);
t.is(reply.workspaceId, workspace.id);
t.is(reply.docId, docId);
const reply2 = await models.comment.getReply(reply.id);
t.deepEqual(reply2, reply);
});
test('should update a reply', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply' }],
},
commentId: comment.id,
});
const reply2 = await models.comment.updateReply({
id: reply.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
});
t.snapshot(reply2.content);
t.true(reply2.updatedAt.getTime() > reply2.createdAt.getTime());
});
test('should delete a reply', async t => {
const docId = randomUUID();
const comment = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply' }],
},
commentId: comment.id,
});
await models.comment.deleteReply(reply.id);
const reply2 = await models.comment.getReply(reply.id);
t.is(reply2, null);
});
test('should list comments with replies', async t => {
const docId = randomUUID();
const comment1 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment2 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment3 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test3' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply1 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply1' }],
},
commentId: comment1.id,
});
const reply2 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
commentId: comment1.id,
});
const reply3 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply3' }],
},
commentId: comment1.id,
});
const reply4 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply4' }],
},
commentId: comment2.id,
});
const comments = await models.comment.list(workspace.id, docId);
t.is(comments.length, 3);
t.is(comments[0].id, comment3.id);
t.is(comments[1].id, comment2.id);
t.is(comments[2].id, comment1.id);
t.is(comments[0].replies.length, 0);
t.is(comments[1].replies.length, 1);
t.is(comments[2].replies.length, 3);
t.is(comments[1].replies[0].id, reply4.id);
t.is(comments[2].replies[0].id, reply1.id);
t.is(comments[2].replies[1].id, reply2.id);
t.is(comments[2].replies[2].id, reply3.id);
// list with sid
const comments2 = await models.comment.list(workspace.id, docId, {
sid: comment2.sid,
});
t.is(comments2.length, 1);
t.is(comments2[0].id, comment1.id);
t.is(comments2[0].replies.length, 3);
// ignore deleted comments
await models.comment.delete(comment1.id);
const comments3 = await models.comment.list(workspace.id, docId);
t.is(comments3.length, 2);
t.is(comments3[0].id, comment3.id);
t.is(comments3[1].id, comment2.id);
t.is(comments3[0].replies.length, 0);
t.is(comments3[1].replies.length, 1);
});
test('should list changes', async t => {
const docId = randomUUID();
const comment1 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const comment2 = await models.comment.create({
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test2' }],
},
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const reply1 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply1' }],
},
commentId: comment1.id,
});
const reply2 = await models.comment.createReply({
userId: owner.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2' }],
},
commentId: comment1.id,
});
// all changes
const changes1 = await models.comment.listChanges(workspace.id, docId);
t.is(changes1.length, 4);
t.is(changes1[0].action, CommentChangeAction.update);
t.is(changes1[0].id, comment1.id);
t.is(changes1[1].action, CommentChangeAction.update);
t.is(changes1[1].id, comment2.id);
t.is(changes1[2].action, CommentChangeAction.update);
t.is(changes1[2].id, reply1.id);
t.is(changes1[3].action, CommentChangeAction.update);
t.is(changes1[3].id, reply2.id);
// reply has commentId
t.is((changes1[2].item as Reply).commentId, comment1.id);
const changes2 = await models.comment.listChanges(workspace.id, docId, {
commentUpdatedAt: comment1.updatedAt,
replyUpdatedAt: reply1.updatedAt,
});
t.is(changes2.length, 2);
t.is(changes2[0].action, CommentChangeAction.update);
t.is(changes2[0].id, comment2.id);
t.is(changes2[1].action, CommentChangeAction.update);
t.is(changes2[1].id, reply2.id);
t.is(changes2[1].commentId, comment1.id);
// update comment1
const comment1Updated = await models.comment.update({
id: comment1.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test3' }],
},
});
const changes3 = await models.comment.listChanges(workspace.id, docId, {
commentUpdatedAt: comment2.updatedAt,
replyUpdatedAt: reply2.updatedAt,
});
t.is(changes3.length, 1);
t.is(changes3[0].action, CommentChangeAction.update);
t.is(changes3[0].id, comment1Updated.id);
// delete comment1 and reply1, update reply2
await models.comment.delete(comment1.id);
await models.comment.deleteReply(reply1.id);
await models.comment.updateReply({
id: reply2.id,
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test reply2 updated' }],
},
});
const changes4 = await models.comment.listChanges(workspace.id, docId, {
commentUpdatedAt: comment1Updated.updatedAt,
replyUpdatedAt: reply2.updatedAt,
});
t.is(changes4.length, 3);
t.is(changes4[0].action, CommentChangeAction.delete);
t.is(changes4[0].id, comment1.id);
t.is(changes4[1].action, CommentChangeAction.delete);
t.is(changes4[1].id, reply1.id);
t.is(changes4[1].commentId, comment1.id);
t.is(changes4[2].action, CommentChangeAction.update);
t.is(changes4[2].id, reply2.id);
t.is(changes4[2].commentId, comment1.id);
// no changes
const changes5 = await models.comment.listChanges(workspace.id, docId, {
commentUpdatedAt: changes4[2].item.updatedAt,
replyUpdatedAt: changes4[2].item.updatedAt,
});
t.is(changes5.length, 0);
});

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
export type CreateCommentAttachmentInput =
Prisma.CommentAttachmentUncheckedCreateInput;
/**
* Comment Attachment Model
*/
@Injectable()
export class CommentAttachmentModel extends BaseModel {
async upsert(input: CreateCommentAttachmentInput) {
return await this.db.commentAttachment.upsert({
where: {
workspaceId_docId_key: {
workspaceId: input.workspaceId,
docId: input.docId,
key: input.key,
},
},
update: {
name: input.name,
mime: input.mime,
size: input.size,
},
create: {
workspaceId: input.workspaceId,
docId: input.docId,
key: input.key,
name: input.name,
mime: input.mime,
size: input.size,
},
});
}
async delete(workspaceId: string, docId: string, key: string) {
await this.db.commentAttachment.deleteMany({
where: {
workspaceId,
docId,
key,
},
});
this.logger.log(`deleted comment attachment ${workspaceId}/${key}`);
}
async get(workspaceId: string, docId: string, key: string) {
return await this.db.commentAttachment.findUnique({
where: {
workspaceId_docId_key: {
workspaceId,
docId,
key,
},
},
});
}
async list(workspaceId: string, docId?: string) {
return await this.db.commentAttachment.findMany({
where: {
workspaceId,
docId,
},
});
}
}

View File

@ -0,0 +1,336 @@
import { Injectable } from '@nestjs/common';
import {
Comment as CommentType,
Reply as ReplyType,
// Prisma,
} from '@prisma/client';
import { z } from 'zod';
import { CommentNotFound } from '../base';
import { BaseModel } from './base';
export interface Comment extends CommentType {
content: Record<string, any>;
}
export interface Reply extends ReplyType {
content: Record<string, any>;
}
// TODO(@fengmk2): move IdSchema to common/base.ts
const IdSchema = z.string().trim().min(1).max(100);
const JSONSchema = z.record(z.any());
export const CommentCreateSchema = z.object({
workspaceId: IdSchema,
docId: IdSchema,
userId: IdSchema,
content: JSONSchema,
});
export const CommentUpdateSchema = z.object({
id: IdSchema,
content: JSONSchema,
});
export const CommentResolveSchema = z.object({
id: IdSchema,
resolved: z.boolean(),
});
export const ReplyCreateSchema = z.object({
commentId: IdSchema,
userId: IdSchema,
content: JSONSchema,
});
export const ReplyUpdateSchema = z.object({
id: IdSchema,
content: JSONSchema,
});
export type CommentCreate = z.input<typeof CommentCreateSchema>;
export type CommentUpdate = z.input<typeof CommentUpdateSchema>;
export type CommentResolve = z.input<typeof CommentResolveSchema>;
export type ReplyCreate = z.input<typeof ReplyCreateSchema>;
export type ReplyUpdate = z.input<typeof ReplyUpdateSchema>;
export interface CommentWithReplies extends Comment {
replies: Reply[];
}
export enum CommentChangeAction {
update = 'update',
delete = 'delete',
}
export interface DeletedChangeItem {
deletedAt: Date;
updatedAt: Date;
}
export interface CommentChange {
action: CommentChangeAction;
id: string;
commentId?: string;
item: Comment | Reply | DeletedChangeItem;
}
@Injectable()
export class CommentModel extends BaseModel {
// #region Comment
/**
* Create a comment
* @param input - The comment create input
* @returns The created comment
*/
async create(input: CommentCreate) {
const data = CommentCreateSchema.parse(input);
return (await this.db.comment.create({
data,
})) as Comment;
}
async get(id: string) {
return (await this.db.comment.findUnique({
where: { id, deletedAt: null },
})) as Comment | null;
}
/**
* Update a comment content
* @param input - The comment update input
* @returns The updated comment
*/
async update(input: CommentUpdate) {
const data = CommentUpdateSchema.parse(input);
return await this.db.comment.update({
where: { id: data.id },
data: {
content: data.content,
},
});
}
/**
* Delete a comment or reply
* @param id - The id of the comment or reply
* @returns The deleted comment or reply
*/
async delete(id: string) {
await this.db.comment.update({
where: { id },
data: { deletedAt: new Date() },
});
this.logger.log(`Comment ${id} deleted`);
}
/**
* Resolve a comment or not
* @param input - The comment resolve input
* @returns The resolved comment
*/
async resolve(input: CommentResolve) {
const data = CommentResolveSchema.parse(input);
return await this.db.comment.update({
where: { id: data.id },
data: { resolved: data.resolved },
});
}
async count(workspaceId: string, docId: string) {
return await this.db.comment.count({
where: { workspaceId, docId, deletedAt: null },
});
}
/**
* List comments ordered by sid descending
* @param workspaceId - The workspace id
* @param docId - The doc id
* @param options - The options
* @returns The list of comments with replies
*/
async list(
workspaceId: string,
docId: string,
options?: {
sid?: number;
take?: number;
}
): Promise<CommentWithReplies[]> {
const comments = (await this.db.comment.findMany({
where: {
workspaceId,
docId,
...(options?.sid ? { sid: { lt: options.sid } } : {}),
deletedAt: null,
},
orderBy: { sid: 'desc' },
take: options?.take ?? 100,
})) as Comment[];
const replies = (await this.db.reply.findMany({
where: {
commentId: { in: comments.map(comment => comment.id) },
deletedAt: null,
},
orderBy: { sid: 'asc' },
})) as Reply[];
const replyMap = new Map<string, Reply[]>();
for (const reply of replies) {
const items = replyMap.get(reply.commentId) ?? [];
items.push(reply);
replyMap.set(reply.commentId, items);
}
const commentWithReplies = comments.map(comment => ({
...comment,
replies: replyMap.get(comment.id) ?? [],
}));
return commentWithReplies;
}
async listChanges(
workspaceId: string,
docId: string,
options?: {
commentUpdatedAt?: Date;
replyUpdatedAt?: Date;
take?: number;
}
): Promise<CommentChange[]> {
const take = options?.take ?? 10000;
const comments = (await this.db.comment.findMany({
where: {
workspaceId,
docId,
...(options?.commentUpdatedAt
? { updatedAt: { gt: options.commentUpdatedAt } }
: {}),
},
take,
orderBy: { updatedAt: 'asc' },
})) as Comment[];
const replies = (await this.db.reply.findMany({
where: {
workspaceId,
docId,
...(options?.replyUpdatedAt
? { updatedAt: { gt: options.replyUpdatedAt } }
: {}),
},
take,
orderBy: { updatedAt: 'asc' },
})) as Reply[];
const changes: CommentChange[] = [];
for (const comment of comments) {
if (comment.deletedAt) {
changes.push({
action: CommentChangeAction.delete,
id: comment.id,
item: {
deletedAt: comment.deletedAt,
updatedAt: comment.updatedAt,
},
});
} else {
changes.push({
action: CommentChangeAction.update,
id: comment.id,
item: comment,
});
}
}
for (const reply of replies) {
if (reply.deletedAt) {
changes.push({
action: CommentChangeAction.delete,
id: reply.id,
commentId: reply.commentId,
item: {
deletedAt: reply.deletedAt,
updatedAt: reply.updatedAt,
},
});
} else {
changes.push({
action: CommentChangeAction.update,
id: reply.id,
commentId: reply.commentId,
item: reply,
});
}
}
return changes;
}
// #endregion
// #region Reply
/**
* Reply to a comment
* @param input - The reply create input
* @returns The created reply
*/
async createReply(input: ReplyCreate) {
const data = ReplyCreateSchema.parse(input);
// find comment
const comment = await this.db.comment.findUnique({
where: { id: data.commentId },
});
if (!comment) {
throw new CommentNotFound();
}
return (await this.db.reply.create({
data: {
...data,
workspaceId: comment.workspaceId,
docId: comment.docId,
},
})) as Reply;
}
async getReply(id: string) {
return (await this.db.reply.findUnique({
where: { id, deletedAt: null },
})) as Reply | null;
}
/**
* Update a reply content
* @param input - The reply update input
* @returns The updated reply
*/
async updateReply(input: ReplyUpdate) {
const data = ReplyUpdateSchema.parse(input);
return await this.db.reply.update({
where: { id: data.id },
data: { content: data.content },
});
}
/**
* Delete a reply
* @param id - The id of the reply
* @returns The deleted reply
*/
async deleteReply(id: string) {
await this.db.reply.update({
where: { id },
data: { deletedAt: new Date() },
});
this.logger.log(`Reply ${id} deleted`);
}
// #endregion
}

View File

@ -5,6 +5,7 @@ export enum DocRole {
None = -(1 << 15),
External = 0,
Reader = 10,
Commenter = 15,
Editor = 20,
Manager = 30,
Owner = 99,

View File

@ -7,6 +7,8 @@ import {
import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { CommentModel } from './comment';
import { CommentAttachmentModel } from './comment-attachment';
import { AppConfigModel } from './config';
import { CopilotContextModel } from './copilot-context';
import { CopilotJobModel } from './copilot-job';
@ -48,6 +50,8 @@ const MODELS = {
copilotWorkspace: CopilotWorkspaceConfigModel,
copilotJob: CopilotJobModel,
appConfig: AppConfigModel,
comment: CommentModel,
commentAttachment: CommentAttachmentModel,
};
type ModelsType = {
@ -99,6 +103,8 @@ const ModelsSymbolProvider: ExistingProvider = {
})
export class ModelsModule {}
export * from './comment';
export * from './comment-attachment';
export * from './common';
export * from './copilot-context';
export * from './copilot-job';

View File

@ -85,13 +85,13 @@ export class UserModel extends BaseModel {
async getPublicUsersMap<T extends ItemWithUserId>(
items: T[]
): Promise<Map<string, PublicUser>> {
const userIds: string[] = [];
const userIds = new Set<string>();
for (const item of items) {
if (item.userId) {
userIds.push(item.userId);
userIds.add(item.userId);
}
}
const users = await this.getPublicUsers(userIds);
const users = await this.getPublicUsers(Array.from(userIds));
return new Map(users.map(user => [user.id, user]));
}

View File

@ -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
@ -455,6 +522,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!
@ -472,6 +543,7 @@ type DocPermissions {
"""User permission in doc"""
enum DocRole {
Commenter
Editor
External
Manager
@ -539,6 +611,9 @@ enum ErrorNames {
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
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
COPILOT_DOCS_NOT_FOUND
@ -628,6 +703,7 @@ enum ErrorNames {
OWNER_CAN_NOT_LEAVE_WORKSPACE
PASSWORD_REQUIRED
QUERY_TOO_LONG
REPLY_NOT_FOUND
RUNTIME_CONFIG_NOT_FOUND
SAME_EMAIL_PROVIDED
SAME_SUBSCRIPTION_RECURRING
@ -1086,6 +1162,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!
@ -1102,6 +1179,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"""
@ -1113,6 +1191,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!
@ -1161,6 +1245,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")
@ -1181,6 +1268,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!
@ -1190,6 +1280,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!
@ -1209,6 +1302,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!
@ -1297,6 +1393,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!
@ -1470,6 +1578,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!
@ -2010,6 +2145,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!

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -0,0 +1,3 @@
mutation deleteComment($id: String!) {
deleteComment(id: $id)
}

View File

@ -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
}
}
}
}

View File

@ -0,0 +1,14 @@
mutation createReply($input: ReplyCreateInput!) {
createReply(input: $input) {
commentId
id
content
createdAt
updatedAt
user {
id
name
avatarUrl
}
}
}

View File

@ -0,0 +1,3 @@
mutation deleteReply($id: String!) {
deleteReply(id: $id)
}

View File

@ -0,0 +1,3 @@
mutation updateReply($input: ReplyUpdateInput!) {
updateReply(input: $input)
}

View File

@ -0,0 +1,3 @@
mutation resolveComment($input: CommentResolveInput!) {
resolveComment(input: $input)
}

View File

@ -0,0 +1,3 @@
mutation updateComment($input: CommentUpdateInput!) {
updateComment(input: $input)
}

View File

@ -0,0 +1,3 @@
mutation uploadCommentAttachment($workspaceId: String!, $docId: String!, $attachment: Upload!) {
uploadCommentAttachment(workspaceId: $workspaceId, docId: $docId, attachment: $attachment)
}

View File

@ -333,6 +333,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',

View File

@ -140,6 +140,68 @@ export interface ChatMessage {
streamObjects: Maybe<Array<StreamObject>>;
}
/** 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<Scalars['ID']['output']>;
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<ReplyObjectType>;
/** 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',
@ -564,6 +626,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'];
@ -581,6 +647,7 @@ export interface DocPermissions {
/** User permission in doc */
export enum DocRole {
Commenter = 'Commenter',
Editor = 'Editor',
External = 'External',
Manager = 'Manager',
@ -708,6 +775,9 @@ export enum ErrorNames {
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
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',
COPILOT_DOCS_NOT_FOUND = 'COPILOT_DOCS_NOT_FOUND',
@ -797,6 +867,7 @@ export enum ErrorNames {
OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE',
PASSWORD_REQUIRED = 'PASSWORD_REQUIRED',
QUERY_TOO_LONG = 'QUERY_TOO_LONG',
REPLY_NOT_FOUND = 'REPLY_NOT_FOUND',
RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND',
SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED',
SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING',
@ -1244,6 +1315,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 */
@ -1255,6 +1327,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;
@ -1263,6 +1336,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'];
@ -1302,6 +1379,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<TranscriptionResultType>;
/** @deprecated use [revokeMember] instead */
@ -1322,6 +1401,8 @@ export interface Mutation {
submitAudioTranscription: Maybe<TranscriptionResultType>;
/** 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 */
@ -1329,6 +1410,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;
@ -1342,6 +1425,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<AppConfigValidateResult>;
verifyEmail: Scalars['Boolean']['output'];
@ -1424,6 +1509,10 @@ export interface MutationCreateCheckoutSessionArgs {
input: CreateCheckoutSessionInput;
}
export interface MutationCreateCommentArgs {
input: CommentCreateInput;
}
export interface MutationCreateCopilotContextArgs {
sessionId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
@ -1446,6 +1535,10 @@ export interface MutationCreateInviteLinkArgs {
workspaceId: Scalars['String']['input'];
}
export interface MutationCreateReplyArgs {
input: ReplyCreateInput;
}
export interface MutationCreateSelfhostWorkspaceCustomerPortalArgs {
workspaceId: Scalars['String']['input'];
}
@ -1469,6 +1562,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'];
}
@ -1582,6 +1683,10 @@ export interface MutationRemoveWorkspaceFeatureArgs {
workspaceId: Scalars['String']['input'];
}
export interface MutationResolveCommentArgs {
input: CommentResolveInput;
}
export interface MutationResumeSubscriptionArgs {
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
plan?: InputMaybe<SubscriptionPlan>;
@ -1666,6 +1771,10 @@ export interface MutationUpdateAppConfigArgs {
updates: Array<UpdateAppConfigInput>;
}
export interface MutationUpdateCommentArgs {
input: CommentUpdateInput;
}
export interface MutationUpdateCopilotPromptArgs {
messages: Array<CopilotPromptMessageInput>;
name: Scalars['String']['input'];
@ -1687,6 +1796,10 @@ export interface MutationUpdateProfileArgs {
input: UpdateUserInput;
}
export interface MutationUpdateReplyArgs {
input: ReplyUpdateInput;
}
export interface MutationUpdateSettingsArgs {
input: UpdateUserSettingsInput;
}
@ -1722,6 +1835,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<UpdateAppConfigInput>;
}
@ -1810,6 +1929,20 @@ export interface PageInfo {
startCursor: Maybe<Scalars['String']['output']>;
}
export interface PaginatedCommentChangeObjectType {
__typename?: 'PaginatedCommentChangeObjectType';
edges: Array<CommentChangeObjectTypeEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
}
export interface PaginatedCommentObjectType {
__typename?: 'PaginatedCommentObjectType';
edges: Array<CommentObjectTypeEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
}
export interface PaginatedCopilotWorkspaceFileType {
__typename?: 'PaginatedCopilotWorkspaceFileType';
edges: Array<CopilotWorkspaceFileTypeEdge>;
@ -2034,6 +2167,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'];
@ -2599,6 +2756,10 @@ export interface WorkspaceType {
blobs: Array<ListedBlob>;
/** 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 */
@ -2666,6 +2827,16 @@ export interface WorkspaceTypeAggregateArgs {
input: AggregateInput;
}
export interface WorkspaceTypeCommentChangesArgs {
docId: Scalars['String']['input'];
pagination: PaginationInput;
}
export interface WorkspaceTypeCommentsArgs {
docId: Scalars['String']['input'];
pagination?: InputMaybe<PaginationInput>;
}
export interface WorkspaceTypeDocArgs {
docId: Scalars['String']['input'];
}
@ -3062,6 +3233,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<PaginationInput>;
}>;
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;
}>;
@ -5207,6 +5583,16 @@ export type Queries =
variables: ListBlobsQueryVariables;
response: ListBlobsQuery;
}
| {
name: 'listCommentChangesQuery';
variables: ListCommentChangesQueryVariables;
response: ListCommentChangesQuery;
}
| {
name: 'listCommentsQuery';
variables: ListCommentsQueryVariables;
response: ListCommentsQuery;
}
| {
name: 'listContextObjectQuery';
variables: ListContextObjectQueryVariables;
@ -5589,6 +5975,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;

View File

@ -8875,6 +8875,22 @@ export function useAFFiNEI18N(): {
["error.INVALID_INDEXER_INPUT"](options: {
readonly reason: string;
}): string;
/**
* `Comment not found.`
*/
["error.COMMENT_NOT_FOUND"](): string;
/**
* `Reply not found.`
*/
["error.REPLY_NOT_FOUND"](): string;
/**
* `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 });

View File

@ -2191,5 +2191,9 @@
"error.INVALID_APP_CONFIG_INPUT": "Invalid app config input: {{message}}",
"error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.",
"error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}",
"error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}"
"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_QUOTA_EXCEEDED": "You have exceeded the comment attachment size quota."
}