fix(server): add mode property on mention doc input (#10853)

This commit is contained in:
fengmk2
2025-03-14 08:23:27 +00:00
parent c31d01b2c2
commit 114e89961f
9 changed files with 180 additions and 13 deletions

View File

@@ -13,7 +13,7 @@ import {
readNotification, readNotification,
TestingApp, TestingApp,
} from '../../../__tests__/utils'; } from '../../../__tests__/utils';
import { Models, NotificationType } from '../../../models'; import { DocMode, Models, NotificationType } from '../../../models';
import { MentionNotificationBodyType, NotificationObjectType } from '../types'; import { MentionNotificationBodyType, NotificationObjectType } from '../types';
let app: TestingApp; let app: TestingApp;
@@ -50,6 +50,7 @@ test('should mention user in a doc', async t => {
id: 'doc-id-1', id: 'doc-id-1',
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}); });
t.truthy(mentionId); t.truthy(mentionId);
@@ -61,6 +62,7 @@ test('should mention user in a doc', async t => {
id: 'doc-id-2', id: 'doc-id-2',
title: 'doc-title-2', title: 'doc-title-2',
elementId: 'element-id-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.id, 'doc-id-1');
t.is(body.doc.title, 'doc-title-1'); t.is(body.doc.title, 'doc-title-1');
t.is(body.doc.blockId, 'block-id-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!.id, owner.id);
t.is(body.createdByUser!.name, owner.name); t.is(body.createdByUser!.name, owner.name);
t.is(body.workspace!.id, workspace.id); 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.id, 'doc-id-2');
t.is(body2.doc.title, 'doc-title-2'); t.is(body2.doc.title, 'doc-title-2');
t.is(body2.doc.elementId, 'element-id-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.createdByUser!.id, owner.id);
t.is(body2.workspace!.id, workspace.id); t.is(body2.workspace!.id, workspace.id);
t.is(body2.workspace!.name, 'test-workspace-name'); t.is(body2.workspace!.name, 'test-workspace-name');
t.truthy(body2.workspace!.avatarUrl); 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 => { test('should throw error when mention user has no Doc.Read role', async t => {
const member = await app.signup(); const member = await app.signup();
const owner = 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, id: docId,
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-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, id: docId,
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}), }),
{ {
@@ -161,6 +220,7 @@ test('should not mention user oneself', async t => {
id: 'doc-id-1', id: 'doc-id-1',
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}), }),
{ {
@@ -187,6 +247,7 @@ test('should mark notification as read', async t => {
id: 'doc-id-1', id: 'doc-id-1',
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}); });
t.truthy(mentionId); t.truthy(mentionId);
@@ -229,6 +290,7 @@ test('should throw error when read the other user notification', async t => {
id: 'doc-id-1', id: 'doc-id-1',
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}); });
t.truthy(mentionId); 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(); const owner = await app.signup();
await app.switchUser(owner); await app.switchUser(owner);
await t.throwsAsync( await t.throwsAsync(
@@ -261,12 +323,34 @@ test.skip('should throw error when mention call with invalid params', async t =>
workspaceId: '', workspaceId: '',
doc: { doc: {
id: '', id: '',
title: '', title: 'doc-title-1'.repeat(100),
blockId: '', 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', id: 'doc-id-1',
title: 'doc-title-1', title: 'doc-title-1',
blockId: 'block-id-1', blockId: 'block-id-1',
mode: DocMode.page,
}, },
}); });
await mentionUser(app, { await mentionUser(app, {
@@ -320,6 +405,7 @@ test('should list and count notifications', async t => {
id: 'doc-id-2', id: 'doc-id-2',
title: 'doc-title-2', title: 'doc-title-2',
blockId: 'block-id-2', blockId: 'block-id-2',
mode: DocMode.page,
}, },
}); });
await mentionUser(app, { await mentionUser(app, {
@@ -329,6 +415,7 @@ test('should list and count notifications', async t => {
id: 'doc-id-3', id: 'doc-id-3',
title: 'doc-title-3', title: 'doc-title-3',
blockId: 'block-id-3', blockId: 'block-id-3',
mode: DocMode.page,
}, },
}); });
// mention user in another workspace // mention user in another workspace
@@ -339,6 +426,7 @@ test('should list and count notifications', async t => {
id: 'doc-id-4', id: 'doc-id-4',
title: 'doc-title-4', title: 'doc-title-4',
blockId: 'block-id-4', blockId: 'block-id-4',
mode: DocMode.page,
}, },
}); });
@@ -469,6 +557,7 @@ test('should list and count notifications', async t => {
id: 'doc-id-5', id: 'doc-id-5',
title: 'doc-title-5', title: 'doc-title-5',
blockId: 'block-id-5', blockId: 'block-id-5',
mode: DocMode.page,
}, },
}); });

View File

@@ -9,6 +9,7 @@ import {
} from '../../../__tests__/utils'; } from '../../../__tests__/utils';
import { NotificationNotFound } from '../../../base'; import { NotificationNotFound } from '../../../base';
import { import {
DocMode,
MentionNotificationBody, MentionNotificationBody,
Models, Models,
NotificationType, NotificationType,
@@ -239,7 +240,12 @@ test('should use latest doc title in mention notification', async t => {
body: { body: {
workspaceId: workspace.id, workspaceId: workspace.id,
createdByUserId: owner.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({ const mentionNotification = await notificationService.createMention({
@@ -247,7 +253,12 @@ test('should use latest doc title in mention notification', async t => {
body: { body: {
workspaceId: workspace.id, workspaceId: workspace.id,
createdByUserId: owner.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); 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); t.is(mention.body.type, NotificationType.Mention);
const body = mention.body as MentionNotificationBody; const body = mention.body as MentionNotificationBody;
t.is(body.doc.title, 'doc-title-2-updated'); t.is(body.doc.title, 'doc-title-2-updated');
t.is(body.doc.mode, DocMode.page);
const mention2 = notifications[1]; const mention2 = notifications[1];
t.is(mention2.body.workspace!.id, workspace.id); 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); t.is(mention2.body.type, NotificationType.Mention);
const body2 = mention2.body as MentionNotificationBody; const body2 = mention2.body as MentionNotificationBody;
t.is(body2.doc.title, 'doc-title-1-updated'); 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 => { 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: { body: {
workspaceId: workspace.id, workspaceId: workspace.id,
createdByUserId: owner.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({ await notificationService.createMention({
@@ -294,7 +312,12 @@ test('should raw doc title in mention notification if no doc found', async t =>
body: { body: {
workspaceId: workspace.id, workspaceId: workspace.id,
createdByUserId: owner.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]); 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); t.is(mention.body.type, NotificationType.Mention);
const body = mention.body as MentionNotificationBody; const body = mention.body as MentionNotificationBody;
t.is(body.doc.title, 'doc-title-2'); t.is(body.doc.title, 'doc-title-2');
t.is(body.doc.mode, DocMode.edgeless);
const mention2 = notifications[1]; const mention2 = notifications[1];
t.is(mention2.body.workspace!.name, 'Test Workspace'); t.is(mention2.body.workspace!.name, 'Test Workspace');
t.is(mention2.body.type, NotificationType.Mention); t.is(mention2.body.type, NotificationType.Mention);
const body2 = mention2.body as MentionNotificationBody; const body2 = mention2.body as MentionNotificationBody;
t.is(body2.doc.title, 'doc-title-1'); t.is(body2.doc.title, 'doc-title-1');
t.is(body2.doc.mode, DocMode.page);
}); });

View File

@@ -10,7 +10,10 @@ import { GraphQLJSONObject } from 'graphql-scalars';
import { Paginated } from '../../base'; import { Paginated } from '../../base';
import { import {
DocMode,
InvitationNotificationBody, InvitationNotificationBody,
MentionDoc,
MentionDocCreate,
Notification, Notification,
NotificationLevel, NotificationLevel,
NotificationType, NotificationType,
@@ -28,6 +31,11 @@ registerEnumType(NotificationType, {
description: 'Notification type', description: 'Notification type',
}); });
registerEnumType(DocMode, {
name: 'DocMode',
description: 'Doc mode',
});
@ObjectType() @ObjectType()
export class NotificationWorkspaceType implements WorkspaceDocInfo { export class NotificationWorkspaceType implements WorkspaceDocInfo {
@Field(() => ID) @Field(() => ID)
@@ -64,13 +72,16 @@ export abstract class BaseNotificationBodyType {
} }
@ObjectType() @ObjectType()
export class MentionDocType { export class MentionDocType implements MentionDoc {
@Field(() => String) @Field(() => String)
id!: string; id!: string;
@Field(() => String) @Field(() => String)
title!: string; title!: string;
@Field(() => DocMode)
mode!: DocMode;
@Field(() => String, { @Field(() => String, {
nullable: true, nullable: true,
}) })
@@ -163,13 +174,16 @@ export class PaginatedNotificationObjectType extends Paginated(
) {} ) {}
@InputType() @InputType()
export class MentionDocInput { export class MentionDocInput implements MentionDocCreate {
@Field(() => String) @Field(() => String)
id!: string; id!: string;
@Field(() => String) @Field(() => String)
title!: string; title!: string;
@Field(() => DocMode)
mode!: DocMode;
@Field(() => String, { @Field(() => String, {
description: 'The block id in the doc', description: 'The block id in the doc',
nullable: true, nullable: true,

View File

@@ -8,7 +8,7 @@ import {
DocNotFound, DocNotFound,
InvalidHistoryTimestamp, InvalidHistoryTimestamp,
} from '../../base'; } from '../../base';
import { Models, PublicDocMode } from '../../models'; import { DocMode, Models, PublicDocMode } from '../../models';
import { CurrentUser, Public } from '../auth'; import { CurrentUser, Public } from '../auth';
import { PgWorkspaceDocStorageAdapter } from '../doc'; import { PgWorkspaceDocStorageAdapter } from '../doc';
import { DocReader } from '../doc/reader'; import { DocReader } from '../doc/reader';
@@ -111,7 +111,9 @@ export class WorkspacesController {
} }
); );
const publishPageMode = const publishPageMode =
docMeta?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page'; docMeta?.mode === PublicDocMode.Edgeless
? DocMode.edgeless
: DocMode.page;
res.setHeader('publish-mode', publishPageMode); res.setHeader('publish-mode', publishPageMode);
} }

View File

@@ -6,13 +6,13 @@ import ava, { TestFn } from 'ava';
import { createTestingModule, type TestingModule } from '../../__tests__/utils'; import { createTestingModule, type TestingModule } from '../../__tests__/utils';
import { Config } from '../../base/config'; import { Config } from '../../base/config';
import { import {
DocMode,
Models, Models,
NotificationLevel, NotificationLevel,
NotificationType, NotificationType,
User, User,
Workspace, Workspace,
} from '../../models'; } from '../../models';
interface Context { interface Context {
config: Config; config: Config;
module: TestingModule; module: TestingModule;
@@ -71,6 +71,7 @@ test('should create a mention notification with default level', async t => {
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -94,6 +95,7 @@ test('should create a mention notification with custom level', async t => {
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
elementId: 'elementId', elementId: 'elementId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -118,6 +120,7 @@ test('should mark a mention notification as read', async t => {
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -174,6 +177,7 @@ test('should find many notifications by user id, order by createdAt descending',
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -204,6 +208,7 @@ test('should find many notifications by user id, filter read notifications', asy
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -234,6 +239,7 @@ test('should clean expired notifications', async t => {
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -270,6 +276,7 @@ test('should not clean unexpired notifications', async t => {
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -290,6 +297,7 @@ test('should find many notifications by user id, order by createdAt descending,
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.edgeless,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -357,6 +365,7 @@ test('should count notifications by user id, exclude read notifications', async
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },
@@ -385,6 +394,7 @@ test('should count notifications by user id, include read notifications', async
id: docId, id: docId,
title: 'doc-title', title: 'doc-title',
blockId: 'blockId', blockId: 'blockId',
mode: DocMode.page,
}, },
createdByUserId: createdBy.id, createdByUserId: createdBy.id,
}, },

View File

@@ -13,7 +13,13 @@ export interface Doc {
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>; export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
// TODO(@fengmk2): only used it inside the DocModel, use DocMode instead on the other places
export enum PublicDocMode { export enum PublicDocMode {
Page, Page,
Edgeless, Edgeless,
} }
export enum DocMode {
page = 'page',
edgeless = 'edgeless',
}

View File

@@ -9,6 +9,7 @@ import { z } from 'zod';
import { PaginationInput } from '../base'; import { PaginationInput } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { DocMode } from './common';
export { NotificationLevel, NotificationType }; export { NotificationLevel, NotificationType };
export type { Notification }; export type { Notification };
@@ -30,11 +31,15 @@ export const MentionDocSchema = z.object({
id: IdSchema, id: IdSchema,
// Allow empty string, will display as `Untitled` at frontend // Allow empty string, will display as `Untitled` at frontend
title: z.string().trim().max(255), title: z.string().trim().max(255),
mode: z.nativeEnum(DocMode),
// blockId or elementId is required at least one // blockId or elementId is required at least one
blockId: IdSchema.optional(), blockId: IdSchema.optional(),
elementId: IdSchema.optional(), elementId: IdSchema.optional(),
}); });
export type MentionDoc = z.infer<typeof MentionDocSchema>;
export type MentionDocCreate = z.input<typeof MentionDocSchema>;
const MentionNotificationBodySchema = z.object({ const MentionNotificationBodySchema = z.object({
workspaceId: IdSchema, workspaceId: IdSchema,
createdByUserId: IdSchema, createdByUserId: IdSchema,

View File

@@ -300,6 +300,12 @@ type DocHistoryType {
workspaceId: String! workspaceId: String!
} }
"""Doc mode"""
enum DocMode {
edgeless
page
}
type DocNotFoundDataType { type DocNotFoundDataType {
docId: String! docId: String!
spaceId: String! spaceId: String!
@@ -755,6 +761,7 @@ input MentionDocInput {
"""The element id in the doc""" """The element id in the doc"""
elementId: String elementId: String
id: String! id: String!
mode: DocMode!
title: String! title: String!
} }
@@ -762,6 +769,7 @@ type MentionDocType {
blockId: String blockId: String
elementId: String elementId: String
id: String! id: String!
mode: DocMode!
title: String! title: String!
} }

View File

@@ -385,6 +385,12 @@ export interface DocHistoryType {
workspaceId: Scalars['String']['output']; workspaceId: Scalars['String']['output'];
} }
/** Doc mode */
export enum DocMode {
edgeless = 'edgeless',
page = 'page',
}
export interface DocNotFoundDataType { export interface DocNotFoundDataType {
__typename?: 'DocNotFoundDataType'; __typename?: 'DocNotFoundDataType';
docId: Scalars['String']['output']; docId: Scalars['String']['output'];
@@ -889,6 +895,7 @@ export interface MentionDocInput {
/** The element id in the doc */ /** The element id in the doc */
elementId?: InputMaybe<Scalars['String']['input']>; elementId?: InputMaybe<Scalars['String']['input']>;
id: Scalars['String']['input']; id: Scalars['String']['input'];
mode: DocMode;
title: Scalars['String']['input']; title: Scalars['String']['input'];
} }
@@ -897,6 +904,7 @@ export interface MentionDocType {
blockId: Maybe<Scalars['String']['output']>; blockId: Maybe<Scalars['String']['output']>;
elementId: Maybe<Scalars['String']['output']>; elementId: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output']; id: Scalars['String']['output'];
mode: DocMode;
title: Scalars['String']['output']; title: Scalars['String']['output'];
} }