From e773930256c3da95469ffa6ba79ee557cf534623 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sat, 28 Jun 2025 16:45:24 +0800 Subject: [PATCH] feat(server): add comment-attachment model (#12909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary by CodeRabbit * **New Features** * Introduced support for comment attachments, allowing users to add, view, and manage attachments linked to comments within documents. * **Tests** * Added comprehensive tests to ensure correct behavior for adding, updating, deleting, listing, and retrieving comment attachments. #### PR Dependency Tree * **PR #12909** 👈 * **PR #12911** * **PR #12761** * **PR #12924** * **PR #12925** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .../migration.sql | 23 ++++ packages/backend/server/schema.prisma | 22 +++ .../__tests__/comment-attachment.spec.ts | 125 ++++++++++++++++++ .../server/src/models/comment-attachment.ts | 70 ++++++++++ packages/backend/server/src/models/index.ts | 3 + 5 files changed, 243 insertions(+) create mode 100644 packages/backend/server/migrations/20250624051534_add_comment_attachments/migration.sql create mode 100644 packages/backend/server/src/models/__tests__/comment-attachment.spec.ts create mode 100644 packages/backend/server/src/models/comment-attachment.ts diff --git a/packages/backend/server/migrations/20250624051534_add_comment_attachments/migration.sql b/packages/backend/server/migrations/20250624051534_add_comment_attachments/migration.sql new file mode 100644 index 000000000..65b46a37b --- /dev/null +++ b/packages/backend/server/migrations/20250624051534_add_comment_attachments/migration.sql @@ -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; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 32bbbeee5..6b45692fc 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -48,6 +48,7 @@ model User { settings UserSettings? comments Comment[] replies Reply[] + commentAttachments CommentAttachment[] @relation("createdCommentAttachments") @@index([email]) @@map("users") @@ -129,6 +130,7 @@ model Workspace { ignoredDocs AiWorkspaceIgnoredDocs[] embedFiles AiWorkspaceFiles[] comments Comment[] + commentAttachments CommentAttachment[] @@map("workspaces") } @@ -906,3 +908,23 @@ model Reply { @@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") +} diff --git a/packages/backend/server/src/models/__tests__/comment-attachment.spec.ts b/packages/backend/server/src/models/__tests__/comment-attachment.spec.ts new file mode 100644 index 000000000..20c49d8b0 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/comment-attachment.spec.ts @@ -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); +}); diff --git a/packages/backend/server/src/models/comment-attachment.ts b/packages/backend/server/src/models/comment-attachment.ts new file mode 100644 index 000000000..20b3ea246 --- /dev/null +++ b/packages/backend/server/src/models/comment-attachment.ts @@ -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, + }, + }); + } +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 869e73e3a..70fc15ba5 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -8,6 +8,7 @@ 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'; @@ -50,6 +51,7 @@ const MODELS = { copilotJob: CopilotJobModel, appConfig: AppConfigModel, comment: CommentModel, + commentAttachment: CommentAttachmentModel, }; type ModelsType = { @@ -102,6 +104,7 @@ const ModelsSymbolProvider: ExistingProvider = { export class ModelsModule {} export * from './comment'; +export * from './comment-attachment'; export * from './common'; export * from './copilot-context'; export * from './copilot-job';