mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-06 08:50:50 +08:00
@@ -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;
|
||||||
@@ -35,6 +35,8 @@ model User {
|
|||||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||||
createdUpdate Update[] @relation("createdUpdate")
|
createdUpdate Update[] @relation("createdUpdate")
|
||||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||||
|
// receive notifications
|
||||||
|
notifications Notification[] @relation("user_notifications")
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -624,3 +626,39 @@ model Blob {
|
|||||||
@@id([workspaceId, key])
|
@@id([workspaceId, key])
|
||||||
@@map("blobs")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './blobs';
|
export * from './blobs';
|
||||||
export * from './invite';
|
export * from './invite';
|
||||||
|
export * from './notification';
|
||||||
export * from './permission';
|
export * from './permission';
|
||||||
export * from './testing-app';
|
export * from './testing-app';
|
||||||
export * from './testing-module';
|
export * from './testing-module';
|
||||||
|
|||||||
@@ -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<PaginatedNotificationObjectType> {
|
||||||
|
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<number> {
|
||||||
|
const res = await app.gql(
|
||||||
|
`
|
||||||
|
query notificationCount {
|
||||||
|
currentUser {
|
||||||
|
notificationCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return res.currentUser.notificationCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mentionUser(
|
||||||
|
app: TestingApp,
|
||||||
|
input: MentionInput
|
||||||
|
): Promise<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { INestApplication, ModuleMetadata } from '@nestjs/common';
|
import { INestApplication, ModuleMetadata } from '@nestjs/common';
|
||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||||
@@ -182,12 +184,19 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
|||||||
return res.body.data;
|
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);
|
const model = this.get(UserModel);
|
||||||
// TODO(@forehalo): model factories
|
// TODO(@forehalo): model factories
|
||||||
// TestingData.user.create()
|
// TestingData.user.create()
|
||||||
const user = await model.create({
|
const user = await model.create({
|
||||||
email,
|
email: email ?? this.randomEmail(),
|
||||||
password: '1',
|
password: '1',
|
||||||
name: email,
|
name: email,
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
@@ -200,8 +209,8 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
|||||||
return user as Omit<User, 'password'> & { password: string };
|
return user as Omit<User, 'password'> & { password: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
async signup(email: string, override?: Partial<User>) {
|
async signup(email?: string, override?: Partial<User>) {
|
||||||
const user = await this.createUser(email, override);
|
const user = await this.createUser(email ?? this.randomEmail(), override);
|
||||||
await this.login(user);
|
await this.login(user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { DocStorageModule } from './core/doc';
|
|||||||
import { DocRendererModule } from './core/doc-renderer';
|
import { DocRendererModule } from './core/doc-renderer';
|
||||||
import { DocServiceModule } from './core/doc-service';
|
import { DocServiceModule } from './core/doc-service';
|
||||||
import { FeatureModule } from './core/features';
|
import { FeatureModule } from './core/features';
|
||||||
|
import { NotificationModule } from './core/notification';
|
||||||
import { PermissionModule } from './core/permission';
|
import { PermissionModule } from './core/permission';
|
||||||
import { QuotaModule } from './core/quota';
|
import { QuotaModule } from './core/quota';
|
||||||
import { SelfhostModule } from './core/selfhost';
|
import { SelfhostModule } from './core/selfhost';
|
||||||
@@ -218,7 +219,7 @@ export function buildAppModule() {
|
|||||||
.use(UserModule, AuthModule, PermissionModule)
|
.use(UserModule, AuthModule, PermissionModule)
|
||||||
|
|
||||||
// business modules
|
// business modules
|
||||||
.use(FeatureModule, QuotaModule, DocStorageModule)
|
.use(FeatureModule, QuotaModule, DocStorageModule, NotificationModule)
|
||||||
|
|
||||||
// sync server only
|
// sync server only
|
||||||
.useIf(config => config.flavor.sync, SyncModule)
|
.useIf(config => config.flavor.sync, SyncModule)
|
||||||
|
|||||||
@@ -767,4 +767,19 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
message: ({ clientVersion, requiredVersion }) =>
|
message: ({ clientVersion, requiredVersion }) =>
|
||||||
`Unsupported client with version [${clientVersion}], required version is [${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<string, UserFriendlyErrorOptions>;
|
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||||
|
|||||||
@@ -855,6 +855,28 @@ export class UnsupportedClientVersion extends UserFriendlyError {
|
|||||||
super('action_forbidden', 'unsupported_client_version', message, args);
|
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 {
|
export enum ErrorNames {
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
NETWORK_ERROR,
|
NETWORK_ERROR,
|
||||||
@@ -963,7 +985,10 @@ export enum ErrorNames {
|
|||||||
INVALID_LICENSE_TO_ACTIVATE,
|
INVALID_LICENSE_TO_ACTIVATE,
|
||||||
INVALID_LICENSE_UPDATE_PARAMS,
|
INVALID_LICENSE_UPDATE_PARAMS,
|
||||||
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE,
|
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, {
|
registerEnumType(ErrorNames, {
|
||||||
name: 'ErrorNames'
|
name: 'ErrorNames'
|
||||||
@@ -972,5 +997,5 @@ registerEnumType(ErrorNames, {
|
|||||||
export const ErrorDataUnionType = createUnionType({
|
export const ErrorDataUnionType = createUnionType({
|
||||||
name: 'ErrorDataUnion',
|
name: 'ErrorDataUnion',
|
||||||
types: () =>
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export class PaginationInput {
|
|||||||
transform: value => {
|
transform: value => {
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
after: value.after ? decode(value.after) : null,
|
after: decode(value.after),
|
||||||
// before: value.before ? decode(value.before) : null,
|
// before: decode(value.before),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -51,9 +51,19 @@ export class PaginationInput {
|
|||||||
// before?: string | null;
|
// before?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encode = (input: string) => Buffer.from(input).toString('base64');
|
const encode = (input: unknown) => {
|
||||||
const decode = (base64String: string) =>
|
let inputStr: string;
|
||||||
Buffer.from(base64String, 'base64').toString('utf-8');
|
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>(
|
export function paginate<T>(
|
||||||
list: T[],
|
list: T[],
|
||||||
@@ -63,7 +73,7 @@ export function paginate<T>(
|
|||||||
): PaginatedType<T> {
|
): PaginatedType<T> {
|
||||||
const edges = list.map(item => ({
|
const edges = list.map(item => ({
|
||||||
node: item,
|
node: item,
|
||||||
cursor: encode(String(item[cursorField])),
|
cursor: encode(item[cursorField]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<Context>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PaginatedNotificationObjectType> {
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InvitationNotificationBody>
|
||||||
|
{
|
||||||
|
@Field(() => ID)
|
||||||
|
inviteId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class InvitationAcceptedNotificationBodyType
|
||||||
|
extends BaseNotificationBodyType
|
||||||
|
implements Partial<InvitationNotificationBody>
|
||||||
|
{
|
||||||
|
@Field(() => String)
|
||||||
|
inviteId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class InvitationBlockedNotificationBodyType
|
||||||
|
extends BaseNotificationBodyType
|
||||||
|
implements Partial<InvitationNotificationBody>
|
||||||
|
{
|
||||||
|
@Field(() => String)
|
||||||
|
inviteId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnionNotificationBodyType = createUnionType({
|
||||||
|
name: 'UnionNotificationBodyType',
|
||||||
|
types: () =>
|
||||||
|
[
|
||||||
|
MentionNotificationBodyType,
|
||||||
|
InvitationNotificationBodyType,
|
||||||
|
InvitationAcceptedNotificationBodyType,
|
||||||
|
InvitationBlockedNotificationBodyType,
|
||||||
|
] as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class NotificationObjectType implements Partial<Notification> {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -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<Context>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { DocModel } from './doc';
|
|||||||
import { DocUserModel } from './doc-user';
|
import { DocUserModel } from './doc-user';
|
||||||
import { FeatureModel } from './feature';
|
import { FeatureModel } from './feature';
|
||||||
import { HistoryModel } from './history';
|
import { HistoryModel } from './history';
|
||||||
|
import { NotificationModel } from './notification';
|
||||||
import { MODELS_SYMBOL } from './provider';
|
import { MODELS_SYMBOL } from './provider';
|
||||||
import { SessionModel } from './session';
|
import { SessionModel } from './session';
|
||||||
import { UserModel } from './user';
|
import { UserModel } from './user';
|
||||||
@@ -34,6 +35,7 @@ const MODELS = {
|
|||||||
workspaceUser: WorkspaceUserModel,
|
workspaceUser: WorkspaceUserModel,
|
||||||
docUser: DocUserModel,
|
docUser: DocUserModel,
|
||||||
history: HistoryModel,
|
history: HistoryModel,
|
||||||
|
notification: NotificationModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelsType = {
|
type ModelsType = {
|
||||||
@@ -90,6 +92,7 @@ export * from './doc';
|
|||||||
export * from './doc-user';
|
export * from './doc-user';
|
||||||
export * from './feature';
|
export * from './feature';
|
||||||
export * from './history';
|
export * from './history';
|
||||||
|
export * from './notification';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './user-doc';
|
export * from './user-doc';
|
||||||
|
|||||||
@@ -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<typeof MentionNotificationCreateSchema>;
|
||||||
|
|
||||||
|
export type InvitationNotification = Notification &
|
||||||
|
z.infer<typeof InvitationNotificationCreateSchema>;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -330,7 +330,7 @@ type EditorType {
|
|||||||
name: String!
|
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 {
|
enum ErrorNames {
|
||||||
ACCESS_DENIED
|
ACCESS_DENIED
|
||||||
@@ -401,8 +401,11 @@ enum ErrorNames {
|
|||||||
MAILER_SERVICE_IS_NOT_CONFIGURED
|
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||||
MEMBER_NOT_FOUND_IN_SPACE
|
MEMBER_NOT_FOUND_IN_SPACE
|
||||||
MEMBER_QUOTA_EXCEEDED
|
MEMBER_QUOTA_EXCEEDED
|
||||||
|
MENTION_USER_DOC_ACCESS_DENIED
|
||||||
|
MENTION_USER_ONESELF_DENIED
|
||||||
MISSING_OAUTH_QUERY_PARAMETER
|
MISSING_OAUTH_QUERY_PARAMETER
|
||||||
NETWORK_ERROR
|
NETWORK_ERROR
|
||||||
|
NOTIFICATION_NOT_FOUND
|
||||||
NOT_FOUND
|
NOT_FOUND
|
||||||
NOT_IN_SPACE
|
NOT_IN_SPACE
|
||||||
NO_COPILOT_PROVIDER_AVAILABLE
|
NO_COPILOT_PROVIDER_AVAILABLE
|
||||||
@@ -531,6 +534,42 @@ type InvalidRuntimeConfigTypeDataType {
|
|||||||
want: String!
|
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 {
|
type InvitationType {
|
||||||
"""Invitee information"""
|
"""Invitee information"""
|
||||||
invitee: UserType!
|
invitee: UserType!
|
||||||
@@ -677,6 +716,45 @@ type MemberNotFoundInSpaceDataType {
|
|||||||
spaceId: String!
|
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 {
|
type MissingOauthQueryParameterDataType {
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
@@ -740,8 +818,14 @@ type Mutation {
|
|||||||
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
|
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
|
||||||
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
||||||
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
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!
|
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
||||||
publishPage(mode: PublicDocMode = Page, pageId: String!, workspaceId: String!): DocType! @deprecated(reason: "use publishDoc instead")
|
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!
|
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||||
|
|
||||||
@@ -798,6 +882,64 @@ type NotInSpaceDataType {
|
|||||||
spaceId: String!
|
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 {
|
enum OAuthProviderType {
|
||||||
GitHub
|
GitHub
|
||||||
Google
|
Google
|
||||||
@@ -817,6 +959,12 @@ type PaginatedGrantedDocUserType {
|
|||||||
totalCount: Int!
|
totalCount: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedNotificationObjectType {
|
||||||
|
edges: [NotificationObjectTypeEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
totalCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
input PaginationInput {
|
input PaginationInput {
|
||||||
"""returns the elements in the list that come after the specified cursor."""
|
"""returns the elements in the list that come after the specified cursor."""
|
||||||
after: String
|
after: String
|
||||||
@@ -1120,6 +1268,8 @@ enum SubscriptionVariant {
|
|||||||
Onetime
|
Onetime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
union UnionNotificationBodyType = InvitationAcceptedNotificationBodyType | InvitationBlockedNotificationBodyType | InvitationNotificationBodyType | MentionNotificationBodyType
|
||||||
|
|
||||||
type UnknownOauthProviderDataType {
|
type UnknownOauthProviderDataType {
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
@@ -1226,6 +1376,12 @@ type UserType {
|
|||||||
|
|
||||||
"""User name"""
|
"""User name"""
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
"""Get user notification count"""
|
||||||
|
notificationCount: Int!
|
||||||
|
|
||||||
|
"""Get current user notifications"""
|
||||||
|
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!
|
||||||
quota: UserQuotaType!
|
quota: UserQuotaType!
|
||||||
quotaUsage: UserQuotaUsageType!
|
quotaUsage: UserQuotaUsageType!
|
||||||
subscriptions: [SubscriptionType!]!
|
subscriptions: [SubscriptionType!]!
|
||||||
|
|||||||
Reference in New Issue
Block a user