Skip to content

Commit

Permalink
feat(server): notification
Browse files Browse the repository at this point in the history
  • Loading branch information
fengmk2 committed Feb 25, 2025
1 parent ed5f40b commit d60677a
Show file tree
Hide file tree
Showing 20 changed files with 1,778 additions and 17 deletions.
42 changes: 42 additions & 0 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
1 change: 1 addition & 0 deletions packages/backend/server/src/__tests__/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './blobs';
export * from './invite';
export * from './notification';
export * from './permission';
export * from './testing-app';
export * from './testing-module';
Expand Down
87 changes: 87 additions & 0 deletions packages/backend/server/src/__tests__/utils/notification.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedNotificationObjectType> {
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<number> {
const res = await app.gql(
`
query notificationCount {
currentUser {
notificationCount
}
}
`
);
return res.currentUser.notificationCount;
}

export async function mentionUser(
app: TestingApp,
input: MentionInput
): Promise<boolean> {
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<boolean> {
const res = await app.gql(
`
mutation readNotification($id: String!) {
readNotification(id: $id)
}
`,
{ id }
);
return res.readNotification;
}
18 changes: 14 additions & 4 deletions packages/backend/server/src/__tests__/utils/testing-app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -162,6 +164,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
});

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,
Expand All @@ -182,12 +185,19 @@ export class TestingApp extends ApplyType<INestApplication>() {
return res.body.data;
}

async createUser(email: string, override?: Partial<User>): Promise<TestUser> {
private randomEmail() {
return `test-${randomUUID()}@affine.pro`;
}

async createUser(
email?: string,
override?: Partial<User>
): Promise<TestUser> {
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(),
Expand All @@ -200,8 +210,8 @@ export class TestingApp extends ApplyType<INestApplication>() {
return user as Omit<User, 'password'> & { password: string };
}

async signup(email: string, override?: Partial<User>) {
const user = await this.createUser(email, override);
async signup(email?: string, override?: Partial<User>) {
const user = await this.createUser(email ?? this.randomEmail(), override);
await this.login(user);
return user;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/__tests__/utils/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function createWorkspace(app: TestingApp): Promise<WorkspaceType> {
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
id,
}
}`,
variables: { init: null },
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, UserFriendlyErrorOptions>;
40 changes: 38 additions & 2 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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,
});
24 changes: 17 additions & 7 deletions packages/backend/server/src/base/graphql/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
},
};
Expand Down Expand Up @@ -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<T>(
list: T[],
Expand All @@ -63,7 +73,7 @@ export function paginate<T>(
): PaginatedType<T> {
const edges = list.map(item => ({
node: item,
cursor: encode(String(item[cursorField])),
cursor: encode(item[cursorField]),
}));

return {
Expand Down Expand Up @@ -102,7 +112,7 @@ export class PageInfo {
hasPreviousPage!: boolean;
}

export function Paginated<T>(classRef: Type<T>): any {
export function Paginated<T>(classRef: Type<T>) {
@ObjectType(`${classRef.name}Edge`)
abstract class EdgeType {
@Field(() => String)
Expand Down
Loading

0 comments on commit d60677a

Please sign in to comment.