Compare commits
3 Commits
canary
...
attachment
Author | SHA1 | Date | |
---|---|---|---|
|
39f34e2a2a | ||
|
9612f8ff74 | ||
|
03391670d9 |
@ -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;
|
@ -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;
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
@ -907,4 +907,18 @@ 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.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
@ -1067,6 +1067,24 @@ 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 enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NETWORK_ERROR,
|
||||
@ -1202,7 +1220,10 @@ 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
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
@ -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);
|
||||
}
|
@ -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 };
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export { AvatarStorage } from './avatar';
|
||||
export { WorkspaceBlobStorage } from './blob';
|
||||
export { CommentAttachmentStorage } from './comment-attachment';
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
Binary file not shown.
@ -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);
|
||||
});
|
494
packages/backend/server/src/models/__tests__/comment.spec.ts
Normal file
494
packages/backend/server/src/models/__tests__/comment.spec.ts
Normal 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);
|
||||
});
|
70
packages/backend/server/src/models/comment-attachment.ts
Normal file
70
packages/backend/server/src/models/comment-attachment.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
336
packages/backend/server/src/models/comment.ts
Normal file
336
packages/backend/server/src/models/comment.ts
Normal 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
|
||||
}
|
@ -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';
|
||||
|
@ -539,6 +539,8 @@ enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
|
||||
CAN_NOT_REVOKE_YOURSELF
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
COMMENT_ATTACHMENT_NOT_FOUND
|
||||
COMMENT_NOT_FOUND
|
||||
COPILOT_ACTION_TAKEN
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
||||
COPILOT_DOCS_NOT_FOUND
|
||||
@ -628,6 +630,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
|
||||
|
@ -708,6 +708,8 @@ 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_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 +799,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',
|
||||
|
@ -8875,6 +8875,18 @@ 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;
|
||||
} { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); }
|
||||
function createComponent(i18nKey: string) {
|
||||
return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props });
|
||||
|
@ -2191,5 +2191,8 @@
|
||||
"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."
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user