diff --git a/packages/backend/server/migrations/20250303105325-notification/migration.sql b/packages/backend/server/migrations/20250303105325-notification/migration.sql new file mode 100644 index 0000000000..28e219f00c --- /dev/null +++ b/packages/backend/server/migrations/20250303105325-notification/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('Mention', 'Invitation', 'InvitationAccepted', 'InvitationBlocked', 'InvitationRejected'); + +-- CreateEnum +CREATE TYPE "NotificationLevel" AS ENUM ('High', 'Default', 'Low', 'Min', 'None'); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" VARCHAR NOT NULL, + "user_id" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "level" "NotificationLevel" NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "type" "NotificationType" NOT NULL, + "body" JSONB NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "notifications_user_id_created_at_read_idx" ON "notifications"("user_id", "created_at", "read"); + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 0eb7fc36ec..f553cc82ef 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") @@ -624,3 +626,39 @@ model Blob { @@id([workspaceId, key]) @@map("blobs") } + +enum NotificationType { + Mention + Invitation + InvitationAccepted + InvitationBlocked + InvitationRejected +} + +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) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + level NotificationLevel + read Boolean @default(false) + type NotificationType + body Json @db.JsonB + + user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade) + + // for user notifications list, including read and unread, ordered by createdAt + @@index([userId, createdAt, read]) + @@map("notifications") +} diff --git a/packages/backend/server/src/__tests__/utils/index.ts b/packages/backend/server/src/__tests__/utils/index.ts index 981cceee28..d472088579 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 0000000000..19de0c6335 --- /dev/null +++ b/packages/backend/server/src/__tests__/utils/notification.ts @@ -0,0 +1,86 @@ +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 + 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 80d4713018..06865f1820 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'; @@ -182,12 +184,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 +209,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/app.module.ts b/packages/backend/server/src/app.module.ts index d46168e690..1cfc2d5f2b 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 5a649f9e3e..ed7778fcc8 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -767,4 +767,19 @@ 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_doc_access_denied: { + type: 'no_permission', + args: { docId: 'string' }, + message: ({ docId }) => `Mentioned user can not access doc ${docId}.`, + }, + mention_user_oneself_denied: { + type: 'action_forbidden', + message: 'You can not mention yourself.', + }, } 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 005903362b..b0f3595fa1 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -855,6 +855,28 @@ 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 MentionUserDocAccessDeniedDataType { + @Field() docId!: string +} + +export class MentionUserDocAccessDenied extends UserFriendlyError { + constructor(args: MentionUserDocAccessDeniedDataType, message?: string | ((args: MentionUserDocAccessDeniedDataType) => string)) { + super('no_permission', 'mention_user_doc_access_denied', message, args); + } +} + +export class MentionUserOneselfDenied extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'mention_user_oneself_denied', message); + } +} export enum ErrorNames { INTERNAL_SERVER_ERROR, NETWORK_ERROR, @@ -963,7 +985,10 @@ 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_DOC_ACCESS_DENIED, + MENTION_USER_ONESELF_DENIED } registerEnumType(ErrorNames, { name: 'ErrorNames' @@ -972,5 +997,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [GraphqlBadRequestDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, 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, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, 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, MentionUserDocAccessDeniedDataType] as const, }); diff --git a/packages/backend/server/src/base/graphql/pagination.ts b/packages/backend/server/src/base/graphql/pagination.ts index 6e851b8254..2bb13f1301 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 { 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 0000000000..c4a22bd395 --- /dev/null +++ b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts @@ -0,0 +1,516 @@ +import { randomUUID } from 'node:crypto'; + +import test from 'ava'; + +import { + acceptInviteById, + createTestingApp, + createWorkspace, + getNotificationCount, + inviteUser, + listNotifications, + mentionUser, + readNotification, + TestingApp, +} from '../../../__tests__/utils'; +import { Models, NotificationType } from '../../../models'; +import { MentionNotificationBodyType, NotificationObjectType } from '../types'; + +let app: TestingApp; +let models: Models; + +test.before(async () => { + app = await createTestingApp(); + models = app.get(Models); +}); + +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); + 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', + }, + }); + t.truthy(mentionId); + // mention user at another doc + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-2', + title: 'doc-title-2', + elementId: 'element-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.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.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); + + const notification2 = notifications[0] as NotificationObjectType; + t.is(notification2.read, 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.doc.id, 'doc-id-2'); + t.is(body2.doc.title, 'doc-title-2'); + t.is(body2.doc.elementId, 'element-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!.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(); + + await app.switchUser(owner); + const workspace = await createWorkspace(app); + + await app.switchUser(owner); + const docId = randomUUID(); + await t.throwsAsync( + mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }), + { + message: `Mentioned user can not access doc ${docId}.`, + } + ); +}); + +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); + const docId = randomUUID(); + await t.throwsAsync( + mentionUser(app, { + userId: 'user-id-not-exists', + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }), + { + message: `Mentioned user can not access doc ${docId}.`, + } + ); +}); + +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, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }), + { + message: 'You can not 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 mentionId = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }); + 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); + const notification = notifications[0] as NotificationObjectType; + t.is(notification.read, 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 mentionId = await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }); + t.truthy(mentionId); + + 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: 'Notification not found.', + }); + // 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: '', + doc: { + id: '', + title: '', + 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); + await models.workspace.update(workspace.id, { + name: 'test-workspace-name1', + avatarKey: 'test-avatar-key1', + }); + const inviteId = await inviteUser(app, workspace.id, member.email); + const workspace2 = await createWorkspace(app); + await models.workspace.update(workspace2.id, { + name: 'test-workspace-name2', + avatarKey: 'test-avatar-key2', + }); + 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, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + }, + }); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-2', + title: 'doc-title-2', + blockId: 'block-id-2', + }, + }); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-3', + title: 'doc-title-3', + blockId: 'block-id-3', + }, + }); + // mention user in another workspace + await mentionUser(app, { + userId: member.id, + workspaceId: workspace2.id, + doc: { + id: 'doc-id-4', + title: 'doc-title-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.type, NotificationType.Mention); + t.is(body.workspace!.id, workspace2.id); + t.is(body.doc.id, 'doc-id-4'); + t.is(body.doc.title, 'doc-title-4'); + t.is(body.doc.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-name2'); + t.truthy(body.workspace!.avatarUrl); + + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.type, NotificationType.Mention); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.doc.id, 'doc-id-3'); + t.is(body2.doc.title, 'doc-title-3'); + t.is(body2.doc.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-name1'); + t.truthy(body2.workspace!.avatarUrl); + } + + { + 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.doc.id, 'doc-id-2'); + t.is(body.doc.title, 'doc-title-2'); + t.is(body.doc.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-name1'); + t.truthy(body.workspace!.avatarUrl); + + 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.doc.id, 'doc-id-1'); + t.is(body2.doc.title, 'doc-title-1'); + t.is(body2.doc.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-name1'); + t.truthy(body2.workspace!.avatarUrl); + } + + { + 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.doc.id, 'doc-id-4'); + t.is(body.doc.title, 'doc-title-4'); + t.is(body.doc.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-name2'); + t.truthy(body.workspace!.avatarUrl); + 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.doc.id, 'doc-id-3'); + t.is(body2.doc.title, 'doc-title-3'); + t.is(body2.doc.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-name1'); + t.truthy(body2.workspace!.avatarUrl); + + await app.switchUser(owner); + await mentionUser(app, { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-5', + title: 'doc-title-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.doc.id, 'doc-id-5'); + t.is(body3.doc.title, 'doc-title-5'); + t.is(body3.doc.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-name1'); + t.truthy(body3.workspace!.avatarUrl); + + // 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/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts new file mode 100644 index 0000000000..b6072589c6 --- /dev/null +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -0,0 +1,314 @@ +import { randomUUID } from 'node:crypto'; +import { mock } from 'node:test'; + +import ava, { TestFn } from 'ava'; + +import { + createTestingModule, + type TestingModule, +} from '../../../__tests__/utils'; +import { NotificationNotFound } from '../../../base'; +import { + MentionNotificationBody, + Models, + NotificationType, + User, + Workspace, +} from '../../../models'; +import { DocReader } from '../../doc'; +import { NotificationService } from '../service'; +interface Context { + module: TestingModule; + notificationService: NotificationService; + models: Models; + docReader: DocReader; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + t.context.module = module; + t.context.notificationService = module.get(NotificationService); + t.context.models = module.get(Models); + t.context.docReader = module.get(DocReader); +}); + +let owner: User; +let member: User; +let workspace: Workspace; + +test.beforeEach(async t => { + await t.context.module.initTestingDB(); + owner = await t.context.models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + member = await t.context.models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + workspace = await t.context.models.workspace.create(owner.id); + await t.context.models.workspace.update(workspace.id, { + name: 'Test Workspace', + avatarKey: 'test-avatar-key', + }); +}); + +test.afterEach.always(() => { + mock.reset(); + mock.timers.reset(); +}); + +test.after.always(async t => { + await t.context.module.close(); +}); + +test('should create invitation notification', async t => { + const { notificationService } = t.context; + const inviteId = randomUUID(); + const notification = await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId, + }, + }); + t.truthy(notification); + t.is(notification!.type, NotificationType.Invitation); + t.is(notification!.userId, member.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, owner.id); + t.is(notification!.body.inviteId, inviteId); +}); + +test('should not create invitation notification if user is already a member', async t => { + const { notificationService, models } = t.context; + const inviteId = randomUUID(); + mock.method(models.workspaceUser, 'getActive', async () => ({ + id: inviteId, + })); + const notification = await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId, + }, + }); + t.is(notification, undefined); +}); + +test('should create invitation accepted notification', async t => { + const { notificationService } = t.context; + const inviteId = randomUUID(); + const notification = await notificationService.createInvitationAccepted({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationAccepted); + t.is(notification!.userId, owner.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, member.id); + t.is(notification!.body.inviteId, inviteId); +}); + +test('should create invitation blocked notification', async t => { + const { notificationService } = t.context; + const inviteId = randomUUID(); + const notification = await notificationService.createInvitationBlocked({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationBlocked); + t.is(notification!.userId, owner.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, member.id); + t.is(notification!.body.inviteId, inviteId); +}); + +test('should create invitation rejected notification', async t => { + const { notificationService } = t.context; + const inviteId = randomUUID(); + const notification = await notificationService.createInvitationRejected({ + userId: owner.id, + body: { + workspaceId: workspace.id, + createdByUserId: member.id, + inviteId, + }, + }); + t.truthy(notification); + t.is(notification!.type, NotificationType.InvitationRejected); + t.is(notification!.userId, owner.id); + t.is(notification!.body.workspaceId, workspace.id); + t.is(notification!.body.createdByUserId, member.id); + t.is(notification!.body.inviteId, inviteId); +}); + +test('should clean expired notifications', async t => { + const { notificationService } = t.context; + await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId: randomUUID(), + }, + }); + let count = await notificationService.countByUserId(member.id); + t.is(count, 1); + // wait for 100 days + mock.timers.enable({ + apis: ['Date'], + now: Date.now() + 1000 * 60 * 60 * 24 * 100, + }); + await t.context.models.notification.cleanExpiredNotifications(); + count = await notificationService.countByUserId(member.id); + t.is(count, 1); + mock.timers.reset(); + // wait for 1 year + mock.timers.enable({ + apis: ['Date'], + now: Date.now() + 1000 * 60 * 60 * 24 * 365, + }); + await t.context.models.notification.cleanExpiredNotifications(); + count = await notificationService.countByUserId(member.id); + t.is(count, 0); +}); + +test('should mark notification as read', async t => { + const { notificationService } = t.context; + const notification = await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId: randomUUID(), + }, + }); + await notificationService.markAsRead(member.id, notification!.id); + const updatedNotification = await t.context.models.notification.get( + notification!.id + ); + t.is(updatedNotification!.read, true); +}); + +test('should throw error on mark notification as read if notification is not found', async t => { + const { notificationService } = t.context; + await t.throwsAsync(notificationService.markAsRead(member.id, randomUUID()), { + instanceOf: NotificationNotFound, + }); +}); + +test('should throw error on mark notification as read if notification user is not the same', async t => { + const { notificationService } = t.context; + const notification = await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + inviteId: randomUUID(), + }, + }); + const otherUser = await t.context.models.user.create({ + email: `${randomUUID()}@affine.pro`, + }); + await t.throwsAsync( + notificationService.markAsRead(otherUser.id, notification!.id), + { + instanceOf: NotificationNotFound, + } + ); +}); + +test('should use latest doc title in mention notification', async t => { + const { notificationService, models } = t.context; + const docId = randomUUID(); + await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { id: docId, title: 'doc-title-1', blockId: 'block-id-1' }, + }, + }); + const mentionNotification = await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { id: docId, title: 'doc-title-2', blockId: 'block-id-2' }, + }, + }); + t.truthy(mentionNotification); + mock.method(models.doc, 'findMetas', async () => [ + { + title: 'doc-title-2-updated', + }, + { + title: 'doc-title-1-updated', + }, + ]); + const notifications = await notificationService.findManyByUserId(member.id); + t.is(notifications.length, 2); + const mention = notifications[0]; + t.is(mention.body.workspace!.id, workspace.id); + t.is(mention.body.workspace!.name, 'Test Workspace'); + t.truthy(mention.body.workspace!.avatarUrl); + t.is(mention.body.type, NotificationType.Mention); + const body = mention.body as MentionNotificationBody; + t.is(body.doc.title, 'doc-title-2-updated'); + + const mention2 = notifications[1]; + t.is(mention2.body.workspace!.id, workspace.id); + t.is(mention2.body.workspace!.name, 'Test Workspace'); + t.truthy(mention2.body.workspace!.avatarUrl); + t.is(mention2.body.type, NotificationType.Mention); + const body2 = mention2.body as MentionNotificationBody; + t.is(body2.doc.title, 'doc-title-1-updated'); +}); + +test('should raw doc title in mention notification if no doc found', async t => { + const { notificationService, models } = t.context; + const docId = randomUUID(); + await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { id: docId, title: 'doc-title-1', blockId: 'block-id-1' }, + }, + }); + await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { id: docId, title: 'doc-title-2', blockId: 'block-id-2' }, + }, + }); + mock.method(models.doc, 'findMetas', async () => [null, null]); + const notifications = await notificationService.findManyByUserId(member.id); + t.is(notifications.length, 2); + const mention = notifications[0]; + t.is(mention.body.workspace!.name, 'Test Workspace'); + t.is(mention.body.type, NotificationType.Mention); + const body = mention.body as MentionNotificationBody; + t.is(body.doc.title, 'doc-title-2'); + + 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'); +}); 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 0000000000..5616f83c39 --- /dev/null +++ b/packages/backend/server/src/core/notification/index.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { DocStorageModule } from '../doc'; +import { PermissionModule } from '../permission'; +import { StorageModule } from '../storage'; +import { NotificationJob } from './job'; +import { NotificationResolver, UserNotificationResolver } from './resolver'; +import { NotificationService } from './service'; + +@Module({ + imports: [PermissionModule, DocStorageModule, StorageModule], + providers: [ + UserNotificationResolver, + NotificationResolver, + NotificationService, + NotificationJob, + ], + 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 0000000000..7ef44ecd54 --- /dev/null +++ b/packages/backend/server/src/core/notification/job.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { JobQueue, OnJob } from '../../base'; +import { NotificationService } from './service'; + +declare global { + interface Jobs { + 'nightly.cleanExpiredNotifications': {}; + } +} + +@Injectable() +export class NotificationJob { + constructor( + private readonly service: NotificationService, + private readonly queue: JobQueue + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async nightlyJob() { + await this.queue.add( + 'nightly.cleanExpiredNotifications', + {}, + { + jobId: 'nightly-notification-clean-expired', + } + ); + } + + @OnJob('nightly.cleanExpiredNotifications') + async cleanExpiredNotifications() { + await this.service.cleanExpiredNotifications(); + } +} 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 0000000000..91461c43c4 --- /dev/null +++ b/packages/backend/server/src/core/notification/resolver.ts @@ -0,0 +1,114 @@ +import { + Args, + ID, + Int, + Mutation, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { + MentionUserDocAccessDenied, + MentionUserOneselfDenied, +} from '../../base/error'; +import { paginate, PaginationInput } from '../../base/graphql'; +import { MentionNotificationCreateSchema } from '../../models'; +import { CurrentUser } from '../auth/session'; +import { AccessController } 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 ac: AccessController + ) {} + + @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(() => ID, { + 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, + doc: input.doc, + createdByUserId: me.id, + }, + }); + if (parsedInput.userId === me.id) { + throw new MentionUserOneselfDenied(); + } + // currentUser can update the doc + await this.ac + .user(me.id) + .doc(parsedInput.body.workspaceId, parsedInput.body.doc.id) + .assert('Doc.Update'); + // mention user can read the doc + if ( + !(await this.ac + .user(parsedInput.userId) + .doc(parsedInput.body.workspaceId, parsedInput.body.doc.id) + .can('Doc.Read')) + ) { + throw new MentionUserDocAccessDenied({ + docId: parsedInput.body.doc.id, + }); + } + const notification = await this.service.createMention(parsedInput); + return notification.id; + } + + @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 0000000000..f49539402c --- /dev/null +++ b/packages/backend/server/src/core/notification/service.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +import { NotificationNotFound, PaginationInput } from '../../base'; +import { + InvitationNotificationCreate, + MentionNotification, + MentionNotificationCreate, + Models, + NotificationType, + UnionNotificationBody, +} from '../../models'; +import { DocReader } from '../doc'; +import { WorkspaceBlobStorage } from '../storage'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + private readonly models: Models, + private readonly docReader: DocReader, + private readonly workspaceBlobStorage: WorkspaceBlobStorage + ) {} + + async cleanExpiredNotifications() { + return await this.models.notification.cleanExpiredNotifications(); + } + + async createMention(input: MentionNotificationCreate) { + return await this.models.notification.createMention(input); + } + + async createInvitation(input: InvitationNotificationCreate) { + const isActive = await this.models.workspaceUser.getActive( + input.body.workspaceId, + input.userId + ); + if (isActive) { + this.logger.debug( + `User ${input.userId} is already a active member of workspace ${input.body.workspaceId}, skip creating notification` + ); + return; + } + await this.ensureWorkspaceContentExists(input.body.workspaceId); + return await this.models.notification.createInvitation( + input, + NotificationType.Invitation + ); + } + + async createInvitationAccepted(input: InvitationNotificationCreate) { + await this.ensureWorkspaceContentExists(input.body.workspaceId); + return await this.models.notification.createInvitation( + input, + NotificationType.InvitationAccepted + ); + } + + async createInvitationBlocked(input: InvitationNotificationCreate) { + await this.ensureWorkspaceContentExists(input.body.workspaceId); + return await this.models.notification.createInvitation( + input, + NotificationType.InvitationBlocked + ); + } + + async createInvitationRejected(input: InvitationNotificationCreate) { + await this.ensureWorkspaceContentExists(input.body.workspaceId); + return await this.models.notification.createInvitation( + input, + NotificationType.InvitationRejected + ); + } + + private async ensureWorkspaceContentExists(workspaceId: string) { + const workspace = await this.models.workspace.get(workspaceId); + if (!workspace || workspace.name) { + return; + } + const content = await this.docReader.getWorkspaceContent(workspaceId); + if (!content?.name) { + return; + } + await this.models.workspace.update(workspaceId, { + name: content.name, + avatarKey: content.avatarKey, + }); + } + + async markAsRead(userId: string, notificationId: string) { + try { + await this.models.notification.markAsRead(notificationId, userId); + } catch (err) { + if ( + err instanceof PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + // https://www.prisma.io/docs/orm/reference/error-reference#p2025 + throw new NotificationNotFound(); + } + throw err; + } + } + + /** + * Find notifications by user id, order by createdAt desc + */ + async findManyByUserId(userId: string, options?: PaginationInput) { + const notifications = await this.models.notification.findManyByUserId( + userId, + options + ); + + // fill user info + 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])); + + // fill workspace info + const workspaceIds = new Set(notifications.map(n => n.body.workspaceId)); + const workspaces = await this.models.workspace.findMany( + Array.from(workspaceIds) + ); + const workspaceInfos = new Map( + workspaces.map(w => [ + w.id, + { + id: w.id, + name: w.name ?? '', + avatarUrl: w.avatarKey + ? this.workspaceBlobStorage.getAvatarUrl(w.id, w.avatarKey) + : undefined, + }, + ]) + ); + + // fill latest doc title + const mentions = notifications.filter( + n => n.type === NotificationType.Mention + ) as MentionNotification[]; + const mentionDocs = await this.models.doc.findMetas( + mentions.map(m => ({ + workspaceId: m.body.workspaceId, + docId: m.body.doc.id, + })) + ); + for (const [index, mention] of mentions.entries()) { + const doc = mentionDocs[index]; + if (doc?.title) { + // use the latest doc title + mention.body.doc.title = doc.title; + } + } + + return notifications.map(n => ({ + ...n, + body: { + ...(n.body as UnionNotificationBody), + // set type to body.type to improve type inference on frontend + type: n.type, + 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 0000000000..4094991c11 --- /dev/null +++ b/packages/backend/server/src/core/notification/types.ts @@ -0,0 +1,196 @@ +import { + createUnionType, + Field, + ID, + InputType, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; +import { GraphQLJSONObject } from 'graphql-scalars'; + +import { Paginated } from '../../base'; +import { + InvitationNotificationBody, + 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: 'Workspace avatar url', + nullable: true, + }) + avatarUrl?: string; +} + +@ObjectType() +export abstract class BaseNotificationBodyType { + @Field(() => NotificationType, { + description: 'The type of the notification', + }) + type!: NotificationType; + + @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 MentionDocType { + @Field(() => String) + id!: string; + + @Field(() => String) + title!: string; + + @Field(() => String, { + nullable: true, + }) + blockId?: string; + + @Field(() => String, { + nullable: true, + }) + elementId?: string; +} + +@ObjectType() +export class MentionNotificationBodyType extends BaseNotificationBodyType { + @Field(() => MentionDocType) + doc!: MentionDocType; +} + +@ObjectType() +export class InvitationNotificationBodyType + extends BaseNotificationBodyType + implements Partial +{ + @Field(() => ID) + inviteId!: string; +} + +@ObjectType() +export class InvitationAcceptedNotificationBodyType + extends BaseNotificationBodyType + implements Partial +{ + @Field(() => String) + inviteId!: string; +} + +@ObjectType() +export class InvitationBlockedNotificationBodyType + extends BaseNotificationBodyType + implements Partial +{ + @Field(() => String) + inviteId!: string; +} + +export const UnionNotificationBodyType = createUnionType({ + name: 'UnionNotificationBodyType', + types: () => + [ + MentionNotificationBodyType, + InvitationNotificationBodyType, + InvitationAcceptedNotificationBodyType, + InvitationBlockedNotificationBodyType, + ] as const, +}); + +@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: '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 MentionDocInput { + @Field(() => String) + id!: string; + + @Field(() => String) + title!: string; + + @Field(() => String, { + description: 'The block id in the doc', + nullable: true, + }) + blockId?: string; + + @Field(() => String, { + description: 'The element id in the doc', + nullable: true, + }) + elementId?: string; +} + +@InputType() +export class MentionInput { + @Field() + userId!: string; + + @Field() + workspaceId!: string; + + @Field(() => MentionDocInput) + doc!: MentionDocInput; +} 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 0000000000..10f36887d1 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/notification.spec.ts @@ -0,0 +1,407 @@ +import { randomUUID } from 'node:crypto'; +import { mock } from 'node:test'; + +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.afterEach.always(() => { + mock.reset(); + mock.timers.reset(); +}); + +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, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + t.is(notification.level, NotificationLevel.Default); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.doc.id, docId); + t.is(notification.body.doc.title, 'doc-title'); + t.is(notification.body.doc.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, + doc: { + id: docId, + title: 'doc-title', + elementId: 'elementId', + }, + createdByUserId: createdBy.id, + }, + level: NotificationLevel.High, + }); + t.is(notification.level, NotificationLevel.High); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.doc.id, docId); + t.is(notification.body.doc.title, 'doc-title'); + t.is(notification.body.doc.elementId, 'elementId'); + 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, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + t.is(notification.read, false); + await t.context.models.notification.markAsRead(notification.id, user.id); + const updatedNotification = await t.context.models.notification.get( + notification.id + ); + t.is(updatedNotification!.read, true); +}); + +test('should create an invite notification', async t => { + const inviteId = randomUUID(); + const notification = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId, + }, + }); + t.is(notification.type, NotificationType.Invitation); + t.is(notification.body.workspaceId, workspace.id); + t.is(notification.body.createdByUserId, createdBy.id); + t.is(notification.body.inviteId, inviteId); + t.is(notification.read, false); +}); + +test('should mark an invite notification as read', async t => { + const inviteId = randomUUID(); + const notification = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId, + }, + }); + t.is(notification.read, false); + await t.context.models.notification.markAsRead(notification.id, user.id); + const updatedNotification = await t.context.models.notification.get( + notification.id + ); + 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, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + const inviteId = randomUUID(); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId, + }, + }); + 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, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + const inviteId = randomUUID(); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId, + }, + }); + await t.context.models.notification.markAsRead(notification2.id, user.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 expired notifications', async t => { + const notification = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title', + 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.cleanExpiredNotifications(); + 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, user.id); + // wait for 1 year + mock.timers.enable({ + apis: ['Date'], + now: Date.now() + 1000 * 60 * 60 * 24 * 365, + }); + count = await t.context.models.notification.cleanExpiredNotifications(); + 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, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + let count = await t.context.models.notification.cleanExpiredNotifications(); + t.is(count, 0); + await t.context.models.notification.markAsRead(notification.id, user.id); + count = await t.context.models.notification.cleanExpiredNotifications(); + t.is(count, 0); +}); + +test('should find many notifications by user id, order by createdAt descending, with pagination', async t => { + const notification1 = await t.context.models.notification.createMention({ + userId: user.id, + body: { + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title', + blockId: 'blockId', + }, + createdByUserId: createdBy.id, + }, + }); + const notification2 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId: randomUUID(), + }, + }); + const notification3 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId: randomUUID(), + }, + }); + const notification4 = await t.context.models.notification.createInvitation({ + userId: user.id, + body: { + workspaceId: workspace.id, + createdByUserId: createdBy.id, + inviteId: randomUUID(), + }, + }); + 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, + doc: { + id: docId, + title: 'doc-title', + 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, + inviteId: randomUUID(), + }, + }); + t.truthy(notification2); + await t.context.models.notification.markAsRead(notification2.id, user.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, + doc: { + id: docId, + title: 'doc-title', + 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, + inviteId: randomUUID(), + }, + }); + t.truthy(notification2); + await t.context.models.notification.markAsRead(notification2.id, user.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 1954e1bc29..53858f80f7 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -11,6 +11,7 @@ import { DocModel } from './doc'; import { DocUserModel } from './doc-user'; import { FeatureModel } from './feature'; import { HistoryModel } from './history'; +import { NotificationModel } from './notification'; import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; import { UserModel } from './user'; @@ -34,6 +35,7 @@ const MODELS = { workspaceUser: WorkspaceUserModel, docUser: DocUserModel, history: HistoryModel, + notification: NotificationModel, }; type ModelsType = { @@ -90,6 +92,7 @@ export * from './doc'; export * from './doc-user'; export * from './feature'; export * from './history'; +export * from './notification'; export * from './session'; export * from './user'; export * from './user-doc'; diff --git a/packages/backend/server/src/models/notification.ts b/packages/backend/server/src/models/notification.ts new file mode 100644 index 0000000000..688ceba413 --- /dev/null +++ b/packages/backend/server/src/models/notification.ts @@ -0,0 +1,200 @@ +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 ONE_YEAR = 1000 * 60 * 60 * 24 * 365; +const IdSchema = z.string().trim().min(1).max(100); + +export const BaseNotificationCreateSchema = z.object({ + userId: IdSchema, + level: z + .nativeEnum(NotificationLevel) + .optional() + .default(NotificationLevel.Default), +}); + +export const MentionDocSchema = z.object({ + id: IdSchema, + // Allow empty string, will display as `Untitled` at frontend + title: z.string().trim().max(255), + // blockId or elementId is required at least one + blockId: IdSchema.optional(), + elementId: IdSchema.optional(), +}); + +const MentionNotificationBodySchema = z.object({ + workspaceId: IdSchema, + createdByUserId: IdSchema, + doc: MentionDocSchema, +}); + +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, + inviteId: 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 = 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, userId: string) { + await this.db.notification.update({ + where: { id: notificationId, userId }, + data: { + read: true, + }, + }); + } + + /** + * 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; + } + + async cleanExpiredNotifications() { + const { count } = await this.db.notification.deleteMany({ + // delete notifications that are older than one year + where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } }, + }); + this.logger.log(`Deleted ${count} expired notifications`); + return count; + } + + // #endregion +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 4d73971275..f14454fb45 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -330,7 +330,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -401,8 +401,11 @@ enum ErrorNames { MAILER_SERVICE_IS_NOT_CONFIGURED MEMBER_NOT_FOUND_IN_SPACE MEMBER_QUOTA_EXCEEDED + MENTION_USER_DOC_ACCESS_DENIED + MENTION_USER_ONESELF_DENIED MISSING_OAUTH_QUERY_PARAMETER NETWORK_ERROR + NOTIFICATION_NOT_FOUND NOT_FOUND NOT_IN_SPACE NO_COPILOT_PROVIDER_AVAILABLE @@ -531,6 +534,42 @@ type InvalidRuntimeConfigTypeDataType { want: String! } +type InvitationAcceptedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + inviteId: String! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + +type InvitationBlockedNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + inviteId: String! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + +type InvitationNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + inviteId: ID! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + type InvitationType { """Invitee information""" invitee: UserType! @@ -677,6 +716,45 @@ type MemberNotFoundInSpaceDataType { spaceId: String! } +input MentionDocInput { + """The block id in the doc""" + blockId: String + + """The element id in the doc""" + elementId: String + id: String! + title: String! +} + +type MentionDocType { + blockId: String + elementId: String + id: String! + title: String! +} + +input MentionInput { + doc: MentionDocInput! + userId: String! + workspaceId: String! +} + +type MentionNotificationBodyType { + """ + The user who created the notification, maybe null when user is deleted or sent by system + """ + createdByUser: PublicUserType + doc: MentionDocType! + + """The type of the notification""" + type: NotificationType! + workspace: NotificationWorkspaceType +} + +type MentionUserDocAccessDeniedDataType { + docId: String! +} + type MissingOauthQueryParameterDataType { name: String! } @@ -740,8 +818,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!): ID! 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! @@ -798,6 +882,64 @@ type NotInSpaceDataType { spaceId: 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! + + """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 + InvitationRejected + Mention +} + +type NotificationWorkspaceType { + """Workspace avatar url""" + avatarUrl: String + id: ID! + + """Workspace name""" + name: String! +} + enum OAuthProviderType { GitHub Google @@ -817,6 +959,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 @@ -1120,6 +1268,8 @@ enum SubscriptionVariant { Onetime } +union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | MentionNotificationBodyType + type UnknownOauthProviderDataType { name: String! } @@ -1226,6 +1376,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!]!