diff --git a/packages/backend/server/migrations/20250609050058_add_comment/migration.sql b/packages/backend/server/migrations/20250609050058_add_comment/migration.sql new file mode 100644 index 000000000..ec1cd3132 --- /dev/null +++ b/packages/backend/server/migrations/20250609050058_add_comment/migration.sql @@ -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; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index aad496f16..32bbbeee5 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -46,6 +46,8 @@ model User { // receive notifications notifications Notification[] @relation("user_notifications") settings UserSettings? + comments Comment[] + replies Reply[] @@index([email]) @@map("users") @@ -126,6 +128,7 @@ model Workspace { blobs Blob[] ignoredDocs AiWorkspaceIgnoredDocs[] embedFiles AiWorkspaceFiles[] + comments Comment[] @@map("workspaces") } @@ -856,3 +859,50 @@ 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") +} diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 27ea6d77a..4881467c3 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -907,4 +907,14 @@ 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.', + }, } satisfies Record; diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index be86944d9..f7dae9ada 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -1067,6 +1067,18 @@ 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 enum ErrorNames { INTERNAL_SERVER_ERROR, NETWORK_ERROR, @@ -1202,7 +1214,9 @@ 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 } registerEnumType(ErrorNames, { name: 'ErrorNames' diff --git a/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.md b/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.md new file mode 100644 index 000000000..3eb15d038 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.md @@ -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', + } diff --git a/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.snap b/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.snap new file mode 100644 index 000000000..6cf2f1a4e Binary files /dev/null and b/packages/backend/server/src/models/__tests__/__snapshots__/comment.spec.ts.snap differ diff --git a/packages/backend/server/src/models/__tests__/comment.spec.ts b/packages/backend/server/src/models/__tests__/comment.spec.ts new file mode 100644 index 000000000..8ca1a8138 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/comment.spec.ts @@ -0,0 +1,526 @@ +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 throw error reply on a deleted 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); + + await t.throwsAsync( + models.comment.createReply({ + userId: owner.id, + content: { + type: 'paragraph', + content: [{ type: 'text', text: 'test reply' }], + }, + commentId: comment.id, + }), + { + message: /Comment not found/, + } + ); +}); + +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); +}); diff --git a/packages/backend/server/src/models/comment.ts b/packages/backend/server/src/models/comment.ts new file mode 100644 index 000000000..d04d6fcaf --- /dev/null +++ b/packages/backend/server/src/models/comment.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@nestjs/common'; +import { Comment as CommentType, Reply as ReplyType } from '@prisma/client'; +import { z } from 'zod'; + +import { CommentNotFound } from '../base'; +import { BaseModel } from './base'; + +export interface Comment extends CommentType { + content: Record; +} + +export interface Reply extends ReplyType { + content: Record; +} + +// 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; +export type CommentUpdate = z.input; +export type CommentResolve = z.input; +export type ReplyCreate = z.input; +export type ReplyUpdate = z.input; + +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, deletedAt: null }, + 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, deletedAt: null }, + 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, deletedAt: null }, + 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 { + 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(); + 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 { + 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.get(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, deletedAt: null }, + 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, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + this.logger.log(`Reply ${id} deleted`); + } + + // #endregion +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 5d8515b20..869e73e3a 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -7,6 +7,7 @@ import { import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; +import { CommentModel } from './comment'; import { AppConfigModel } from './config'; import { CopilotContextModel } from './copilot-context'; import { CopilotJobModel } from './copilot-job'; @@ -48,6 +49,7 @@ const MODELS = { copilotWorkspace: CopilotWorkspaceConfigModel, copilotJob: CopilotJobModel, appConfig: AppConfigModel, + comment: CommentModel, }; type ModelsType = { @@ -99,6 +101,7 @@ const ModelsSymbolProvider: ExistingProvider = { }) export class ModelsModule {} +export * from './comment'; export * from './common'; export * from './copilot-context'; export * from './copilot-job'; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 72c861b92..b8ea21d09 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -539,6 +539,7 @@ enum ErrorNames { CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS CAN_NOT_REVOKE_YOURSELF CAPTCHA_VERIFICATION_FAILED + COMMENT_NOT_FOUND COPILOT_ACTION_TAKEN COPILOT_CONTEXT_FILE_NOT_SUPPORTED COPILOT_DOCS_NOT_FOUND @@ -628,6 +629,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 diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index bd97e7258..0b02648ab 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -708,6 +708,7 @@ 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_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 +798,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', diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index a626c9bb3..45916230a 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8883,6 +8883,14 @@ 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; } { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); } function createComponent(i18nKey: string) { return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props }); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index d9359f298..ab7d1b9b5 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2193,5 +2193,7 @@ "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." }