diff --git a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts index c4a22bd395..3b326c951b 100644 --- a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts +++ b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts @@ -13,7 +13,7 @@ import { readNotification, TestingApp, } from '../../../__tests__/utils'; -import { Models, NotificationType } from '../../../models'; +import { DocMode, Models, NotificationType } from '../../../models'; import { MentionNotificationBodyType, NotificationObjectType } from '../types'; let app: TestingApp; @@ -50,6 +50,7 @@ test('should mention user in a doc', async t => { id: 'doc-id-1', title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }); t.truthy(mentionId); @@ -61,6 +62,7 @@ test('should mention user in a doc', async t => { id: 'doc-id-2', title: 'doc-title-2', elementId: 'element-id-2', + mode: DocMode.edgeless, }, }); @@ -82,6 +84,7 @@ test('should mention user in a doc', async t => { t.is(body.doc.id, 'doc-id-1'); t.is(body.doc.title, 'doc-title-1'); t.is(body.doc.blockId, 'block-id-1'); + t.is(body.doc.mode, DocMode.page); t.is(body.createdByUser!.id, owner.id); t.is(body.createdByUser!.name, owner.name); t.is(body.workspace!.id, workspace.id); @@ -97,12 +100,66 @@ test('should mention user in a doc', async t => { t.is(body2.doc.id, 'doc-id-2'); t.is(body2.doc.title, 'doc-title-2'); t.is(body2.doc.elementId, 'element-id-2'); + t.is(body2.doc.mode, DocMode.edgeless); t.is(body2.createdByUser!.id, owner.id); t.is(body2.workspace!.id, workspace.id); t.is(body2.workspace!.name, 'test-workspace-name'); t.truthy(body2.workspace!.avatarUrl); }); +test('should mention doc mode support string value', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + await models.workspace.update(workspace.id, { + name: 'test-workspace-name', + avatarKey: 'test-avatar-key', + }); + const inviteId = await inviteUser(app, workspace.id, member.email); + await app.switchUser(member); + await acceptInviteById(app, workspace.id, inviteId); + + await app.switchUser(owner); + const mentionId = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: 'page' as DocMode, + }, + }); + t.truthy(mentionId); + + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 0, + }); + t.is(result.totalCount, 1); + const notifications = result.edges.map(edge => edge.node); + t.is(notifications.length, 1); + + const notification = notifications[0] as NotificationObjectType; + t.is(notification.read, false); + t.truthy(notification.createdAt); + t.truthy(notification.updatedAt); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace.id); + t.is(body.doc.id, 'doc-id-1'); + t.is(body.doc.title, 'doc-title-1'); + t.is(body.doc.blockId, 'block-id-1'); + t.is(body.doc.mode, DocMode.page); + t.is(body.createdByUser!.id, owner.id); + t.is(body.createdByUser!.name, owner.name); + t.is(body.workspace!.id, workspace.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.truthy(body.workspace!.avatarUrl); +}); + test('should throw error when mention user has no Doc.Read role', async t => { const member = await app.signup(); const owner = await app.signup(); @@ -120,6 +177,7 @@ test('should throw error when mention user has no Doc.Read role', async t => { id: docId, title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }), { @@ -141,6 +199,7 @@ test('should throw error when mention a not exists user', async t => { id: docId, title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }), { @@ -161,6 +220,7 @@ test('should not mention user oneself', async t => { id: 'doc-id-1', title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }), { @@ -187,6 +247,7 @@ test('should mark notification as read', async t => { id: 'doc-id-1', title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }); t.truthy(mentionId); @@ -229,6 +290,7 @@ test('should throw error when read the other user notification', async t => { id: 'doc-id-1', title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }); t.truthy(mentionId); @@ -252,7 +314,7 @@ test('should throw error when read the other user notification', async t => { }); }); -test.skip('should throw error when mention call with invalid params', async t => { +test('should throw error when mention call with invalid params', async t => { const owner = await app.signup(); await app.switchUser(owner); await t.throwsAsync( @@ -261,12 +323,34 @@ test.skip('should throw error when mention call with invalid params', async t => workspaceId: '', doc: { id: '', - title: '', + title: 'doc-title-1'.repeat(100), blockId: '', + mode: DocMode.page, }, }), { - message: 'Mention user not found.', + message: /Validation error/, + } + ); +}); + +test('should throw error when mention mode value is invalid', async t => { + const owner = await app.signup(); + await app.switchUser(owner); + await t.throwsAsync( + mentionUser(app, { + userId: randomUUID(), + workspaceId: randomUUID(), + doc: { + id: randomUUID(), + title: 'doc-title-1', + blockId: 'block-id-1', + mode: 'invalid-mode' as DocMode, + }, + }), + { + message: + 'Variable "$input" got invalid value "invalid-mode" at "input.doc.mode"; Value "invalid-mode" does not exist in "DocMode" enum.', } ); }); @@ -311,6 +395,7 @@ test('should list and count notifications', async t => { id: 'doc-id-1', title: 'doc-title-1', blockId: 'block-id-1', + mode: DocMode.page, }, }); await mentionUser(app, { @@ -320,6 +405,7 @@ test('should list and count notifications', async t => { id: 'doc-id-2', title: 'doc-title-2', blockId: 'block-id-2', + mode: DocMode.page, }, }); await mentionUser(app, { @@ -329,6 +415,7 @@ test('should list and count notifications', async t => { id: 'doc-id-3', title: 'doc-title-3', blockId: 'block-id-3', + mode: DocMode.page, }, }); // mention user in another workspace @@ -339,6 +426,7 @@ test('should list and count notifications', async t => { id: 'doc-id-4', title: 'doc-title-4', blockId: 'block-id-4', + mode: DocMode.page, }, }); @@ -469,6 +557,7 @@ test('should list and count notifications', async t => { id: 'doc-id-5', title: 'doc-title-5', blockId: 'block-id-5', + mode: DocMode.page, }, }); diff --git a/packages/backend/server/src/core/notification/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts index b6072589c6..7093a548fd 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -9,6 +9,7 @@ import { } from '../../../__tests__/utils'; import { NotificationNotFound } from '../../../base'; import { + DocMode, MentionNotificationBody, Models, NotificationType, @@ -239,7 +240,12 @@ test('should use latest doc title in mention notification', async t => { body: { workspaceId: workspace.id, createdByUserId: owner.id, - doc: { id: docId, title: 'doc-title-1', blockId: 'block-id-1' }, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, }, }); const mentionNotification = await notificationService.createMention({ @@ -247,7 +253,12 @@ test('should use latest doc title in mention notification', async t => { body: { workspaceId: workspace.id, createdByUserId: owner.id, - doc: { id: docId, title: 'doc-title-2', blockId: 'block-id-2' }, + doc: { + id: docId, + title: 'doc-title-2', + blockId: 'block-id-2', + mode: DocMode.page, + }, }, }); t.truthy(mentionNotification); @@ -268,6 +279,7 @@ test('should use latest doc title in mention notification', async t => { t.is(mention.body.type, NotificationType.Mention); const body = mention.body as MentionNotificationBody; t.is(body.doc.title, 'doc-title-2-updated'); + t.is(body.doc.mode, DocMode.page); const mention2 = notifications[1]; t.is(mention2.body.workspace!.id, workspace.id); @@ -276,6 +288,7 @@ test('should use latest doc title in mention notification', async t => { t.is(mention2.body.type, NotificationType.Mention); const body2 = mention2.body as MentionNotificationBody; t.is(body2.doc.title, 'doc-title-1-updated'); + t.is(body2.doc.mode, DocMode.page); }); test('should raw doc title in mention notification if no doc found', async t => { @@ -286,7 +299,12 @@ test('should raw doc title in mention notification if no doc found', async t => body: { workspaceId: workspace.id, createdByUserId: owner.id, - doc: { id: docId, title: 'doc-title-1', blockId: 'block-id-1' }, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, }, }); await notificationService.createMention({ @@ -294,7 +312,12 @@ test('should raw doc title in mention notification if no doc found', async t => body: { workspaceId: workspace.id, createdByUserId: owner.id, - doc: { id: docId, title: 'doc-title-2', blockId: 'block-id-2' }, + doc: { + id: docId, + title: 'doc-title-2', + blockId: 'block-id-2', + mode: DocMode.edgeless, + }, }, }); mock.method(models.doc, 'findMetas', async () => [null, null]); @@ -305,10 +328,12 @@ test('should raw doc title in mention notification if no doc found', async t => t.is(mention.body.type, NotificationType.Mention); const body = mention.body as MentionNotificationBody; t.is(body.doc.title, 'doc-title-2'); + t.is(body.doc.mode, DocMode.edgeless); const mention2 = notifications[1]; t.is(mention2.body.workspace!.name, 'Test Workspace'); t.is(mention2.body.type, NotificationType.Mention); const body2 = mention2.body as MentionNotificationBody; t.is(body2.doc.title, 'doc-title-1'); + t.is(body2.doc.mode, DocMode.page); }); diff --git a/packages/backend/server/src/core/notification/types.ts b/packages/backend/server/src/core/notification/types.ts index 4094991c11..12bf7ed2a6 100644 --- a/packages/backend/server/src/core/notification/types.ts +++ b/packages/backend/server/src/core/notification/types.ts @@ -10,7 +10,10 @@ import { GraphQLJSONObject } from 'graphql-scalars'; import { Paginated } from '../../base'; import { + DocMode, InvitationNotificationBody, + MentionDoc, + MentionDocCreate, Notification, NotificationLevel, NotificationType, @@ -28,6 +31,11 @@ registerEnumType(NotificationType, { description: 'Notification type', }); +registerEnumType(DocMode, { + name: 'DocMode', + description: 'Doc mode', +}); + @ObjectType() export class NotificationWorkspaceType implements WorkspaceDocInfo { @Field(() => ID) @@ -64,13 +72,16 @@ export abstract class BaseNotificationBodyType { } @ObjectType() -export class MentionDocType { +export class MentionDocType implements MentionDoc { @Field(() => String) id!: string; @Field(() => String) title!: string; + @Field(() => DocMode) + mode!: DocMode; + @Field(() => String, { nullable: true, }) @@ -163,13 +174,16 @@ export class PaginatedNotificationObjectType extends Paginated( ) {} @InputType() -export class MentionDocInput { +export class MentionDocInput implements MentionDocCreate { @Field(() => String) id!: string; @Field(() => String) title!: string; + @Field(() => DocMode) + mode!: DocMode; + @Field(() => String, { description: 'The block id in the doc', nullable: true, diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 9af4c2eae3..1d9367e265 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -8,7 +8,7 @@ import { DocNotFound, InvalidHistoryTimestamp, } from '../../base'; -import { Models, PublicDocMode } from '../../models'; +import { DocMode, Models, PublicDocMode } from '../../models'; import { CurrentUser, Public } from '../auth'; import { PgWorkspaceDocStorageAdapter } from '../doc'; import { DocReader } from '../doc/reader'; @@ -111,7 +111,9 @@ export class WorkspacesController { } ); const publishPageMode = - docMeta?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page'; + docMeta?.mode === PublicDocMode.Edgeless + ? DocMode.edgeless + : DocMode.page; res.setHeader('publish-mode', publishPageMode); } diff --git a/packages/backend/server/src/models/__tests__/notification.spec.ts b/packages/backend/server/src/models/__tests__/notification.spec.ts index 10f36887d1..47602f2a29 100644 --- a/packages/backend/server/src/models/__tests__/notification.spec.ts +++ b/packages/backend/server/src/models/__tests__/notification.spec.ts @@ -6,13 +6,13 @@ import ava, { TestFn } from 'ava'; import { createTestingModule, type TestingModule } from '../../__tests__/utils'; import { Config } from '../../base/config'; import { + DocMode, Models, NotificationLevel, NotificationType, User, Workspace, } from '../../models'; - interface Context { config: Config; module: TestingModule; @@ -71,6 +71,7 @@ test('should create a mention notification with default level', async t => { id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -94,6 +95,7 @@ test('should create a mention notification with custom level', async t => { id: docId, title: 'doc-title', elementId: 'elementId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -118,6 +120,7 @@ test('should mark a mention notification as read', async t => { id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -174,6 +177,7 @@ test('should find many notifications by user id, order by createdAt descending', id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -204,6 +208,7 @@ test('should find many notifications by user id, filter read notifications', asy id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -234,6 +239,7 @@ test('should clean expired notifications', async t => { id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -270,6 +276,7 @@ test('should not clean unexpired notifications', async t => { id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -290,6 +297,7 @@ test('should find many notifications by user id, order by createdAt descending, id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.edgeless, }, createdByUserId: createdBy.id, }, @@ -357,6 +365,7 @@ test('should count notifications by user id, exclude read notifications', async id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, @@ -385,6 +394,7 @@ test('should count notifications by user id, include read notifications', async id: docId, title: 'doc-title', blockId: 'blockId', + mode: DocMode.page, }, createdByUserId: createdBy.id, }, diff --git a/packages/backend/server/src/models/common/doc.ts b/packages/backend/server/src/models/common/doc.ts index 0c393d81c8..5259df201e 100644 --- a/packages/backend/server/src/models/common/doc.ts +++ b/packages/backend/server/src/models/common/doc.ts @@ -13,7 +13,13 @@ export interface Doc { export type DocEditor = Pick; +// TODO(@fengmk2): only used it inside the DocModel, use DocMode instead on the other places export enum PublicDocMode { Page, Edgeless, } + +export enum DocMode { + page = 'page', + edgeless = 'edgeless', +} diff --git a/packages/backend/server/src/models/notification.ts b/packages/backend/server/src/models/notification.ts index 688ceba413..64aaaca4ad 100644 --- a/packages/backend/server/src/models/notification.ts +++ b/packages/backend/server/src/models/notification.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { PaginationInput } from '../base'; import { BaseModel } from './base'; +import { DocMode } from './common'; export { NotificationLevel, NotificationType }; export type { Notification }; @@ -30,11 +31,15 @@ export const MentionDocSchema = z.object({ id: IdSchema, // Allow empty string, will display as `Untitled` at frontend title: z.string().trim().max(255), + mode: z.nativeEnum(DocMode), // blockId or elementId is required at least one blockId: IdSchema.optional(), elementId: IdSchema.optional(), }); +export type MentionDoc = z.infer; +export type MentionDocCreate = z.input; + const MentionNotificationBodySchema = z.object({ workspaceId: IdSchema, createdByUserId: IdSchema, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 652a87dc2b..6609e8cee8 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -300,6 +300,12 @@ type DocHistoryType { workspaceId: String! } +"""Doc mode""" +enum DocMode { + edgeless + page +} + type DocNotFoundDataType { docId: String! spaceId: String! @@ -755,6 +761,7 @@ input MentionDocInput { """The element id in the doc""" elementId: String id: String! + mode: DocMode! title: String! } @@ -762,6 +769,7 @@ type MentionDocType { blockId: String elementId: String id: String! + mode: DocMode! title: String! } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index d172209260..e2405da888 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -385,6 +385,12 @@ export interface DocHistoryType { workspaceId: Scalars['String']['output']; } +/** Doc mode */ +export enum DocMode { + edgeless = 'edgeless', + page = 'page', +} + export interface DocNotFoundDataType { __typename?: 'DocNotFoundDataType'; docId: Scalars['String']['output']; @@ -889,6 +895,7 @@ export interface MentionDocInput { /** The element id in the doc */ elementId?: InputMaybe; id: Scalars['String']['input']; + mode: DocMode; title: Scalars['String']['input']; } @@ -897,6 +904,7 @@ export interface MentionDocType { blockId: Maybe; elementId: Maybe; id: Scalars['String']['output']; + mode: DocMode; title: Scalars['String']['output']; }