Compare commits

...

1 Commits

Author SHA1 Message Date
fengmk2
03391670d9
feat(server): comment model 2025-06-26 13:01:10 +08:00
13 changed files with 1023 additions and 2 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

@ -46,6 +46,8 @@ model User {
// receive notifications // receive notifications
notifications Notification[] @relation("user_notifications") notifications Notification[] @relation("user_notifications")
settings UserSettings? settings UserSettings?
comments Comment[]
replies Reply[]
@@index([email]) @@index([email])
@@map("users") @@map("users")
@ -126,6 +128,7 @@ model Workspace {
blobs Blob[] blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[] ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[] embedFiles AiWorkspaceFiles[]
comments Comment[]
@@map("workspaces") @@map("workspaces")
} }
@ -856,3 +859,50 @@ model UserSettings {
@@map("user_settings") @@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")
}

View File

@ -907,4 +907,14 @@ export const USER_FRIENDLY_ERRORS = {
args: { reason: 'string' }, args: { reason: 'string' },
message: ({ reason }) => `Invalid indexer input: ${reason}`, 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<string, UserFriendlyErrorOptions>; } satisfies Record<string, UserFriendlyErrorOptions>;

View File

@ -1067,6 +1067,18 @@ export class InvalidIndexerInput extends UserFriendlyError {
super('invalid_input', 'invalid_indexer_input', message, args); 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 { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
NETWORK_ERROR, NETWORK_ERROR,
@ -1202,7 +1214,9 @@ export enum ErrorNames {
INVALID_APP_CONFIG_INPUT, INVALID_APP_CONFIG_INPUT,
SEARCH_PROVIDER_NOT_FOUND, SEARCH_PROVIDER_NOT_FOUND,
INVALID_SEARCH_PROVIDER_REQUEST, INVALID_SEARCH_PROVIDER_REQUEST,
INVALID_INDEXER_INPUT INVALID_INDEXER_INPUT,
COMMENT_NOT_FOUND,
REPLY_NOT_FOUND
} }
registerEnumType(ErrorNames, { registerEnumType(ErrorNames, {
name: 'ErrorNames' name: 'ErrorNames'

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,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,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

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

View File

@ -539,6 +539,7 @@ 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
COPILOT_ACTION_TAKEN COPILOT_ACTION_TAKEN
COPILOT_CONTEXT_FILE_NOT_SUPPORTED COPILOT_CONTEXT_FILE_NOT_SUPPORTED
COPILOT_DOCS_NOT_FOUND COPILOT_DOCS_NOT_FOUND
@ -628,6 +629,7 @@ 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
RUNTIME_CONFIG_NOT_FOUND RUNTIME_CONFIG_NOT_FOUND
SAME_EMAIL_PROVIDED SAME_EMAIL_PROVIDED
SAME_SUBSCRIPTION_RECURRING SAME_SUBSCRIPTION_RECURRING

View File

@ -708,6 +708,7 @@ export enum ErrorNames {
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS', CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF', CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF',
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED', CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED', COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED',
COPILOT_DOCS_NOT_FOUND = 'COPILOT_DOCS_NOT_FOUND', 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', OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE',
PASSWORD_REQUIRED = 'PASSWORD_REQUIRED', PASSWORD_REQUIRED = 'PASSWORD_REQUIRED',
QUERY_TOO_LONG = 'QUERY_TOO_LONG', QUERY_TOO_LONG = 'QUERY_TOO_LONG',
REPLY_NOT_FOUND = 'REPLY_NOT_FOUND',
RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND', RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND',
SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED', SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED',
SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING', SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING',

View File

@ -8875,6 +8875,14 @@ export function useAFFiNEI18N(): {
["error.INVALID_INDEXER_INPUT"](options: { ["error.INVALID_INDEXER_INPUT"](options: {
readonly reason: string; readonly reason: string;
}): 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]); } } { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); }
function createComponent(i18nKey: string) { function createComponent(i18nKey: string) {
return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props }); return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props });

View File

@ -2191,5 +2191,7 @@
"error.INVALID_APP_CONFIG_INPUT": "Invalid app config input: {{message}}", "error.INVALID_APP_CONFIG_INPUT": "Invalid app config input: {{message}}",
"error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.", "error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.",
"error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}", "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."
} }