1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-17 01:17:29 +02:00

feat: user settings

This commit is contained in:
Tymoteusz Czech 2024-10-28 16:20:07 +01:00
parent 7c37de373f
commit dcca4f8e15
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
18 changed files with 474 additions and 2 deletions

View File

@ -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<Record<string, string>> {
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<string, string | null>,
): Promise<Record<string, string>> {
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<string, string>;
}
}
module.exports = UserStore;

View File

@ -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<IUnleashStores, 'userStore'>,
config: Pick<IUnleashConfig, 'getLogger'>,
eventService: EventService,
): UserSettingsService => {
return new UserSettingsService(stores, config, eventService);
};

View File

@ -0,0 +1,98 @@
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) {
console.log({ req, flag: this.flagResolver.isEnabled('userSettings') });
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();
}
}

View File

@ -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<IUnleashStores, 'userStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
eventService: EventService,
) {
this.userStore = userStore;
this.eventService = eventService;
this.logger = getLogger('services/user-settings-service.js');
}
async getAll(userId: number): Promise<UserSettingsSchema['settings']> {
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 },
}),
);
}
}

View File

@ -0,0 +1,111 @@
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);
userStore = db.stores.userStore;
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {
userSettings: true,
},
},
},
db.rawDatabase,
);
});
afterAll(async () => {
getLogger.setMuteError(false);
await app.destroy();
await db.destroy();
});
// beforeEach(async () => {
// await db.stores.featureToggleStore.deleteAll();
// await db.stores.userStore.deleteAll();
// await db.stores.eventStore.deleteAll();
// await db.stores.userStore.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/settings')
.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',
// });
// });
// });
});

View File

@ -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';

View File

@ -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<typeof setUserSettingSchema>;

View File

@ -0,0 +1,44 @@
import { validateSchema } from '../validate';
test('upsertSegmentSchema', () => {
const validObjects = [
{
name: 'segment',
constraints: [],
},
{
name: 'segment',
description: 'description',
constraints: [],
},
{
name: 'segment',
description: 'description',
constraints: [],
additional: 'property',
},
];
validObjects.forEach((obj) =>
expect(
validateSchema('#/components/schemas/upsertSegmentSchema', obj),
).toBeUndefined(),
);
const invalidObjects = [
{
name: 'segment',
},
{
description: 'description',
constraints: [],
},
{},
];
invalidObjects.forEach((obj) =>
expect(
validateSchema('#/components/schemas/upsertSegmentSchema', obj),
).toMatchSnapshot(),
);
});

View File

@ -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<typeof userSettingsSchema>;

View File

@ -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',

View File

@ -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,
};
};

View File

@ -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;
}
}

View File

@ -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 = {

View File

@ -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<AccessService>;
@ -126,4 +127,5 @@ export interface IUnleashServices {
integrationEventsService: IntegrationEventsService;
onboardingService: OnboardingService;
personalDashboardService: PersonalDashboardService;
userSettingsService: UserSettingsService;
}

View File

@ -39,4 +39,9 @@ export interface IUserStore extends Store<IUser, number> {
successfullyLogin(user: IUser): Promise<number>;
count(): Promise<number>;
countServiceAccounts(): Promise<number>;
getSettings(userId: number): Promise<Record<string, string>>;
setSettings(
userId: number,
settings: Record<string, string | null>,
): Promise<Record<string, string>>;
}

View File

@ -15,6 +15,7 @@ export interface UserData {
createdAt?: Date;
isService?: boolean;
scimId?: string;
settings?: Record<string, string>;
}
export interface IUser {
@ -31,6 +32,7 @@ export interface IUser {
imageUrl?: string;
accountType?: AccountType;
scimId?: string;
settings?: Record<string, string>;
}
export type MinimalUser = Pick<
@ -73,6 +75,8 @@ export default class User implements IUser {
scimId?: string;
settings?: Record<string, string>;
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 {

View File

@ -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',
});
});

View File

@ -182,6 +182,17 @@ class UserStoreMock implements IUserStore {
async markSeenAt(secrets: string[]): Promise<void> {
throw new Error('Not implemented');
}
async getSettings(userId: number): Promise<Record<string, string>> {
throw new Error('Not implemented');
}
async setSettings(
userId: number,
settings: Record<string, string>,
): Promise<Record<string, string>> {
throw new Error('Not implemented');
}
}
module.exports = UserStoreMock;