diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index b5f06fa4d87b0..98745a5e102a3 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -35,6 +35,8 @@ model User { updatedSnapshot Snapshot[] @relation("updatedSnapshot") createdUpdate Update[] @relation("createdUpdate") createdHistory SnapshotHistory[] @relation("createdHistory") + // receive notifications + notifications Notification[] @relation("user_notifications") @@index([email]) @@map("users") @@ -618,3 +620,43 @@ model Blob { @@id([workspaceId, key]) @@map("blobs") } + +enum NotificationType { + Mention + Invitation + InvitationAccepted + InvitationBlocked +} + +enum NotificationLevel { + // Makes a sound and appears as a heads-up notification + High + // Makes a sound + Default + // Makes no sound + Low + Min + None +} + +model Notification { + id String @id @default(uuid()) @db.VarChar + userId String @map("user_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + level NotificationLevel + read Boolean @default(false) + starred Boolean @default(false) + type NotificationType + body Json @db.Json + + user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade) + + // TODO(@fengmk2): should confirm index works as expected in postgres + // for user notifications list, including read and unread, ordered by createdAt + @@index([userId, read, createdAt]) + // for expired notifications cleanup + @@index([expiredAt, read]) + @@map("notifications") +} diff --git a/packages/backend/server/src/__tests__/utils/index.ts b/packages/backend/server/src/__tests__/utils/index.ts index 981cceee283d1..d472088579334 100644 --- a/packages/backend/server/src/__tests__/utils/index.ts +++ b/packages/backend/server/src/__tests__/utils/index.ts @@ -1,5 +1,6 @@ export * from './blobs'; export * from './invite'; +export * from './notification'; export * from './permission'; export * from './testing-app'; export * from './testing-module'; diff --git a/packages/backend/server/src/__tests__/utils/notification.ts b/packages/backend/server/src/__tests__/utils/notification.ts new file mode 100644 index 0000000000000..91f4ebc1c93a4 --- /dev/null +++ b/packages/backend/server/src/__tests__/utils/notification.ts @@ -0,0 +1,87 @@ +import { PaginationInput } from '../../base/graphql/pagination'; +import type { + MentionInput, + PaginatedNotificationObjectType, +} from '../../core/notification/types'; +import type { TestingApp } from './testing-app'; + +export async function listNotifications( + app: TestingApp, + pagination: PaginationInput +): Promise { + const res = await app.gql( + ` + query listNotifications($pagination: PaginationInput!) { + currentUser { + notifications(pagination: $pagination) { + totalCount + edges { + cursor + node { + id + type + level + read + starred + createdAt + updatedAt + body + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + } + `, + { pagination } + ); + return res.currentUser.notifications; +} + +export async function getNotificationCount(app: TestingApp): Promise { + const res = await app.gql( + ` + query notificationCount { + currentUser { + notificationCount + } + } + ` + ); + return res.currentUser.notificationCount; +} + +export async function mentionUser( + app: TestingApp, + input: MentionInput +): Promise { + const res = await app.gql( + ` + mutation mentionUser($input: MentionInput!) { + mentionUser(input: $input) + } + `, + { input } + ); + return res.mentionUser; +} + +export async function readNotification( + app: TestingApp, + id: string +): Promise { + const res = await app.gql( + ` + mutation readNotification($id: String!) { + readNotification(id: $id) + } + `, + { id } + ); + return res.readNotification; +} diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index 80d47130182e7..f79d4f8d86afd 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { INestApplication, ModuleMetadata } from '@nestjs/common'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { TestingModuleBuilder } from '@nestjs/testing'; @@ -162,6 +164,7 @@ export class TestingApp extends ApplyType() { }); if (res.status !== 200) { + console.error('%o', res.body); throw new Error( `Failed to execute gql: ${query}, status: ${res.status}, body: ${JSON.stringify( res.body, @@ -182,12 +185,19 @@ export class TestingApp extends ApplyType() { return res.body.data; } - async createUser(email: string, override?: Partial): Promise { + private randomEmail() { + return `test-${randomUUID()}@affine.pro`; + } + + async createUser( + email?: string, + override?: Partial + ): Promise { const model = this.get(UserModel); // TODO(@forehalo): model factories // TestingData.user.create() const user = await model.create({ - email, + email: email ?? this.randomEmail(), password: '1', name: email, emailVerifiedAt: new Date(), @@ -200,8 +210,8 @@ export class TestingApp extends ApplyType() { return user as Omit & { password: string }; } - async signup(email: string, override?: Partial) { - const user = await this.createUser(email, override); + async signup(email?: string, override?: Partial) { + const user = await this.createUser(email ?? this.randomEmail(), override); await this.login(user); return user; } diff --git a/packages/backend/server/src/__tests__/utils/workspace.ts b/packages/backend/server/src/__tests__/utils/workspace.ts index b3a61ce9ef4d9..aefa00f52dd1a 100644 --- a/packages/backend/server/src/__tests__/utils/workspace.ts +++ b/packages/backend/server/src/__tests__/utils/workspace.ts @@ -15,7 +15,7 @@ export async function createWorkspace(app: TestingApp): Promise { name: 'createWorkspace', query: `mutation createWorkspace($init: Upload!) { createWorkspace(init: $init) { - id + id, } }`, variables: { init: null }, diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index d46168e690493..1cfc2d5f2b61c 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -43,6 +43,7 @@ import { DocStorageModule } from './core/doc'; import { DocRendererModule } from './core/doc-renderer'; import { DocServiceModule } from './core/doc-service'; import { FeatureModule } from './core/features'; +import { NotificationModule } from './core/notification'; import { PermissionModule } from './core/permission'; import { QuotaModule } from './core/quota'; import { SelfhostModule } from './core/selfhost'; @@ -218,7 +219,7 @@ export function buildAppModule() { .use(UserModule, AuthModule, PermissionModule) // business modules - .use(FeatureModule, QuotaModule, DocStorageModule) + .use(FeatureModule, QuotaModule, DocStorageModule, NotificationModule) // sync server only .useIf(config => config.flavor.sync, SyncModule) diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index c91d8e27e9a4c..ada160b3334a9 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -735,4 +735,26 @@ export const USER_FRIENDLY_ERRORS = { message: ({ clientVersion, requiredVersion }) => `Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`, }, + + // Notification Errors + notification_not_found: { + type: 'resource_not_found', + message: 'Notification not found.', + }, + mention_user_space_access_denied: { + type: 'no_permission', + args: { spaceId: 'string' }, + message: ({ spaceId }) => + `Mention user do not have permission to access space ${spaceId}.`, + }, + mention_user_oneself_denied: { + type: 'action_forbidden', + message: 'You cannot mention yourself.', + }, + notification_access_denied: { + type: 'no_permission', + args: { notificationId: 'string' }, + message: ({ notificationId }) => + `You do not have permission to access notification ${notificationId}.`, + }, } satisfies Record; diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index b8cb1dff8166e..869a75555066e 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -805,6 +805,38 @@ export class UnsupportedClientVersion extends UserFriendlyError { super('action_forbidden', 'unsupported_client_version', message, args); } } + +export class NotificationNotFound extends UserFriendlyError { + constructor(message?: string) { + super('resource_not_found', 'notification_not_found', message); + } +} +@ObjectType() +class MentionUserSpaceAccessDeniedDataType { + @Field() spaceId!: string +} + +export class MentionUserSpaceAccessDenied extends UserFriendlyError { + constructor(args: MentionUserSpaceAccessDeniedDataType, message?: string | ((args: MentionUserSpaceAccessDeniedDataType) => string)) { + super('no_permission', 'mention_user_space_access_denied', message, args); + } +} + +export class MentionUserOneselfDenied extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'mention_user_oneself_denied', message); + } +} +@ObjectType() +class NotificationAccessDeniedDataType { + @Field() notificationId!: string +} + +export class NotificationAccessDenied extends UserFriendlyError { + constructor(args: NotificationAccessDeniedDataType, message?: string | ((args: NotificationAccessDeniedDataType) => string)) { + super('no_permission', 'notification_access_denied', message, args); + } +} export enum ErrorNames { INTERNAL_SERVER_ERROR, TOO_MANY_REQUEST, @@ -907,7 +939,11 @@ export enum ErrorNames { INVALID_LICENSE_TO_ACTIVATE, INVALID_LICENSE_UPDATE_PARAMS, WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE, - UNSUPPORTED_CLIENT_VERSION + UNSUPPORTED_CLIENT_VERSION, + NOTIFICATION_NOT_FOUND, + MENTION_USER_SPACE_ACCESS_DENIED, + MENTION_USER_ONESELF_DENIED, + NOTIFICATION_ACCESS_DENIED } registerEnumType(ErrorNames, { name: 'ErrorNames' @@ -916,5 +952,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType] as const, + [GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType, MentionUserSpaceAccessDeniedDataType, NotificationAccessDeniedDataType] as const, }); diff --git a/packages/backend/server/src/base/graphql/pagination.ts b/packages/backend/server/src/base/graphql/pagination.ts index 1c0fde85b8fa8..2bb13f1301419 100644 --- a/packages/backend/server/src/base/graphql/pagination.ts +++ b/packages/backend/server/src/base/graphql/pagination.ts @@ -15,8 +15,8 @@ export class PaginationInput { transform: value => { return { ...value, - after: value.after ? decode(value.after) : null, - // before: value.before ? decode(value.before) : null, + after: decode(value.after), + // before: decode(value.before), }; }, }; @@ -51,9 +51,19 @@ export class PaginationInput { // before?: string | null; } -const encode = (input: string) => Buffer.from(input).toString('base64'); -const decode = (base64String: string) => - Buffer.from(base64String, 'base64').toString('utf-8'); +const encode = (input: unknown) => { + let inputStr: string; + if (input instanceof Date) { + inputStr = input.toISOString(); + } else if (typeof input === 'string') { + inputStr = input; + } else { + inputStr = String(input); + } + return Buffer.from(inputStr).toString('base64'); +}; +const decode = (base64String?: string | null) => + base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null; export function paginate( list: T[], @@ -63,7 +73,7 @@ export function paginate( ): PaginatedType { const edges = list.map(item => ({ node: item, - cursor: encode(String(item[cursorField])), + cursor: encode(item[cursorField]), })); return { @@ -102,7 +112,7 @@ export class PageInfo { hasPreviousPage!: boolean; } -export function Paginated(classRef: Type): any { +export function Paginated(classRef: Type) { @ObjectType(`${classRef.name}Edge`) abstract class EdgeType { @Field(() => String) diff --git a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts new file mode 100644 index 0000000000000..bab7e8673c232 --- /dev/null +++ b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts @@ -0,0 +1,466 @@ +import { mock } from 'node:test'; + +import test from 'ava'; + +import { + acceptInviteById, + createTestingApp, + createWorkspace, + getNotificationCount, + inviteUser, + listNotifications, + mentionUser, + readNotification, + TestingApp, +} from '../../../__tests__/utils'; +import { DocReader } from '../../doc'; +import { MentionNotificationBodyType, NotificationObjectType } from '../types'; + +let app: TestingApp; +let docReader: DocReader; + +test.before(async () => { + app = await createTestingApp(); + docReader = app.get(DocReader); +}); + +test.beforeEach(() => { + // @ts-expect-error parseWorkspaceContent is private + mock.method(docReader, 'parseWorkspaceContent', () => ({ + name: 'test-workspace-name', + })); +}); + +test.afterEach.always(() => { + mock.reset(); +}); + +test.after.always(async () => { + await app.close(); +}); + +test('should mention user in a doc', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + const inviteId = await inviteUser(app, workspace.id, member.email); + await app.switchUser(member); + await acceptInviteById(app, workspace.id, inviteId); + + await app.switchUser(owner); + const success = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }); + t.is(success, true); + // mention user at another doc + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-2', + blockId: 'block-id-2', + }); + + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 0, + }); + t.is(result.totalCount, 2); + const notifications = result.edges.map(edge => edge.node); + t.is(notifications.length, 2); + + const notification = notifications[1] as NotificationObjectType; + t.is(notification.read, false); + t.is(notification.starred, 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.docId, 'doc-id-1'); + t.is(body.blockId, 'block-id-1'); + 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!.avatar); + + const notification2 = notifications[0] as NotificationObjectType; + t.is(notification2.read, false); + t.is(notification2.starred, false); + t.truthy(notification2.createdAt); + t.truthy(notification2.updatedAt); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.docId, 'doc-id-2'); + t.is(body2.blockId, 'block-id-2'); + 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!.avatar); +}); + +test('should throw error when mention user has no Doc.Read role', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + + await app.switchUser(owner); + await t.throwsAsync( + mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }), + { + message: `Mention user do not have permission to access space ${workspace.id}.`, + } + ); +}); + +test('should throw error when mention a not exists user', async t => { + const owner = await app.signup(); + const workspace = await createWorkspace(app); + await app.switchUser(owner); + await t.throwsAsync( + mentionUser(app, { + userId: 'user-id-not-exists', + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }), + { + message: `Mention user do not have permission to access space ${workspace.id}.`, + } + ); +}); + +test('should not mention user oneself', async t => { + const owner = await app.signup(); + const workspace = await createWorkspace(app); + await app.switchUser(owner); + await t.throwsAsync( + mentionUser(app, { + userId: owner.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }), + { + message: 'You cannot mention yourself.', + } + ); +}); + +test('should mark notification as read', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + const inviteId = await inviteUser(app, workspace.id, member.email); + await app.switchUser(member); + await acceptInviteById(app, workspace.id, inviteId); + + await app.switchUser(owner); + const success = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }); + t.is(success, true); + + 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); + const notification = notifications[0] as NotificationObjectType; + t.is(notification.read, false); + t.is(notification.starred, false); + + await readNotification(app, notification.id); + + const count = await getNotificationCount(app); + t.is(count, 0); + + // read again should work + await readNotification(app, notification.id); +}); + +test('should throw error when read the other user notification', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + const inviteId = await inviteUser(app, workspace.id, member.email); + await app.switchUser(member); + await acceptInviteById(app, workspace.id, inviteId); + + await app.switchUser(owner); + const success = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }); + t.is(success, true); + + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 0, + }); + const notifications = result.edges.map(edge => edge.node); + const notification = notifications[0] as NotificationObjectType; + t.is(notification.read, false); + + await app.switchUser(owner); + await t.throwsAsync(readNotification(app, notification.id), { + message: `You do not have permission to access notification ${notification.id}.`, + }); + // notification not exists + await t.throwsAsync(readNotification(app, 'notification-id-not-exists'), { + message: 'Notification not found.', + }); +}); + +test.skip('should throw error when mention call with invalid params', async t => { + const owner = await app.signup(); + await app.switchUser(owner); + await t.throwsAsync( + mentionUser(app, { + userId: '', + workspaceId: '', + docId: '', + blockId: '', + }), + { + message: 'Mention user not found.', + } + ); +}); + +test('should list and count notifications', async t => { + const member = await app.signup(); + const owner = await app.signup(); + + { + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 0, + }); + const notifications = result.edges.map(edge => edge.node); + t.is(notifications.length, 0); + t.is(result.totalCount, 0); + } + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + const inviteId = await inviteUser(app, workspace.id, member.email); + const workspace2 = await createWorkspace(app); + const inviteId2 = await inviteUser(app, workspace2.id, member.email); + await app.switchUser(member); + await acceptInviteById(app, workspace.id, inviteId); + await acceptInviteById(app, workspace2.id, inviteId2); + + await app.switchUser(owner); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-1', + blockId: 'block-id-1', + }); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-2', + blockId: 'block-id-2', + }); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-3', + blockId: 'block-id-3', + }); + // mention user in another workspace + await mentionUser(app, { + userId: member.id, + workspaceId: workspace2.id, + docId: 'doc-id-4', + blockId: 'block-id-4', + }); + + { + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 0, + }); + const notifications = result.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 4); + t.is(result.totalCount, 4); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace2.id); + t.is(body.docId, 'doc-id-4'); + t.is(body.blockId, 'block-id-4'); + t.is(body.createdByUser!.id, owner.id); + t.is(body.workspace!.id, workspace2.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.truthy(body.workspace!.avatar); + + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.docId, 'doc-id-3'); + t.is(body2.blockId, 'block-id-3'); + 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!.avatar); + } + + { + await app.switchUser(member); + const result = await listNotifications(app, { + first: 10, + offset: 2, + }); + t.is(result.totalCount, 4); + t.is(result.pageInfo.hasNextPage, false); + t.is(result.pageInfo.hasPreviousPage, true); + const notifications = result.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 2); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace.id); + t.is(body.docId, 'doc-id-2'); + t.is(body.blockId, 'block-id-2'); + t.is(body.createdByUser!.id, owner.id); + t.is(body.workspace!.id, workspace.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.truthy(body.workspace!.avatar); + + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.docId, 'doc-id-1'); + t.is(body2.blockId, 'block-id-1'); + 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!.avatar); + } + + { + await app.switchUser(member); + const result = await listNotifications(app, { + first: 2, + offset: 0, + }); + t.is(result.totalCount, 4); + t.is(result.pageInfo.hasNextPage, true); + t.is(result.pageInfo.hasPreviousPage, false); + const notifications = result.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 2); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace2.id); + t.is(body.docId, 'doc-id-4'); + t.is(body.blockId, 'block-id-4'); + t.is(body.createdByUser!.id, owner.id); + t.is(body.workspace!.id, workspace2.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.truthy(body.workspace!.avatar); + t.is( + notification.createdAt.toString(), + Buffer.from(result.pageInfo.startCursor!, 'base64').toString('utf-8') + ); + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.docId, 'doc-id-3'); + t.is(body2.blockId, 'block-id-3'); + 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!.avatar); + + await app.switchUser(owner); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + docId: 'doc-id-5', + blockId: 'block-id-5', + }); + + // get new notifications + await app.switchUser(member); + const result2 = await listNotifications(app, { + first: 2, + offset: 0, + after: result.pageInfo.startCursor, + }); + t.is(result2.totalCount, 5); + t.is(result2.pageInfo.hasNextPage, false); + t.is(result2.pageInfo.hasPreviousPage, true); + const notifications2 = result2.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications2.length, 1); + + const notification3 = notifications2[0]; + t.is(notification3.read, false); + const body3 = notification3.body as MentionNotificationBodyType; + t.is(body3.workspace!.id, workspace.id); + t.is(body3.docId, 'doc-id-5'); + t.is(body3.blockId, 'block-id-5'); + t.is(body3.createdByUser!.id, owner.id); + t.is(body3.createdByUser!.name, owner.name); + t.is(body3.workspace!.id, workspace.id); + t.is(body3.workspace!.name, 'test-workspace-name'); + t.truthy(body3.workspace!.avatar); + + // no new notifications + const result3 = await listNotifications(app, { + first: 2, + offset: 0, + after: result2.pageInfo.startCursor, + }); + t.is(result3.totalCount, 5); + t.is(result3.pageInfo.hasNextPage, false); + t.is(result3.pageInfo.hasPreviousPage, true); + t.is(result3.pageInfo.startCursor, null); + t.is(result3.pageInfo.endCursor, null); + t.is(result3.edges.length, 0); + } +}); diff --git a/packages/backend/server/src/core/notification/index.ts b/packages/backend/server/src/core/notification/index.ts new file mode 100644 index 0000000000000..b382910963cc7 --- /dev/null +++ b/packages/backend/server/src/core/notification/index.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { DocStorageModule } from '../doc'; +import { PermissionModule } from '../permission'; +import { NotificationCronJob } from './job'; +import { NotificationResolver, UserNotificationResolver } from './resolver'; +import { NotificationService } from './service'; + +@Module({ + imports: [PermissionModule, DocStorageModule], + providers: [ + UserNotificationResolver, + NotificationResolver, + NotificationService, + NotificationCronJob, + ], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/packages/backend/server/src/core/notification/job.ts b/packages/backend/server/src/core/notification/job.ts new file mode 100644 index 0000000000000..9813064ae6a60 --- /dev/null +++ b/packages/backend/server/src/core/notification/job.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { NotificationService } from './service'; + +@Injectable() +export class NotificationCronJob { + constructor(private readonly service: NotificationService) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async cleanReadExpiredNotifications() { + await this.service.cleanReadExpiredNotifications(); + } +} diff --git a/packages/backend/server/src/core/notification/resolver.ts b/packages/backend/server/src/core/notification/resolver.ts new file mode 100644 index 0000000000000..8efdc013ac8ac --- /dev/null +++ b/packages/backend/server/src/core/notification/resolver.ts @@ -0,0 +1,110 @@ +import { Args, Int, Mutation, ResolveField, Resolver } from '@nestjs/graphql'; + +import { + MentionUserOneselfDenied, + MentionUserSpaceAccessDenied, +} from '../../base/error'; +import { paginate, PaginationInput } from '../../base/graphql'; +import { MentionNotificationCreateSchema } from '../../models'; +import { CurrentUser } from '../auth/session'; +import { PermissionService } from '../permission'; +import { UserType } from '../user'; +import { NotificationService } from './service'; +import { + MentionInput, + NotificationObjectType, + PaginatedNotificationObjectType, + UnionNotificationBodyType, +} from './types'; + +@Resolver(() => UserType) +export class UserNotificationResolver { + constructor( + private readonly service: NotificationService, + private readonly permission: PermissionService + ) {} + + @ResolveField(() => PaginatedNotificationObjectType, { + description: 'Get current user notifications', + }) + async notifications( + @CurrentUser() me: UserType, + @Args('pagination', PaginationInput.decode) pagination: PaginationInput + ): Promise { + const [notifications, totalCount] = await Promise.all([ + this.service.findManyByUserId(me.id, pagination), + this.service.countByUserId(me.id), + ]); + return paginate(notifications, 'createdAt', pagination, totalCount); + } + + @ResolveField(() => Int, { + description: 'Get user notification count', + }) + async notificationCount(@CurrentUser() me: UserType): Promise { + return await this.service.countByUserId(me.id); + } + + @Mutation(() => Boolean, { + description: 'mention user in a doc', + }) + async mentionUser( + @CurrentUser() me: UserType, + @Args('input') input: MentionInput + ) { + const parsedInput = MentionNotificationCreateSchema.parse({ + userId: input.userId, + body: { + workspaceId: input.workspaceId, + docId: input.docId, + blockId: input.blockId, + createdByUserId: me.id, + }, + }); + if (parsedInput.userId === me.id) { + throw new MentionUserOneselfDenied(); + } + // currentUser can update the doc + await this.permission.checkPagePermission( + parsedInput.body.workspaceId, + parsedInput.body.docId, + 'Doc.Update', + parsedInput.body.createdByUserId + ); + // mention user should be a member of the workspace + if ( + !(await this.permission.isWorkspaceMember( + parsedInput.body.workspaceId, + parsedInput.userId + )) + ) { + throw new MentionUserSpaceAccessDenied({ + spaceId: parsedInput.body.workspaceId, + }); + } + await this.service.createMention(parsedInput); + return true; + } + + @Mutation(() => Boolean, { + description: 'mark notification as read', + }) + async readNotification( + @CurrentUser() me: UserType, + @Args('id') notificationId: string + ) { + await this.service.markAsRead(me.id, notificationId); + return true; + } +} + +@Resolver(() => NotificationObjectType) +export class NotificationResolver { + @ResolveField(() => UnionNotificationBodyType, { + description: + "Just a placeholder to export UnionNotificationBodyType, don't use it", + }) + async _placeholderForUnionNotificationBodyType() { + return null; + } +} diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts new file mode 100644 index 0000000000000..f5524e9d4aadb --- /dev/null +++ b/packages/backend/server/src/core/notification/service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; + +import { + NotificationAccessDenied, + NotificationNotFound, + PaginationInput, +} from '../../base'; +import { + InvitationNotificationCreate, + MentionNotificationCreate, + Models, + UnionNotification, + UnionNotificationBody, +} from '../../models'; +import { DocReader } from '../doc'; +import { PermissionService } from '../permission'; + +@Injectable() +export class NotificationService { + constructor( + private readonly models: Models, + private readonly permission: PermissionService, + private readonly docReader: DocReader + ) {} + + async cleanReadExpiredNotifications() { + return await this.models.notification.cleanReadExpiredNotifications(); + } + + async createMention(input: MentionNotificationCreate) { + return await this.models.notification.createMention(input); + } + + async createInvitation(input: InvitationNotificationCreate) { + // is user already a member, skip it + const isMember = await this.permission.isWorkspaceMember( + input.body.workspaceId, + input.userId + ); + if (isMember) { + return; + } + return await this.models.notification.createInvitation(input); + } + + async markAsRead(userId: string, notificationId: string) { + const notification = await this.models.notification.get(notificationId); + if (!notification) { + throw new NotificationNotFound(); + } + if (notification.userId !== userId) { + throw new NotificationAccessDenied({ notificationId }); + } + await this.models.notification.markAsRead(notificationId); + } + + async findManyByUserId(userId: string, options?: PaginationInput) { + const items = await this.models.notification.findManyByUserId( + userId, + options + ); + return await this.fillWorkspaceAndCreatedByUser(items); + } + + private async fillWorkspaceAndCreatedByUser( + notifications: UnionNotification[] + ) { + const userIds = new Set(notifications.map(n => n.body.createdByUserId)); + const users = await this.models.user.getPublicUsers(Array.from(userIds)); + const userInfos = new Map(users.map(u => [u.id, u])); + + const workspaceIds = new Set(notifications.map(n => n.body.workspaceId)); + const workspaces = await Promise.all( + Array.from(workspaceIds).map(async id => { + const workspace = await this.docReader.getWorkspaceContent(id); + return { + id, + workspace, + }; + }) + ); + const workspaceInfos = new Map( + workspaces.map(w => [w.id, w.workspace ?? undefined]) + ); + return notifications.map(n => ({ + ...n, + body: { + ...(n.body as UnionNotificationBody), + workspace: workspaceInfos.get(n.body.workspaceId), + createdByUser: userInfos.get(n.body.createdByUserId), + }, + })); + } + + async countByUserId(userId: string) { + return await this.models.notification.countByUserId(userId); + } +} diff --git a/packages/backend/server/src/core/notification/types.ts b/packages/backend/server/src/core/notification/types.ts new file mode 100644 index 0000000000000..2dafa4aefa781 --- /dev/null +++ b/packages/backend/server/src/core/notification/types.ts @@ -0,0 +1,156 @@ +import { + createUnionType, + Field, + ID, + InputType, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; +import { GraphQLJSONObject } from 'graphql-scalars'; + +import { Paginated } from '../../base'; +import { + MentionNotificationBody, + Notification, + NotificationLevel, + NotificationType, +} from '../../models'; +import { WorkspaceDocInfo } from '../doc/reader'; +import { PublicUserType } from '../user'; + +registerEnumType(NotificationLevel, { + name: 'NotificationLevel', + description: 'Notification level', +}); + +registerEnumType(NotificationType, { + name: 'NotificationType', + description: 'Notification type', +}); + +@ObjectType() +export class NotificationWorkspaceType implements WorkspaceDocInfo { + @Field(() => ID) + id!: string; + + @Field({ description: 'Workspace name' }) + name!: string; + + @Field(() => String, { + description: 'Base64 encoded avatar', + }) + avatar!: string; +} + +@ObjectType() +abstract class BaseNotificationBodyType { + @Field(() => PublicUserType, { + nullable: true, + description: + 'The user who created the notification, maybe null when user is deleted or sent by system', + }) + createdByUser?: PublicUserType; + + @Field(() => NotificationWorkspaceType, { + nullable: true, + }) + workspace?: NotificationWorkspaceType; +} + +@ObjectType() +export class MentionNotificationBodyType + extends BaseNotificationBodyType + implements Partial +{ + @Field(() => String) + docId!: string; + + @Field(() => String) + blockId!: string; +} + +@ObjectType() +export class InvitationNotificationBodyType extends BaseNotificationBodyType {} + +@ObjectType() +export class InvitationAcceptedNotificationBodyType extends BaseNotificationBodyType {} + +@ObjectType() +export class InvitationBlockedNotificationBodyType extends BaseNotificationBodyType {} + +export const UnionNotificationBodyType = createUnionType({ + name: 'UnionNotificationBodyType', + types: () => + [ + MentionNotificationBodyType, + InvitationNotificationBodyType, + InvitationAcceptedNotificationBodyType, + InvitationBlockedNotificationBodyType, + ] as const, + resolveType(value: Notification) { + if (value.type === NotificationType.Mention) { + return MentionNotificationBodyType; + } else if (value.type === NotificationType.Invitation) { + return InvitationNotificationBodyType; + } else if (value.type === NotificationType.InvitationAccepted) { + return InvitationAcceptedNotificationBodyType; + } else if (value.type === NotificationType.InvitationBlocked) { + return InvitationBlockedNotificationBodyType; + } + return null; + }, +}); + +@ObjectType() +export class NotificationObjectType implements Partial { + @Field(() => ID) + id!: string; + + @Field(() => NotificationLevel, { + description: 'The level of the notification', + }) + level!: NotificationLevel; + + @Field(() => NotificationType, { + description: 'The type of the notification', + }) + type!: NotificationType; + + @Field({ description: 'Whether the notification has been read' }) + read!: boolean; + + @Field({ description: 'Whether the notification has been starred' }) + starred!: boolean; + + @Field({ description: 'The created at time of the notification' }) + createdAt!: Date; + + @Field({ description: 'The updated at time of the notification' }) + updatedAt!: Date; + + @Field(() => GraphQLJSONObject, { + description: + 'The body of the notification, different types have different fields, see UnionNotificationBodyType', + }) + body!: object; +} + +@ObjectType() +export class PaginatedNotificationObjectType extends Paginated( + NotificationObjectType +) {} + +@InputType() +export class MentionInput { + @Field() + userId!: string; + + @Field() + workspaceId!: string; + + @Field() + docId!: string; + + @Field() + blockId!: string; +} diff --git a/packages/backend/server/src/models/__tests__/notification.spec.ts b/packages/backend/server/src/models/__tests__/notification.spec.ts new file mode 100644 index 0000000000000..f86b047bb3433 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/notification.spec.ts @@ -0,0 +1,356 @@ +import { randomUUID } from 'node:crypto'; + +import ava, { TestFn } from 'ava'; + +import { createTestingModule, type TestingModule } from '../../__tests__/utils'; +import { Config } from '../../base/config'; +import { + Models, + NotificationLevel, + NotificationType, + User, + Workspace, +} from '../../models'; + +interface Context { + config: Config; + module: TestingModule; + models: Models; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + + t.context.models = module.get(Models); + t.context.config = module.get(Config); + t.context.module = module; +}); + +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); + docId = randomUUID(); + await t.context.models.doc.upsert({ + spaceId: user.id, + docId, + blob: Buffer.from('hello'), + timestamp: Date.now(), + editorId: user.id, + }); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should create a mention notification with default level', async t => { + const notification = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + t.is(notification.level, NotificationLevel.Default); + t.is(notification.expiredAt, null); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.docId, docId); + t.is(notification.body.blockId, 'blockId'); + t.is(notification.body.createdByUserId, createdBy.id); + t.is(notification.type, NotificationType.Mention); + t.is(notification.read, false); +}); + +test('should create a mention notification with custom level', async t => { + const notification = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + level: NotificationLevel.High, + }); + t.is(notification.level, NotificationLevel.High); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.docId, docId); + t.is(notification.body.blockId, 'blockId'); + t.is(notification.body.createdByUserId, createdBy.id); + t.is(notification.type, NotificationType.Mention); + t.is(notification.read, false); +}); + +test('should mark a mention notification as read', async t => { + const notification = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + t.is(notification.read, false); + await t.context.models.notification.markAsRead(notification.id); + const updatedNotification = await t.context.models.notification.get( + notification.id + ); + t.is(updatedNotification!.read, true); +}); + +test('should create an invite notification', async t => { + const notification = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + t.is(notification.type, NotificationType.Invitation); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.createdByUserId, createdBy.id); + t.is(notification.read, false); +}); + +test('should mark an invite notification as read', async t => { + const notification = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + t.is(notification.read, false); + await t.context.models.notification.markAsRead(notification.id); + const updatedNotification = await t.context.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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + const notifications = await t.context.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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + await t.context.models.notification.markAsRead(notification2.id); + const notifications = await t.context.models.notification.findManyByUserId( + user.id + ); + t.is(notifications.length, 1); + t.is(notifications[0].id, notification1.id); +}); + +test('should clean read and expired notifications', async t => { + const notification = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + t.truthy(notification); + let notifications = await t.context.models.notification.findManyByUserId( + user.id + ); + t.is(notifications.length, 1); + let count = + await t.context.models.notification.cleanReadExpiredNotifications(); + t.is(count, 0); + notifications = await t.context.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, + new Date(Date.now() - 1000) + ); + count = await t.context.models.notification.cleanReadExpiredNotifications(); + t.is(count, 1); + notifications = await t.context.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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + let count = + await t.context.models.notification.cleanReadExpiredNotifications(); + t.is(count, 0); + await t.context.models.notification.markAsRead(notification.id); + count = await t.context.models.notification.cleanReadExpiredNotifications(); + 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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + const notification3 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + const notification4 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + const notifications = await t.context.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, + } + ); + 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, + } + ); + 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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + t.truthy(notification1); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + t.truthy(notification2); + await t.context.models.notification.markAsRead(notification2.id); + const count = await t.context.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({ + userId: user.id, + body: { + workspaceId: workspace.id, + docId, + blockId: 'blockId', + createdByUserId: createdBy.id, + }, + }); + t.truthy(notification1); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + }, + }); + t.truthy(notification2); + await t.context.models.notification.markAsRead(notification2.id); + const count = await t.context.models.notification.countByUserId(user.id, { + includeRead: true, + }); + t.is(count, 2); +}); diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 745d652bbe99e..17b13abbf89aa 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -9,6 +9,7 @@ import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; import { DocModel } from './doc'; import { FeatureModel } from './feature'; +import { NotificationModel } from './notification'; import { PageModel } from './page'; import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; @@ -30,6 +31,7 @@ const MODELS = { workspaceFeature: WorkspaceFeatureModel, doc: DocModel, userDoc: UserDocModel, + notification: NotificationModel, }; type ModelsType = { @@ -84,6 +86,7 @@ export class ModelsModule {} export * from './common'; export * from './doc'; export * from './feature'; +export * from './notification'; export * from './page'; export * from './session'; export * from './user'; diff --git a/packages/backend/server/src/models/notification.ts b/packages/backend/server/src/models/notification.ts new file mode 100644 index 0000000000000..6f3bf521d1167 --- /dev/null +++ b/packages/backend/server/src/models/notification.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { + Notification, + NotificationLevel, + NotificationType, + Prisma, +} from '@prisma/client'; +import { z } from 'zod'; + +import { PaginationInput } from '../base'; +import { BaseModel } from './base'; + +export { NotificationLevel, NotificationType }; +export type { Notification }; + +// #region input + +export const NOTIFICATION_EXPIRED_AFTER_ONE_YEAR = 1000 * 60 * 60 * 24 * 365; +const IdSchema = z.string().trim().min(1); + +export const BaseNotificationCreateSchema = z.object({ + userId: IdSchema, + level: z + .nativeEnum(NotificationLevel) + .optional() + .default(NotificationLevel.Default), +}); + +const MentionNotificationBodySchema = z.object({ + workspaceId: IdSchema, + docId: IdSchema, + blockId: IdSchema, + createdByUserId: IdSchema, +}); + +export type MentionNotificationBody = z.infer< + typeof MentionNotificationBodySchema +>; + +export const MentionNotificationCreateSchema = + BaseNotificationCreateSchema.extend({ + body: MentionNotificationBodySchema, + }); + +export type MentionNotificationCreate = z.input< + typeof MentionNotificationCreateSchema +>; + +const InvitationNotificationBodySchema = z.object({ + workspaceId: IdSchema, + createdByUserId: IdSchema, +}); + +export type InvitationNotificationBody = z.infer< + typeof InvitationNotificationBodySchema +>; + +export const InvitationNotificationCreateSchema = + BaseNotificationCreateSchema.extend({ + body: InvitationNotificationBodySchema, + }); + +export type InvitationNotificationCreate = z.input< + typeof InvitationNotificationCreateSchema +>; + +export type UnionNotificationBody = + | MentionNotificationBody + | InvitationNotificationBody; + +// #endregion + +// #region output + +export type MentionNotification = Notification & + z.infer; + +export type InvitationNotification = Notification & + z.infer; + +export type UnionNotification = MentionNotification | InvitationNotification; + +// #endregion + +@Injectable() +export class NotificationModel extends BaseModel { + // #region mention + + async createMention(input: MentionNotificationCreate) { + const data = MentionNotificationCreateSchema.parse(input); + const row = await this.create({ + userId: data.userId, + level: data.level, + type: NotificationType.Mention, + body: data.body, + }); + this.logger.log( + `Created mention notification:${row.id} for user:${data.userId} in workspace:${data.body.workspaceId}` + ); + return row as MentionNotification; + } + + // #endregion + + // #region invitation + + async createInvitation( + input: InvitationNotificationCreate, + type = NotificationType.Invitation + ) { + const data = InvitationNotificationCreateSchema.parse(input); + const row = await this.create({ + userId: data.userId, + level: data.level, + type, + body: data.body, + }); + this.logger.log( + `Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}` + ); + return row as InvitationNotification; + } + + // #endregion + + // #region common + + private async create(data: Prisma.NotificationUncheckedCreateInput) { + return await this.db.notification.create({ + data, + }); + } + + async markAsRead(notificationId: string, expiredAt?: Date) { + await this.db.notification.update({ + where: { id: notificationId }, + data: { + read: true, + expiredAt: + expiredAt ?? + new Date(Date.now() + NOTIFICATION_EXPIRED_AFTER_ONE_YEAR), + }, + }); + } + + /** + * Find many notifications by user id, exclude read notifications by default + */ + async findManyByUserId( + userId: string, + options?: { + includeRead?: boolean; + } & PaginationInput + ) { + const rows = await this.db.notification.findMany({ + where: { + userId, + ...(options?.includeRead ? {} : { read: false }), + ...(options?.after ? { createdAt: { gt: options.after } } : {}), + }, + orderBy: { createdAt: 'desc' }, + skip: options?.offset, + take: options?.first, + }); + return rows as UnionNotification[]; + } + + async countByUserId(userId: string, options: { includeRead?: boolean } = {}) { + return this.db.notification.count({ + where: { + userId, + ...(options.includeRead ? {} : { read: false }), + }, + }); + } + + async get(notificationId: string) { + const row = await this.db.notification.findUnique({ + where: { id: notificationId }, + }); + return row as UnionNotification; + } + + /** + * Clean up read notifications that are expired + */ + async cleanReadExpiredNotifications() { + const { count } = await this.db.notification.deleteMany({ + where: { read: true, expiredAt: { lt: new Date() } }, + }); + this.logger.log(`Deleted ${count} expired notifications`); + return count; + } + + // #endregion +} diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index 3f945509d1df1..ac6543f8e7bff 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -12,7 +12,7 @@ import { import { BaseModel } from './base'; import type { Workspace } from './workspace'; -const publicUserSelect = { +export const publicUserSelect = { id: true, name: true, email: true, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 8e614e0b1b6db..6e823e1964353 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -316,7 +316,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserSpaceAccessDeniedDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | NotificationAccessDeniedDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -384,7 +384,11 @@ enum ErrorNames { MAILER_SERVICE_IS_NOT_CONFIGURED MEMBER_NOT_FOUND_IN_SPACE MEMBER_QUOTA_EXCEEDED + MENTION_USER_ONESELF_DENIED + MENTION_USER_SPACE_ACCESS_DENIED MISSING_OAUTH_QUERY_PARAMETER + NOTIFICATION_ACCESS_DENIED + NOTIFICATION_NOT_FOUND NOT_FOUND NOT_IN_SPACE NO_COPILOT_PROVIDER_AVAILABLE @@ -506,6 +510,30 @@ type InvalidRuntimeConfigTypeDataType { want: String! } +type InvitationAcceptedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + workspace: NotificationWorkspaceType +} + +type InvitationBlockedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + workspace: NotificationWorkspaceType +} + +type InvitationNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + workspace: NotificationWorkspaceType +} + type InvitationType { """Invitee information""" invitee: UserType! @@ -652,6 +680,28 @@ type MemberNotFoundInSpaceDataType { spaceId: String! } +input MentionInput { + blockId: String! + docId: String! + userId: String! + workspaceId: String! +} + +type MentionNotificationBodyType { + blockId: String! + + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + docId: String! + workspace: NotificationWorkspaceType +} + +type MentionUserSpaceAccessDeniedDataType { + spaceId: String! +} + type MissingOauthQueryParameterDataType { name: String! } @@ -715,8 +765,14 @@ type Mutation { invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! + + """mention user in a doc""" + mentionUser(input: MentionInput!): Boolean! publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType! publishPage(mode: PublicDocMode = Page, pageId: String!, workspaceId: String!): DocType! @deprecated(reason: "use publishDoc instead") + + """mark notification as read""" + readNotification(id: String!): Boolean! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! releaseDeletedBlobs(workspaceId: String!): Boolean! @@ -773,6 +829,70 @@ type NotInSpaceDataType { spaceId: String! } +type NotificationAccessDeniedDataType { + notificationId: String! +} + +"""Notification level""" +enum NotificationLevel { + Default + High + Low + Min + None +} + +type NotificationObjectType { + """Just a placeholder to export UnionNotificationBodyType, don't use it""" + _placeholderForUnionNotificationBodyType: UnionNotificationBodyType! + + """ + The body of the notification, different types have different fields, see UnionNotificationBodyType + """ + body: JSONObject! + + """The created at time of the notification""" + createdAt: DateTime! + id: ID! + + """The level of the notification""" + level: NotificationLevel! + + """Whether the notification has been read""" + read: Boolean! + + """Whether the notification has been starred""" + starred: Boolean! + + """The type of the notification""" + type: NotificationType! + + """The updated at time of the notification""" + updatedAt: DateTime! +} + +type NotificationObjectTypeEdge { + cursor: String! + node: NotificationObjectType! +} + +"""Notification type""" +enum NotificationType { + Invitation + InvitationAccepted + InvitationBlocked + Mention +} + +type NotificationWorkspaceType { + """Base64 encoded avatar""" + avatar: String! + id: ID! + + """Workspace name""" + name: String! +} + enum OAuthProviderType { GitHub Google @@ -792,6 +912,12 @@ type PaginatedGrantedDocUserType { totalCount: Int! } +type PaginatedNotificationObjectType { + edges: [NotificationObjectTypeEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + input PaginationInput { """returns the elements in the list that come after the specified cursor.""" after: String @@ -1098,6 +1224,8 @@ enum SubscriptionVariant { Onetime } +union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | MentionNotificationBodyType + type UnknownOauthProviderDataType { name: String! } @@ -1204,6 +1332,12 @@ type UserType { """User name""" name: String! + + """Get user notification count""" + notificationCount: Int! + + """Get current user notifications""" + notifications(pagination: PaginationInput!): PaginatedNotificationObjectType! quota: UserQuotaType! quotaUsage: UserQuotaUsageType! subscriptions: [SubscriptionType!]!