Compare commits
6 Commits
canary
...
send-comme
Author | SHA1 | Date | |
---|---|---|---|
|
c582ed6f35 | ||
|
559aa849c8 | ||
|
6cb63ddc48 | ||
|
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;
|
@ -0,0 +1,10 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'Comment';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'CommentMention';
|
@ -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")
|
||||
}
|
||||
@ -816,6 +821,8 @@ enum NotificationType {
|
||||
InvitationReviewRequest
|
||||
InvitationReviewApproved
|
||||
InvitationReviewDeclined
|
||||
Comment
|
||||
CommentMention
|
||||
}
|
||||
|
||||
enum NotificationLevel {
|
||||
@ -856,3 +863,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")
|
||||
}
|
||||
|
@ -1513,6 +1513,179 @@ Generated by [AVA](https://avajs.dev).
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com commented on Test Doc
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody>␊
|
||||
<tr>␊
|
||||
<td>␊
|
||||
<p␊
|
||||
style="font-size:20px;line-height:28px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
|
||||
You have a new comment␊
|
||||
</p>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody>␊
|
||||
<tr>␊
|
||||
<td>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
|
||||
<span style="font-weight:600">test@test.com</span> commented on␊
|
||||
<a␊
|
||||
href="https://app.affine.pro"␊
|
||||
style="color:#067df7;text-decoration-line:none"␊
|
||||
target="_blank"␊
|
||||
><span style="font-weight:600">Test Doc</span></a␊
|
||||
>.␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<a␊
|
||||
href="https://app.affine.pro"␊
|
||||
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
|
||||
target="_blank"␊
|
||||
><span␊
|
||||
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>  </i><![endif]--></span␊
|
||||
><span␊
|
||||
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
|
||||
>View Comment</span␊
|
||||
><span␊
|
||||
><!--[if mso]><i style="mso-font-width:450%" hidden>  ​</i><![endif]--></span␊
|
||||
></a␊
|
||||
>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> test@test.com mentioned you in a comment on Test Doc
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
<!--$-->␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody>␊
|
||||
<tr>␊
|
||||
<td>␊
|
||||
<p␊
|
||||
style="font-size:20px;line-height:28px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
|
||||
You are mentioned in a comment␊
|
||||
</p>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody>␊
|
||||
<tr>␊
|
||||
<td>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<p␊
|
||||
style="font-size:15px;line-height:24px;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#141414">␊
|
||||
<span style="font-weight:600">test@test.com</span> mentioned you␊
|
||||
in a comment on␊
|
||||
<a␊
|
||||
href="https://app.affine.pro"␊
|
||||
style="color:#067df7;text-decoration-line:none"␊
|
||||
target="_blank"␊
|
||||
><span style="font-weight:600">Test Doc</span></a␊
|
||||
>.␊
|
||||
</p>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<table␊
|
||||
align="center"␊
|
||||
width="100%"␊
|
||||
border="0"␊
|
||||
cellpadding="0"␊
|
||||
cellspacing="0"␊
|
||||
role="presentation">␊
|
||||
<tbody style="width:100%">␊
|
||||
<tr style="width:100%">␊
|
||||
<a␊
|
||||
href="https://app.affine.pro"␊
|
||||
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin-top:24px;margin-bottom:0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
|
||||
target="_blank"␊
|
||||
><span␊
|
||||
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>  </i><![endif]--></span␊
|
||||
><span␊
|
||||
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
|
||||
>View Comment</span␊
|
||||
><span␊
|
||||
><!--[if mso]><i style="mso-font-width:450%" hidden>  ​</i><![endif]--></span␊
|
||||
></a␊
|
||||
>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
</td>␊
|
||||
</tr>␊
|
||||
</tbody>␊
|
||||
</table>␊
|
||||
<!--/$-->␊
|
||||
`
|
||||
|
||||
> Your workspace has been upgraded to team workspace! 🎉
|
||||
|
||||
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||
|
Binary file not shown.
1475
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
1475
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -231,7 +231,7 @@ export async function createApp(
|
||||
app.useBodyParser('raw', { limit: 1 * OneMB });
|
||||
app.use(
|
||||
graphqlUploadExpress({
|
||||
maxFileSize: 10 * OneMB,
|
||||
maxFileSize: 100 * OneMB,
|
||||
maxFiles: 5,
|
||||
})
|
||||
);
|
||||
|
@ -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
|
@ -28,6 +28,7 @@ import { StorageProviderModule } from './base/storage';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { CommentModule } from './core/comment';
|
||||
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
|
||||
import { DocStorageModule } from './core/doc';
|
||||
import { DocRendererModule } from './core/doc-renderer';
|
||||
@ -180,7 +181,8 @@ export function buildAppModule(env: Env) {
|
||||
CopilotModule,
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CustomerIoModule
|
||||
CustomerIoModule,
|
||||
CommentModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
|
@ -907,4 +907,22 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
args: { reason: 'string' },
|
||||
message: ({ reason }) => `Invalid indexer input: ${reason}`,
|
||||
},
|
||||
|
||||
// comment and reply errors
|
||||
comment_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Comment not found.',
|
||||
},
|
||||
reply_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Reply not found.',
|
||||
},
|
||||
comment_attachment_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Comment attachment not found.',
|
||||
},
|
||||
comment_attachment_quota_exceeded: {
|
||||
type: 'quota_exceeded',
|
||||
message: 'You have exceeded the comment attachment size quota.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
@ -1067,6 +1067,30 @@ export class InvalidIndexerInput extends UserFriendlyError {
|
||||
super('invalid_input', 'invalid_indexer_input', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'comment_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReplyNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'reply_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentAttachmentNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'comment_attachment_not_found', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentAttachmentQuotaExceeded extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('quota_exceeded', 'comment_attachment_quota_exceeded', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NETWORK_ERROR,
|
||||
@ -1202,7 +1226,11 @@ export enum ErrorNames {
|
||||
INVALID_APP_CONFIG_INPUT,
|
||||
SEARCH_PROVIDER_NOT_FOUND,
|
||||
INVALID_SEARCH_PROVIDER_REQUEST,
|
||||
INVALID_INDEXER_INPUT
|
||||
INVALID_INDEXER_INPUT,
|
||||
COMMENT_NOT_FOUND,
|
||||
REPLY_NOT_FOUND,
|
||||
COMMENT_ATTACHMENT_NOT_FOUND,
|
||||
COMMENT_ATTACHMENT_QUOTA_EXCEEDED
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
@ -79,3 +79,88 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should return encode pageInfo with custom cursor
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
edges: [
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 13,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 17,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 18,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: '',
|
||||
node: {
|
||||
id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: 'eyJpZCI6MjAsIm5hbWUiOiJ0ZXN0MiJ9',
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0=',
|
||||
},
|
||||
totalCount: 105,
|
||||
}
|
||||
|
||||
## should decode with json
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
id: 10,
|
||||
name: 'test',
|
||||
}
|
||||
|
Binary file not shown.
@ -4,7 +4,13 @@ import Sinon from 'sinon';
|
||||
|
||||
import { createTestingApp } from '../../../__tests__/utils';
|
||||
import { Public } from '../../../core/auth';
|
||||
import { paginate, Paginated, PaginationInput } from '../pagination';
|
||||
import {
|
||||
decodeWithJson,
|
||||
paginate,
|
||||
Paginated,
|
||||
paginateWithCustomCursor,
|
||||
PaginationInput,
|
||||
} from '../pagination';
|
||||
|
||||
const TOTAL_COUNT = 105;
|
||||
const ITEMS = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1 }));
|
||||
@ -104,3 +110,24 @@ test('should return encode pageInfo', async t => {
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should return encode pageInfo with custom cursor', async t => {
|
||||
const result = paginateWithCustomCursor(
|
||||
ITEMS.slice(10, 20),
|
||||
TOTAL_COUNT,
|
||||
{ id: 10, name: 'test' },
|
||||
{ id: 20, name: 'test2' }
|
||||
);
|
||||
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
test('should decode with json', async t => {
|
||||
const result = decodeWithJson<{ id: number; name: string }>(
|
||||
'eyJpZCI6MTAsIm5hbWUiOiJ0ZXN0In0='
|
||||
);
|
||||
t.snapshot(result);
|
||||
|
||||
const result2 = decodeWithJson<{ id: number; name: string }>('');
|
||||
t.is(result2, null);
|
||||
});
|
||||
|
@ -65,6 +65,15 @@ const encode = (input: unknown) => {
|
||||
const decode = (base64String?: string | null) =>
|
||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||
|
||||
function encodeWithJson(input: unknown) {
|
||||
return encode(JSON.stringify(input ?? null));
|
||||
}
|
||||
|
||||
export function decodeWithJson<T>(base64String?: string | null): T | null {
|
||||
const str = decode(base64String);
|
||||
return str ? (JSON.parse(str) as T) : null;
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
list: T[],
|
||||
cursorField: keyof T,
|
||||
@ -88,6 +97,31 @@ export function paginate<T>(
|
||||
};
|
||||
}
|
||||
|
||||
export function paginateWithCustomCursor<T>(
|
||||
list: T[],
|
||||
total: number,
|
||||
startCursor: unknown,
|
||||
endCursor: unknown,
|
||||
hasPreviousPage = false
|
||||
): PaginatedType<T> {
|
||||
const edges = list.map(item => ({
|
||||
node: item,
|
||||
// set cursor to empty string for ignore it
|
||||
cursor: '',
|
||||
}));
|
||||
|
||||
return {
|
||||
totalCount: total,
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasNextPage: list.length > 0,
|
||||
hasPreviousPage,
|
||||
endCursor: encodeWithJson(endCursor),
|
||||
startCursor: encodeWithJson(startCursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedType<T> {
|
||||
totalCount: number;
|
||||
edges: {
|
||||
|
@ -60,3 +60,11 @@ export async function readBufferWithLimit(
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function readableToBuffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
@ -0,0 +1,33 @@
|
||||
# Snapshot report for `src/core/comment/__tests__/service.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `service.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should update a comment
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: 'test2',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
}
|
||||
|
||||
## should update a reply
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
text: 'test2',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
}
|
Binary file not shown.
@ -0,0 +1,411 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { Comment, CommentChangeAction } from '../../../models';
|
||||
import { CommentModule } from '..';
|
||||
import { CommentService } from '../service';
|
||||
|
||||
const module = await createModule({
|
||||
imports: [CommentModule],
|
||||
});
|
||||
|
||||
const commentService = module.get(CommentService);
|
||||
const owner = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
const member = await module.create(Mockers.User);
|
||||
await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create a comment', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(comment);
|
||||
});
|
||||
|
||||
test('should update a comment', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
const updatedComment = await commentService.updateComment({
|
||||
id: comment.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(updatedComment.content);
|
||||
});
|
||||
|
||||
test('should delete a comment', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
await commentService.deleteComment(comment.id);
|
||||
const deletedComment = await commentService.getComment(comment.id);
|
||||
|
||||
t.is(deletedComment, null);
|
||||
});
|
||||
|
||||
test('should resolve a comment', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const resolvedComment = await commentService.resolveComment({
|
||||
id: comment.id,
|
||||
resolved: true,
|
||||
});
|
||||
|
||||
t.is(resolvedComment.resolved, true);
|
||||
|
||||
// unresolved
|
||||
const unresolvedComment = await commentService.resolveComment({
|
||||
id: comment.id,
|
||||
resolved: false,
|
||||
});
|
||||
|
||||
t.is(unresolvedComment.resolved, false);
|
||||
});
|
||||
|
||||
test('should create a reply', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await commentService.createReply({
|
||||
commentId: comment.id,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(reply);
|
||||
});
|
||||
|
||||
test('should update a reply', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await commentService.createReply({
|
||||
commentId: comment.id,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const updatedReply = await commentService.updateReply({
|
||||
id: reply.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(updatedReply.content);
|
||||
});
|
||||
|
||||
test('should delete a reply', async t => {
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await commentService.createReply({
|
||||
commentId: comment.id,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
await commentService.deleteReply(reply.id);
|
||||
const deletedReply = await commentService.getReply(reply.id);
|
||||
|
||||
t.is(deletedReply, null);
|
||||
});
|
||||
|
||||
test('should list comments', async t => {
|
||||
const docId = randomUUID();
|
||||
// empty comments
|
||||
let comments = await commentService.listComments(workspace.id, docId, {
|
||||
take: 2,
|
||||
});
|
||||
|
||||
t.is(comments.length, 0);
|
||||
|
||||
const comment0 = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test0' }],
|
||||
},
|
||||
});
|
||||
|
||||
const comment1 = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
const comment2 = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply1 = await commentService.createReply({
|
||||
commentId: comment2.id,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply2 = await commentService.createReply({
|
||||
commentId: comment2.id,
|
||||
userId: member.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply3 = await commentService.createReply({
|
||||
commentId: comment0.id,
|
||||
userId: member.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply3' }],
|
||||
},
|
||||
});
|
||||
|
||||
// order by sid desc
|
||||
comments = await commentService.listComments(workspace.id, docId, {
|
||||
take: 2,
|
||||
});
|
||||
|
||||
t.is(comments.length, 2);
|
||||
t.is(comments[0].id, comment2.id);
|
||||
t.is(comments[0].user.id, owner.id);
|
||||
// replies order by sid asc
|
||||
t.is(comments[0].replies.length, 2);
|
||||
t.is(comments[0].replies[0].id, reply1.id);
|
||||
t.is(comments[0].replies[0].user.id, owner.id);
|
||||
t.is(comments[0].replies[1].id, reply2.id);
|
||||
t.is(comments[0].replies[1].user.id, member.id);
|
||||
|
||||
t.is(comments[1].id, comment1.id);
|
||||
t.is(comments[1].user.id, member.id);
|
||||
t.is(comments[1].replies.length, 0);
|
||||
|
||||
// next page
|
||||
const comments2 = await commentService.listComments(workspace.id, docId, {
|
||||
take: 2,
|
||||
sid: comments[1].sid,
|
||||
});
|
||||
|
||||
t.is(comments2.length, 1);
|
||||
t.is(comments2[0].id, comment0.id);
|
||||
t.is(comments2[0].user.id, member.id);
|
||||
t.is(comments2[0].replies.length, 1);
|
||||
t.is(comments2[0].replies[0].id, reply3.id);
|
||||
t.is(comments2[0].replies[0].user.id, member.id);
|
||||
|
||||
// no more comments
|
||||
const comments3 = await commentService.listComments(workspace.id, docId, {
|
||||
take: 2,
|
||||
sid: comments2[0].sid,
|
||||
});
|
||||
|
||||
t.is(comments3.length, 0);
|
||||
});
|
||||
|
||||
test('should list comment changes from scratch', async t => {
|
||||
const docId = randomUUID();
|
||||
let changes = await commentService.listCommentChanges(workspace.id, docId, {
|
||||
take: 2,
|
||||
});
|
||||
|
||||
t.is(changes.length, 0);
|
||||
let commentUpdatedAt: Date | undefined;
|
||||
let replyUpdatedAt: Date | undefined;
|
||||
|
||||
const comment = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
});
|
||||
|
||||
changes = await commentService.listCommentChanges(workspace.id, docId, {
|
||||
commentUpdatedAt,
|
||||
replyUpdatedAt,
|
||||
});
|
||||
|
||||
t.is(changes.length, 1);
|
||||
t.is(changes[0].action, CommentChangeAction.update);
|
||||
t.is(changes[0].id, comment.id);
|
||||
t.deepEqual(changes[0].item, comment);
|
||||
|
||||
commentUpdatedAt = changes[0].item.updatedAt;
|
||||
|
||||
// 2 new replies, 1 new comment and update it, 3 changes
|
||||
const reply1 = await commentService.createReply({
|
||||
commentId: comment.id,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply1' }],
|
||||
},
|
||||
});
|
||||
|
||||
const reply2 = await commentService.createReply({
|
||||
commentId: comment.id,
|
||||
userId: member.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test reply2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const comment2 = await commentService.createComment({
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test comment2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const updateContent = {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test comment2 update' }],
|
||||
};
|
||||
await commentService.updateComment({
|
||||
id: comment2.id,
|
||||
content: updateContent,
|
||||
});
|
||||
|
||||
changes = await commentService.listCommentChanges(workspace.id, docId, {
|
||||
commentUpdatedAt,
|
||||
replyUpdatedAt,
|
||||
});
|
||||
|
||||
t.is(changes.length, 3);
|
||||
t.is(changes[0].action, CommentChangeAction.update);
|
||||
t.is(changes[0].id, comment2.id);
|
||||
t.deepEqual((changes[0].item as Comment).content, updateContent);
|
||||
t.is(changes[1].action, CommentChangeAction.update);
|
||||
t.is(changes[1].id, reply1.id);
|
||||
t.is(changes[1].commentId, comment.id);
|
||||
t.deepEqual(changes[1].item, reply1);
|
||||
t.is(changes[2].action, CommentChangeAction.update);
|
||||
t.is(changes[2].id, reply2.id);
|
||||
t.is(changes[2].commentId, comment.id);
|
||||
t.deepEqual(changes[2].item, reply2);
|
||||
|
||||
commentUpdatedAt = changes[0].item.updatedAt;
|
||||
replyUpdatedAt = changes[2].item.updatedAt;
|
||||
|
||||
// delete comment2 and reply1, 2 changes
|
||||
await commentService.deleteComment(comment2.id);
|
||||
await commentService.deleteReply(reply1.id);
|
||||
|
||||
changes = await commentService.listCommentChanges(workspace.id, docId, {
|
||||
commentUpdatedAt,
|
||||
replyUpdatedAt,
|
||||
});
|
||||
|
||||
t.is(changes.length, 2);
|
||||
t.is(changes[0].action, CommentChangeAction.delete);
|
||||
t.is(changes[0].id, comment2.id);
|
||||
t.is(changes[1].action, CommentChangeAction.delete);
|
||||
t.is(changes[1].id, reply1.id);
|
||||
|
||||
commentUpdatedAt = changes[0].item.updatedAt;
|
||||
replyUpdatedAt = changes[1].item.updatedAt;
|
||||
|
||||
// no changes
|
||||
changes = await commentService.listCommentChanges(workspace.id, docId, {
|
||||
commentUpdatedAt,
|
||||
replyUpdatedAt,
|
||||
});
|
||||
|
||||
t.is(changes.length, 0);
|
||||
});
|
13
packages/backend/server/src/core/comment/index.ts
Normal file
13
packages/backend/server/src/core/comment/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { CommentResolver } from './resolver';
|
||||
import { CommentService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [PermissionModule, StorageModule],
|
||||
providers: [CommentResolver, CommentService],
|
||||
exports: [CommentService],
|
||||
})
|
||||
export class CommentModule {}
|
452
packages/backend/server/src/core/comment/resolver.ts
Normal file
452
packages/backend/server/src/core/comment/resolver.ts
Normal file
@ -0,0 +1,452 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Args,
|
||||
Mutation,
|
||||
Parent,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import {
|
||||
CommentAttachmentQuotaExceeded,
|
||||
CommentNotFound,
|
||||
type FileUpload,
|
||||
JobQueue,
|
||||
readableToBuffer,
|
||||
ReplyNotFound,
|
||||
} from '../../base';
|
||||
import {
|
||||
decodeWithJson,
|
||||
paginateWithCustomCursor,
|
||||
PaginationInput,
|
||||
} from '../../base/graphql';
|
||||
import { Comment, DocMode, Models, Reply } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { AccessController, DocAction } from '../permission';
|
||||
import { CommentAttachmentStorage } from '../storage';
|
||||
import { UserType } from '../user';
|
||||
import { WorkspaceType } from '../workspaces';
|
||||
import { CommentService } from './service';
|
||||
import {
|
||||
CommentCreateInput,
|
||||
CommentObjectType,
|
||||
CommentResolveInput,
|
||||
CommentUpdateInput,
|
||||
PaginatedCommentChangeObjectType,
|
||||
PaginatedCommentObjectType,
|
||||
ReplyCreateInput,
|
||||
ReplyObjectType,
|
||||
ReplyUpdateInput,
|
||||
} from './types';
|
||||
|
||||
export interface CommentCursor {
|
||||
sid?: number;
|
||||
commentUpdatedAt?: Date;
|
||||
replyUpdatedAt?: Date;
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class CommentResolver {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
@Mutation(() => CommentObjectType)
|
||||
async createComment(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('input') input: CommentCreateInput
|
||||
): Promise<CommentObjectType> {
|
||||
await this.assertPermission(me, input, 'Doc.Comments.Create');
|
||||
|
||||
const comment = await this.service.createComment({
|
||||
...input,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
await this.sendCommentNotification(
|
||||
me,
|
||||
comment,
|
||||
input.docTitle,
|
||||
input.docMode,
|
||||
input.mentions
|
||||
);
|
||||
|
||||
return {
|
||||
...comment,
|
||||
user: {
|
||||
id: me.id,
|
||||
name: me.name,
|
||||
avatarUrl: me.avatarUrl,
|
||||
},
|
||||
replies: [],
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Update a comment content',
|
||||
})
|
||||
async updateComment(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('input') input: CommentUpdateInput
|
||||
) {
|
||||
const comment = await this.service.getComment(input.id);
|
||||
if (!comment) {
|
||||
throw new CommentNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Update');
|
||||
|
||||
await this.service.updateComment(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Resolve a comment or not',
|
||||
})
|
||||
async resolveComment(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('input') input: CommentResolveInput
|
||||
) {
|
||||
const comment = await this.service.getComment(input.id);
|
||||
if (!comment) {
|
||||
throw new CommentNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Resolve');
|
||||
|
||||
await this.service.resolveComment(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a comment',
|
||||
})
|
||||
async deleteComment(@CurrentUser() me: UserType, @Args('id') id: string) {
|
||||
const comment = await this.service.getComment(id);
|
||||
if (!comment) {
|
||||
throw new CommentNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Delete');
|
||||
|
||||
await this.service.deleteComment(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => ReplyObjectType)
|
||||
async createReply(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('input') input: ReplyCreateInput
|
||||
): Promise<ReplyObjectType> {
|
||||
const comment = await this.service.getComment(input.commentId);
|
||||
if (!comment) {
|
||||
throw new CommentNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, comment, 'Doc.Comments.Create');
|
||||
|
||||
const reply = await this.service.createReply({
|
||||
...input,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
await this.sendCommentNotification(
|
||||
me,
|
||||
comment,
|
||||
input.docTitle,
|
||||
input.docMode,
|
||||
input.mentions,
|
||||
reply
|
||||
);
|
||||
|
||||
return {
|
||||
...reply,
|
||||
user: {
|
||||
id: me.id,
|
||||
name: me.name,
|
||||
avatarUrl: me.avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Update a reply content',
|
||||
})
|
||||
async updateReply(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('input') input: ReplyUpdateInput
|
||||
) {
|
||||
const reply = await this.service.getReply(input.id);
|
||||
if (!reply) {
|
||||
throw new ReplyNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, reply, 'Doc.Comments.Update');
|
||||
|
||||
await this.service.updateReply(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a reply',
|
||||
})
|
||||
async deleteReply(@CurrentUser() me: UserType, @Args('id') id: string) {
|
||||
const reply = await this.service.getReply(id);
|
||||
if (!reply) {
|
||||
throw new ReplyNotFound();
|
||||
}
|
||||
|
||||
await this.assertPermission(me, reply, 'Doc.Comments.Delete');
|
||||
|
||||
await this.service.deleteReply(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ResolveField(() => PaginatedCommentObjectType, {
|
||||
description: 'Get comments of a doc',
|
||||
})
|
||||
async comments(
|
||||
@CurrentUser() me: UserType,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('docId') docId: string,
|
||||
@Args({
|
||||
name: 'pagination',
|
||||
nullable: true,
|
||||
})
|
||||
pagination?: PaginationInput
|
||||
): Promise<PaginatedCommentObjectType> {
|
||||
await this.assertPermission(
|
||||
me,
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
},
|
||||
'Doc.Comments.Read'
|
||||
);
|
||||
|
||||
const cursor: CommentCursor = decodeWithJson(pagination?.after) ?? {};
|
||||
const [totalCount, comments] = await Promise.all([
|
||||
this.service.getCommentCount(workspace.id, docId),
|
||||
this.service.listComments(workspace.id, docId, {
|
||||
sid: cursor.sid,
|
||||
take: pagination?.first,
|
||||
}),
|
||||
]);
|
||||
const endCursor: CommentCursor = {};
|
||||
const startCursor: CommentCursor = {};
|
||||
if (comments.length > 0) {
|
||||
const lastComment = comments[comments.length - 1];
|
||||
// next page cursor
|
||||
endCursor.sid = lastComment.sid;
|
||||
const firstComment = comments[0];
|
||||
startCursor.sid = firstComment.sid;
|
||||
startCursor.commentUpdatedAt = firstComment.updatedAt;
|
||||
let replyUpdatedAt: Date | undefined;
|
||||
|
||||
// find latest reply
|
||||
for (const comment of comments) {
|
||||
for (const reply of comment.replies) {
|
||||
if (
|
||||
!replyUpdatedAt ||
|
||||
reply.updatedAt.getTime() > replyUpdatedAt.getTime()
|
||||
) {
|
||||
replyUpdatedAt = reply.updatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replyUpdatedAt) {
|
||||
// if no reply, use comment updated at as reply updated at
|
||||
replyUpdatedAt = startCursor.commentUpdatedAt;
|
||||
}
|
||||
startCursor.replyUpdatedAt = replyUpdatedAt;
|
||||
}
|
||||
|
||||
return paginateWithCustomCursor(
|
||||
comments,
|
||||
totalCount,
|
||||
startCursor,
|
||||
endCursor,
|
||||
// not support to get previous page
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => PaginatedCommentChangeObjectType, {
|
||||
description: 'Get comment changes of a doc',
|
||||
})
|
||||
async commentChanges(
|
||||
@CurrentUser() me: UserType,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('docId') docId: string,
|
||||
@Args({
|
||||
name: 'pagination',
|
||||
})
|
||||
pagination: PaginationInput
|
||||
): Promise<PaginatedCommentChangeObjectType> {
|
||||
await this.assertPermission(
|
||||
me,
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
},
|
||||
'Doc.Comments.Read'
|
||||
);
|
||||
|
||||
const cursor: CommentCursor = decodeWithJson(pagination.after) ?? {};
|
||||
const changes = await this.service.listCommentChanges(workspace.id, docId, {
|
||||
commentUpdatedAt: cursor.commentUpdatedAt,
|
||||
replyUpdatedAt: cursor.replyUpdatedAt,
|
||||
take: pagination.first,
|
||||
});
|
||||
|
||||
const endCursor = cursor;
|
||||
for (const c of changes) {
|
||||
if (c.commentId) {
|
||||
// is reply change
|
||||
endCursor.replyUpdatedAt = c.item.updatedAt;
|
||||
} else {
|
||||
// is comment change
|
||||
endCursor.commentUpdatedAt = c.item.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return paginateWithCustomCursor(
|
||||
changes,
|
||||
changes.length,
|
||||
// not support to get start cursor
|
||||
null,
|
||||
endCursor,
|
||||
// not support to get previous page
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Upload a comment attachment and return the access url',
|
||||
})
|
||||
async uploadCommentAttachment(
|
||||
@CurrentUser() me: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('docId') docId: string,
|
||||
@Args({ name: 'attachment', type: () => GraphQLUpload })
|
||||
attachment: FileUpload
|
||||
) {
|
||||
await this.assertPermission(
|
||||
me,
|
||||
{ workspaceId, docId },
|
||||
'Doc.Comments.Create'
|
||||
);
|
||||
|
||||
// TODO(@fengmk2): should check total attachment quota in the future version
|
||||
const buffer = await readableToBuffer(attachment.createReadStream());
|
||||
// max attachment size is 10MB
|
||||
if (buffer.length > 10 * 1024 * 1024) {
|
||||
throw new CommentAttachmentQuotaExceeded();
|
||||
}
|
||||
|
||||
const key = randomUUID();
|
||||
await this.commentAttachmentStorage.put(
|
||||
workspaceId,
|
||||
docId,
|
||||
key,
|
||||
attachment.filename ?? key,
|
||||
buffer
|
||||
);
|
||||
return this.commentAttachmentStorage.getUrl(workspaceId, docId, key);
|
||||
}
|
||||
|
||||
private async sendCommentNotification(
|
||||
sender: UserType,
|
||||
comment: Comment,
|
||||
docTitle: string,
|
||||
docMode: DocMode,
|
||||
mentions?: string[],
|
||||
reply?: Reply
|
||||
) {
|
||||
// send comment notification to doc owners
|
||||
const owner = await this.models.docUser.getOwner(
|
||||
comment.workspaceId,
|
||||
comment.docId
|
||||
);
|
||||
if (owner && owner.userId !== sender.id) {
|
||||
await this.queue.add('notification.sendComment', {
|
||||
userId: owner.userId,
|
||||
body: {
|
||||
workspaceId: comment.workspaceId,
|
||||
createdByUserId: sender.id,
|
||||
commentId: comment.id,
|
||||
replyId: reply?.id,
|
||||
doc: {
|
||||
id: comment.docId,
|
||||
title: docTitle,
|
||||
mode: docMode,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// send comment mention notification to mentioned users
|
||||
if (mentions) {
|
||||
for (const mentionUserId of mentions) {
|
||||
// skip if the mention user is the doc owner
|
||||
if (mentionUserId === owner?.userId || mentionUserId === sender.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the mention user has Doc.Comments.Read permission
|
||||
const hasPermission = await this.ac
|
||||
.user(mentionUserId)
|
||||
.workspace(comment.workspaceId)
|
||||
.doc(comment.docId)
|
||||
.can('Doc.Comments.Read');
|
||||
|
||||
if (!hasPermission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.queue.add('notification.sendComment', {
|
||||
isMention: true,
|
||||
userId: mentionUserId,
|
||||
body: {
|
||||
workspaceId: comment.workspaceId,
|
||||
createdByUserId: sender.id,
|
||||
commentId: comment.id,
|
||||
replyId: reply?.id,
|
||||
doc: {
|
||||
id: comment.docId,
|
||||
title: docTitle,
|
||||
mode: docMode,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
me: UserType,
|
||||
item: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId?: string;
|
||||
},
|
||||
action: DocAction
|
||||
) {
|
||||
// the owner of the comment/reply can update, delete, resolve it
|
||||
if (item.userId === me.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ac
|
||||
.user(me.id)
|
||||
.workspace(item.workspaceId)
|
||||
.doc(item.docId)
|
||||
.assert(action);
|
||||
}
|
||||
}
|
131
packages/backend/server/src/core/comment/service.ts
Normal file
131
packages/backend/server/src/core/comment/service.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
CommentCreate,
|
||||
CommentResolve,
|
||||
CommentUpdate,
|
||||
ItemWithUserId,
|
||||
Models,
|
||||
ReplyCreate,
|
||||
ReplyUpdate,
|
||||
} from '../../models';
|
||||
import { PublicUserType } from '../user';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
async createComment(input: CommentCreate) {
|
||||
const comment = await this.models.comment.create(input);
|
||||
return await this.fillUser(comment);
|
||||
}
|
||||
|
||||
async getComment(id: string) {
|
||||
const comment = await this.models.comment.get(id);
|
||||
return comment ? await this.fillUser(comment) : null;
|
||||
}
|
||||
|
||||
async updateComment(input: CommentUpdate) {
|
||||
return await this.models.comment.update(input);
|
||||
}
|
||||
|
||||
async resolveComment(input: CommentResolve) {
|
||||
return await this.models.comment.resolve(input);
|
||||
}
|
||||
|
||||
async deleteComment(id: string) {
|
||||
return await this.models.comment.delete(id);
|
||||
}
|
||||
|
||||
async createReply(input: ReplyCreate) {
|
||||
const reply = await this.models.comment.createReply(input);
|
||||
return await this.fillUser(reply);
|
||||
}
|
||||
|
||||
async getReply(id: string) {
|
||||
const reply = await this.models.comment.getReply(id);
|
||||
return reply ? await this.fillUser(reply) : null;
|
||||
}
|
||||
|
||||
async updateReply(input: ReplyUpdate) {
|
||||
return await this.models.comment.updateReply(input);
|
||||
}
|
||||
|
||||
async deleteReply(id: string) {
|
||||
return await this.models.comment.deleteReply(id);
|
||||
}
|
||||
|
||||
async getCommentCount(workspaceId: string, docId: string) {
|
||||
return await this.models.comment.count(workspaceId, docId);
|
||||
}
|
||||
|
||||
async listComments(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
options?: {
|
||||
sid?: number;
|
||||
take?: number;
|
||||
}
|
||||
) {
|
||||
const comments = await this.models.comment.list(
|
||||
workspaceId,
|
||||
docId,
|
||||
options
|
||||
);
|
||||
|
||||
// fill user info
|
||||
const userMap = await this.models.user.getPublicUsersMap([
|
||||
...comments,
|
||||
...comments.flatMap(c => c.replies),
|
||||
]);
|
||||
|
||||
return comments.map(c => ({
|
||||
...c,
|
||||
user: userMap.get(c.userId) as PublicUserType,
|
||||
replies: c.replies.map(r => ({
|
||||
...r,
|
||||
user: userMap.get(r.userId) as PublicUserType,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async listCommentChanges(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
options: {
|
||||
commentUpdatedAt?: Date;
|
||||
replyUpdatedAt?: Date;
|
||||
take?: number;
|
||||
}
|
||||
) {
|
||||
const changes = await this.models.comment.listChanges(
|
||||
workspaceId,
|
||||
docId,
|
||||
options
|
||||
);
|
||||
|
||||
// fill user info
|
||||
const userMap = await this.models.user.getPublicUsersMap(
|
||||
changes.map(c => c.item as ItemWithUserId)
|
||||
);
|
||||
|
||||
return changes.map(c => ({
|
||||
...c,
|
||||
item:
|
||||
'userId' in c.item
|
||||
? {
|
||||
...c.item,
|
||||
user: userMap.get(c.item.userId) as PublicUserType,
|
||||
}
|
||||
: c.item,
|
||||
}));
|
||||
}
|
||||
|
||||
private async fillUser<T extends { userId: string }>(item: T) {
|
||||
const user = await this.models.user.getPublicUser(item.userId);
|
||||
return {
|
||||
...item,
|
||||
user: user as PublicUserType,
|
||||
};
|
||||
}
|
||||
}
|
220
packages/backend/server/src/core/comment/types.ts
Normal file
220
packages/backend/server/src/core/comment/types.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import {
|
||||
createUnionType,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Paginated } from '../../base';
|
||||
import {
|
||||
Comment,
|
||||
CommentChange,
|
||||
CommentChangeAction,
|
||||
CommentCreate,
|
||||
CommentResolve,
|
||||
CommentUpdate,
|
||||
DeletedChangeItem,
|
||||
DocMode,
|
||||
Reply,
|
||||
ReplyCreate,
|
||||
ReplyUpdate,
|
||||
} from '../../models';
|
||||
import { PublicUserType } from '../user';
|
||||
|
||||
@ObjectType()
|
||||
export class CommentObjectType implements Partial<Comment> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
description: 'The content of the comment',
|
||||
})
|
||||
content!: object;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Whether the comment is resolved',
|
||||
})
|
||||
resolved!: boolean;
|
||||
|
||||
@Field(() => PublicUserType, {
|
||||
description: 'The user who created the comment',
|
||||
})
|
||||
user!: PublicUserType;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'The created at time of the comment',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'The updated at time of the comment',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@Field(() => [ReplyObjectType], {
|
||||
description: 'The replies of the comment',
|
||||
})
|
||||
replies!: ReplyObjectType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ReplyObjectType implements Partial<Reply> {
|
||||
@Field(() => ID)
|
||||
commentId!: string;
|
||||
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
description: 'The content of the reply',
|
||||
})
|
||||
content!: object;
|
||||
|
||||
@Field(() => PublicUserType, {
|
||||
description: 'The user who created the reply',
|
||||
})
|
||||
user!: PublicUserType;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'The created at time of the reply',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'The updated at time of the reply',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class DeletedCommentObjectType implements DeletedChangeItem {
|
||||
@Field(() => Date, {
|
||||
description: 'The deleted at time of the comment or reply',
|
||||
})
|
||||
deletedAt!: Date;
|
||||
|
||||
@Field(() => Date, {
|
||||
description: 'The updated at time of the comment or reply',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export const UnionCommentObjectType = createUnionType({
|
||||
name: 'UnionCommentObjectType',
|
||||
types: () =>
|
||||
[CommentObjectType, ReplyObjectType, DeletedCommentObjectType] as const,
|
||||
});
|
||||
|
||||
registerEnumType(CommentChangeAction, {
|
||||
name: 'CommentChangeAction',
|
||||
description: 'Comment change action',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class CommentChangeObjectType implements Omit<CommentChange, 'item'> {
|
||||
@Field(() => CommentChangeAction, {
|
||||
description: 'The action of the comment change',
|
||||
})
|
||||
action!: CommentChangeAction;
|
||||
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
})
|
||||
commentId?: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
description:
|
||||
'The item of the comment or reply, different types have different fields, see UnionCommentObjectType',
|
||||
})
|
||||
item!: object;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PaginatedCommentObjectType extends Paginated(CommentObjectType) {}
|
||||
|
||||
@ObjectType()
|
||||
export class PaginatedCommentChangeObjectType extends Paginated(
|
||||
CommentChangeObjectType
|
||||
) {}
|
||||
|
||||
@InputType()
|
||||
export class CommentCreateInput implements Partial<CommentCreate> {
|
||||
@Field(() => ID)
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => ID)
|
||||
docId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
docTitle!: string;
|
||||
|
||||
@Field(() => DocMode)
|
||||
docMode!: DocMode;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
content!: object;
|
||||
|
||||
@Field(() => [String], {
|
||||
nullable: true,
|
||||
description:
|
||||
'The mention user ids, if not provided, the comment will not be mentioned',
|
||||
})
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CommentUpdateInput implements Partial<CommentUpdate> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
content!: object;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CommentResolveInput implements Partial<CommentResolve> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Whether the comment is resolved',
|
||||
})
|
||||
resolved!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ReplyCreateInput implements Partial<ReplyCreate> {
|
||||
@Field(() => ID)
|
||||
commentId!: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
content!: object;
|
||||
|
||||
@Field(() => String)
|
||||
docTitle!: string;
|
||||
|
||||
@Field(() => DocMode)
|
||||
docMode!: DocMode;
|
||||
|
||||
@Field(() => [String], {
|
||||
nullable: true,
|
||||
description:
|
||||
'The mention user ids, if not provided, the comment reply will not be mentioned',
|
||||
})
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ReplyUpdateInput implements Partial<ReplyUpdate> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
content!: object;
|
||||
}
|
@ -44,7 +44,8 @@ export class MailSender {
|
||||
}
|
||||
|
||||
get configured() {
|
||||
return this.smtp !== null;
|
||||
// NOTE: testing environment will use mock queue, so we need to return true
|
||||
return this.smtp !== null || env.testing;
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import {
|
||||
DocMode,
|
||||
Models,
|
||||
User,
|
||||
Workspace,
|
||||
@ -204,3 +205,24 @@ test('should create invitation review declined notification', async t => {
|
||||
t.is(spy.firstCall.args[0].body.workspaceId, workspace.id);
|
||||
t.is(spy.firstCall.args[0].body.createdByUserId, owner.id);
|
||||
});
|
||||
|
||||
test('should create comment notification', async t => {
|
||||
const { notificationJob, notificationService } = t.context;
|
||||
const spy = Sinon.spy(notificationService, 'createComment');
|
||||
|
||||
await notificationJob.sendComment({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
doc: {
|
||||
id: randomUUID(),
|
||||
title: 'doc-title-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
commentId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
t.is(spy.callCount, 1);
|
||||
});
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { NotificationNotFound } from '../../../base';
|
||||
import { Due, NotificationNotFound } from '../../../base';
|
||||
import {
|
||||
DocMode,
|
||||
MentionNotificationBody,
|
||||
@ -18,33 +15,33 @@ import {
|
||||
Workspace,
|
||||
WorkspaceMemberStatus,
|
||||
} from '../../../models';
|
||||
import { DocReader } from '../../doc';
|
||||
import { DocStorageModule } from '../../doc';
|
||||
import { FeatureModule } from '../../features';
|
||||
import { MailModule } from '../../mail';
|
||||
import { PermissionModule } from '../../permission';
|
||||
import { StorageModule } from '../../storage';
|
||||
import { NotificationModule } from '..';
|
||||
import { NotificationService } from '../service';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
notificationService: NotificationService;
|
||||
models: Models;
|
||||
docReader: DocReader;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
t.context.module = module;
|
||||
t.context.notificationService = module.get(NotificationService);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.docReader = module.get(DocReader);
|
||||
const module = await createModule({
|
||||
imports: [
|
||||
FeatureModule,
|
||||
PermissionModule,
|
||||
DocStorageModule,
|
||||
StorageModule,
|
||||
MailModule,
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [NotificationService],
|
||||
});
|
||||
const notificationService = module.get(NotificationService);
|
||||
const models = module.get(Models);
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let workspace: Workspace;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { module } = t.context;
|
||||
await module.initTestingDB();
|
||||
test.beforeEach(async () => {
|
||||
owner = await module.create(Mockers.User);
|
||||
member = await module.create(Mockers.User);
|
||||
workspace = await module.create(Mockers.Workspace, {
|
||||
@ -61,13 +58,13 @@ test.afterEach.always(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create invitation notification and email', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -76,25 +73,28 @@ test('should create invitation notification and email', async t => {
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.Invitation);
|
||||
t.is(notification!.userId, member.id);
|
||||
t.is(notification!.body.workspaceId, workspace.id);
|
||||
t.is(notification!.body.createdByUserId, owner.id);
|
||||
t.is(notification!.body.inviteId, inviteId);
|
||||
|
||||
// should send invitation email
|
||||
const invitationMail = t.context.module.mails.last('MemberInvitation');
|
||||
t.is(invitationMail.to, member.email);
|
||||
const invitationMail = module.queue.last('notification.sendMail');
|
||||
t.is(invitationMail.payload.to, member.email);
|
||||
t.is(invitationMail.payload.name, 'MemberInvitation');
|
||||
});
|
||||
|
||||
test('should not send invitation email if user setting is not to receive invitation email', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
await module.create(Mockers.UserSettings, {
|
||||
userId: member.id,
|
||||
receiveInvitationEmail: false,
|
||||
});
|
||||
const invitationMailCount = module.mails.count('MemberInvitation');
|
||||
const invitationMailCount = module.queue.count('notification.sendMail');
|
||||
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -103,17 +103,19 @@ test('should not send invitation email if user setting is not to receive invitat
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
// no new invitation email should be sent
|
||||
t.is(t.context.module.mails.count('MemberInvitation'), invitationMailCount);
|
||||
t.is(module.queue.count('notification.sendMail'), invitationMailCount);
|
||||
});
|
||||
|
||||
test('should not create invitation notification if user is already a member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -122,15 +124,16 @@ test('should not create invitation notification if user is already a member', as
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(notification, undefined);
|
||||
});
|
||||
|
||||
test('should create invitation accepted notification and email', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitationAccepted({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -139,6 +142,7 @@ test('should create invitation accepted notification and email', async t => {
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationAccepted);
|
||||
t.is(notification!.userId, owner.id);
|
||||
@ -147,12 +151,12 @@ test('should create invitation accepted notification and email', async t => {
|
||||
t.is(notification!.body.inviteId, inviteId);
|
||||
|
||||
// should send email
|
||||
const invitationAcceptedMail = module.mails.last('MemberAccepted');
|
||||
t.is(invitationAcceptedMail.to, owner.email);
|
||||
const invitationAcceptedMail = module.queue.last('notification.sendMail');
|
||||
t.is(invitationAcceptedMail.payload.to, owner.email);
|
||||
t.is(invitationAcceptedMail.payload.name, 'MemberAccepted');
|
||||
});
|
||||
|
||||
test('should not send invitation accepted email if user settings is not receive invitation email', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
@ -162,8 +166,10 @@ test('should not send invitation accepted email if user settings is not receive
|
||||
userId: owner.id,
|
||||
receiveInvitationEmail: false,
|
||||
});
|
||||
const invitationAcceptedMailCount =
|
||||
t.context.module.mails.count('MemberAccepted');
|
||||
const invitationAcceptedMailCount = module.queue.count(
|
||||
'notification.sendMail'
|
||||
);
|
||||
|
||||
const notification = await notificationService.createInvitationAccepted({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -172,17 +178,19 @@ test('should not send invitation accepted email if user settings is not receive
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
// no new invitation accepted email should be sent
|
||||
t.is(
|
||||
t.context.module.mails.count('MemberAccepted'),
|
||||
module.queue.count('notification.sendMail'),
|
||||
invitationAcceptedMailCount
|
||||
);
|
||||
});
|
||||
|
||||
test('should not create invitation accepted notification if user is not an active member', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createInvitationAccepted({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -191,12 +199,13 @@ test('should not create invitation accepted notification if user is not an activ
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(notification, undefined);
|
||||
});
|
||||
|
||||
test('should create invitation blocked notification', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createInvitationBlocked({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -205,6 +214,7 @@ test('should create invitation blocked notification', async t => {
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationBlocked);
|
||||
t.is(notification!.userId, owner.id);
|
||||
@ -214,8 +224,8 @@ test('should create invitation blocked notification', async t => {
|
||||
});
|
||||
|
||||
test('should create invitation rejected notification', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createInvitationRejected({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -224,6 +234,7 @@ test('should create invitation rejected notification', async t => {
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationRejected);
|
||||
t.is(notification!.userId, owner.id);
|
||||
@ -233,8 +244,8 @@ test('should create invitation rejected notification', async t => {
|
||||
});
|
||||
|
||||
test('should create invitation review request notification if user is not an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const inviteId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createInvitationReviewRequest({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -243,6 +254,7 @@ test('should create invitation review request notification if user is not an act
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationReviewRequest);
|
||||
t.is(notification!.userId, owner.id);
|
||||
@ -251,18 +263,19 @@ test('should create invitation review request notification if user is not an act
|
||||
t.is(notification!.body.inviteId, inviteId);
|
||||
|
||||
// should send email
|
||||
const invitationReviewRequestMail = module.mails.last(
|
||||
'LinkInvitationReviewRequest'
|
||||
const invitationReviewRequestMail = module.queue.last(
|
||||
'notification.sendMail'
|
||||
);
|
||||
t.is(invitationReviewRequestMail.to, owner.email);
|
||||
t.is(invitationReviewRequestMail.payload.to, owner.email);
|
||||
t.is(invitationReviewRequestMail.payload.name, 'LinkInvitationReviewRequest');
|
||||
});
|
||||
|
||||
test('should not create invitation review request notification if user is an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitationReviewRequest({
|
||||
userId: owner.id,
|
||||
body: {
|
||||
@ -271,15 +284,16 @@ test('should not create invitation review request notification if user is an act
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(notification, undefined);
|
||||
});
|
||||
|
||||
test('should create invitation review approved notification if user is an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitationReviewApproved(
|
||||
{
|
||||
userId: member.id,
|
||||
@ -290,6 +304,7 @@ test('should create invitation review approved notification if user is an active
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationReviewApproved);
|
||||
t.is(notification!.userId, member.id);
|
||||
@ -298,19 +313,20 @@ test('should create invitation review approved notification if user is an active
|
||||
t.is(notification!.body.inviteId, inviteId);
|
||||
|
||||
// should send email
|
||||
const invitationReviewApprovedMail = t.context.module.mails.last(
|
||||
'LinkInvitationApprove'
|
||||
const invitationReviewApprovedMail = module.queue.last(
|
||||
'notification.sendMail'
|
||||
);
|
||||
t.is(invitationReviewApprovedMail.to, member.email);
|
||||
t.is(invitationReviewApprovedMail.payload.to, member.email);
|
||||
t.is(invitationReviewApprovedMail.payload.name, 'LinkInvitationApprove');
|
||||
});
|
||||
|
||||
test('should not create invitation review approved notification if user is not an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const { id: inviteId } = await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitationReviewApproved(
|
||||
{
|
||||
userId: member.id,
|
||||
@ -321,11 +337,11 @@ test('should not create invitation review approved notification if user is not a
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
t.is(notification, undefined);
|
||||
});
|
||||
|
||||
test('should create invitation review declined notification if user is not an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const notification = await notificationService.createInvitationReviewDeclined(
|
||||
{
|
||||
userId: member.id,
|
||||
@ -335,6 +351,7 @@ test('should create invitation review declined notification if user is not an ac
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
t.truthy(notification);
|
||||
t.is(notification!.type, NotificationType.InvitationReviewDeclined);
|
||||
t.is(notification!.userId, member.id);
|
||||
@ -342,18 +359,19 @@ test('should create invitation review declined notification if user is not an ac
|
||||
t.is(notification!.body.createdByUserId, owner.id);
|
||||
|
||||
// should send email
|
||||
const invitationReviewDeclinedMail = module.mails.last(
|
||||
'LinkInvitationDecline'
|
||||
const invitationReviewDeclinedMail = module.queue.last(
|
||||
'notification.sendMail'
|
||||
);
|
||||
t.is(invitationReviewDeclinedMail.to, member.email);
|
||||
t.is(invitationReviewDeclinedMail.payload.to, member.email);
|
||||
t.is(invitationReviewDeclinedMail.payload.name, 'LinkInvitationDecline');
|
||||
});
|
||||
|
||||
test('should not create invitation review declined notification if user is an active member', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
await module.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
|
||||
const notification = await notificationService.createInvitationReviewDeclined(
|
||||
{
|
||||
userId: owner.id,
|
||||
@ -363,11 +381,11 @@ test('should not create invitation review declined notification if user is an ac
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
t.is(notification, undefined);
|
||||
});
|
||||
|
||||
test('should clean expired notifications', async t => {
|
||||
const { notificationService } = t.context;
|
||||
await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -376,29 +394,35 @@ test('should clean expired notifications', async t => {
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
let count = await notificationService.countByUserId(member.id);
|
||||
t.is(count, 1);
|
||||
|
||||
// wait for 100 days
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 100,
|
||||
now: Due.after('100d'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
|
||||
await models.notification.cleanExpiredNotifications();
|
||||
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
t.is(count, 1);
|
||||
|
||||
mock.timers.reset();
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
|
||||
await models.notification.cleanExpiredNotifications();
|
||||
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
t.is(count, 0);
|
||||
});
|
||||
|
||||
test('should mark notification as read', async t => {
|
||||
const { notificationService } = t.context;
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -407,22 +431,20 @@ test('should mark notification as read', async t => {
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
await notificationService.markAsRead(member.id, notification!.id);
|
||||
const updatedNotification = await t.context.models.notification.get(
|
||||
notification!.id
|
||||
);
|
||||
|
||||
const updatedNotification = await models.notification.get(notification!.id);
|
||||
t.is(updatedNotification!.read, true);
|
||||
});
|
||||
|
||||
test('should throw error on mark notification as read if notification is not found', async t => {
|
||||
const { notificationService } = t.context;
|
||||
await t.throwsAsync(notificationService.markAsRead(member.id, randomUUID()), {
|
||||
instanceOf: NotificationNotFound,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw error on mark notification as read if notification user is not the same', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const notification = await notificationService.createInvitation({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -431,7 +453,9 @@ test('should throw error on mark notification as read if notification user is no
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
const otherUser = await module.create(Mockers.User);
|
||||
|
||||
await t.throwsAsync(
|
||||
notificationService.markAsRead(otherUser.id, notification!.id),
|
||||
{
|
||||
@ -441,8 +465,8 @@ test('should throw error on mark notification as read if notification user is no
|
||||
});
|
||||
|
||||
test('should use latest doc title in mention notification', async t => {
|
||||
const { notificationService, models } = t.context;
|
||||
const docId = randomUUID();
|
||||
|
||||
await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -456,6 +480,7 @@ test('should use latest doc title in mention notification', async t => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mentionNotification = await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -469,7 +494,9 @@ test('should use latest doc title in mention notification', async t => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(mentionNotification);
|
||||
|
||||
mock.method(models.doc, 'findMetas', async () => [
|
||||
{
|
||||
title: 'doc-title-2-updated',
|
||||
@ -478,7 +505,9 @@ test('should use latest doc title in mention notification', async t => {
|
||||
title: 'doc-title-1-updated',
|
||||
},
|
||||
]);
|
||||
|
||||
const notifications = await notificationService.findManyByUserId(member.id);
|
||||
|
||||
t.is(notifications.length, 2);
|
||||
const mention = notifications[0];
|
||||
t.is(mention.body.workspace!.id, workspace.id);
|
||||
@ -498,8 +527,8 @@ test('should use latest doc title in mention notification', async t => {
|
||||
});
|
||||
|
||||
test('should raw doc title in mention notification if no doc found', async t => {
|
||||
const { notificationService, models } = t.context;
|
||||
const docId = randomUUID();
|
||||
|
||||
await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -526,7 +555,9 @@ test('should raw doc title in mention notification if no doc found', async t =>
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.method(models.doc, 'findMetas', async () => [null, null]);
|
||||
|
||||
const notifications = await notificationService.findManyByUserId(member.id);
|
||||
t.is(notifications.length, 2);
|
||||
const mention = notifications[0];
|
||||
@ -545,8 +576,8 @@ test('should raw doc title in mention notification if no doc found', async t =>
|
||||
});
|
||||
|
||||
test('should send mention email by user setting', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const docId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -560,17 +591,21 @@ test('should send mention email by user setting', async t => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
// should send mention email
|
||||
const mentionMail = module.mails.last('Mention');
|
||||
t.is(mentionMail.to, member.email);
|
||||
const mentionMail = module.queue.last('notification.sendMail');
|
||||
t.is(mentionMail.payload.to, member.email);
|
||||
t.is(mentionMail.payload.name, 'Mention');
|
||||
|
||||
// update user setting to not receive mention email
|
||||
const mentionMailCount = module.mails.count('Mention');
|
||||
const mentionMailCount = module.queue.count('notification.sendMail');
|
||||
await module.create(Mockers.UserSettings, {
|
||||
userId: member.id,
|
||||
receiveMentionEmail: false,
|
||||
});
|
||||
|
||||
await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -584,12 +619,12 @@ test('should send mention email by user setting', async t => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// should not send mention email
|
||||
t.is(module.mails.count('Mention'), mentionMailCount);
|
||||
t.is(module.queue.count('notification.sendMail'), mentionMailCount);
|
||||
});
|
||||
|
||||
test('should send mention email with use client doc title if server doc title is empty', async t => {
|
||||
const { notificationService, module } = t.context;
|
||||
const docId = randomUUID();
|
||||
await module.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
@ -597,6 +632,7 @@ test('should send mention email with use client doc title if server doc title is
|
||||
// mock empty title
|
||||
title: '',
|
||||
});
|
||||
|
||||
const notification = await notificationService.createMention({
|
||||
userId: member.id,
|
||||
body: {
|
||||
@ -610,8 +646,115 @@ test('should send mention email with use client doc title if server doc title is
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
const mentionMail = module.mails.last('Mention');
|
||||
t.is(mentionMail.to, member.email);
|
||||
t.is(mentionMail.props.doc.title, 'doc-title-1');
|
||||
|
||||
const mentionMail = module.queue.last('notification.sendMail');
|
||||
t.is(mentionMail.payload.to, member.email);
|
||||
t.is(mentionMail.payload.name, 'Mention');
|
||||
// @ts-expect-error - payload is not typed
|
||||
t.is(mentionMail.payload.props.doc.title, 'doc-title-1');
|
||||
});
|
||||
|
||||
test('should send comment notification and email', async t => {
|
||||
const docId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createComment({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
commentId,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
const commentMail = module.queue.last('notification.sendMail');
|
||||
t.is(commentMail.payload.to, member.email);
|
||||
t.is(commentMail.payload.name, 'Comment');
|
||||
});
|
||||
|
||||
test('should send comment mention notification and email', async t => {
|
||||
const docId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const replyId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createComment(
|
||||
{
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
commentId,
|
||||
replyId,
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
const commentMentionMail = module.queue.last('notification.sendMail');
|
||||
t.is(commentMentionMail.payload.to, member.email);
|
||||
t.is(commentMentionMail.payload.name, 'CommentMention');
|
||||
});
|
||||
|
||||
test('should send comment email by user setting', async t => {
|
||||
const docId = randomUUID();
|
||||
|
||||
const notification = await notificationService.createComment({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-1',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
commentId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(notification);
|
||||
|
||||
const commentMail = module.queue.last('notification.sendMail');
|
||||
t.is(commentMail.payload.to, member.email);
|
||||
t.is(commentMail.payload.name, 'Comment');
|
||||
|
||||
// update user setting to not receive comment email
|
||||
const commentMailCount = module.queue.count('notification.sendMail');
|
||||
await module.create(Mockers.UserSettings, {
|
||||
userId: member.id,
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
|
||||
await notificationService.createComment({
|
||||
userId: member.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: owner.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title-2',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
commentId: randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
// should not send comment email
|
||||
t.is(module.queue.count('notification.sendMail'), commentMailCount);
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue, OnJob } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CommentNotificationBody, Models } from '../../models';
|
||||
import { NotificationService } from './service';
|
||||
|
||||
declare global {
|
||||
@ -29,6 +29,11 @@ declare global {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
'notification.sendComment': {
|
||||
userId: string;
|
||||
isMention?: boolean;
|
||||
body: CommentNotificationBody;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,4 +151,19 @@ export class NotificationJob {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnJob('notification.sendComment')
|
||||
async sendComment({
|
||||
userId,
|
||||
isMention,
|
||||
body,
|
||||
}: Jobs['notification.sendComment']) {
|
||||
await this.service.createComment(
|
||||
{
|
||||
userId,
|
||||
body,
|
||||
},
|
||||
isMention
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ import { Prisma } from '@prisma/client';
|
||||
|
||||
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
|
||||
import {
|
||||
CommentNotification,
|
||||
CommentNotificationCreate,
|
||||
DEFAULT_WORKSPACE_NAME,
|
||||
InvitationNotificationCreate,
|
||||
InvitationReviewDeclinedNotificationCreate,
|
||||
@ -36,6 +38,58 @@ export class NotificationService {
|
||||
return await this.models.notification.cleanExpiredNotifications();
|
||||
}
|
||||
|
||||
async createComment(input: CommentNotificationCreate, isMention?: boolean) {
|
||||
const notification = isMention
|
||||
? await this.models.notification.createCommentMention(input)
|
||||
: await this.models.notification.createComment(input);
|
||||
await this.sendCommentEmail(input, isMention);
|
||||
return notification;
|
||||
}
|
||||
|
||||
private async sendCommentEmail(
|
||||
input: CommentNotificationCreate,
|
||||
isMention?: boolean
|
||||
) {
|
||||
const userSetting = await this.models.userSettings.get(input.userId);
|
||||
if (!userSetting.receiveCommentEmail) {
|
||||
return;
|
||||
}
|
||||
const receiver = await this.models.user.getWorkspaceUser(input.userId);
|
||||
if (!receiver) {
|
||||
return;
|
||||
}
|
||||
const doc = await this.models.doc.getMeta(
|
||||
input.body.workspaceId,
|
||||
input.body.doc.id
|
||||
);
|
||||
const title = doc?.title || input.body.doc.title;
|
||||
const url = this.url.link(
|
||||
generateDocPath({
|
||||
workspaceId: input.body.workspaceId,
|
||||
docId: input.body.doc.id,
|
||||
mode: input.body.doc.mode,
|
||||
blockId: input.body.doc.blockId,
|
||||
elementId: input.body.doc.elementId,
|
||||
commentId: input.body.commentId,
|
||||
replyId: input.body.replyId,
|
||||
})
|
||||
);
|
||||
await this.mailer.trySend({
|
||||
name: isMention ? 'CommentMention' : 'Comment',
|
||||
to: receiver.email,
|
||||
props: {
|
||||
user: {
|
||||
$$userId: input.body.createdByUserId,
|
||||
},
|
||||
doc: {
|
||||
title,
|
||||
url,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.debug(`Comment email sent to user ${receiver.id}`);
|
||||
}
|
||||
|
||||
async createMention(input: MentionNotificationCreate) {
|
||||
const notification = await this.models.notification.createMention(input);
|
||||
await this.sendMentionEmail(input);
|
||||
@ -370,8 +424,11 @@ export class NotificationService {
|
||||
|
||||
// fill latest doc title
|
||||
const mentions = notifications.filter(
|
||||
n => n.type === NotificationType.Mention
|
||||
) as MentionNotification[];
|
||||
n =>
|
||||
n.type === NotificationType.Mention ||
|
||||
n.type === NotificationType.CommentMention ||
|
||||
n.type === NotificationType.Comment
|
||||
) as (MentionNotification | CommentNotification)[];
|
||||
const mentionDocs = await this.models.doc.findMetas(
|
||||
mentions.map(m => ({
|
||||
workspaceId: m.body.workspaceId,
|
||||
|
@ -18,6 +18,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
'Reader'
|
||||
|
||||
> WorkspaceRole: External, DocRole: Commenter
|
||||
|
||||
'Commenter'
|
||||
|
||||
> WorkspaceRole: External, DocRole: Editor
|
||||
|
||||
'Editor'
|
||||
@ -42,6 +46,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
'Reader'
|
||||
|
||||
> WorkspaceRole: Collaborator, DocRole: Commenter
|
||||
|
||||
'Commenter'
|
||||
|
||||
> WorkspaceRole: Collaborator, DocRole: Editor
|
||||
|
||||
'Editor'
|
||||
@ -66,6 +74,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
'Manager'
|
||||
|
||||
> WorkspaceRole: Admin, DocRole: Commenter
|
||||
|
||||
'Manager'
|
||||
|
||||
> WorkspaceRole: Admin, DocRole: Editor
|
||||
|
||||
'Manager'
|
||||
@ -90,6 +102,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
'Owner'
|
||||
|
||||
> WorkspaceRole: Owner, DocRole: Commenter
|
||||
|
||||
'Owner'
|
||||
|
||||
> WorkspaceRole: Owner, DocRole: Editor
|
||||
|
||||
'Owner'
|
||||
@ -209,6 +225,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: None
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': false,
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': false,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Copy': false,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@ -227,6 +247,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: External
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': false,
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': false,
|
||||
@ -245,6 +269,32 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: Reader
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': false,
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
'Doc.Properties.Read': true,
|
||||
'Doc.Properties.Update': false,
|
||||
'Doc.Publish': false,
|
||||
'Doc.Read': true,
|
||||
'Doc.Restore': false,
|
||||
'Doc.TransferOwner': false,
|
||||
'Doc.Trash': false,
|
||||
'Doc.Update': false,
|
||||
'Doc.Users.Manage': false,
|
||||
'Doc.Users.Read': true,
|
||||
}
|
||||
|
||||
> DocRole: Commenter
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': true,
|
||||
'Doc.Comments.Delete': false,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': false,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': false,
|
||||
'Doc.Duplicate': true,
|
||||
@ -263,6 +313,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: Editor
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': true,
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@ -281,6 +335,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: Manager
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': true,
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@ -299,6 +357,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> DocRole: Owner
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': true,
|
||||
'Doc.Comments.Delete': true,
|
||||
'Doc.Comments.Read': true,
|
||||
'Doc.Comments.Resolve': true,
|
||||
'Doc.Copy': true,
|
||||
'Doc.Delete': true,
|
||||
'Doc.Duplicate': true,
|
||||
@ -346,6 +408,10 @@ Generated by [AVA](https://avajs.dev).
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
'Doc.Comments.Create': 'Commenter',
|
||||
'Doc.Comments.Delete': 'Editor',
|
||||
'Doc.Comments.Read': 'External',
|
||||
'Doc.Comments.Resolve': 'Editor',
|
||||
'Doc.Copy': 'External',
|
||||
'Doc.Delete': 'Editor',
|
||||
'Doc.Duplicate': 'Reader',
|
||||
|
Binary file not shown.
@ -65,6 +65,13 @@ export const Actions = {
|
||||
Read: '',
|
||||
Manage: '',
|
||||
},
|
||||
Comments: {
|
||||
Read: '',
|
||||
Create: '',
|
||||
Update: '',
|
||||
Delete: '',
|
||||
Resolve: '',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -112,7 +119,12 @@ export const RoleActionsMap = {
|
||||
},
|
||||
DocRole: {
|
||||
get [DocRole.External]() {
|
||||
return [Action.Doc.Read, Action.Doc.Copy, Action.Doc.Properties.Read];
|
||||
return [
|
||||
Action.Doc.Read,
|
||||
Action.Doc.Copy,
|
||||
Action.Doc.Properties.Read,
|
||||
Action.Doc.Comments.Read,
|
||||
];
|
||||
},
|
||||
get [DocRole.Reader]() {
|
||||
return [
|
||||
@ -121,14 +133,20 @@ export const RoleActionsMap = {
|
||||
Action.Doc.Duplicate,
|
||||
];
|
||||
},
|
||||
get [DocRole.Commenter]() {
|
||||
return [...this[DocRole.Reader], Action.Doc.Comments.Create];
|
||||
},
|
||||
get [DocRole.Editor]() {
|
||||
return [
|
||||
...this[DocRole.Reader],
|
||||
...this[DocRole.Commenter],
|
||||
Action.Doc.Trash,
|
||||
Action.Doc.Restore,
|
||||
Action.Doc.Delete,
|
||||
Action.Doc.Properties.Update,
|
||||
Action.Doc.Update,
|
||||
Action.Doc.Comments.Resolve,
|
||||
Action.Doc.Comments.Delete,
|
||||
];
|
||||
},
|
||||
get [DocRole.Manager]() {
|
||||
|
@ -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';
|
||||
|
@ -121,6 +121,9 @@ export class UserSettingsType implements UserSettings {
|
||||
|
||||
@Field({ description: 'Receive mention email' })
|
||||
receiveMentionEmail!: boolean;
|
||||
|
||||
@Field({ description: 'Receive comment email' })
|
||||
receiveCommentEmail!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@ -145,4 +148,7 @@ export class UpdateUserSettingsInput implements UserSettingsInput {
|
||||
|
||||
@Field({ description: 'Receive mention email', nullable: true })
|
||||
receiveMentionEmail?: boolean;
|
||||
|
||||
@Field({ description: 'Receive comment email', nullable: true })
|
||||
receiveCommentEmail?: boolean;
|
||||
}
|
||||
|
@ -128,12 +128,14 @@ type DocPathParams = {
|
||||
mode: DocMode;
|
||||
blockId?: string;
|
||||
elementId?: string;
|
||||
commentId?: string;
|
||||
replyId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* To generate a doc url path like
|
||||
*
|
||||
* /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId}
|
||||
* /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId}&commentId={commentId}&replyId={replyId}
|
||||
*/
|
||||
export function generateDocPath(params: DocPathParams) {
|
||||
const search = new URLSearchParams({
|
||||
@ -145,5 +147,11 @@ export function generateDocPath(params: DocPathParams) {
|
||||
if (params.blockId) {
|
||||
search.set('blockIds', params.blockId);
|
||||
}
|
||||
if (params.commentId) {
|
||||
search.set('commentId', params.commentId);
|
||||
}
|
||||
if (params.replyId) {
|
||||
search.set('replyId', params.replyId);
|
||||
}
|
||||
return `/workspace/${params.workspaceId}/${params.docId}?${search.toString()}`;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
37
packages/backend/server/src/mails/docs/comment-mention.tsx
Normal file
37
packages/backend/server/src/mails/docs/comment-mention.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { TEST_DOC, TEST_USER } from '../common';
|
||||
import {
|
||||
Button,
|
||||
Content,
|
||||
Doc,
|
||||
type DocProps,
|
||||
P,
|
||||
Template,
|
||||
Title,
|
||||
User,
|
||||
type UserProps,
|
||||
} from '../components';
|
||||
|
||||
export type CommentMentionProps = {
|
||||
user: UserProps;
|
||||
doc: DocProps;
|
||||
};
|
||||
|
||||
export function CommentMention(props: CommentMentionProps) {
|
||||
const { user, doc } = props;
|
||||
return (
|
||||
<Template>
|
||||
<Title>You are mentioned in a comment</Title>
|
||||
<Content>
|
||||
<P>
|
||||
<User {...user} /> mentioned you in a comment on <Doc {...doc} />.
|
||||
</P>
|
||||
<Button href={doc.url}>View Comment</Button>
|
||||
</Content>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
CommentMention.PreviewProps = {
|
||||
user: TEST_USER,
|
||||
doc: TEST_DOC,
|
||||
};
|
37
packages/backend/server/src/mails/docs/comment.tsx
Normal file
37
packages/backend/server/src/mails/docs/comment.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { TEST_DOC, TEST_USER } from '../common';
|
||||
import {
|
||||
Button,
|
||||
Content,
|
||||
Doc,
|
||||
type DocProps,
|
||||
P,
|
||||
Template,
|
||||
Title,
|
||||
User,
|
||||
type UserProps,
|
||||
} from '../components';
|
||||
|
||||
export type CommentProps = {
|
||||
user: UserProps;
|
||||
doc: DocProps;
|
||||
};
|
||||
|
||||
export function Comment(props: CommentProps) {
|
||||
const { user, doc } = props;
|
||||
return (
|
||||
<Template>
|
||||
<Title>You have a new comment</Title>
|
||||
<Content>
|
||||
<P>
|
||||
<User {...user} /> commented on <Doc {...doc} />.
|
||||
</P>
|
||||
<Button href={doc.url}>View Comment</Button>
|
||||
</Content>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
Comment.PreviewProps = {
|
||||
user: TEST_USER,
|
||||
doc: TEST_DOC,
|
||||
};
|
@ -1 +1,3 @@
|
||||
export * from './comment';
|
||||
export * from './comment-mention';
|
||||
export * from './mention';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { render as rawRender } from '@react-email/components';
|
||||
|
||||
import { Mention } from './docs';
|
||||
import { Comment, CommentMention, Mention } from './docs';
|
||||
import {
|
||||
TeamBecomeAdmin,
|
||||
TeamBecomeCollaborator,
|
||||
@ -125,6 +125,15 @@ export const Renderers = {
|
||||
Mention,
|
||||
props => `${props.user.email} mentioned you in ${props.doc.title}`
|
||||
),
|
||||
Comment: make(
|
||||
Comment,
|
||||
props => `${props.user.email} commented on ${props.doc.title}`
|
||||
),
|
||||
CommentMention: make(
|
||||
CommentMention,
|
||||
props =>
|
||||
`${props.user.email} mentioned you in a comment on ${props.doc.title}`
|
||||
),
|
||||
//#endregion
|
||||
|
||||
//#region Team
|
||||
|
@ -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,51 @@
|
||||
# Snapshot report for `src/models/__tests__/user-settings.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `user-settings.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should get a user settings with default value
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
receiveCommentEmail: true,
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
}
|
||||
|
||||
## should update a user settings
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
receiveCommentEmail: true,
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: true,
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
receiveCommentEmail: true,
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
}
|
||||
|
||||
> Snapshot 3
|
||||
|
||||
{
|
||||
receiveCommentEmail: true,
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: false,
|
||||
}
|
||||
|
||||
## should set receiveCommentEmail to false
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
receiveCommentEmail: false,
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
}
|
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);
|
||||
});
|
@ -1,10 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import test from 'ava';
|
||||
|
||||
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||
import { Config } from '../../base/config';
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Due } from '../../base';
|
||||
import {
|
||||
DocMode,
|
||||
Models,
|
||||
@ -13,38 +14,20 @@ import {
|
||||
User,
|
||||
Workspace,
|
||||
} from '../../models';
|
||||
interface Context {
|
||||
config: Config;
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
|
||||
t.context.models = module.get(Models);
|
||||
t.context.config = module.get(Config);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
let user: User;
|
||||
let createdBy: User;
|
||||
let workspace: Workspace;
|
||||
let docId: string;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
user = await t.context.models.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
createdBy = await t.context.models.user.create({
|
||||
email: 'createdBy@affine.pro',
|
||||
});
|
||||
workspace = await t.context.models.workspace.create(user.id);
|
||||
test.beforeEach(async () => {
|
||||
user = await module.create(Mockers.User);
|
||||
createdBy = await module.create(Mockers.User);
|
||||
workspace = await module.create(Mockers.Workspace);
|
||||
docId = randomUUID();
|
||||
await t.context.models.doc.upsert({
|
||||
await models.doc.upsert({
|
||||
spaceId: user.id,
|
||||
docId,
|
||||
blob: Buffer.from('hello'),
|
||||
@ -58,12 +41,12 @@ test.afterEach.always(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create a mention notification with default level', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
const notification = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -87,7 +70,7 @@ test('should create a mention notification with default level', async t => {
|
||||
});
|
||||
|
||||
test('should create a mention notification with custom level', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
const notification = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -112,7 +95,7 @@ test('should create a mention notification with custom level', async t => {
|
||||
});
|
||||
|
||||
test('should mark a mention notification as read', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
const notification = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -126,16 +109,14 @@ test('should mark a mention notification as read', async t => {
|
||||
},
|
||||
});
|
||||
t.is(notification.read, false);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await t.context.models.notification.get(
|
||||
notification.id
|
||||
);
|
||||
await models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await models.notification.get(notification.id);
|
||||
t.is(updatedNotification!.read, true);
|
||||
});
|
||||
|
||||
test('should create an invite notification', async t => {
|
||||
const inviteId = randomUUID();
|
||||
const notification = await t.context.models.notification.createInvitation({
|
||||
const notification = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -152,7 +133,7 @@ test('should create an invite notification', async t => {
|
||||
|
||||
test('should mark an invite notification as read', async t => {
|
||||
const inviteId = randomUUID();
|
||||
const notification = await t.context.models.notification.createInvitation({
|
||||
const notification = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -161,15 +142,13 @@ test('should mark an invite notification as read', async t => {
|
||||
},
|
||||
});
|
||||
t.is(notification.read, false);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await t.context.models.notification.get(
|
||||
notification.id
|
||||
);
|
||||
await models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await models.notification.get(notification.id);
|
||||
t.is(updatedNotification!.read, true);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, order by createdAt descending', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
const notification1 = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -183,7 +162,7 @@ test('should find many notifications by user id, order by createdAt descending',
|
||||
},
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
const notification2 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -191,16 +170,14 @@ test('should find many notifications by user id, order by createdAt descending',
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
const notifications = await models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 2);
|
||||
t.is(notifications[0].id, notification2.id);
|
||||
t.is(notifications[1].id, notification1.id);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, filter read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
const notification1 = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -214,7 +191,7 @@ test('should find many notifications by user id, filter read notifications', asy
|
||||
},
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
const notification2 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -222,16 +199,14 @@ test('should find many notifications by user id, filter read notifications', asy
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
await models.notification.markAsRead(notification2.id, user.id);
|
||||
const notifications = await models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 1);
|
||||
t.is(notifications[0].id, notification1.id);
|
||||
});
|
||||
|
||||
test('should clean expired notifications', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
const notification = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -245,30 +220,28 @@ test('should clean expired notifications', async t => {
|
||||
},
|
||||
});
|
||||
t.truthy(notification);
|
||||
let notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
let notifications = await models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 1);
|
||||
let count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
let count = await models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
notifications = await t.context.models.notification.findManyByUserId(user.id);
|
||||
notifications = await models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 1);
|
||||
t.is(notifications[0].id, notification.id);
|
||||
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
await models.notification.markAsRead(notification.id, user.id);
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 1);
|
||||
notifications = await t.context.models.notification.findManyByUserId(user.id);
|
||||
count = await models.notification.cleanExpiredNotifications();
|
||||
t.true(count > 0);
|
||||
notifications = await models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 0);
|
||||
});
|
||||
|
||||
test('should not clean unexpired notifications', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
const notification = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -281,15 +254,15 @@ test('should not clean unexpired notifications', async t => {
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
let count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
let count = await models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
await models.notification.markAsRead(notification.id, user.id);
|
||||
count = await models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, order by createdAt descending, with pagination', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
const notification1 = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -302,7 +275,7 @@ test('should find many notifications by user id, order by createdAt descending,
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
const notification2 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -310,7 +283,7 @@ test('should find many notifications by user id, order by createdAt descending,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notification3 = await t.context.models.notification.createInvitation({
|
||||
const notification3 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -318,7 +291,7 @@ test('should find many notifications by user id, order by createdAt descending,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notification4 = await t.context.models.notification.createInvitation({
|
||||
const notification4 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -326,38 +299,29 @@ test('should find many notifications by user id, order by createdAt descending,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 0,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
const notifications = await models.notification.findManyByUserId(user.id, {
|
||||
offset: 0,
|
||||
first: 2,
|
||||
});
|
||||
t.is(notifications.length, 2);
|
||||
t.is(notifications[0].id, notification4.id);
|
||||
t.is(notifications[1].id, notification3.id);
|
||||
const notifications2 = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 2,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
const notifications2 = await models.notification.findManyByUserId(user.id, {
|
||||
offset: 2,
|
||||
first: 2,
|
||||
});
|
||||
t.is(notifications2.length, 2);
|
||||
t.is(notifications2[0].id, notification2.id);
|
||||
t.is(notifications2[1].id, notification1.id);
|
||||
const notifications3 = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 4,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
const notifications3 = await models.notification.findManyByUserId(user.id, {
|
||||
offset: 4,
|
||||
first: 2,
|
||||
});
|
||||
t.is(notifications3.length, 0);
|
||||
});
|
||||
|
||||
test('should count notifications by user id, exclude read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
const notification1 = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -371,7 +335,7 @@ test('should count notifications by user id, exclude read notifications', async
|
||||
},
|
||||
});
|
||||
t.truthy(notification1);
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
const notification2 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -380,13 +344,13 @@ test('should count notifications by user id, exclude read notifications', async
|
||||
},
|
||||
});
|
||||
t.truthy(notification2);
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await t.context.models.notification.countByUserId(user.id);
|
||||
await models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await models.notification.countByUserId(user.id);
|
||||
t.is(count, 1);
|
||||
});
|
||||
|
||||
test('should count notifications by user id, include read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
const notification1 = await models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -400,7 +364,7 @@ test('should count notifications by user id, include read notifications', async
|
||||
},
|
||||
});
|
||||
t.truthy(notification1);
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
const notification2 = await models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
@ -409,9 +373,60 @@ test('should count notifications by user id, include read notifications', async
|
||||
},
|
||||
});
|
||||
t.truthy(notification2);
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await t.context.models.notification.countByUserId(user.id, {
|
||||
await models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await models.notification.countByUserId(user.id, {
|
||||
includeRead: true,
|
||||
});
|
||||
t.is(count, 2);
|
||||
});
|
||||
|
||||
test('should create a comment notification', async t => {
|
||||
const commentId = randomUUID();
|
||||
|
||||
const notification = await models.notification.createComment({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
commentId,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(notification.type, NotificationType.Comment);
|
||||
t.is(notification.body.workspaceId, workspace.id);
|
||||
t.is(notification.body.doc.id, docId);
|
||||
t.is(notification.body.doc.title, 'doc-title');
|
||||
t.is(notification.body.commentId, commentId);
|
||||
});
|
||||
|
||||
test('should create a comment mention notification', async t => {
|
||||
const commentId = randomUUID();
|
||||
const replyId = randomUUID();
|
||||
|
||||
const notification = await models.notification.createCommentMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
mode: DocMode.page,
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
commentId,
|
||||
replyId,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(notification.type, NotificationType.CommentMention);
|
||||
t.is(notification.body.workspaceId, workspace.id);
|
||||
t.is(notification.body.doc.id, docId);
|
||||
t.is(notification.body.doc.title, 'doc-title');
|
||||
t.is(notification.body.commentId, commentId);
|
||||
t.is(notification.body.replyId, replyId);
|
||||
});
|
||||
|
@ -1,92 +1,80 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import test from 'ava';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||
import { Config } from '../../base/config';
|
||||
import { Models, User } from '..';
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Models } from '..';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
}
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
|
||||
t.context.models = module.get(Models);
|
||||
t.context.config = module.get(Config);
|
||||
t.context.module = module;
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
let user: User;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
user = await t.context.models.user.create({
|
||||
email: `test-${randomUUID()}@affine.pro`,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach.always(() => {
|
||||
mock.reset();
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should get a user settings with default value', async t => {
|
||||
const settings = await t.context.models.userSettings.get(user.id);
|
||||
t.deepEqual(settings, {
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
});
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const settings = await models.userSettings.get(user.id);
|
||||
|
||||
t.snapshot(settings);
|
||||
});
|
||||
|
||||
test('should update a user settings', async t => {
|
||||
const settings = await t.context.models.userSettings.set(user.id, {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const settings = await models.userSettings.set(user.id, {
|
||||
receiveInvitationEmail: false,
|
||||
});
|
||||
t.deepEqual(settings, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: true,
|
||||
});
|
||||
const settings2 = await t.context.models.userSettings.get(user.id);
|
||||
|
||||
t.snapshot(settings);
|
||||
|
||||
const settings2 = await models.userSettings.get(user.id);
|
||||
|
||||
t.deepEqual(settings2, settings);
|
||||
|
||||
// update existing setting
|
||||
const setting3 = await t.context.models.userSettings.set(user.id, {
|
||||
const setting3 = await models.userSettings.set(user.id, {
|
||||
receiveInvitationEmail: true,
|
||||
});
|
||||
t.deepEqual(setting3, {
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
});
|
||||
const setting4 = await t.context.models.userSettings.get(user.id);
|
||||
|
||||
t.snapshot(setting3);
|
||||
|
||||
const setting4 = await models.userSettings.get(user.id);
|
||||
|
||||
t.deepEqual(setting4, setting3);
|
||||
|
||||
const setting5 = await t.context.models.userSettings.set(user.id, {
|
||||
const setting5 = await models.userSettings.set(user.id, {
|
||||
receiveMentionEmail: false,
|
||||
receiveInvitationEmail: false,
|
||||
});
|
||||
t.deepEqual(setting5, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: false,
|
||||
});
|
||||
const setting6 = await t.context.models.userSettings.get(user.id);
|
||||
|
||||
t.snapshot(setting5);
|
||||
|
||||
const setting6 = await models.userSettings.get(user.id);
|
||||
|
||||
t.deepEqual(setting6, setting5);
|
||||
});
|
||||
|
||||
test('should set receiveCommentEmail to false', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const settings = await models.userSettings.set(user.id, {
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
|
||||
t.snapshot(settings);
|
||||
|
||||
const settings2 = await models.userSettings.get(user.id);
|
||||
|
||||
t.deepEqual(settings2, settings);
|
||||
});
|
||||
|
||||
test('should throw error when update settings with invalid payload', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.models.userSettings.set(user.id, {
|
||||
models.userSettings.set(user.id, {
|
||||
// @ts-expect-error invalid setting input types
|
||||
receiveInvitationEmail: 1,
|
||||
}),
|
||||
|
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
|
||||
}
|
@ -5,6 +5,7 @@ export enum DocRole {
|
||||
None = -(1 << 15),
|
||||
External = 0,
|
||||
Reader = 10,
|
||||
Commenter = 15,
|
||||
Editor = 20,
|
||||
Manager = 30,
|
||||
Owner = 99,
|
||||
|
@ -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';
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { Due, PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { DocMode } from './common';
|
||||
|
||||
@ -16,7 +16,7 @@ export type { Notification };
|
||||
|
||||
// #region input
|
||||
|
||||
export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
export const ONE_YEAR = Due.ms('1y');
|
||||
const IdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
export const BaseNotificationCreateSchema = z.object({
|
||||
@ -96,10 +96,37 @@ export type InvitationReviewDeclinedNotificationCreate = z.input<
|
||||
typeof InvitationReviewDeclinedNotificationCreateSchema
|
||||
>;
|
||||
|
||||
export const CommentNotificationBodySchema = z.object({
|
||||
workspaceId: IdSchema,
|
||||
createdByUserId: IdSchema,
|
||||
commentId: IdSchema,
|
||||
replyId: IdSchema.optional(),
|
||||
doc: MentionDocSchema,
|
||||
});
|
||||
|
||||
export type CommentNotificationBody = z.infer<
|
||||
typeof CommentNotificationBodySchema
|
||||
>;
|
||||
|
||||
export const CommentNotificationCreateSchema =
|
||||
BaseNotificationCreateSchema.extend({
|
||||
body: CommentNotificationBodySchema,
|
||||
});
|
||||
|
||||
export type CommentNotificationCreate = z.input<
|
||||
typeof CommentNotificationCreateSchema
|
||||
>;
|
||||
|
||||
export const CommentMentionNotificationCreateSchema =
|
||||
BaseNotificationCreateSchema.extend({
|
||||
body: CommentNotificationBodySchema,
|
||||
});
|
||||
|
||||
export type UnionNotificationBody =
|
||||
| MentionNotificationBody
|
||||
| InvitationNotificationBody
|
||||
| InvitationReviewDeclinedNotificationBody;
|
||||
| InvitationReviewDeclinedNotificationBody
|
||||
| CommentNotificationBody;
|
||||
|
||||
// #endregion
|
||||
|
||||
@ -114,10 +141,14 @@ export type InvitationNotification = Notification &
|
||||
export type InvitationReviewDeclinedNotification = Notification &
|
||||
z.infer<typeof InvitationReviewDeclinedNotificationCreateSchema>;
|
||||
|
||||
export type CommentNotification = Notification &
|
||||
z.infer<typeof CommentNotificationCreateSchema>;
|
||||
|
||||
export type UnionNotification =
|
||||
| MentionNotification
|
||||
| InvitationNotification
|
||||
| InvitationReviewDeclinedNotification;
|
||||
| InvitationReviewDeclinedNotification
|
||||
| CommentNotification;
|
||||
|
||||
// #endregion
|
||||
|
||||
@ -179,6 +210,40 @@ export class NotificationModel extends BaseModel {
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region comment
|
||||
|
||||
async createComment(input: CommentNotificationCreate) {
|
||||
const data = CommentNotificationCreateSchema.parse(input);
|
||||
const type = NotificationType.Comment;
|
||||
const row = await this.create({
|
||||
userId: data.userId,
|
||||
level: data.level,
|
||||
type,
|
||||
body: data.body,
|
||||
});
|
||||
this.logger.debug(
|
||||
`Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}`
|
||||
);
|
||||
return row as CommentNotification;
|
||||
}
|
||||
|
||||
async createCommentMention(input: CommentNotificationCreate) {
|
||||
const data = CommentMentionNotificationCreateSchema.parse(input);
|
||||
const type = NotificationType.CommentMention;
|
||||
const row = await this.create({
|
||||
userId: data.userId,
|
||||
level: data.level,
|
||||
type,
|
||||
body: data.body,
|
||||
});
|
||||
this.logger.debug(
|
||||
`Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}`
|
||||
);
|
||||
return row as CommentNotification;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region common
|
||||
|
||||
private async create(data: Prisma.NotificationUncheckedCreateInput) {
|
||||
|
@ -7,6 +7,7 @@ import { BaseModel } from './base';
|
||||
export const UserSettingsSchema = z.object({
|
||||
receiveInvitationEmail: z.boolean().default(true),
|
||||
receiveMentionEmail: z.boolean().default(true),
|
||||
receiveCommentEmail: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type UserSettingsInput = z.input<typeof UserSettingsSchema>;
|
||||
|
@ -85,13 +85,13 @@ export class UserModel extends BaseModel {
|
||||
async getPublicUsersMap<T extends ItemWithUserId>(
|
||||
items: T[]
|
||||
): Promise<Map<string, PublicUser>> {
|
||||
const userIds: string[] = [];
|
||||
const userIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.userId) {
|
||||
userIds.push(item.userId);
|
||||
userIds.add(item.userId);
|
||||
}
|
||||
}
|
||||
const users = await this.getPublicUsers(userIds);
|
||||
const users = await this.getPublicUsers(Array.from(userIds));
|
||||
return new Map(users.map(user => [user.id, user]));
|
||||
}
|
||||
|
||||
|
@ -99,6 +99,80 @@ type ChatMessage {
|
||||
streamObjects: [StreamObject!]
|
||||
}
|
||||
|
||||
"""Comment change action"""
|
||||
enum CommentChangeAction {
|
||||
delete
|
||||
update
|
||||
}
|
||||
|
||||
type CommentChangeObjectType {
|
||||
"""The action of the comment change"""
|
||||
action: CommentChangeAction!
|
||||
commentId: ID
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
The item of the comment or reply, different types have different fields, see UnionCommentObjectType
|
||||
"""
|
||||
item: JSONObject!
|
||||
}
|
||||
|
||||
type CommentChangeObjectTypeEdge {
|
||||
cursor: String!
|
||||
node: CommentChangeObjectType!
|
||||
}
|
||||
|
||||
input CommentCreateInput {
|
||||
content: JSONObject!
|
||||
docId: ID!
|
||||
docMode: DocMode!
|
||||
docTitle: String!
|
||||
|
||||
"""
|
||||
The mention user ids, if not provided, the comment will not be mentioned
|
||||
"""
|
||||
mentions: [String!]
|
||||
workspaceId: ID!
|
||||
}
|
||||
|
||||
type CommentObjectType {
|
||||
"""The content of the comment"""
|
||||
content: JSONObject!
|
||||
|
||||
"""The created at time of the comment"""
|
||||
createdAt: DateTime!
|
||||
id: ID!
|
||||
|
||||
"""The replies of the comment"""
|
||||
replies: [ReplyObjectType!]!
|
||||
|
||||
"""Whether the comment is resolved"""
|
||||
resolved: Boolean!
|
||||
|
||||
"""The updated at time of the comment"""
|
||||
updatedAt: DateTime!
|
||||
|
||||
"""The user who created the comment"""
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
type CommentObjectTypeEdge {
|
||||
cursor: String!
|
||||
node: CommentObjectType!
|
||||
}
|
||||
|
||||
input CommentResolveInput {
|
||||
id: ID!
|
||||
|
||||
"""Whether the comment is resolved"""
|
||||
resolved: Boolean!
|
||||
}
|
||||
|
||||
input CommentUpdateInput {
|
||||
content: JSONObject!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
enum ContextCategories {
|
||||
Collection
|
||||
Tag
|
||||
@ -455,6 +529,10 @@ type DocNotFoundDataType {
|
||||
}
|
||||
|
||||
type DocPermissions {
|
||||
Doc_Comments_Create: Boolean!
|
||||
Doc_Comments_Delete: Boolean!
|
||||
Doc_Comments_Read: Boolean!
|
||||
Doc_Comments_Resolve: Boolean!
|
||||
Doc_Copy: Boolean!
|
||||
Doc_Delete: Boolean!
|
||||
Doc_Duplicate: Boolean!
|
||||
@ -472,6 +550,7 @@ type DocPermissions {
|
||||
|
||||
"""User permission in doc"""
|
||||
enum DocRole {
|
||||
Commenter
|
||||
Editor
|
||||
External
|
||||
Manager
|
||||
@ -539,6 +618,9 @@ enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
|
||||
CAN_NOT_REVOKE_YOURSELF
|
||||
CAPTCHA_VERIFICATION_FAILED
|
||||
COMMENT_ATTACHMENT_NOT_FOUND
|
||||
COMMENT_ATTACHMENT_QUOTA_EXCEEDED
|
||||
COMMENT_NOT_FOUND
|
||||
COPILOT_ACTION_TAKEN
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED
|
||||
COPILOT_DOCS_NOT_FOUND
|
||||
@ -628,6 +710,7 @@ enum ErrorNames {
|
||||
OWNER_CAN_NOT_LEAVE_WORKSPACE
|
||||
PASSWORD_REQUIRED
|
||||
QUERY_TOO_LONG
|
||||
REPLY_NOT_FOUND
|
||||
RUNTIME_CONFIG_NOT_FOUND
|
||||
SAME_EMAIL_PROVIDED
|
||||
SAME_SUBSCRIPTION_RECURRING
|
||||
@ -1086,6 +1169,7 @@ type Mutation {
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||
createComment(input: CommentCreateInput!): CommentObjectType!
|
||||
|
||||
"""Create a context session"""
|
||||
createCopilotContext(sessionId: String!, workspaceId: String!): String!
|
||||
@ -1102,6 +1186,7 @@ type Mutation {
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
|
||||
createReply(input: ReplyCreateInput!): ReplyObjectType!
|
||||
createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String!
|
||||
|
||||
"""Create a new user"""
|
||||
@ -1113,6 +1198,12 @@ type Mutation {
|
||||
deleteAccount: DeleteAccount!
|
||||
deleteBlob(hash: String @deprecated(reason: "use parameter [key]"), key: String, permanently: Boolean! = false, workspaceId: String!): Boolean!
|
||||
|
||||
"""Delete a comment"""
|
||||
deleteComment(id: String!): Boolean!
|
||||
|
||||
"""Delete a reply"""
|
||||
deleteReply(id: String!): Boolean!
|
||||
|
||||
"""Delete a user account"""
|
||||
deleteUser(id: String!): DeleteAccount!
|
||||
deleteWorkspace(id: String!): Boolean!
|
||||
@ -1161,6 +1252,9 @@ type Mutation {
|
||||
"""Remove workspace embedding files"""
|
||||
removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||
|
||||
"""Resolve a comment or not"""
|
||||
resolveComment(input: CommentResolveInput!): Boolean!
|
||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||
retryAudioTranscription(jobId: String!, workspaceId: String!): TranscriptionResultType
|
||||
revoke(userId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use [revokeMember] instead")
|
||||
@ -1181,6 +1275,9 @@ type Mutation {
|
||||
"""update app configuration"""
|
||||
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
|
||||
|
||||
"""Update a comment content"""
|
||||
updateComment(input: CommentUpdateInput!): Boolean!
|
||||
|
||||
"""Update a copilot prompt"""
|
||||
updateCopilotPrompt(messages: [CopilotPromptMessageInput!]!, name: String!): CopilotPromptType!
|
||||
|
||||
@ -1190,6 +1287,9 @@ type Mutation {
|
||||
updateDocUserRole(input: UpdateDocUserRoleInput!): Boolean!
|
||||
updateProfile(input: UpdateUserInput!): UserType!
|
||||
|
||||
"""Update a reply content"""
|
||||
updateReply(input: ReplyUpdateInput!): Boolean!
|
||||
|
||||
"""Update user settings"""
|
||||
updateSettings(input: UpdateUserSettingsInput!): Boolean!
|
||||
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType!
|
||||
@ -1209,6 +1309,9 @@ type Mutation {
|
||||
"""Upload user avatar"""
|
||||
uploadAvatar(avatar: Upload!): UserType!
|
||||
|
||||
"""Upload a comment attachment and return the access url"""
|
||||
uploadCommentAttachment(attachment: Upload!, docId: String!, workspaceId: String!): String!
|
||||
|
||||
"""validate app configuration"""
|
||||
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]!
|
||||
verifyEmail(token: String!): Boolean!
|
||||
@ -1264,6 +1367,8 @@ type NotificationObjectTypeEdge {
|
||||
|
||||
"""Notification type"""
|
||||
enum NotificationType {
|
||||
Comment
|
||||
CommentMention
|
||||
Invitation
|
||||
InvitationAccepted
|
||||
InvitationBlocked
|
||||
@ -1297,6 +1402,18 @@ type PageInfo {
|
||||
startCursor: String
|
||||
}
|
||||
|
||||
type PaginatedCommentChangeObjectType {
|
||||
edges: [CommentChangeObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedCommentObjectType {
|
||||
edges: [CommentObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedCopilotWorkspaceFileType {
|
||||
edges: [CopilotWorkspaceFileTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@ -1470,6 +1587,40 @@ input RemoveContextFileInput {
|
||||
fileId: String!
|
||||
}
|
||||
|
||||
input ReplyCreateInput {
|
||||
commentId: ID!
|
||||
content: JSONObject!
|
||||
docMode: DocMode!
|
||||
docTitle: String!
|
||||
|
||||
"""
|
||||
The mention user ids, if not provided, the comment reply will not be mentioned
|
||||
"""
|
||||
mentions: [String!]
|
||||
}
|
||||
|
||||
type ReplyObjectType {
|
||||
commentId: ID!
|
||||
|
||||
"""The content of the reply"""
|
||||
content: JSONObject!
|
||||
|
||||
"""The created at time of the reply"""
|
||||
createdAt: DateTime!
|
||||
id: ID!
|
||||
|
||||
"""The updated at time of the reply"""
|
||||
updatedAt: DateTime!
|
||||
|
||||
"""The user who created the reply"""
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
input ReplyUpdateInput {
|
||||
content: JSONObject!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input RevokeDocUserRoleInput {
|
||||
docId: String!
|
||||
userId: String!
|
||||
@ -1794,6 +1945,9 @@ input UpdateUserInput {
|
||||
}
|
||||
|
||||
input UpdateUserSettingsInput {
|
||||
"""Receive comment email"""
|
||||
receiveCommentEmail: Boolean
|
||||
|
||||
"""Receive invitation email"""
|
||||
receiveInvitationEmail: Boolean
|
||||
|
||||
@ -1854,6 +2008,9 @@ type UserQuotaUsageType {
|
||||
}
|
||||
|
||||
type UserSettingsType {
|
||||
"""Receive comment email"""
|
||||
receiveCommentEmail: Boolean!
|
||||
|
||||
"""Receive invitation email"""
|
||||
receiveInvitationEmail: Boolean!
|
||||
|
||||
@ -2010,6 +2167,12 @@ type WorkspaceType {
|
||||
"""Blobs size of workspace"""
|
||||
blobsSize: Int!
|
||||
|
||||
"""Get comment changes of a doc"""
|
||||
commentChanges(docId: String!, pagination: PaginationInput!): PaginatedCommentChangeObjectType!
|
||||
|
||||
"""Get comments of a doc"""
|
||||
comments(docId: String!, pagination: PaginationInput): PaginatedCommentObjectType!
|
||||
|
||||
"""Workspace created date"""
|
||||
createdAt: DateTime!
|
||||
|
||||
|
22
packages/common/graphql/src/graphql/comment-change-list.gql
Normal file
22
packages/common/graphql/src/graphql/comment-change-list.gql
Normal file
@ -0,0 +1,22 @@
|
||||
query listCommentChanges($workspaceId: String!, $docId: String!, $pagination: PaginationInput!) {
|
||||
workspace(id: $workspaceId) {
|
||||
commentChanges(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
action
|
||||
id
|
||||
commentId
|
||||
item
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
packages/common/graphql/src/graphql/comment-create.gql
Normal file
26
packages/common/graphql/src/graphql/comment-create.gql
Normal file
@ -0,0 +1,26 @@
|
||||
mutation createComment($input: CommentCreateInput!) {
|
||||
createComment(input: $input) {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
packages/common/graphql/src/graphql/comment-delete.gql
Normal file
3
packages/common/graphql/src/graphql/comment-delete.gql
Normal file
@ -0,0 +1,3 @@
|
||||
mutation deleteComment($id: String!) {
|
||||
deleteComment(id: $id)
|
||||
}
|
40
packages/common/graphql/src/graphql/comment-list.gql
Normal file
40
packages/common/graphql/src/graphql/comment-list.gql
Normal file
@ -0,0 +1,40 @@
|
||||
query listComments($workspaceId: String!, $docId: String!, $pagination: PaginationInput) {
|
||||
workspace(id: $workspaceId) {
|
||||
comments(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
14
packages/common/graphql/src/graphql/comment-reply-create.gql
Normal file
14
packages/common/graphql/src/graphql/comment-reply-create.gql
Normal file
@ -0,0 +1,14 @@
|
||||
mutation createReply($input: ReplyCreateInput!) {
|
||||
createReply(input: $input) {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
mutation deleteReply($id: String!) {
|
||||
deleteReply(id: $id)
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
mutation updateReply($input: ReplyUpdateInput!) {
|
||||
updateReply(input: $input)
|
||||
}
|
3
packages/common/graphql/src/graphql/comment-resolve.gql
Normal file
3
packages/common/graphql/src/graphql/comment-resolve.gql
Normal file
@ -0,0 +1,3 @@
|
||||
mutation resolveComment($input: CommentResolveInput!) {
|
||||
resolveComment(input: $input)
|
||||
}
|
3
packages/common/graphql/src/graphql/comment-update.gql
Normal file
3
packages/common/graphql/src/graphql/comment-update.gql
Normal file
@ -0,0 +1,3 @@
|
||||
mutation updateComment($input: CommentUpdateInput!) {
|
||||
updateComment(input: $input)
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
mutation uploadCommentAttachment($workspaceId: String!, $docId: String!, $attachment: Upload!) {
|
||||
uploadCommentAttachment(workspaceId: $workspaceId, docId: $docId, attachment: $attachment)
|
||||
}
|
@ -333,6 +333,181 @@ export const changePasswordMutation = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const listCommentChangesQuery = {
|
||||
id: 'listCommentChangesQuery' as const,
|
||||
op: 'listCommentChanges',
|
||||
query: `query listCommentChanges($workspaceId: String!, $docId: String!, $pagination: PaginationInput!) {
|
||||
workspace(id: $workspaceId) {
|
||||
commentChanges(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
action
|
||||
id
|
||||
commentId
|
||||
item
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const createCommentMutation = {
|
||||
id: 'createCommentMutation' as const,
|
||||
op: 'createComment',
|
||||
query: `mutation createComment($input: CommentCreateInput!) {
|
||||
createComment(input: $input) {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const deleteCommentMutation = {
|
||||
id: 'deleteCommentMutation' as const,
|
||||
op: 'deleteComment',
|
||||
query: `mutation deleteComment($id: String!) {
|
||||
deleteComment(id: $id)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const listCommentsQuery = {
|
||||
id: 'listCommentsQuery' as const,
|
||||
op: 'listComments',
|
||||
query: `query listComments($workspaceId: String!, $docId: String!, $pagination: PaginationInput) {
|
||||
workspace(id: $workspaceId) {
|
||||
comments(docId: $docId, pagination: $pagination) {
|
||||
totalCount
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
content
|
||||
resolved
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
replies {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const createReplyMutation = {
|
||||
id: 'createReplyMutation' as const,
|
||||
op: 'createReply',
|
||||
query: `mutation createReply($input: ReplyCreateInput!) {
|
||||
createReply(input: $input) {
|
||||
commentId
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const deleteReplyMutation = {
|
||||
id: 'deleteReplyMutation' as const,
|
||||
op: 'deleteReply',
|
||||
query: `mutation deleteReply($id: String!) {
|
||||
deleteReply(id: $id)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const updateReplyMutation = {
|
||||
id: 'updateReplyMutation' as const,
|
||||
op: 'updateReply',
|
||||
query: `mutation updateReply($input: ReplyUpdateInput!) {
|
||||
updateReply(input: $input)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const resolveCommentMutation = {
|
||||
id: 'resolveCommentMutation' as const,
|
||||
op: 'resolveComment',
|
||||
query: `mutation resolveComment($input: CommentResolveInput!) {
|
||||
resolveComment(input: $input)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const updateCommentMutation = {
|
||||
id: 'updateCommentMutation' as const,
|
||||
op: 'updateComment',
|
||||
query: `mutation updateComment($input: CommentUpdateInput!) {
|
||||
updateComment(input: $input)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const uploadCommentAttachmentMutation = {
|
||||
id: 'uploadCommentAttachmentMutation' as const,
|
||||
op: 'uploadCommentAttachment',
|
||||
query: `mutation uploadCommentAttachment($workspaceId: String!, $docId: String!, $attachment: Upload!) {
|
||||
uploadCommentAttachment(
|
||||
workspaceId: $workspaceId
|
||||
docId: $docId
|
||||
attachment: $attachment
|
||||
)
|
||||
}`,
|
||||
file: true,
|
||||
};
|
||||
|
||||
export const addContextCategoryMutation = {
|
||||
id: 'addContextCategoryMutation' as const,
|
||||
op: 'addContextCategory',
|
||||
|
@ -140,6 +140,72 @@ export interface ChatMessage {
|
||||
streamObjects: Maybe<Array<StreamObject>>;
|
||||
}
|
||||
|
||||
/** Comment change action */
|
||||
export enum CommentChangeAction {
|
||||
delete = 'delete',
|
||||
update = 'update',
|
||||
}
|
||||
|
||||
export interface CommentChangeObjectType {
|
||||
__typename?: 'CommentChangeObjectType';
|
||||
/** The action of the comment change */
|
||||
action: CommentChangeAction;
|
||||
commentId: Maybe<Scalars['ID']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
/** The item of the comment or reply, different types have different fields, see UnionCommentObjectType */
|
||||
item: Scalars['JSONObject']['output'];
|
||||
}
|
||||
|
||||
export interface CommentChangeObjectTypeEdge {
|
||||
__typename?: 'CommentChangeObjectTypeEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: CommentChangeObjectType;
|
||||
}
|
||||
|
||||
export interface CommentCreateInput {
|
||||
content: Scalars['JSONObject']['input'];
|
||||
docId: Scalars['ID']['input'];
|
||||
docMode: DocMode;
|
||||
docTitle: Scalars['String']['input'];
|
||||
/** The mention user ids, if not provided, the comment will not be mentioned */
|
||||
mentions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
}
|
||||
|
||||
export interface CommentObjectType {
|
||||
__typename?: 'CommentObjectType';
|
||||
/** The content of the comment */
|
||||
content: Scalars['JSONObject']['output'];
|
||||
/** The created at time of the comment */
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
/** The replies of the comment */
|
||||
replies: Array<ReplyObjectType>;
|
||||
/** Whether the comment is resolved */
|
||||
resolved: Scalars['Boolean']['output'];
|
||||
/** The updated at time of the comment */
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** The user who created the comment */
|
||||
user: PublicUserType;
|
||||
}
|
||||
|
||||
export interface CommentObjectTypeEdge {
|
||||
__typename?: 'CommentObjectTypeEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: CommentObjectType;
|
||||
}
|
||||
|
||||
export interface CommentResolveInput {
|
||||
id: Scalars['ID']['input'];
|
||||
/** Whether the comment is resolved */
|
||||
resolved: Scalars['Boolean']['input'];
|
||||
}
|
||||
|
||||
export interface CommentUpdateInput {
|
||||
content: Scalars['JSONObject']['input'];
|
||||
id: Scalars['ID']['input'];
|
||||
}
|
||||
|
||||
export enum ContextCategories {
|
||||
Collection = 'Collection',
|
||||
Tag = 'Tag',
|
||||
@ -564,6 +630,10 @@ export interface DocNotFoundDataType {
|
||||
|
||||
export interface DocPermissions {
|
||||
__typename?: 'DocPermissions';
|
||||
Doc_Comments_Create: Scalars['Boolean']['output'];
|
||||
Doc_Comments_Delete: Scalars['Boolean']['output'];
|
||||
Doc_Comments_Read: Scalars['Boolean']['output'];
|
||||
Doc_Comments_Resolve: Scalars['Boolean']['output'];
|
||||
Doc_Copy: Scalars['Boolean']['output'];
|
||||
Doc_Delete: Scalars['Boolean']['output'];
|
||||
Doc_Duplicate: Scalars['Boolean']['output'];
|
||||
@ -581,6 +651,7 @@ export interface DocPermissions {
|
||||
|
||||
/** User permission in doc */
|
||||
export enum DocRole {
|
||||
Commenter = 'Commenter',
|
||||
Editor = 'Editor',
|
||||
External = 'External',
|
||||
Manager = 'Manager',
|
||||
@ -708,6 +779,9 @@ export enum ErrorNames {
|
||||
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
|
||||
CAN_NOT_REVOKE_YOURSELF = 'CAN_NOT_REVOKE_YOURSELF',
|
||||
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
|
||||
COMMENT_ATTACHMENT_NOT_FOUND = 'COMMENT_ATTACHMENT_NOT_FOUND',
|
||||
COMMENT_ATTACHMENT_QUOTA_EXCEEDED = 'COMMENT_ATTACHMENT_QUOTA_EXCEEDED',
|
||||
COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND',
|
||||
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
|
||||
COPILOT_CONTEXT_FILE_NOT_SUPPORTED = 'COPILOT_CONTEXT_FILE_NOT_SUPPORTED',
|
||||
COPILOT_DOCS_NOT_FOUND = 'COPILOT_DOCS_NOT_FOUND',
|
||||
@ -797,6 +871,7 @@ export enum ErrorNames {
|
||||
OWNER_CAN_NOT_LEAVE_WORKSPACE = 'OWNER_CAN_NOT_LEAVE_WORKSPACE',
|
||||
PASSWORD_REQUIRED = 'PASSWORD_REQUIRED',
|
||||
QUERY_TOO_LONG = 'QUERY_TOO_LONG',
|
||||
REPLY_NOT_FOUND = 'REPLY_NOT_FOUND',
|
||||
RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND',
|
||||
SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED',
|
||||
SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING',
|
||||
@ -1244,6 +1319,7 @@ export interface Mutation {
|
||||
createChangePasswordUrl: Scalars['String']['output'];
|
||||
/** Create a subscription checkout link of stripe */
|
||||
createCheckoutSession: Scalars['String']['output'];
|
||||
createComment: CommentObjectType;
|
||||
/** Create a context session */
|
||||
createCopilotContext: Scalars['String']['output'];
|
||||
/** Create a chat message */
|
||||
@ -1255,6 +1331,7 @@ export interface Mutation {
|
||||
/** Create a stripe customer portal to manage payment methods */
|
||||
createCustomerPortal: Scalars['String']['output'];
|
||||
createInviteLink: InviteLink;
|
||||
createReply: ReplyObjectType;
|
||||
createSelfhostWorkspaceCustomerPortal: Scalars['String']['output'];
|
||||
/** Create a new user */
|
||||
createUser: UserType;
|
||||
@ -1263,6 +1340,10 @@ export interface Mutation {
|
||||
deactivateLicense: Scalars['Boolean']['output'];
|
||||
deleteAccount: DeleteAccount;
|
||||
deleteBlob: Scalars['Boolean']['output'];
|
||||
/** Delete a comment */
|
||||
deleteComment: Scalars['Boolean']['output'];
|
||||
/** Delete a reply */
|
||||
deleteReply: Scalars['Boolean']['output'];
|
||||
/** Delete a user account */
|
||||
deleteUser: DeleteAccount;
|
||||
deleteWorkspace: Scalars['Boolean']['output'];
|
||||
@ -1302,6 +1383,8 @@ export interface Mutation {
|
||||
/** Remove workspace embedding files */
|
||||
removeWorkspaceEmbeddingFiles: Scalars['Boolean']['output'];
|
||||
removeWorkspaceFeature: Scalars['Boolean']['output'];
|
||||
/** Resolve a comment or not */
|
||||
resolveComment: Scalars['Boolean']['output'];
|
||||
resumeSubscription: SubscriptionType;
|
||||
retryAudioTranscription: Maybe<TranscriptionResultType>;
|
||||
/** @deprecated use [revokeMember] instead */
|
||||
@ -1322,6 +1405,8 @@ export interface Mutation {
|
||||
submitAudioTranscription: Maybe<TranscriptionResultType>;
|
||||
/** update app configuration */
|
||||
updateAppConfig: Scalars['JSONObject']['output'];
|
||||
/** Update a comment content */
|
||||
updateComment: Scalars['Boolean']['output'];
|
||||
/** Update a copilot prompt */
|
||||
updateCopilotPrompt: CopilotPromptType;
|
||||
/** Update a chat session */
|
||||
@ -1329,6 +1414,8 @@ export interface Mutation {
|
||||
updateDocDefaultRole: Scalars['Boolean']['output'];
|
||||
updateDocUserRole: Scalars['Boolean']['output'];
|
||||
updateProfile: UserType;
|
||||
/** Update a reply content */
|
||||
updateReply: Scalars['Boolean']['output'];
|
||||
/** Update user settings */
|
||||
updateSettings: Scalars['Boolean']['output'];
|
||||
updateSubscriptionRecurring: SubscriptionType;
|
||||
@ -1342,6 +1429,8 @@ export interface Mutation {
|
||||
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
|
||||
/** Upload user avatar */
|
||||
uploadAvatar: UserType;
|
||||
/** Upload a comment attachment and return the access url */
|
||||
uploadCommentAttachment: Scalars['String']['output'];
|
||||
/** validate app configuration */
|
||||
validateAppConfig: Array<AppConfigValidateResult>;
|
||||
verifyEmail: Scalars['Boolean']['output'];
|
||||
@ -1424,6 +1513,10 @@ export interface MutationCreateCheckoutSessionArgs {
|
||||
input: CreateCheckoutSessionInput;
|
||||
}
|
||||
|
||||
export interface MutationCreateCommentArgs {
|
||||
input: CommentCreateInput;
|
||||
}
|
||||
|
||||
export interface MutationCreateCopilotContextArgs {
|
||||
sessionId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
@ -1446,6 +1539,10 @@ export interface MutationCreateInviteLinkArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationCreateReplyArgs {
|
||||
input: ReplyCreateInput;
|
||||
}
|
||||
|
||||
export interface MutationCreateSelfhostWorkspaceCustomerPortalArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
@ -1469,6 +1566,14 @@ export interface MutationDeleteBlobArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationDeleteCommentArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationDeleteReplyArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationDeleteUserArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
@ -1582,6 +1687,10 @@ export interface MutationRemoveWorkspaceFeatureArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationResolveCommentArgs {
|
||||
input: CommentResolveInput;
|
||||
}
|
||||
|
||||
export interface MutationResumeSubscriptionArgs {
|
||||
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
@ -1666,6 +1775,10 @@ export interface MutationUpdateAppConfigArgs {
|
||||
updates: Array<UpdateAppConfigInput>;
|
||||
}
|
||||
|
||||
export interface MutationUpdateCommentArgs {
|
||||
input: CommentUpdateInput;
|
||||
}
|
||||
|
||||
export interface MutationUpdateCopilotPromptArgs {
|
||||
messages: Array<CopilotPromptMessageInput>;
|
||||
name: Scalars['String']['input'];
|
||||
@ -1687,6 +1800,10 @@ export interface MutationUpdateProfileArgs {
|
||||
input: UpdateUserInput;
|
||||
}
|
||||
|
||||
export interface MutationUpdateReplyArgs {
|
||||
input: ReplyUpdateInput;
|
||||
}
|
||||
|
||||
export interface MutationUpdateSettingsArgs {
|
||||
input: UpdateUserSettingsInput;
|
||||
}
|
||||
@ -1722,6 +1839,12 @@ export interface MutationUploadAvatarArgs {
|
||||
avatar: Scalars['Upload']['input'];
|
||||
}
|
||||
|
||||
export interface MutationUploadCommentAttachmentArgs {
|
||||
attachment: Scalars['Upload']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationValidateAppConfigArgs {
|
||||
updates: Array<UpdateAppConfigInput>;
|
||||
}
|
||||
@ -1776,6 +1899,8 @@ export interface NotificationObjectTypeEdge {
|
||||
|
||||
/** Notification type */
|
||||
export enum NotificationType {
|
||||
Comment = 'Comment',
|
||||
CommentMention = 'CommentMention',
|
||||
Invitation = 'Invitation',
|
||||
InvitationAccepted = 'InvitationAccepted',
|
||||
InvitationBlocked = 'InvitationBlocked',
|
||||
@ -1810,6 +1935,20 @@ export interface PageInfo {
|
||||
startCursor: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
export interface PaginatedCommentChangeObjectType {
|
||||
__typename?: 'PaginatedCommentChangeObjectType';
|
||||
edges: Array<CommentChangeObjectTypeEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
}
|
||||
|
||||
export interface PaginatedCommentObjectType {
|
||||
__typename?: 'PaginatedCommentObjectType';
|
||||
edges: Array<CommentObjectTypeEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
}
|
||||
|
||||
export interface PaginatedCopilotWorkspaceFileType {
|
||||
__typename?: 'PaginatedCopilotWorkspaceFileType';
|
||||
edges: Array<CopilotWorkspaceFileTypeEdge>;
|
||||
@ -2034,6 +2173,34 @@ export interface RemoveContextFileInput {
|
||||
fileId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface ReplyCreateInput {
|
||||
commentId: Scalars['ID']['input'];
|
||||
content: Scalars['JSONObject']['input'];
|
||||
docMode: DocMode;
|
||||
docTitle: Scalars['String']['input'];
|
||||
/** The mention user ids, if not provided, the comment reply will not be mentioned */
|
||||
mentions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
}
|
||||
|
||||
export interface ReplyObjectType {
|
||||
__typename?: 'ReplyObjectType';
|
||||
commentId: Scalars['ID']['output'];
|
||||
/** The content of the reply */
|
||||
content: Scalars['JSONObject']['output'];
|
||||
/** The created at time of the reply */
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
/** The updated at time of the reply */
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** The user who created the reply */
|
||||
user: PublicUserType;
|
||||
}
|
||||
|
||||
export interface ReplyUpdateInput {
|
||||
content: Scalars['JSONObject']['input'];
|
||||
id: Scalars['ID']['input'];
|
||||
}
|
||||
|
||||
export interface RevokeDocUserRoleInput {
|
||||
docId: Scalars['String']['input'];
|
||||
userId: Scalars['String']['input'];
|
||||
@ -2370,6 +2537,8 @@ export interface UpdateUserInput {
|
||||
}
|
||||
|
||||
export interface UpdateUserSettingsInput {
|
||||
/** Receive comment email */
|
||||
receiveCommentEmail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** Receive invitation email */
|
||||
receiveInvitationEmail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** Receive mention email */
|
||||
@ -2429,6 +2598,8 @@ export interface UserQuotaUsageType {
|
||||
|
||||
export interface UserSettingsType {
|
||||
__typename?: 'UserSettingsType';
|
||||
/** Receive comment email */
|
||||
receiveCommentEmail: Scalars['Boolean']['output'];
|
||||
/** Receive invitation email */
|
||||
receiveInvitationEmail: Scalars['Boolean']['output'];
|
||||
/** Receive mention email */
|
||||
@ -2599,6 +2770,10 @@ export interface WorkspaceType {
|
||||
blobs: Array<ListedBlob>;
|
||||
/** Blobs size of workspace */
|
||||
blobsSize: Scalars['Int']['output'];
|
||||
/** Get comment changes of a doc */
|
||||
commentChanges: PaginatedCommentChangeObjectType;
|
||||
/** Get comments of a doc */
|
||||
comments: PaginatedCommentObjectType;
|
||||
/** Workspace created date */
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Get get with given id */
|
||||
@ -2666,6 +2841,16 @@ export interface WorkspaceTypeAggregateArgs {
|
||||
input: AggregateInput;
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeCommentChangesArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
pagination: PaginationInput;
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeCommentsArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
pagination?: InputMaybe<PaginationInput>;
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeDocArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
}
|
||||
@ -3062,6 +3247,211 @@ export type ChangePasswordMutation = {
|
||||
changePassword: boolean;
|
||||
};
|
||||
|
||||
export type ListCommentChangesQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
pagination: PaginationInput;
|
||||
}>;
|
||||
|
||||
export type ListCommentChangesQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
commentChanges: {
|
||||
__typename?: 'PaginatedCommentChangeObjectType';
|
||||
totalCount: number;
|
||||
edges: Array<{
|
||||
__typename?: 'CommentChangeObjectTypeEdge';
|
||||
cursor: string;
|
||||
node: {
|
||||
__typename?: 'CommentChangeObjectType';
|
||||
action: CommentChangeAction;
|
||||
id: string;
|
||||
commentId: string | null;
|
||||
item: any;
|
||||
};
|
||||
}>;
|
||||
pageInfo: {
|
||||
__typename?: 'PageInfo';
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateCommentMutationVariables = Exact<{
|
||||
input: CommentCreateInput;
|
||||
}>;
|
||||
|
||||
export type CreateCommentMutation = {
|
||||
__typename?: 'Mutation';
|
||||
createComment: {
|
||||
__typename?: 'CommentObjectType';
|
||||
id: string;
|
||||
content: any;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
replies: Array<{
|
||||
__typename?: 'ReplyObjectType';
|
||||
commentId: string;
|
||||
id: string;
|
||||
content: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteCommentMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type DeleteCommentMutation = {
|
||||
__typename?: 'Mutation';
|
||||
deleteComment: boolean;
|
||||
};
|
||||
|
||||
export type ListCommentsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
pagination?: InputMaybe<PaginationInput>;
|
||||
}>;
|
||||
|
||||
export type ListCommentsQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
comments: {
|
||||
__typename?: 'PaginatedCommentObjectType';
|
||||
totalCount: number;
|
||||
edges: Array<{
|
||||
__typename?: 'CommentObjectTypeEdge';
|
||||
cursor: string;
|
||||
node: {
|
||||
__typename?: 'CommentObjectType';
|
||||
id: string;
|
||||
content: any;
|
||||
resolved: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
replies: Array<{
|
||||
__typename?: 'ReplyObjectType';
|
||||
commentId: string;
|
||||
id: string;
|
||||
content: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
pageInfo: {
|
||||
__typename?: 'PageInfo';
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateReplyMutationVariables = Exact<{
|
||||
input: ReplyCreateInput;
|
||||
}>;
|
||||
|
||||
export type CreateReplyMutation = {
|
||||
__typename?: 'Mutation';
|
||||
createReply: {
|
||||
__typename?: 'ReplyObjectType';
|
||||
commentId: string;
|
||||
id: string;
|
||||
content: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteReplyMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type DeleteReplyMutation = {
|
||||
__typename?: 'Mutation';
|
||||
deleteReply: boolean;
|
||||
};
|
||||
|
||||
export type UpdateReplyMutationVariables = Exact<{
|
||||
input: ReplyUpdateInput;
|
||||
}>;
|
||||
|
||||
export type UpdateReplyMutation = {
|
||||
__typename?: 'Mutation';
|
||||
updateReply: boolean;
|
||||
};
|
||||
|
||||
export type ResolveCommentMutationVariables = Exact<{
|
||||
input: CommentResolveInput;
|
||||
}>;
|
||||
|
||||
export type ResolveCommentMutation = {
|
||||
__typename?: 'Mutation';
|
||||
resolveComment: boolean;
|
||||
};
|
||||
|
||||
export type UpdateCommentMutationVariables = Exact<{
|
||||
input: CommentUpdateInput;
|
||||
}>;
|
||||
|
||||
export type UpdateCommentMutation = {
|
||||
__typename?: 'Mutation';
|
||||
updateComment: boolean;
|
||||
};
|
||||
|
||||
export type UploadCommentAttachmentMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
attachment: Scalars['Upload']['input'];
|
||||
}>;
|
||||
|
||||
export type UploadCommentAttachmentMutation = {
|
||||
__typename?: 'Mutation';
|
||||
uploadCommentAttachment: string;
|
||||
};
|
||||
|
||||
export type AddContextCategoryMutationVariables = Exact<{
|
||||
options: AddContextCategoryInput;
|
||||
}>;
|
||||
@ -5207,6 +5597,16 @@ export type Queries =
|
||||
variables: ListBlobsQueryVariables;
|
||||
response: ListBlobsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'listCommentChangesQuery';
|
||||
variables: ListCommentChangesQueryVariables;
|
||||
response: ListCommentChangesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'listCommentsQuery';
|
||||
variables: ListCommentsQueryVariables;
|
||||
response: ListCommentsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'listContextObjectQuery';
|
||||
variables: ListContextObjectQueryVariables;
|
||||
@ -5589,6 +5989,46 @@ export type Mutations =
|
||||
variables: ChangePasswordMutationVariables;
|
||||
response: ChangePasswordMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createCommentMutation';
|
||||
variables: CreateCommentMutationVariables;
|
||||
response: CreateCommentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'deleteCommentMutation';
|
||||
variables: DeleteCommentMutationVariables;
|
||||
response: DeleteCommentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createReplyMutation';
|
||||
variables: CreateReplyMutationVariables;
|
||||
response: CreateReplyMutation;
|
||||
}
|
||||
| {
|
||||
name: 'deleteReplyMutation';
|
||||
variables: DeleteReplyMutationVariables;
|
||||
response: DeleteReplyMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateReplyMutation';
|
||||
variables: UpdateReplyMutationVariables;
|
||||
response: UpdateReplyMutation;
|
||||
}
|
||||
| {
|
||||
name: 'resolveCommentMutation';
|
||||
variables: ResolveCommentMutationVariables;
|
||||
response: ResolveCommentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'updateCommentMutation';
|
||||
variables: UpdateCommentMutationVariables;
|
||||
response: UpdateCommentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'uploadCommentAttachmentMutation';
|
||||
variables: UploadCommentAttachmentMutationVariables;
|
||||
response: UploadCommentAttachmentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'addContextCategoryMutation';
|
||||
variables: AddContextCategoryMutationVariables;
|
||||
|
@ -8875,6 +8875,22 @@ export function useAFFiNEI18N(): {
|
||||
["error.INVALID_INDEXER_INPUT"](options: {
|
||||
readonly reason: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Comment not found.`
|
||||
*/
|
||||
["error.COMMENT_NOT_FOUND"](): string;
|
||||
/**
|
||||
* `Reply not found.`
|
||||
*/
|
||||
["error.REPLY_NOT_FOUND"](): string;
|
||||
/**
|
||||
* `Comment attachment not found.`
|
||||
*/
|
||||
["error.COMMENT_ATTACHMENT_NOT_FOUND"](): string;
|
||||
/**
|
||||
* `You have exceeded the comment attachment size quota.`
|
||||
*/
|
||||
["error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED"](): string;
|
||||
} { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); }
|
||||
function createComponent(i18nKey: string) {
|
||||
return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props });
|
||||
|
@ -2191,5 +2191,9 @@
|
||||
"error.INVALID_APP_CONFIG_INPUT": "Invalid app config input: {{message}}",
|
||||
"error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.",
|
||||
"error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}",
|
||||
"error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}"
|
||||
"error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}",
|
||||
"error.COMMENT_NOT_FOUND": "Comment not found.",
|
||||
"error.REPLY_NOT_FOUND": "Reply not found.",
|
||||
"error.COMMENT_ATTACHMENT_NOT_FOUND": "Comment attachment not found.",
|
||||
"error.COMMENT_ATTACHMENT_QUOTA_EXCEEDED": "You have exceeded the comment attachment size quota."
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user