diff --git a/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts new file mode 100644 index 0000000000..59c60b7213 --- /dev/null +++ b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts @@ -0,0 +1,9 @@ +import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; + +export class FakePersonalDashboardReadModel + implements IPersonalDashboardReadModel +{ + async getPersonalFeatures(userId: number): Promise<{ name: string }[]> { + return []; + } +} diff --git a/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts b/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts new file mode 100644 index 0000000000..260b0bac1e --- /dev/null +++ b/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts @@ -0,0 +1,63 @@ +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'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('personal_dashboard', getLogger); + app = await setupAppWithAuth( + db.stores, + { + experimental: { + flags: {}, + }, + }, + db.rawDatabase, + ); +}); + +const loginUser = (email: string) => { + return app.request + .post(`/auth/demo/login`) + .send({ + email, + }) + .expect(200); +}; + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => { + await db.stores.featureToggleStore.deleteAll(); +}); + +test('should return personal dashboard with own flags and favorited flags', async () => { + await loginUser('other_user@getunleash.io'); + await app.createFeature('other_feature_a'); + await app.createFeature('other_feature_b'); + + await loginUser('my_user@getunleash.io'); + await app.createFeature('my_feature_c'); + await app.createFeature('my_feature_d'); + await app.favoriteFeature('other_feature_b'); + await app.favoriteFeature('my_feature_d'); + + const { body } = await app.request.get(`/api/admin/personal-dashboard`); + + expect(body).toMatchObject({ + projects: [], + flags: [ + { name: 'my_feature_c' }, + { name: 'my_feature_d' }, + { name: 'other_feature_b' }, + ], + }); +}); diff --git a/src/lib/features/personal-dashboard/personal-dashboard-controller.ts b/src/lib/features/personal-dashboard/personal-dashboard-controller.ts new file mode 100644 index 0000000000..057002884c --- /dev/null +++ b/src/lib/features/personal-dashboard/personal-dashboard-controller.ts @@ -0,0 +1,73 @@ +import { type IUnleashConfig, type IUnleashServices, NONE } from '../../types'; +import type { OpenApiService } from '../../services'; +import { + createResponseSchema, + getStandardResponses, + personalDashboardSchema, + type PersonalDashboardSchema, +} from '../../openapi'; +import Controller from '../../routes/controller'; +import type { Response } from 'express'; +import type { IAuthRequest } from '../../routes/unleash-types'; +import type { PersonalDashboardService } from './personal-dashboard-service'; + +const PATH = ''; + +export default class PersonalDashboardController extends Controller { + private openApiService: OpenApiService; + + private personalDashboardService: PersonalDashboardService; + + constructor( + config: IUnleashConfig, + { + openApiService, + personalDashboardService, + }: Pick< + IUnleashServices, + 'openApiService' | 'personalDashboardService' + >, + ) { + super(config); + this.openApiService = openApiService; + this.personalDashboardService = personalDashboardService; + + this.route({ + method: 'get', + path: PATH, + handler: this.getPersonalDashboard, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + summary: 'Get personal dashboard', + description: + 'Return all projects and flags that are relevant to the user.', + operationId: 'getPersonalDashboard', + responses: { + 200: createResponseSchema('personalDashboardSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async getPersonalDashboard( + req: IAuthRequest, + res: Response, + ): Promise { + const user = req.user; + + const flags = await this.personalDashboardService.getPersonalFeatures( + user.id, + ); + + this.openApiService.respondWithValidation( + 200, + res, + personalDashboardSchema.$id, + { projects: [], flags }, + ); + } +} diff --git a/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts b/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts new file mode 100644 index 0000000000..234b6878c1 --- /dev/null +++ b/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts @@ -0,0 +1,3 @@ +export interface IPersonalDashboardReadModel { + getPersonalFeatures(userId: number): Promise<{ name: string }[]>; +} diff --git a/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts b/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts new file mode 100644 index 0000000000..8d6074aa2c --- /dev/null +++ b/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts @@ -0,0 +1,22 @@ +import type { Db } from '../../db/db'; +import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; + +export class PersonalDashboardReadModel implements IPersonalDashboardReadModel { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + getPersonalFeatures(userId: number): Promise<{ name: string }[]> { + return this.db<{ name: string }>('favorite_features') + .where('favorite_features.user_id', userId) + .select('feature as name') + .union(function () { + this.select('name') + .from('features') + .where('features.created_by_user_id', userId); + }) + .orderBy('name', 'asc'); + } +} diff --git a/src/lib/features/personal-dashboard/personal-dashboard-service.ts b/src/lib/features/personal-dashboard/personal-dashboard-service.ts new file mode 100644 index 0000000000..e24b34a5a3 --- /dev/null +++ b/src/lib/features/personal-dashboard/personal-dashboard-service.ts @@ -0,0 +1,13 @@ +import type { IPersonalDashboardReadModel } from './personal-dashboard-read-model-type'; + +export class PersonalDashboardService { + private personalDashboardReadModel: IPersonalDashboardReadModel; + + constructor(personalDashboardReadModel: IPersonalDashboardReadModel) { + this.personalDashboardReadModel = personalDashboardReadModel; + } + + getPersonalFeatures(userId: number): Promise<{ name: string }[]> { + return this.personalDashboardReadModel.getPersonalFeatures(userId); + } +} diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index a18706ab4e..8b6fa7457e 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -133,6 +133,7 @@ export * from './patch-schema'; export * from './patches-schema'; export * from './pats-schema'; export * from './permission-schema'; +export * from './personal-dashboard-schema'; export * from './playground-constraint-schema'; export * from './playground-feature-schema'; export * from './playground-request-schema'; diff --git a/src/lib/openapi/spec/personal-dashboard-schema.ts b/src/lib/openapi/spec/personal-dashboard-schema.ts new file mode 100644 index 0000000000..4c902a2455 --- /dev/null +++ b/src/lib/openapi/spec/personal-dashboard-schema.ts @@ -0,0 +1,51 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const personalDashboardSchema = { + $id: '#/components/schemas/personalDashboardSchema', + type: 'object', + description: 'Project and flags relevant to the user', + additionalProperties: false, + required: ['projects', 'flags'], + properties: { + projects: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id'], + properties: { + id: { + type: 'string', + example: 'my-project-id', + description: 'The id of the project', + }, + }, + }, + description: + 'A list of projects that a user participates in with any role e.g. member or owner or any custom role', + }, + flags: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { + type: 'string', + example: 'my-flag', + description: 'The name of the flag', + }, + }, + }, + description: 'A list of flags a user created or favorited', + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type PersonalDashboardSchema = FromSchema< + typeof personalDashboardSchema +>; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index f645465eeb..0763c50582 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -35,6 +35,7 @@ import { SegmentsController } from '../../features/segment/segment-controller'; import { InactiveUsersController } from '../../users/inactive/inactive-users-controller'; import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller'; import { SearchApi } from './search'; +import PersonalDashboardController from '../../features/personal-dashboard/personal-dashboard-controller'; export class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { @@ -120,6 +121,10 @@ export class AdminApi extends Controller { '/projects', new ProjectController(config, services, db).router, ); + this.app.use( + '/personal-dashboard', + new PersonalDashboardController(config, services).router, + ); this.app.use( '/environments', new EnvironmentsController(config, services).router, diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index ff98fc293c..1be5769f15 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -146,6 +146,9 @@ import { createOnboardingService, } from '../features/onboarding/createOnboardingService'; import { OnboardingService } from '../features/onboarding/onboarding-service'; +import { PersonalDashboardService } from '../features/personal-dashboard/personal-dashboard-service'; +import { PersonalDashboardReadModel } from '../features/personal-dashboard/personal-dashboard-read-model'; +import { FakePersonalDashboardReadModel } from '../features/personal-dashboard/fake-personal-dashboard-read-model'; export const createServices = ( stores: IUnleashStores, @@ -401,6 +404,12 @@ export const createServices = ( : createFakeOnboardingService(config).onboardingService; onboardingService.listen(); + const personalDashboardService = new PersonalDashboardService( + db + ? new PersonalDashboardReadModel(db) + : new FakePersonalDashboardReadModel(), + ); + return { accessService, accountService, @@ -464,6 +473,7 @@ export const createServices = ( transactionalFeatureLifecycleService, integrationEventsService, onboardingService, + personalDashboardService, }; }; @@ -514,4 +524,5 @@ export { FeatureLifecycleService, IntegrationEventsService, OnboardingService, + PersonalDashboardService, }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 6386b686f6..f84ca20e8f 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -56,6 +56,7 @@ import type { JobService } from '../features/scheduler/job-service'; import type { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; 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'; export interface IUnleashServices { accessService: AccessService; @@ -123,4 +124,5 @@ export interface IUnleashServices { transactionalFeatureLifecycleService: WithTransactional; integrationEventsService: IntegrationEventsService; onboardingService: OnboardingService; + personalDashboardService: PersonalDashboardService; }