From 87b9f4f713742a194f18bc91c23b019704a70798 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 20 Mar 2024 15:06:11 +0100 Subject: [PATCH] refactor: Project insights subdomain (#6634) --- .../createProjectInsightsService.ts | 67 +++++++ .../project-insights-controller.ts | 70 ++++++++ .../project-insights-service.ts | 164 ++++++++++++++++++ .../projects-insights.e2e.test.ts | 57 ++++++ .../features/project/project-controller.ts | 40 +---- src/lib/features/project/projects.e2e.test.ts | 29 +--- src/lib/services/index.ts | 10 ++ src/lib/types/services.ts | 2 + 8 files changed, 373 insertions(+), 66 deletions(-) create mode 100644 src/lib/features/project-insights/createProjectInsightsService.ts create mode 100644 src/lib/features/project-insights/project-insights-controller.ts create mode 100644 src/lib/features/project-insights/project-insights-service.ts create mode 100644 src/lib/features/project-insights/projects-insights.e2e.test.ts diff --git a/src/lib/features/project-insights/createProjectInsightsService.ts b/src/lib/features/project-insights/createProjectInsightsService.ts new file mode 100644 index 00000000000..c2b65e101b5 --- /dev/null +++ b/src/lib/features/project-insights/createProjectInsightsService.ts @@ -0,0 +1,67 @@ +import type { Db, IUnleashConfig } from '../../server-impl'; +import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; +import ProjectStatsStore from '../../db/project-stats-store'; +import { + createFakeFeatureToggleService, + createFeatureToggleService, +} from '../feature-toggle/createFeatureToggleService'; +import FakeProjectStore from '../../../test/fixtures/fake-project-store'; +import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store'; +import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store'; +import FeatureTypeStore from '../../db/feature-type-store'; +import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store'; +import { ProjectInsightsService } from './project-insights-service'; +import ProjectStore from '../project/project-store'; + +export const createProjectInsightsService = ( + db: Db, + config: IUnleashConfig, +): ProjectInsightsService => { + const { eventBus, getLogger, flagResolver } = config; + const projectStore = new ProjectStore( + db, + eventBus, + getLogger, + flagResolver, + ); + const featureToggleStore = new FeatureToggleStore( + db, + eventBus, + getLogger, + flagResolver, + ); + + const featureTypeStore = new FeatureTypeStore(db, getLogger); + const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger); + const featureToggleService = createFeatureToggleService(db, config); + + return new ProjectInsightsService( + { + projectStore, + featureToggleStore, + featureTypeStore, + projectStatsStore, + }, + featureToggleService, + ); +}; + +export const createFakeProjectInsightsService = ( + config: IUnleashConfig, +): ProjectInsightsService => { + const projectStore = new FakeProjectStore(); + const featureToggleStore = new FakeFeatureToggleStore(); + const featureTypeStore = new FakeFeatureTypeStore(); + const projectStatsStore = new FakeProjectStatsStore(); + const featureToggleService = createFakeFeatureToggleService(config); + + return new ProjectInsightsService( + { + projectStore, + featureToggleStore, + featureTypeStore, + projectStatsStore, + }, + featureToggleService, + ); +}; diff --git a/src/lib/features/project-insights/project-insights-controller.ts b/src/lib/features/project-insights/project-insights-controller.ts new file mode 100644 index 00000000000..ee905aa63c7 --- /dev/null +++ b/src/lib/features/project-insights/project-insights-controller.ts @@ -0,0 +1,70 @@ +import type { Response } from 'express'; +import Controller from '../../routes/controller'; +import { + type IFlagResolver, + type IProjectParam, + type IUnleashConfig, + type IUnleashServices, + NONE, + serializeDates, +} from '../../types'; +import type { ProjectInsightsService } from './project-insights-service'; +import { + createResponseSchema, + projectInsightsSchema, + type ProjectInsightsSchema, +} from '../../openapi'; +import { getStandardResponses } from '../../openapi/util/standard-responses'; +import type { OpenApiService } from '../../services'; +import type { IAuthRequest } from '../../routes/unleash-types'; + +export default class ProjectInsightsController extends Controller { + private projectInsightsService: ProjectInsightsService; + + private openApiService: OpenApiService; + + private flagResolver: IFlagResolver; + + constructor(config: IUnleashConfig, services: IUnleashServices) { + super(config); + this.projectInsightsService = services.projectInsightsService; + this.openApiService = services.openApiService; + this.flagResolver = config.flagResolver; + + this.route({ + method: 'get', + path: '/:projectId/insights', + handler: this.getProjectInsights, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getProjectInsights', + summary: 'Get an overview of a project insights.', + description: + 'This endpoint returns insights into the specified projects stats, health, lead time for changes, feature types used, members and change requests.', + responses: { + 200: createResponseSchema('projectInsightsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async getProjectInsights( + req: IAuthRequest, + res: Response, + ): Promise { + const { projectId } = req.params; + const insights = + await this.projectInsightsService.getProjectInsights(projectId); + + this.openApiService.respondWithValidation( + 200, + res, + projectInsightsSchema.$id, + serializeDates(insights), + ); + } +} diff --git a/src/lib/features/project-insights/project-insights-service.ts b/src/lib/features/project-insights/project-insights-service.ts new file mode 100644 index 00000000000..a8f20ded20a --- /dev/null +++ b/src/lib/features/project-insights/project-insights-service.ts @@ -0,0 +1,164 @@ +import type { + IFeatureToggleStore, + IFeatureTypeStore, + IProjectHealth, + IProjectStore, + IUnleashStores, +} from '../../types'; +import type FeatureToggleService from '../feature-toggle/feature-toggle-service'; +import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production/time-to-production'; +import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type'; +import type { ProjectDoraMetricsSchema } from '../../openapi'; +import { calculateProjectHealth } from '../../domain/project-health/project-health'; + +export class ProjectInsightsService { + private projectStore: IProjectStore; + + private featureToggleStore: IFeatureToggleStore; + + private featureTypeStore: IFeatureTypeStore; + + private featureToggleService: FeatureToggleService; + + private projectStatsStore: IProjectStatsStore; + + constructor( + { + projectStore, + featureToggleStore, + featureTypeStore, + projectStatsStore, + }: Pick< + IUnleashStores, + | 'projectStore' + | 'featureToggleStore' + | 'projectStatsStore' + | 'featureTypeStore' + >, + featureToggleService: FeatureToggleService, + ) { + this.projectStore = projectStore; + this.featureToggleStore = featureToggleStore; + this.featureTypeStore = featureTypeStore; + this.featureToggleService = featureToggleService; + this.projectStatsStore = projectStatsStore; + } + + private async getDoraMetrics( + projectId: string, + ): Promise { + const activeFeatureToggles = ( + await this.featureToggleStore.getAll({ project: projectId }) + ).map((feature) => feature.name); + + const archivedFeatureToggles = ( + await this.featureToggleStore.getAll({ + project: projectId, + archived: true, + }) + ).map((feature) => feature.name); + + const featureToggleNames = [ + ...activeFeatureToggles, + ...archivedFeatureToggles, + ]; + + const projectAverage = calculateAverageTimeToProd( + await this.projectStatsStore.getTimeToProdDates(projectId), + ); + + const toggleAverage = + await this.projectStatsStore.getTimeToProdDatesForFeatureToggles( + projectId, + featureToggleNames, + ); + + return { + features: toggleAverage, + projectAverage: projectAverage, + }; + } + + private async getHealthInsights(projectId: string) { + const [overview, featureTypes] = await Promise.all([ + this.getProjectHealth(projectId, false, undefined), + this.featureTypeStore.getAll(), + ]); + + const { activeCount, potentiallyStaleCount, staleCount } = + calculateProjectHealth(overview.features, featureTypes); + + return { + activeCount, + potentiallyStaleCount, + staleCount, + rating: overview.health, + }; + } + + async getProjectInsights(projectId: string) { + const result = { + members: { + active: 20, + inactive: 3, + totalPreviousMonth: 15, + }, + changeRequests: { + total: 24, + approved: 5, + applied: 2, + rejected: 4, + reviewRequired: 10, + scheduled: 3, + }, + }; + + const [stats, featureTypeCounts, health, leadTime] = await Promise.all([ + this.projectStatsStore.getProjectStats(projectId), + this.featureToggleService.getFeatureTypeCounts({ + projectId, + archived: false, + }), + this.getHealthInsights(projectId), + this.getDoraMetrics(projectId), + ]); + + return { ...result, stats, featureTypeCounts, health, leadTime }; + } + + private async getProjectHealth( + projectId: string, + archived: boolean = false, + userId?: number, + ): Promise { + const [project, environments, features, members, projectStats] = + await Promise.all([ + this.projectStore.get(projectId), + this.projectStore.getEnvironmentsForProject(projectId), + this.featureToggleService.getFeatureOverview({ + projectId, + archived, + userId, + }), + this.projectStore.getMembersCountByProject(projectId), + this.projectStatsStore.getProjectStats(projectId), + ]); + + return { + stats: projectStats, + name: project.name, + description: project.description!, + mode: project.mode, + featureLimit: project.featureLimit, + featureNaming: project.featureNaming, + defaultStickiness: project.defaultStickiness, + health: project.health || 0, + updatedAt: project.updatedAt, + createdAt: project.createdAt, + environments, + features: features, + members, + version: 1, + }; + } +} diff --git a/src/lib/features/project-insights/projects-insights.e2e.test.ts b/src/lib/features/project-insights/projects-insights.e2e.test.ts new file mode 100644 index 00000000000..a7205c1cda8 --- /dev/null +++ b/src/lib/features/project-insights/projects-insights.e2e.test.ts @@ -0,0 +1,57 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import { + type IUnleashTest, + setupAppWithCustomConfig, +} from '../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../test/fixtures/no-logger'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('projects_insights', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + }, + }, + }, + db.rawDatabase, + ); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('project insights happy path', async () => { + const { body } = await app.request + .get('/api/admin/projects/default/insights') + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toMatchObject({ + stats: { + avgTimeToProdCurrentWindow: 0, + createdCurrentWindow: 0, + createdPastWindow: 0, + archivedCurrentWindow: 0, + archivedPastWindow: 0, + projectActivityCurrentWindow: 0, + projectActivityPastWindow: 0, + projectMembersAddedCurrentWindow: 0, + }, + leadTime: { features: [], projectAverage: 0 }, + featureTypeCounts: [], + health: { + activeCount: 0, + potentiallyStaleCount: 0, + staleCount: 0, + rating: 100, + }, + }); +}); diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 605f2e60547..60cd1eecfd4 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -20,8 +20,6 @@ import { deprecatedProjectOverviewSchema, type ProjectDoraMetricsSchema, projectDoraMetricsSchema, - projectInsightsSchema, - type ProjectInsightsSchema, projectOverviewSchema, type ProjectsSchema, projectsSchema, @@ -42,6 +40,7 @@ import { import { NotFoundError } from '../../error'; import { projectApplicationsQueryParameters } from '../../openapi/spec/project-applications-query-parameters'; import { normalizeQueryParams } from '../feature-search/search-utils'; +import ProjectInsightsController from '../project-insights/project-insights-controller'; export default class ProjectController extends Controller { private projectService: ProjectService; @@ -119,26 +118,6 @@ export default class ProjectController extends Controller { ], }); - this.route({ - method: 'get', - path: '/:projectId/insights', - handler: this.getProjectInsights, - permission: NONE, - middleware: [ - this.openApiService.validPath({ - tags: ['Unstable'], - operationId: 'getProjectInsights', - summary: 'Get an overview of a project insights.', - description: - 'This endpoint returns insights into the specified projects stats, health, lead time for changes, feature types used, members and change requests.', - responses: { - 200: createResponseSchema('projectInsightsSchema'), - ...getStandardResponses(401, 403, 404), - }, - }), - ], - }); - this.route({ method: 'get', path: '/:projectId/dora', @@ -201,6 +180,7 @@ export default class ProjectController extends Controller { createKnexTransactionStarter(db), ).router, ); + this.use('/', new ProjectInsightsController(config, services).router); } async getProjects( @@ -244,22 +224,6 @@ export default class ProjectController extends Controller { ); } - async getProjectInsights( - req: IAuthRequest, - res: Response, - ): Promise { - const { projectId } = req.params; - const insights = - await this.projectService.getProjectInsights(projectId); - - this.openApiService.respondWithValidation( - 200, - res, - projectInsightsSchema.$id, - serializeDates(insights), - ); - } - async getProjectOverview( req: IAuthRequest, res: Response, diff --git a/src/lib/features/project/projects.e2e.test.ts b/src/lib/features/project/projects.e2e.test.ts index f656b2388fd..13e4eeec86c 100644 --- a/src/lib/features/project/projects.e2e.test.ts +++ b/src/lib/features/project/projects.e2e.test.ts @@ -1,8 +1,8 @@ import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import { - type IUnleashTest, insertFeatureEnvironmentsLastSeen, insertLastSeenAt, + type IUnleashTest, setupAppWithCustomConfig, } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; @@ -287,30 +287,3 @@ test('response should include last seen at per environment for multiple environm expect(body.features[1].lastSeenAt).toBe('2023-10-01T12:34:56.000Z'); }); - -test('project insights happy path', async () => { - const { body } = await app.request - .get('/api/admin/projects/default/insights') - .expect('Content-Type', /json/) - .expect(200); - - expect(body).toMatchObject({ - stats: { - avgTimeToProdCurrentWindow: 0, - createdCurrentWindow: 0, - createdPastWindow: 0, - archivedCurrentWindow: 0, - archivedPastWindow: 0, - projectActivityCurrentWindow: 0, - projectActivityPastWindow: 0, - projectMembersAddedCurrentWindow: 0, - }, - featureTypeCounts: [], - health: { - activeCount: 0, - potentiallyStaleCount: 0, - staleCount: 0, - rating: 100, - }, - }); -}); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 2ad4cfe1cb3..33ec7e51de8 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -45,6 +45,7 @@ import { FavoritesService } from './favorites-service'; import MaintenanceService from '../features/maintenance/maintenance-service'; import { AccountService } from './account-service'; import { SchedulerService } from '../features/scheduler/scheduler-service'; +import { ProjectInsightsService } from '../features/project-insights/project-insights-service'; import type { Knex } from 'knex'; import { createExportImportTogglesService, @@ -119,6 +120,10 @@ import { createFakeFrontendApiService, createFrontendApiService, } from '../features/frontend-api/createFrontendApiService'; +import { + createFakeProjectInsightsService, + createProjectInsightsService, +} from '../features/project-insights/createProjectInsightsService'; export const createServices = ( stores: IUnleashStores, @@ -263,6 +268,9 @@ export const createServices = ( const projectService = db ? createProjectService(db, config) : createFakeProjectService(config); + const projectInsightsService = db + ? createProjectInsightsService(db, config) + : createFakeProjectInsightsService(config); const projectHealthService = new ProjectHealthService( stores, @@ -397,6 +405,7 @@ export const createServices = ( clientFeatureToggleService, featureSearchService, inactiveUsersService, + projectInsightsService, }; }; @@ -443,4 +452,5 @@ export { DependentFeaturesService, ClientFeatureToggleService, FeatureSearchService, + ProjectInsightsService, }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index f128a8c3341..67fe865bad0 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -52,6 +52,7 @@ import type { WithTransactional } from '../db/transaction'; import type { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service'; import type { FeatureSearchService } from '../features/feature-search/feature-search-service'; import type { InactiveUsersService } from '../users/inactive/inactive-users-service'; +import type { ProjectInsightsService } from '../features/project-insights/project-insights-service'; export interface IUnleashServices { accessService: AccessService; @@ -113,4 +114,5 @@ export interface IUnleashServices { clientFeatureToggleService: ClientFeatureToggleService; featureSearchService: FeatureSearchService; inactiveUsersService: InactiveUsersService; + projectInsightsService: ProjectInsightsService; }