diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index a7d0c07d04..fbe9cec78f 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -29,6 +29,7 @@ import EnvironmentStore from './environment-store'; import FeatureTagStore from './feature-tag-store'; import { FeatureEnvironmentStore } from './feature-environment-store'; import { ClientMetricsStoreV2 } from './client-metrics-store-v2'; +import UserSplashStore from './user-splash-store'; export const createStores = ( config: IUnleashConfig, @@ -85,6 +86,7 @@ export const createStores = ( eventBus, getLogger, ), + userSplashStore: new UserSplashStore(db, eventBus, getLogger), }; }; diff --git a/src/lib/db/user-splash-store.ts b/src/lib/db/user-splash-store.ts new file mode 100644 index 0000000000..6b867ee4d7 --- /dev/null +++ b/src/lib/db/user-splash-store.ts @@ -0,0 +1,105 @@ +import { Knex } from 'knex'; +import { EventEmitter } from 'events'; +import { LogProvider, Logger } from '../logger'; +import { + IUserSplash, + IUserSplashKey, + IUserSplashStore, +} from '../types/stores/user-splash-store'; + +const COLUMNS = ['user_id', 'splash_id', 'seen']; +const TABLE = 'user_splash'; + +interface IUserSplashTable { + seen?: boolean; + splash_id: string; + user_id: number; +} + +const fieldToRow = (fields: IUserSplash): IUserSplashTable => ({ + seen: fields.seen, + splash_id: fields.splashId, + user_id: fields.userId, +}); + +const rowToField = (row: IUserSplashTable): IUserSplash => ({ + seen: row.seen, + splashId: row.splash_id, + userId: row.user_id, +}); + +export default class UserSplashStore implements IUserSplashStore { + private db: Knex; + + private logger: Logger; + + constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('user-splash-store.ts'); + } + + async getAllUserSplashs(userId: number): Promise { + const userSplash = await this.db + .table(TABLE) + .select() + .where({ user_id: userId }); + + return userSplash.map(rowToField); + } + + async getSplash(userId: number, splashId: string): Promise { + const userSplash = await this.db + .table(TABLE) + .select() + .where({ user_id: userId, splash_id: splashId }) + .first(); + + return rowToField(userSplash); + } + + async updateSplash(splash: IUserSplash): Promise { + const insertedSplash = await this.db + .table(TABLE) + .insert(fieldToRow(splash)) + .onConflict(['user_id', 'splash_id']) + .merge() + .returning(COLUMNS); + + return rowToField(insertedSplash[0]); + } + + async delete({ userId, splashId }: IUserSplashKey): Promise { + await this.db(TABLE) + .where({ user_id: userId, splash_id: splashId }) + .del(); + } + + async deleteAll(): Promise { + await this.db(TABLE).del(); + } + + destroy(): void {} + + async exists({ userId, splashId }: IUserSplashKey): Promise { + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE user_id = ? AND splash_id = ?) AS present`, + [userId, splashId], + ); + const { present } = result.rows[0]; + return present; + } + + async get({ userId, splashId }: IUserSplashKey): Promise { + return this.getSplash(userId, splashId); + } + + async getAll(): Promise { + const userSplashs = await this.db + .table(TABLE) + .select(); + + return userSplashs.map(rowToField); + } +} + +module.exports = UserSplashStore; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 3e0edf6114..f1aa75c7fa 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -21,6 +21,7 @@ import ApiTokenController from './api-token-controller'; import UserAdminController from './user-admin'; import EmailController from './email'; import UserFeedbackController from './user-feedback-controller'; +import UserSplashController from './user-splash-controller'; import ProjectApi from './project'; import { EnvironmentsController } from './environments-controller'; @@ -92,6 +93,10 @@ class AdminApi extends Controller { '/environments', new EnvironmentsController(config, services).router, ); + this.app.use( + '/splash', + new UserSplashController(config, services).router, + ); } index(req, res) { diff --git a/src/lib/routes/admin-api/user-splash-controller.ts b/src/lib/routes/admin-api/user-splash-controller.ts new file mode 100644 index 0000000000..07f3b7a049 --- /dev/null +++ b/src/lib/routes/admin-api/user-splash-controller.ts @@ -0,0 +1,49 @@ +import { Response } from 'express'; + +import Controller from '../controller'; +import { Logger } from '../../logger'; +import { IUnleashConfig } from '../../types/option'; +import { IUnleashServices } from '../../types/services'; +import UserSplashService from '../../services/user-splash-service'; +import { IAuthRequest } from '../unleash-types'; + +interface ISplashBody { + seen: boolean; + splashId: string; +} + +class UserSplashController extends Controller { + private logger: Logger; + + private userSplashService: UserSplashService; + + constructor( + config: IUnleashConfig, + { userSplashService }: Pick, + ) { + super(config); + this.logger = config.getLogger('splash-controller.ts'); + this.userSplashService = userSplashService; + + this.post('/:id', this.updateSplashSettings); + } + + private async updateSplashSettings( + req: IAuthRequest, + res: Response, + ): Promise { + const { user } = req; + const { id } = req.params; + + const splash = { + splashId: id, + userId: user.id, + seen: true, + }; + const updated = await this.userSplashService.updateSplash(splash); + res.json(updated); + } +} + +module.exports = UserSplashController; +export default UserSplashController; diff --git a/src/lib/routes/admin-api/user.ts b/src/lib/routes/admin-api/user.ts index 6b5299311f..a0449d1b8d 100644 --- a/src/lib/routes/admin-api/user.ts +++ b/src/lib/routes/admin-api/user.ts @@ -7,6 +7,7 @@ import { IUnleashServices } from '../../types/services'; import UserService from '../../services/user-service'; import SessionService from '../../services/session-service'; import UserFeedbackService from '../../services/user-feedback-service'; +import UserSplashService from '../../services/user-splash-service'; interface IChangeUserRequest { password: string; @@ -22,6 +23,8 @@ class UserController extends Controller { private sessionService: SessionService; + private userSplashService: UserSplashService; + constructor( config: IUnleashConfig, { @@ -29,12 +32,14 @@ class UserController extends Controller { userService, sessionService, userFeedbackService, + userSplashService, }: Pick< IUnleashServices, | 'accessService' | 'userService' | 'sessionService' | 'userFeedbackService' + | 'userSplashService' >, ) { super(config); @@ -42,6 +47,7 @@ class UserController extends Controller { this.userService = userService; this.sessionService = sessionService; this.userFeedbackService = userFeedbackService; + this.userSplashService = userSplashService; this.get('/', this.getUser); this.post('/change-password', this.updateUserPass); @@ -57,11 +63,15 @@ class UserController extends Controller { const feedback = await this.userFeedbackService.getAllUserFeedback( user, ); + const splash = await this.userSplashService.getAllUserSplashs(user); // TODO: remove this line after we remove it from db. delete user.permissions; - return res.status(200).json({ user, permissions, feedback }).end(); + return res + .status(200) + .json({ user, permissions, feedback, splash }) + .end(); } async updateUserPass( diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 8e39e913cb..5d3ddc3e95 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -27,6 +27,7 @@ import FeatureToggleService from './feature-toggle-service'; import EnvironmentService from './environment-service'; import FeatureTagService from './feature-tag-service'; import ProjectHealthService from './project-health-service'; +import UserSplashService from './user-splash-service'; export const createServices = ( stores: IUnleashStores, @@ -72,6 +73,7 @@ export const createServices = ( accessService, featureToggleServiceV2, ); + const userSplashService = new UserSplashService(stores, config); return { accessService, @@ -100,6 +102,7 @@ export const createServices = ( userFeedbackService, featureTagService, projectHealthService, + userSplashService, }; }; diff --git a/src/lib/services/user-splash-service.ts b/src/lib/services/user-splash-service.ts new file mode 100644 index 0000000000..7d919c7dce --- /dev/null +++ b/src/lib/services/user-splash-service.ts @@ -0,0 +1,54 @@ +import { Logger } from '../logger'; +import { IUnleashStores } from '../types/stores'; +import { IUnleashConfig } from '../types/option'; +import User from '../types/user'; +import { + IUserSplash, + IUserSplashStore, +} from '../types/stores/user-splash-store'; + +export default class UserSplashService { + private userSplashStore: IUserSplashStore; + + private logger: Logger; + + constructor( + { userSplashStore }: Pick, + { getLogger }: Pick, + ) { + this.userSplashStore = userSplashStore; + this.logger = getLogger('services/user-splash-service.js'); + } + + async getAllUserSplashs(user: User): Promise { + if (user.isAPI) { + return []; + } + try { + const splashs = ( + await this.userSplashStore.getAllUserSplashs(user.id) + ).reduce( + (splashObject, splash) => ({ + ...splashObject, + [splash.splashId]: splash.seen, + }), + {}, + ); + return splashs; + } catch (err) { + this.logger.error(err); + + return {}; + } + } + + async getSplash(user_id: number, splash_id: string): Promise { + return this.userSplashStore.getSplash(user_id, splash_id); + } + + async updateSplash(splash: IUserSplash): Promise { + return this.userSplashStore.updateSplash(splash); + } +} + +module.exports = UserSplashService; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index b2528df9f8..52dab846bb 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -23,6 +23,7 @@ import EnvironmentService from '../services/environment-service'; import FeatureTagService from '../services/feature-tag-service'; import ProjectHealthService from '../services/project-health-service'; import ClientMetricsServiceV2 from '../services/client-metrics/client-metrics-service-v2'; +import UserSplashService from '../services/user-splash-service'; export interface IUnleashServices { accessService: AccessService; @@ -51,4 +52,5 @@ export interface IUnleashServices { userFeedbackService: UserFeedbackService; userService: UserService; versionService: VersionService; + userSplashService: UserSplashService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index ff22f24b3f..cb92c0c44a 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -23,6 +23,7 @@ import { IFeatureStrategiesStore } from './stores/feature-strategies-store'; import { IEnvironmentStore } from './stores/environment-store'; import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store'; import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2'; +import { IUserSplashStore } from './stores/user-splash-store'; export interface IUnleashStores { accessStore: IAccessStore; @@ -50,4 +51,5 @@ export interface IUnleashStores { tagTypeStore: ITagTypeStore; userFeedbackStore: IUserFeedbackStore; userStore: IUserStore; + userSplashStore: IUserSplashStore; } diff --git a/src/lib/types/stores/user-splash-store.ts b/src/lib/types/stores/user-splash-store.ts new file mode 100644 index 0000000000..75938981ef --- /dev/null +++ b/src/lib/types/stores/user-splash-store.ts @@ -0,0 +1,18 @@ +import { Store } from './store'; + +export interface IUserSplash { + seen: boolean; + splashId: string; + userId: number; +} + +export interface IUserSplashKey { + userId: number; + splashId: string; +} + +export interface IUserSplashStore extends Store { + getAllUserSplashs(userId: number): Promise; + getSplash(userId: number, splashId: string): Promise; + updateSplash(splash: IUserSplash): Promise; +} diff --git a/src/migrations/20211108130333-create-user-splash-table.js b/src/migrations/20211108130333-create-user-splash-table.js new file mode 100644 index 0000000000..38ea54e3d9 --- /dev/null +++ b/src/migrations/20211108130333-create-user-splash-table.js @@ -0,0 +1,25 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + CREATE TABLE IF NOT EXISTS user_splash + (user_id INTEGER NOT NULL references users (id) ON DELETE CASCADE, + splash_id TEXT, + seen BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (user_id, splash_id)); + CREATE INDEX user_splash_user_id_idx ON user_splash (user_id); + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + DROP INDEX user_splash_user_id_idx; + DROP TABLE user_splash; +`, + cb, + ); +}; diff --git a/src/migrations/20211109103930-add-splash-entry-for-users.js b/src/migrations/20211109103930-add-splash-entry-for-users.js new file mode 100644 index 0000000000..65d70586ea --- /dev/null +++ b/src/migrations/20211109103930-add-splash-entry-for-users.js @@ -0,0 +1,10 @@ +exports.up = function (db, cb) { + db.runSql( + `INSERT INTO user_splash(splash_id, user_id, seen) SELECT 'environment', u.id, false FROM users u`, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql('DELETE FROM user_splash', cb); +}; diff --git a/src/test/e2e/api/admin/splash.e2e.test.ts b/src/test/e2e/api/admin/splash.e2e.test.ts new file mode 100644 index 0000000000..61d0c6db26 --- /dev/null +++ b/src/test/e2e/api/admin/splash.e2e.test.ts @@ -0,0 +1,68 @@ +import { Application, NextFunction, Request, Response } from 'express'; +import { setupAppWithCustomAuth } from '../../helpers/test-helper'; +import dbInit from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { IUnleashConfig } from '../../../../lib/types/option'; +import { IUnleashServices } from '../../../../lib/types/services'; + +let stores; +let db; +let app; + +beforeAll(async () => { + db = await dbInit('splash_api_serial', getLogger); + stores = db.stores; + + const email = 'custom-user@mail.com'; + + const preHook = ( + application: Application, + config: IUnleashConfig, + { userService }: IUnleashServices, + ) => { + application.use( + '/api/admin/', + async (req: Request, res: Response, next: NextFunction) => { + // @ts-ignore + req.user = await userService.loginUserWithoutPassword( + email, + true, + ); + next(); + }, + ); + }; + + app = await setupAppWithCustomAuth(stores, preHook); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('it updates splash for user', async () => { + expect.assertions(1); + + return app.request + .post('/api/admin/splash/environment') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.seen).toBe(true); + }); +}); + +test('it retrieves splash for user', async () => { + expect.assertions(1); + + return app.request + .get('/api/admin/user') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.splash).toStrictEqual({ environment: true }); + }); +}); diff --git a/src/test/e2e/stores/user-splash-store.e2e.test.ts b/src/test/e2e/stores/user-splash-store.e2e.test.ts new file mode 100644 index 0000000000..73050f2c0b --- /dev/null +++ b/src/test/e2e/stores/user-splash-store.e2e.test.ts @@ -0,0 +1,82 @@ +import { IUserSplashStore } from 'lib/types/stores/user-splash-store'; +import { IUserStore } from 'lib/types/stores/user-store'; +import dbInit from '../helpers/database-init'; +import getLogger from '../../fixtures/no-logger'; + +let stores; +let db; +let userSplashStore: IUserSplashStore; +let userStore: IUserStore; +let currentUser; + +beforeAll(async () => { + db = await dbInit('user_splash_store', getLogger); + stores = db.stores; + userSplashStore = stores.userSplashStore; + userStore = stores.userStore; + currentUser = await userStore.upsert({ email: 'me.feedback@mail.com' }); +}); + +afterAll(async () => { + await db.destroy(); +}); + +afterEach(async () => { + await userSplashStore.deleteAll(); +}); + +test('should create userSplash', async () => { + await userSplashStore.updateSplash({ + splashId: 'some-id', + userId: currentUser.id, + seen: false, + }); + const userSplashs = await userSplashStore.getAllUserSplashs(currentUser.id); + expect(userSplashs).toHaveLength(1); + expect(userSplashs[0].splashId).toBe('some-id'); +}); + +test('should get userSplash', async () => { + await userSplashStore.updateSplash({ + splashId: 'some-id', + userId: currentUser.id, + seen: false, + }); + const userSplash = await userSplashStore.getSplash( + currentUser.id, + 'some-id', + ); + expect(userSplash.splashId).toBe('some-id'); +}); + +test('should exists', async () => { + await userSplashStore.updateSplash({ + splashId: 'some-id-3', + userId: currentUser.id, + seen: false, + }); + const exists = await userSplashStore.exists({ + userId: currentUser.id, + splashId: 'some-id-3', + }); + expect(exists).toBe(true); +}); + +test('should not exists', async () => { + const exists = await userSplashStore.exists({ + userId: currentUser.id, + splashId: 'some-id-not-here', + }); + expect(exists).toBe(false); +}); + +test('should get all userSplashs', async () => { + await userSplashStore.updateSplash({ + splashId: 'some-id-2', + userId: currentUser.id, + seen: false, + }); + const userSplashs = await userSplashStore.getAll(); + expect(userSplashs).toHaveLength(1); + expect(userSplashs[0].splashId).toBe('some-id-2'); +}); diff --git a/src/test/fixtures/fake-user-splash-store.ts b/src/test/fixtures/fake-user-splash-store.ts new file mode 100644 index 0000000000..5b3c6e8331 --- /dev/null +++ b/src/test/fixtures/fake-user-splash-store.ts @@ -0,0 +1,50 @@ +import { + IUserSplashKey, + IUserSplash, + IUserSplashStore, +} from '../../lib/types/stores/user-splash-store'; + +export default class FakeUserSplashStore implements IUserSplashStore { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAllUserSplashs(userId: number): Promise { + return Promise.resolve([]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getSplash(userId: number, splashId: string): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + updateSplash(splash: IUserSplash): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + exists(key: IUserSplashKey): Promise { + return Promise.resolve(false); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get(key: IUserSplashKey): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAll(): Promise { + return Promise.resolve([]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + delete(key: IUserSplashKey): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deleteAll(): Promise { + return Promise.resolve(undefined); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + destroy(): void {} +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 5e36e35ff0..6b6bf25d04 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -24,6 +24,7 @@ import FakeFeatureTypeStore from './fake-feature-type-store'; import FakeResetTokenStore from './fake-reset-token-store'; import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store'; import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2'; +import FakeUserSplashStore from './fake-user-splash-store'; const createStores: () => IUnleashStores = () => { const db = { @@ -59,6 +60,7 @@ const createStores: () => IUnleashStores = () => { featureTypeStore: new FakeFeatureTypeStore(), resetTokenStore: new FakeResetTokenStore(), sessionStore: new FakeSessionStore(), + userSplashStore: new FakeUserSplashStore(), }; };