Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: user settings #8556

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/lib/db/user-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -60,6 +65,7 @@ const rowToUser = (row) => {
createdAt: row.created_at,
isService: row.is_service,
scimId: row.scim_id,
settings: row.settings,
});
};

Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/lib/features/user-settings/createUserSettingsService.ts
Original file line number Diff line number Diff line change
@@ -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);
};
97 changes: 97 additions & 0 deletions src/lib/features/user-settings/user-settings-controller.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
48 changes: 48 additions & 0 deletions src/lib/features/user-settings/user-settings-service.ts
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async set(
async setProperty(

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 },
}),
);
}
}
107 changes: 107 additions & 0 deletions src/lib/features/user-settings/user-settings.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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('[email protected]');
// 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',
// });
// });
// });
});
2 changes: 2 additions & 0 deletions src/lib/openapi/spec/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
23 changes: 23 additions & 0 deletions src/lib/openapi/spec/set-user-setting-schema.ts
Original file line number Diff line number Diff line change
@@ -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>;
Loading
Loading