diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index e6a783c564f6..ce0c3fe7a4c6 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -27,7 +27,12 @@ const USER_COLUMNS_PUBLIC = [ 'scim_id', ]; -const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at']; +const USER_COLUMNS = [ + ...USER_COLUMNS_PUBLIC, + 'login_attempts', + 'created_at', + 'settings', +]; const emptify = (value) => { if (!value) { @@ -60,6 +65,7 @@ const rowToUser = (row) => { createdAt: row.created_at, isService: row.is_service, scimId: row.scim_id, + settings: row.settings, }); }; @@ -308,6 +314,31 @@ class UserStore implements IUserStore { return firstInstanceUser ? firstInstanceUser.created_at : null; } + + async getSettings(userId: number): Promise> { + const row = await this.activeUsers() + .where({ id: userId }) + .first('settings'); + if (!row) { + throw new NotFoundError('User not found'); + } + return row.settings || {}; + } + + async setSettings( + userId: number, + newSettings: Record, + ): Promise> { + const oldSettings = await this.getSettings(userId); + const settings = { ...oldSettings, ...newSettings }; + Object.keys(settings).forEach((key) => { + if (settings[key] === null) { + delete settings[key]; + } + }); + await this.activeUsers().where({ id: userId }).update({ settings }); + return settings as Record; + } } module.exports = UserStore; diff --git a/src/lib/features/user-settings/createUserSettingsService.ts b/src/lib/features/user-settings/createUserSettingsService.ts new file mode 100644 index 000000000000..194ff743f2fd --- /dev/null +++ b/src/lib/features/user-settings/createUserSettingsService.ts @@ -0,0 +1,11 @@ +import type { IUnleashConfig, IUnleashStores } from '../../types'; +import type EventService from '../events/event-service'; +import { UserSettingsService } from './user-settings-service'; + +export const createUserSettingsService = ( + stores: Pick, + config: Pick, + eventService: EventService, +): UserSettingsService => { + return new UserSettingsService(stores, config, eventService); +}; diff --git a/src/lib/features/user-settings/user-settings-controller.ts b/src/lib/features/user-settings/user-settings-controller.ts new file mode 100644 index 000000000000..3feae57e5f5b --- /dev/null +++ b/src/lib/features/user-settings/user-settings-controller.ts @@ -0,0 +1,97 @@ +import Controller from '../../routes/controller'; +import type { OpenApiService } from '../../services'; +import type { UserSettingsService } from './user-settings-service'; +import type { + IFlagResolver, + IUnleashConfig, + IUnleashServices, +} from '../../types'; +import { + createRequestSchema, + createResponseSchema, + getStandardResponses, +} from '../../openapi'; +import { ForbiddenError } from '../../error'; + +export default class UserSettingsController extends Controller { + private userSettingsService: UserSettingsService; + + private flagResolver: IFlagResolver; + + private openApiService: OpenApiService; + + constructor(config: IUnleashConfig, services: IUnleashServices) { + super(config); + this.userSettingsService = services.userSettingsService; + this.openApiService = services.openApiService; + this.flagResolver = config.flagResolver; + + this.route({ + method: 'get', + path: '', + handler: this.getUserSettings, + permission: 'user', + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], // TODO: Remove this tag when the endpoint is stable + operationId: 'getUserSettings', + summary: 'Get user settings', + description: + 'Get the settings for the currently authenticated user.', + responses: { + 200: createResponseSchema('userSettingsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + + this.route({ + method: 'put', + path: '', + handler: this.updateUserSettings, + permission: 'user', + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], // TODO: Update/remove when endpoint stabilizes + operationId: 'updateUserSettings', + summary: 'Update user settings', + description: 'Update a specific user setting by key.', + requestBody: createRequestSchema('setUserSettingSchema'), + responses: { + 204: { description: 'Setting updated successfully' }, + ...getStandardResponses(400, 401, 403, 409, 415), + }, + }), + ], + }); + } + + async getUserSettings(req, res) { + if (!this.flagResolver.isEnabled('userSettings')) { + throw new ForbiddenError('User settings feature is not enabled'); + } + const { user } = req; + const settings = await this.userSettingsService.getAll(user.id); + res.json(settings); + } + + async updateUserSettings(req, res) { + if (!this.flagResolver.isEnabled('userSettings')) { + throw new ForbiddenError('User settings feature is not enabled'); + } + const { user } = req; + const { key, value } = req.body; + const allowedSettings = ['productivity-insights-email']; + + if (!allowedSettings.includes(key)) { + res.status(400).json({ + message: `Invalid setting key`, + }); + return; + } + + await this.userSettingsService.set(user.id, key, value, user); + res.status(204).end(); + } +} diff --git a/src/lib/features/user-settings/user-settings-service.ts b/src/lib/features/user-settings/user-settings-service.ts new file mode 100644 index 000000000000..cfe75201cdc2 --- /dev/null +++ b/src/lib/features/user-settings/user-settings-service.ts @@ -0,0 +1,48 @@ +import type { IUnleashStores } from '../../types/stores'; + +import type { Logger } from '../../logger'; +import type { IUnleashConfig } from '../../types/option'; +import type EventService from '../events/event-service'; +import { + type IAuditUser, + UserSettingsUpdatedEvent, + type IUserStore, +} from '../../types'; +import type { UserSettingsSchema } from '../../openapi/spec/user-settings-schema'; + +export class UserSettingsService { + private userStore: IUserStore; + + private eventService: EventService; + + private logger: Logger; + + constructor( + { userStore }: Pick, + { getLogger }: Pick, + eventService: EventService, + ) { + this.userStore = userStore; + this.eventService = eventService; + this.logger = getLogger('services/user-settings-service.js'); + } + + async getAll(userId: number): Promise { + return this.userStore.getSettings(userId); + } + + async set( + userId: number, + param: string, + value: string, + auditUser: IAuditUser, + ) { + await this.userStore.setSettings(userId, { [param]: value }); + await this.eventService.storeEvent( + new UserSettingsUpdatedEvent({ + auditUser, + data: { userId, param, value }, + }), + ); + } +} diff --git a/src/lib/features/user-settings/user-settings.e2e.test.ts b/src/lib/features/user-settings/user-settings.e2e.test.ts new file mode 100644 index 000000000000..7136ae2ad44e --- /dev/null +++ b/src/lib/features/user-settings/user-settings.e2e.test.ts @@ -0,0 +1,107 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import { + type IUnleashTest, + setupAppWithAuth, +} from '../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../test/fixtures/no-logger'; +import type { IUserStore } from '../../types'; + +let app: IUnleashTest; +let db: ITestDb; +let userStore: IUserStore; + +const loginUser = (email: string) => { + return app.request + .post(`/auth/demo/login`) + .send({ + email, + }) + .expect(200); +}; + +beforeAll(async () => { + db = await dbInit('user_settings', getLogger); + app = await setupAppWithAuth( + db.stores, + { + experimental: { + flags: { + userSettings: true, + }, + }, + }, + db.rawDatabase, + ); + userStore = db.stores.userStore; +}); + +afterAll(async () => { + getLogger.setMuteError(false); + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => { + await db.stores.userStore.deleteAll(); + await db.stores.eventStore.deleteAll(); +}); + +describe('UserSettingsController', () => { + test('should return user settings', async () => { + const { body: user } = await loginUser('test@example.com'); + // console.log({user}) + // await db.stores.userStore.setSettings(1, { + // 'productivity-insights-email': 'true', + // }); + // const { body } = await app.request + // .put(`/api/admin/user/settings`) + // .send({ + // key: 'productivity-insights-email', + // value: 'new_value', + // }) + // .expect(204); + + const res = await app.request.get('/api/admin/user').expect(200); + + // expect(res.body).toEqual({ 'productivity-insights-email': 'true' }); + }); + + // test('should return empty object if no settings are available', async () => { + // const res = await app.request + // .get('/api/admin/user/settings') + // // .set('Authorization', `Bearer ${userId}`) + // .expect(200); + + // expect(res.body).toEqual({}); + // }); + + // describe('PUT /settings/:key', () => { + // const allowedKey = 'productivity-insights-email'; + + // test('should update user setting if key is valid', async () => { + // const res = await app.request + // .put(`/api/admin/user/settings/${allowedKey}`) + // // .set('Authorization', `Bearer ${userId}`) + // .send({ value: 'new_value' }) + // .expect(204); + + // expect(res.body).toEqual({}); + + // const updatedSetting = + // await db.stores.userStore.getSettings(userId); + // expect(updatedSetting.value).toEqual('new_value'); + // }); + + // test('should return 400 for invalid setting key', async () => { + // const res = await app.request + // .put(`/api/admin/user/settings/invalid-key`) + // // .set('Authorization', `Bearer ${userId}`) + // .send({ value: 'some_value' }) + // .expect(400); + + // expect(res.body).toEqual({ + // message: 'Invalid setting key', + // }); + // }); + // }); +}); diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index aea41ffcbfdb..bc1874cb9060 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -179,6 +179,7 @@ export * from './segment-strategies-schema'; export * from './segments-schema'; export * from './set-strategy-sort-order-schema'; export * from './set-ui-config-schema'; +export * from './set-user-setting-schema'; export * from './sort-order-schema'; export * from './splash-request-schema'; export * from './splash-response-schema'; @@ -208,6 +209,7 @@ export * from './update-tags-schema'; export * from './update-user-schema'; export * from './upsert-segment-schema'; export * from './user-schema'; +export * from './user-settings-schema'; export * from './users-groups-base-schema'; export * from './users-schema'; export * from './users-search-schema'; diff --git a/src/lib/openapi/spec/set-user-setting-schema.ts b/src/lib/openapi/spec/set-user-setting-schema.ts new file mode 100644 index 000000000000..9dffe0d2395a --- /dev/null +++ b/src/lib/openapi/spec/set-user-setting-schema.ts @@ -0,0 +1,23 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const setUserSettingSchema = { + $id: '#/components/schemas/setUserSettingSchema', + type: 'object', + description: 'Schema for setting a user-specific value', + required: ['key', 'value'], + properties: { + key: { + type: 'string', + description: 'Setting key', + example: 'email', + }, + value: { + type: 'string', + description: 'The setting value for the user', + example: 'optOut', + }, + }, + components: {}, +} as const; + +export type SetUserSettingSchema = FromSchema; diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index dff7f74afcef..bb44dae6bdd6 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -99,6 +99,17 @@ export const userSchema = { nullable: true, example: '01HTMEXAMPLESCIMID7SWWGHN6', }, + settings: { + description: 'User settings', + type: 'object', + nullable: true, + additionalProperties: { + type: 'string', + }, + example: { + 'productivity-insights-email': 'true', + }, + }, }, components: {}, } as const; diff --git a/src/lib/openapi/spec/user-settings-schema.ts b/src/lib/openapi/spec/user-settings-schema.ts new file mode 100644 index 000000000000..e61b5365b997 --- /dev/null +++ b/src/lib/openapi/spec/user-settings-schema.ts @@ -0,0 +1,23 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const userSettingsSchema = { + $id: '#/components/schemas/userSettingsSchema', + type: 'object', + required: ['settings'], + description: 'Schema representing user-specific settings in the system.', + properties: { + settings: { + type: 'object', + additionalProperties: { + type: 'string', + description: 'A user setting, represented as a key-value pair.', + example: '{"dark_mode_enabled": "true"}', + }, + description: + 'An object containing key-value pairs representing user settings.', + }, + }, + components: {}, +} as const; + +export type UserSettingsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 0763c50582d8..70e0fc94aba4 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -36,6 +36,7 @@ import { InactiveUsersController } from '../../users/inactive/inactive-users-con import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller'; import { SearchApi } from './search'; import PersonalDashboardController from '../../features/personal-dashboard/personal-dashboard-controller'; +import UserSettingsController from '../../features/user-settings/user-settings-controller'; export class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { @@ -80,6 +81,10 @@ export class AdminApi extends Controller { '/user/tokens', new PatController(config, services).router, ); + this.app.use( + '/user/settings', + new UserSettingsController(config, services).router, + ); this.app.use( '/ui-config', diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index c0c6fa78a944..d90d23fb0e33 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -153,6 +153,7 @@ import { createFakePersonalDashboardService, createPersonalDashboardService, } from '../features/personal-dashboard/createPersonalDashboardService'; +import { createUserSettingsService } from '../features/user-settings/createUserSettingsService'; export const createServices = ( stores: IUnleashStores, @@ -236,6 +237,12 @@ export const createServices = ( sessionService, settingService, }); + const userSettingsService = createUserSettingsService( + stores, + config, + eventService, + ); + const accountService = new AccountService(stores, config, { accessService, }); @@ -482,6 +489,7 @@ export const createServices = ( integrationEventsService, onboardingService, personalDashboardService, + userSettingsService, }; }; diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index d71c28df5f91..865a67f391c8 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -204,6 +204,8 @@ export const ACTIONS_CREATED = 'actions-created' as const; export const ACTIONS_UPDATED = 'actions-updated' as const; export const ACTIONS_DELETED = 'actions-deleted' as const; +export const USER_SETTINGS_UPDATED = 'user-settings-updated' as const; + export const IEventTypes = [ APPLICATION_CREATED, FEATURE_CREATED, @@ -351,6 +353,7 @@ export const IEventTypes = [ ACTIONS_CREATED, ACTIONS_UPDATED, ACTIONS_DELETED, + USER_SETTINGS_UPDATED, ] as const; export type IEventType = (typeof IEventTypes)[number]; @@ -2024,3 +2027,16 @@ function mapUserToData(user: IUserEventData): any { rootRole: user.rootRole, }; } + +export class UserSettingsUpdatedEvent extends BaseEvent { + readonly data: any; + readonly preData: any; + + constructor(eventData: { + auditUser: IAuditUser; + data: any; + }) { + super(USER_SETTINGS_UPDATED, eventData.auditUser); + this.data = eventData.data; + } +} diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 7d0373adafb5..124a04951e86 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -62,7 +62,8 @@ export type IFlagKey = | 'addonUsageMetrics' | 'releasePlans' | 'navigationSidebar' - | 'productivityReportEmail'; + | 'productivityReportEmail' + | 'userSettings'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -311,6 +312,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_PRODUCTIVITY_REPORT_EMAIL, false, ), + userSettings: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_USER_SETTINGS, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 626553f6c1ce..5ae2ac8092a2 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -57,6 +57,7 @@ import type { FeatureLifecycleService } from '../features/feature-lifecycle/feat import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; import type { OnboardingService } from '../features/onboarding/onboarding-service'; import type { PersonalDashboardService } from '../features/personal-dashboard/personal-dashboard-service'; +import type { UserSettingsService } from '../features/user-settings/user-settings-service'; export interface IUnleashServices { transactionalAccessService: WithTransactional; @@ -126,4 +127,5 @@ export interface IUnleashServices { integrationEventsService: IntegrationEventsService; onboardingService: OnboardingService; personalDashboardService: PersonalDashboardService; + userSettingsService: UserSettingsService; } diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 2ca1f39a1e32..e5f6495004ab 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -39,4 +39,9 @@ export interface IUserStore extends Store { successfullyLogin(user: IUser): Promise; count(): Promise; countServiceAccounts(): Promise; + getSettings(userId: number): Promise>; + setSettings( + userId: number, + settings: Record, + ): Promise>; } diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index c07e994cdd65..5602cfcd5521 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -15,6 +15,7 @@ export interface UserData { createdAt?: Date; isService?: boolean; scimId?: string; + settings?: Record; } export interface IUser { @@ -31,6 +32,7 @@ export interface IUser { imageUrl?: string; accountType?: AccountType; scimId?: string; + settings?: Record; } export type MinimalUser = Pick< @@ -73,6 +75,8 @@ export default class User implements IUser { scimId?: string; + settings?: Record; + constructor({ id, name, @@ -84,6 +88,7 @@ export default class User implements IUser { createdAt, isService, scimId, + settings, }: UserData) { if (!id) { throw new ValidationError('Id is required', [], undefined); @@ -102,6 +107,7 @@ export default class User implements IUser { this.createdAt = createdAt; this.accountType = isService ? 'Service Account' : 'User'; this.scimId = scimId; + this.settings = settings; } generateImageUrl(): string { diff --git a/src/test/e2e/stores/user-store.e2e.test.ts b/src/test/e2e/stores/user-store.e2e.test.ts index dda39b36e491..2d85b929bd48 100644 --- a/src/test/e2e/stores/user-store.e2e.test.ts +++ b/src/test/e2e/stores/user-store.e2e.test.ts @@ -193,3 +193,26 @@ test('should delete user', async () => { new NotFoundError('No user found'), ); }); + +test('should set and update user settings', async () => { + const user = await stores.userStore.upsert({ + email: 'user.with.settings@example.com', + }); + + await stores.userStore.setSettings(user.id, { + theme: 'dark', + }); + + expect(await stores.userStore.getSettings(user.id)).toEqual({ + theme: 'dark', + }); + + await stores.userStore.setSettings(user.id, { + emailOptOut: 'true', + theme: null, + }); + + expect(await stores.userStore.getSettings(user.id)).toEqual({ + emailOptOut: 'true', + }); +}); diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index a201d70c4029..bd4e393fc826 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -182,6 +182,17 @@ class UserStoreMock implements IUserStore { async markSeenAt(secrets: string[]): Promise { throw new Error('Not implemented'); } + + async getSettings(userId: number): Promise> { + throw new Error('Not implemented'); + } + + async setSettings( + userId: number, + settings: Record, + ): Promise> { + throw new Error('Not implemented'); + } } module.exports = UserStoreMock;