From 3f0981a6fa645ac5f9e6b683ebb6a933ad2b6bce Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Tue, 18 Mar 2025 00:41:21 +0000 Subject: [PATCH] feat(server): add settings resolver (#10797) close CLOUD-166 --- .../server/src/__tests__/utils/index.ts | 1 + .../server/src/__tests__/utils/settings.ts | 33 +++++++ .../src/core/user/__tests__/resolver.e2e.ts | 86 +++++++++++++++++++ .../backend/server/src/core/user/index.ts | 13 ++- .../backend/server/src/core/user/resolver.ts | 32 ++++++- .../backend/server/src/core/user/types.ts | 25 +++++- packages/backend/server/src/schema.gql | 22 +++++ packages/frontend/graphql/src/schema.ts | 23 +++++ 8 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 packages/backend/server/src/__tests__/utils/settings.ts create mode 100644 packages/backend/server/src/core/user/__tests__/resolver.e2e.ts diff --git a/packages/backend/server/src/__tests__/utils/index.ts b/packages/backend/server/src/__tests__/utils/index.ts index d472088579..5d5e388143 100644 --- a/packages/backend/server/src/__tests__/utils/index.ts +++ b/packages/backend/server/src/__tests__/utils/index.ts @@ -2,6 +2,7 @@ export * from './blobs'; export * from './invite'; export * from './notification'; export * from './permission'; +export * from './settings'; export * from './testing-app'; export * from './testing-module'; export * from './user'; diff --git a/packages/backend/server/src/__tests__/utils/settings.ts b/packages/backend/server/src/__tests__/utils/settings.ts new file mode 100644 index 0000000000..a0729c729c --- /dev/null +++ b/packages/backend/server/src/__tests__/utils/settings.ts @@ -0,0 +1,33 @@ +import type { SettingsType, UpdateSettingsInput } from '../../core/user/types'; +import type { TestingApp } from './testing-app'; + +export async function getSettings(app: TestingApp): Promise { + const res = await app.gql( + ` + query settings { + currentUser { + settings { + receiveInvitationEmail + receiveMentionEmail + } + } + } + ` + ); + return res.currentUser.settings; +} + +export async function updateSettings( + app: TestingApp, + input: UpdateSettingsInput +): Promise { + const res = await app.gql( + ` + mutation updateSettings($input: UpdateSettingsInput!) { + updateSettings(input: $input) + } + `, + { input } + ); + return res.updateSettings; +} diff --git a/packages/backend/server/src/core/user/__tests__/resolver.e2e.ts b/packages/backend/server/src/core/user/__tests__/resolver.e2e.ts new file mode 100644 index 0000000000..f1311608f7 --- /dev/null +++ b/packages/backend/server/src/core/user/__tests__/resolver.e2e.ts @@ -0,0 +1,86 @@ +import test from 'ava'; + +import { + createTestingApp, + getSettings, + TestingApp, + updateSettings, +} from '../../../__tests__/utils'; + +let app: TestingApp; + +test.before(async () => { + app = await createTestingApp(); +}); + +test.after.always(async () => { + await app.close(); +}); + +test('should get user settings', async t => { + await app.signup(); + const settings = await getSettings(app); + t.deepEqual(settings, { + receiveInvitationEmail: true, + receiveMentionEmail: true, + }); +}); + +test('should update user settings', async t => { + await app.signup(); + await updateSettings(app, { + receiveInvitationEmail: false, + receiveMentionEmail: false, + }); + const settings = await getSettings(app); + t.deepEqual(settings, { + receiveInvitationEmail: false, + receiveMentionEmail: false, + }); + + await updateSettings(app, { + receiveMentionEmail: true, + }); + const settings2 = await getSettings(app); + t.deepEqual(settings2, { + receiveInvitationEmail: false, + receiveMentionEmail: true, + }); + + await updateSettings(app, { + // ignore undefined value + receiveInvitationEmail: undefined, + }); + const settings3 = await getSettings(app); + t.deepEqual(settings3, { + receiveInvitationEmail: false, + receiveMentionEmail: true, + }); +}); + +test('should throw error when update user settings with invalid input', async t => { + await app.signup(); + await t.throwsAsync( + updateSettings(app, { + receiveInvitationEmail: false, + // @ts-expect-error invalid value + receiveMentionEmail: null, + }), + { + message: /Expected boolean, received null/, + } + ); +}); + +test('should not update user settings when not logged in', async t => { + await app.logout(); + await t.throwsAsync( + updateSettings(app, { + receiveInvitationEmail: false, + receiveMentionEmail: false, + }), + { + message: 'You must sign in first to access this resource.', + } + ); +}); diff --git a/packages/backend/server/src/core/user/index.ts b/packages/backend/server/src/core/user/index.ts index 1b084162a6..14aca45408 100644 --- a/packages/backend/server/src/core/user/index.ts +++ b/packages/backend/server/src/core/user/index.ts @@ -4,11 +4,20 @@ import { PermissionModule } from '../permission'; import { StorageModule } from '../storage'; import { UserAvatarController } from './controller'; import { UserEventsListener } from './event'; -import { UserManagementResolver, UserResolver } from './resolver'; +import { + UserManagementResolver, + UserResolver, + UserSettingsResolver, +} from './resolver'; @Module({ imports: [StorageModule, PermissionModule], - providers: [UserResolver, UserManagementResolver, UserEventsListener], + providers: [ + UserResolver, + UserManagementResolver, + UserEventsListener, + UserSettingsResolver, + ], controllers: [UserAvatarController], }) export class UserModule {} diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index a69679d415..63a7bf595a 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -7,6 +7,7 @@ import { Mutation, ObjectType, Query, + ResolveField, Resolver, } from '@nestjs/graphql'; import { PrismaClient } from '@prisma/client'; @@ -19,7 +20,7 @@ import { Throttle, UserNotFound, } from '../../base'; -import { Models } from '../../models'; +import { Models, SettingsSchema } from '../../models'; import { Public } from '../auth/guard'; import { sessionUser } from '../auth/service'; import { CurrentUser } from '../auth/session'; @@ -31,6 +32,8 @@ import { ManageUserInput, PublicUserType, RemoveAvatar, + SettingsType, + UpdateSettingsInput, UpdateUserInput, UserOrLimitedUser, UserType, @@ -155,6 +158,33 @@ export class UserResolver { } } +@Resolver(() => UserType) +export class UserSettingsResolver { + constructor(private readonly models: Models) {} + + @Mutation(() => Boolean, { + name: 'updateSettings', + description: 'Update user settings', + }) + async updateSettings( + @CurrentUser() user: CurrentUser, + @Args('input', { type: () => UpdateSettingsInput }) + input: UpdateSettingsInput + ) { + SettingsSchema.parse(input); + await this.models.settings.set(user.id, input); + return true; + } + + @ResolveField(() => SettingsType, { + name: 'settings', + description: 'Get user settings', + }) + async getSettings(@CurrentUser() me: CurrentUser): Promise { + return await this.models.settings.get(me.id); + } +} + @InputType() class ListUserInput { @Field(() => Int, { nullable: true, defaultValue: 0 }) diff --git a/packages/backend/server/src/core/user/types.ts b/packages/backend/server/src/core/user/types.ts index 6affbf9c07..ecfc48ff2e 100644 --- a/packages/backend/server/src/core/user/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -7,7 +7,12 @@ import { } from '@nestjs/graphql'; import type { User } from '@prisma/client'; -import { PublicUser, WorkspaceUser } from '../../models'; +import { + PublicUser, + Settings, + SettingsInput, + WorkspaceUser, +} from '../../models'; import { type CurrentUser } from '../auth/session'; @ObjectType() @@ -109,6 +114,15 @@ export class RemoveAvatar { success!: boolean; } +@ObjectType() +export class SettingsType implements Settings { + @Field({ description: 'Receive invitation email' }) + receiveInvitationEmail!: boolean; + + @Field({ description: 'Receive mention email' }) + receiveMentionEmail!: boolean; +} + @InputType() export class UpdateUserInput implements Partial { @Field({ description: 'User name', nullable: true }) @@ -123,3 +137,12 @@ export class ManageUserInput { @Field({ description: 'User name', nullable: true }) name?: string; } + +@InputType() +export class UpdateSettingsInput implements SettingsInput { + @Field({ description: 'Receive invitation email', nullable: true }) + receiveInvitationEmail?: boolean; + + @Field({ description: 'Receive mention email', nullable: true }) + receiveMentionEmail?: boolean; +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 6050039555..c5d82eeb0d 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -943,6 +943,9 @@ type Mutation { """update multiple server runtime configurable settings""" updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! + + """Update user settings""" + updateSettings(input: UpdateSettingsInput!): Boolean! updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType! """Update an user""" @@ -1278,6 +1281,14 @@ type ServerServiceConfig { name: String! } +type SettingsType { + """Receive invitation email""" + receiveInvitationEmail: Boolean! + + """Receive mention email""" + receiveMentionEmail: Boolean! +} + type SpaceAccessDeniedDataType { spaceId: String! } @@ -1403,6 +1414,14 @@ input UpdateDocUserRoleInput { workspaceId: String! } +input UpdateSettingsInput { + """Receive invitation email""" + receiveInvitationEmail: Boolean + + """Receive mention email""" + receiveMentionEmail: Boolean +} + input UpdateUserInput { """User name""" name: String @@ -1495,6 +1514,9 @@ type UserType { notifications(pagination: PaginationInput!): PaginatedNotificationObjectType! quota: UserQuotaType! quotaUsage: UserQuotaUsageType! + + """Get user settings""" + settings: SettingsType! subscriptions: [SubscriptionType!]! token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead") } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index bd5ec0a545..879950d84c 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -1053,6 +1053,8 @@ export interface Mutation { updateRuntimeConfig: ServerRuntimeConfigType; /** update multiple server runtime configurable settings */ updateRuntimeConfigs: Array; + /** Update user settings */ + updateSettings: Scalars['Boolean']['output']; updateSubscriptionRecurring: SubscriptionType; /** Update an user */ updateUser: UserType; @@ -1372,6 +1374,10 @@ export interface MutationUpdateRuntimeConfigsArgs { updates: Scalars['JSONObject']['input']; } +export interface MutationUpdateSettingsArgs { + input: UpdateSettingsInput; +} + export interface MutationUpdateSubscriptionRecurringArgs { idempotencyKey?: InputMaybe; plan?: InputMaybe; @@ -1756,6 +1762,14 @@ export interface ServerServiceConfig { name: Scalars['String']['output']; } +export interface SettingsType { + __typename?: 'SettingsType'; + /** Receive invitation email */ + receiveInvitationEmail: Scalars['Boolean']['output']; + /** Receive mention email */ + receiveMentionEmail: Scalars['Boolean']['output']; +} + export interface SpaceAccessDeniedDataType { __typename?: 'SpaceAccessDeniedDataType'; spaceId: Scalars['String']['output']; @@ -1897,6 +1911,13 @@ export interface UpdateDocUserRoleInput { workspaceId: Scalars['String']['input']; } +export interface UpdateSettingsInput { + /** Receive invitation email */ + receiveInvitationEmail?: InputMaybe; + /** Receive mention email */ + receiveMentionEmail?: InputMaybe; +} + export interface UpdateUserInput { /** User name */ name?: InputMaybe; @@ -1983,6 +2004,8 @@ export interface UserType { notifications: PaginatedNotificationObjectType; quota: UserQuotaType; quotaUsage: UserQuotaUsageType; + /** Get user settings */ + settings: SettingsType; subscriptions: Array; /** @deprecated use [/api/auth/sign-in?native=true] instead */ token: TokenType;