From 63f6af06da35294ac1eab855eec3edd6dfb11078 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 1 Dec 2023 11:20:24 +0200 Subject: [PATCH] feat: new project overview backend (#5344) Adding new project overview endpoint and deprecating the old one. The new one has extra info about feature types, but does not have features anymore, because features are coming from search endpoint. --- .../fakes/fake-feature-toggle-store.ts | 8 + .../feature-toggle/feature-toggle-service.ts | 7 + .../feature-toggle/feature-toggle-store.ts | 61 +++++-- .../types/feature-toggle-store-type.ts | 35 +++- src/lib/openapi/index.ts | 6 +- ...cated-project-overview-schema.test.ts.snap | 18 +++ .../feature-type-count-schema.test.ts.snap | 18 +++ .../project-overview-schema.test.ts.snap | 2 +- ...deprecated-project-overview-schema.test.ts | 28 ++++ .../deprecated-project-overview-schema.ts | 153 ++++++++++++++++++ .../spec/feature-type-count-schema.test.ts | 18 +++ .../openapi/spec/feature-type-count-schema.ts | 35 ++++ src/lib/openapi/spec/index.ts | 2 + .../spec/project-overview-schema.test.ts | 11 +- .../openapi/spec/project-overview-schema.ts | 10 +- src/lib/routes/admin-api/index.ts | 2 +- .../project/{index.ts => project-api.ts} | 52 +++++- src/lib/services/project-health-service.ts | 2 +- src/lib/services/project-service.ts | 54 ++++++- src/lib/types/model.ts | 27 +++- .../api/admin/project/projects.e2e.test.ts | 18 +++ 21 files changed, 532 insertions(+), 35 deletions(-) create mode 100644 src/lib/openapi/spec/__snapshots__/deprecated-project-overview-schema.test.ts.snap create mode 100644 src/lib/openapi/spec/__snapshots__/feature-type-count-schema.test.ts.snap create mode 100644 src/lib/openapi/spec/deprecated-project-overview-schema.test.ts create mode 100644 src/lib/openapi/spec/deprecated-project-overview-schema.ts create mode 100644 src/lib/openapi/spec/feature-type-count-schema.test.ts create mode 100644 src/lib/openapi/spec/feature-type-count-schema.ts rename src/lib/routes/admin-api/project/{index.ts => project-api.ts} (78%) diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts index 56ec62bcb5c7..5ef9b9192757 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts @@ -8,11 +8,13 @@ import { FeatureToggleDTO, IFeatureEnvironment, IFeatureToggleQuery, + IFeatureTypeCount, IVariant, } from 'lib/types/model'; import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service'; import { EnvironmentFeatureNames } from '../feature-toggle-store'; import { FeatureConfigurationClient } from '../types/feature-toggle-strategies-store-type'; +import { IFeatureProjectUserParams } from '../feature-toggle-controller'; export default class FakeFeatureToggleStore implements IFeatureToggleStore { features: FeatureToggle[] = []; @@ -314,4 +316,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { isPotentiallyStale(): Promise { throw new Error('Method not implemented.'); } + + getFeatureTypeCounts( + params: IFeatureProjectUserParams, + ): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 21ab5895a694..7e993bb87761 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -30,6 +30,7 @@ import { IFeatureToggleClientStore, IFeatureToggleQuery, IFeatureToggleStore, + IFeatureTypeCount, IFlagResolver, IProjectStore, ISegment, @@ -1087,6 +1088,12 @@ class FeatureToggleService { return this.featureStrategiesStore.getFeatureOverview(params); } + async getFeatureTypeCounts( + params: IFeatureProjectUserParams, + ): Promise { + return this.featureToggleStore.getFeatureTypeCounts(params); + } + async getFeatureToggle( featureName: string, ): Promise { diff --git a/src/lib/features/feature-toggle/feature-toggle-store.ts b/src/lib/features/feature-toggle/feature-toggle-store.ts index c3f78f9e58e8..5d76efaad099 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -18,10 +18,13 @@ import { DEFAULT_ENV } from '../../../lib/util'; import { FeatureToggleListBuilder } from './query-builders/feature-toggle-list-builder'; import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type'; -import { IFlagResolver } from '../../../lib/types'; +import { IFeatureTypeCount, IFlagResolver } from '../../../lib/types'; import { FeatureToggleRowConverter } from './converters/feature-toggle-row-converter'; +import { IFeatureProjectUserParams } from './feature-toggle-controller'; -export type EnvironmentFeatureNames = { [key: string]: string[] }; +export type EnvironmentFeatureNames = { + [key: string]: string[]; +}; const FEATURE_COLUMNS = [ 'name', @@ -278,6 +281,27 @@ export default class FeatureToggleStore implements IFeatureToggleStore { ); } + async getFeatureTypeCounts({ + projectId, + archived, + }: IFeatureProjectUserParams): Promise { + const query = this.db(TABLE) + .select('type') + .count('type') + .groupBy('type'); + + query.where({ + project: projectId, + archived, + }); + + const result = await query; + return result.map((row) => ({ + type: row.type, + count: Number(row.count), + })); + } + async getAllByNames(names: string[]): Promise { const query = this.db(TABLE).orderBy('name', 'asc'); query.whereIn('name', names); @@ -596,9 +620,10 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .update('variants', variantsString) .where('feature_name', featureName); - const row = await this.db(TABLE) - .select(FEATURE_COLUMNS) - .where({ project: project, name: featureName }); + const row = await this.db(TABLE).select(FEATURE_COLUMNS).where({ + project: project, + name: featureName, + }); const toggle = this.rowToFeature(row[0]); toggle.variants = newVariants; @@ -606,17 +631,23 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return toggle; } - async updatePotentiallyStaleFeatures( - currentTime?: string, - ): Promise<{ name: string; potentiallyStale: boolean; project: string }[]> { + async updatePotentiallyStaleFeatures(currentTime?: string): Promise< + { + name: string; + potentiallyStale: boolean; + project: string; + }[] + > { const query = this.db.raw( - `SELECT name, project, potentially_stale, (? > (features.created_at + (( - SELECT feature_types.lifetime_days - FROM feature_types - WHERE feature_types.id = features.type - ) * INTERVAL '1 day'))) as current_staleness - FROM features - WHERE NOT stale = true`, + `SELECT name, + project, + potentially_stale, + (? > (features.created_at + ((SELECT feature_types.lifetime_days + FROM feature_types + WHERE feature_types.id = features.type) * + INTERVAL '1 day'))) as current_staleness + FROM features + WHERE NOT stale = true`, [currentTime || this.db.fn.now()], ); diff --git a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts index e94630c48ec2..32c42fbd86b9 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts @@ -2,11 +2,13 @@ import { FeatureToggle, FeatureToggleDTO, IFeatureToggleQuery, + IFeatureTypeCount, IVariant, } from '../../../types/model'; import { Store } from '../../../types/stores/store'; import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service'; import { FeatureConfigurationClient } from './feature-toggle-strategies-store-type'; +import { IFeatureProjectUserParams } from '../feature-toggle-controller'; export interface IFeatureToggleStoreQuery { archived: boolean; @@ -17,30 +19,46 @@ export interface IFeatureToggleStoreQuery { export interface IFeatureToggleStore extends Store { count(query?: Partial): Promise; + setLastSeen(data: LastSeenInput[]): Promise; + getProjectId(name: string): Promise; + create(project: string, data: FeatureToggleDTO): Promise; + update(project: string, data: FeatureToggleDTO): Promise; + archive(featureName: string): Promise; + batchArchive(featureNames: string[]): Promise; + batchStale( featureNames: string[], stale: boolean, ): Promise; + batchDelete(featureNames: string[]): Promise; + batchRevive(featureNames: string[]): Promise; + revive(featureName: string): Promise; + getAll(query?: Partial): Promise; + getAllByNames(names: string[]): Promise; + getFeatureToggleList( featureQuery?: IFeatureToggleQuery, userId?: number, archived?: boolean, ): Promise; + getArchivedFeatures(project?: string): Promise; + getPlaygroundFeatures( featureQuery?: IFeatureToggleQuery, ): Promise; + countByDate(queryModifiers: { archived?: boolean; project?: string; @@ -48,9 +66,15 @@ export interface IFeatureToggleStore extends Store { range?: string[]; dateAccessor: string; }): Promise; - updatePotentiallyStaleFeatures( - currentTime?: string, - ): Promise<{ name: string; potentiallyStale: boolean; project: string }[]>; + + updatePotentiallyStaleFeatures(currentTime?: string): Promise< + { + name: string; + potentiallyStale: boolean; + project: string; + }[] + >; + isPotentiallyStale(featureName: string): Promise; /** @@ -59,6 +83,7 @@ export interface IFeatureToggleStore extends Store { * TODO: Remove before release 5.0 */ getVariants(featureName: string): Promise; + /** * TODO: Remove before release 5.0 * @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments) @@ -73,4 +98,8 @@ export interface IFeatureToggleStore extends Store { ): Promise; disableAllEnvironmentsForFeatures(names: string[]): Promise; + + getFeatureTypeCounts( + params: IFeatureProjectUserParams, + ): Promise; } diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 5865df238e44..e0b90cb7924b 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -91,6 +91,7 @@ import { profileSchema, projectEnvironmentSchema, projectOverviewSchema, + deprecatedProjectOverviewSchema, projectSchema, projectsSchema, projectStatsSchema, @@ -167,6 +168,7 @@ import { dependenciesExistSchema, validateArchiveFeaturesSchema, searchFeaturesSchema, + featureTypeCountSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -375,7 +377,7 @@ export const schemas: UnleashSchemas = { variantFlagSchema, variantsSchema, versionSchema, - projectOverviewSchema, + deprecatedProjectOverviewSchema, importTogglesSchema, importTogglesValidateSchema, importTogglesValidateItemSchema, @@ -397,6 +399,8 @@ export const schemas: UnleashSchemas = { dependenciesExistSchema, validateArchiveFeaturesSchema, searchFeaturesSchema, + featureTypeCountSchema, + projectOverviewSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/__snapshots__/deprecated-project-overview-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/deprecated-project-overview-schema.test.ts.snap new file mode 100644 index 000000000000..7c69fc5e6ee0 --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/deprecated-project-overview-schema.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`deprecatedProjectOverviewSchema 1`] = ` +{ + "errors": [ + { + "instancePath": "", + "keyword": "required", + "message": "must have required property 'version'", + "params": { + "missingProperty": "version", + }, + "schemaPath": "#/required", + }, + ], + "schema": "#/components/schemas/deprecatedProjectOverviewSchema", +} +`; diff --git a/src/lib/openapi/spec/__snapshots__/feature-type-count-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/feature-type-count-schema.test.ts.snap new file mode 100644 index 000000000000..bf67ba097f12 --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/feature-type-count-schema.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`featureTypeCountSchema 1`] = ` +{ + "errors": [ + { + "instancePath": "", + "keyword": "required", + "message": "must have required property 'type'", + "params": { + "missingProperty": "type", + }, + "schemaPath": "#/required", + }, + ], + "schema": "#/components/schemas/featureTypeCountSchema", +} +`; diff --git a/src/lib/openapi/spec/__snapshots__/project-overview-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/project-overview-schema.test.ts.snap index fde0ce369561..42eae6ea1225 100644 --- a/src/lib/openapi/spec/__snapshots__/project-overview-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/project-overview-schema.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`updateProjectEnterpriseSettings schema 1`] = ` +exports[`projectOverviewSchema 1`] = ` { "errors": [ { diff --git a/src/lib/openapi/spec/deprecated-project-overview-schema.test.ts b/src/lib/openapi/spec/deprecated-project-overview-schema.test.ts new file mode 100644 index 000000000000..98c798b397dd --- /dev/null +++ b/src/lib/openapi/spec/deprecated-project-overview-schema.test.ts @@ -0,0 +1,28 @@ +import { DeprecatedProjectOverviewSchema } from './deprecated-project-overview-schema'; +import { validateSchema } from '../validate'; + +test('deprecatedProjectOverviewSchema', () => { + const data: DeprecatedProjectOverviewSchema = { + name: 'project', + version: 3, + featureNaming: { + description: 'naming description', + example: 'a', + pattern: '[aZ]', + }, + }; + + expect( + validateSchema( + '#/components/schemas/deprecatedProjectOverviewSchema', + data, + ), + ).toBeUndefined(); + + expect( + validateSchema( + '#/components/schemas/deprecatedProjectOverviewSchema', + {}, + ), + ).toMatchSnapshot(); +}); diff --git a/src/lib/openapi/spec/deprecated-project-overview-schema.ts b/src/lib/openapi/spec/deprecated-project-overview-schema.ts new file mode 100644 index 000000000000..c46e35108f06 --- /dev/null +++ b/src/lib/openapi/spec/deprecated-project-overview-schema.ts @@ -0,0 +1,153 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { parametersSchema } from './parameters-schema'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; +import { featureStrategySchema } from './feature-strategy-schema'; +import { featureSchema } from './feature-schema'; +import { constraintSchema } from './constraint-schema'; +import { environmentSchema } from './environment-schema'; +import { featureEnvironmentSchema } from './feature-environment-schema'; +import { projectStatsSchema } from './project-stats-schema'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema'; +import { projectEnvironmentSchema } from './project-environment-schema'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema'; +import { strategyVariantSchema } from './strategy-variant-schema'; +import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; + +export const deprecatedProjectOverviewSchema = { + $id: '#/components/schemas/deprecatedProjectOverviewSchema', + type: 'object', + additionalProperties: false, + required: ['version', 'name'], + description: + 'A high-level overview of a project. It contains information such as project statistics, the name of the project, what members and what features it contains, etc.', + properties: { + stats: { + $ref: '#/components/schemas/projectStatsSchema', + description: 'Project statistics', + }, + version: { + type: 'integer', + example: 1, + description: + 'The schema version used to describe the project overview', + }, + name: { + type: 'string', + example: 'dx-squad', + description: 'The name of this project', + }, + description: { + type: 'string', + nullable: true, + example: 'DX squad feature release', + description: 'Additional information about the project', + }, + defaultStickiness: { + type: 'string', + example: 'userId', + description: + 'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy', + }, + mode: { + type: 'string', + enum: ['open', 'protected', 'private'], + example: 'open', + description: + "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", + }, + featureLimit: { + type: 'number', + nullable: true, + example: 100, + description: + 'A limit on the number of features allowed in the project. Null if no limit.', + }, + featureNaming: { + $ref: '#/components/schemas/createFeatureNamingPatternSchema', + }, + members: { + type: 'number', + example: 4, + description: 'The number of members this project has', + }, + health: { + type: 'number', + example: 50, + description: + "An indicator of the [project's health](https://docs.getunleash.io/reference/technical-debt#health-rating) on a scale from 0 to 100", + }, + environments: { + type: 'array', + items: { + $ref: '#/components/schemas/projectEnvironmentSchema', + }, + example: [ + { environment: 'development' }, + { + environment: 'production', + defaultStrategy: { + name: 'flexibleRollout', + constraints: [], + parameters: { + rollout: '50', + stickiness: 'customAppName', + groupId: 'stickytoggle', + }, + }, + }, + ], + description: 'The environments that are enabled for this project', + }, + features: { + type: 'array', + items: { + $ref: '#/components/schemas/featureSchema', + }, + description: + 'The full list of features in this project (excluding archived features)', + }, + updatedAt: { + type: 'string', + format: 'date-time', + nullable: true, + example: '2023-02-10T08:36:35.262Z', + description: 'When the project was last updated.', + }, + createdAt: { + type: 'string', + format: 'date-time', + nullable: true, + example: '2023-02-10T08:36:35.262Z', + description: 'When the project was created.', + }, + favorite: { + type: 'boolean', + example: true, + description: + '`true` if the project was favorited, otherwise `false`.', + }, + }, + components: { + schemas: { + environmentSchema, + projectEnvironmentSchema, + createFeatureStrategySchema, + createStrategyVariantSchema, + constraintSchema, + featureSchema, + featureEnvironmentSchema, + overrideSchema, + parametersSchema, + featureStrategySchema, + strategyVariantSchema, + variantSchema, + projectStatsSchema, + createFeatureNamingPatternSchema, + }, + }, +} as const; + +export type DeprecatedProjectOverviewSchema = FromSchema< + typeof deprecatedProjectOverviewSchema +>; diff --git a/src/lib/openapi/spec/feature-type-count-schema.test.ts b/src/lib/openapi/spec/feature-type-count-schema.test.ts new file mode 100644 index 000000000000..6abb11dbae1d --- /dev/null +++ b/src/lib/openapi/spec/feature-type-count-schema.test.ts @@ -0,0 +1,18 @@ +import { DeprecatedProjectOverviewSchema } from './deprecated-project-overview-schema'; +import { validateSchema } from '../validate'; +import { FeatureTypeCountSchema } from './feature-type-count-schema'; + +test('featureTypeCountSchema', () => { + const data: FeatureTypeCountSchema = { + type: 'release', + count: 1, + }; + + expect( + validateSchema('#/components/schemas/featureTypeCountSchema', data), + ).toBeUndefined(); + + expect( + validateSchema('#/components/schemas/featureTypeCountSchema', {}), + ).toMatchSnapshot(); +}); diff --git a/src/lib/openapi/spec/feature-type-count-schema.ts b/src/lib/openapi/spec/feature-type-count-schema.ts new file mode 100644 index 000000000000..70e2d93fd8e8 --- /dev/null +++ b/src/lib/openapi/spec/feature-type-count-schema.ts @@ -0,0 +1,35 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { variantSchema } from './variant-schema'; +import { constraintSchema } from './constraint-schema'; +import { overrideSchema } from './override-schema'; +import { parametersSchema } from './parameters-schema'; +import { featureStrategySchema } from './feature-strategy-schema'; +import { tagSchema } from './tag-schema'; +import { featureEnvironmentSchema } from './feature-environment-schema'; +import { strategyVariantSchema } from './strategy-variant-schema'; + +export const featureTypeCountSchema = { + $id: '#/components/schemas/featureTypeCountSchema', + type: 'object', + additionalProperties: false, + required: ['type', 'count'], + description: 'A count of feature flags of a specific type', + properties: { + type: { + type: 'string', + example: 'kill-switch', + description: + 'Type of the flag e.g. experiment, kill-switch, release, operational, permission', + }, + count: { + type: 'number', + example: 1, + description: 'Number of feature flags of this type', + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type FeatureTypeCountSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 56b47d1b9b4b..cdd1b2d4d284 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -135,6 +135,7 @@ export * from './export-result-schema'; export * from './export-query-schema'; export * from './push-variants-schema'; export * from './project-stats-schema'; +export * from './deprecated-project-overview-schema'; export * from './project-overview-schema'; export * from './import-toggles-validate-item-schema'; export * from './import-toggles-validate-schema'; @@ -168,3 +169,4 @@ export * from './dependencies-exist-schema'; export * from './validate-archive-features-schema'; export * from './search-features-schema'; export * from './feature-search-query-parameters'; +export * from './feature-type-count-schema'; diff --git a/src/lib/openapi/spec/project-overview-schema.test.ts b/src/lib/openapi/spec/project-overview-schema.test.ts index 339b926a18cb..da36a27655a0 100644 --- a/src/lib/openapi/spec/project-overview-schema.test.ts +++ b/src/lib/openapi/spec/project-overview-schema.test.ts @@ -1,7 +1,8 @@ -import { ProjectOverviewSchema } from './project-overview-schema'; +import { DeprecatedProjectOverviewSchema } from './deprecated-project-overview-schema'; import { validateSchema } from '../validate'; +import { ProjectOverviewSchema } from './project-overview-schema'; -test('updateProjectEnterpriseSettings schema', () => { +test('projectOverviewSchema', () => { const data: ProjectOverviewSchema = { name: 'project', version: 3, @@ -10,6 +11,12 @@ test('updateProjectEnterpriseSettings schema', () => { example: 'a', pattern: '[aZ]', }, + featureTypeCounts: [ + { + type: 'release', + count: 1, + }, + ], }; expect( diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 5c791d4702f2..1faedbabf1e2 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -13,6 +13,7 @@ import { projectEnvironmentSchema } from './project-environment-schema'; import { createStrategyVariantSchema } from './create-strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; +import { featureTypeCountSchema } from './feature-type-count-schema'; export const projectOverviewSchema = { $id: '#/components/schemas/projectOverviewSchema', @@ -92,20 +93,20 @@ export const projectOverviewSchema = { parameters: { rollout: '50', stickiness: 'customAppName', - groupId: 'stickytoggle', + groupId: 'stickyFlag', }, }, }, ], description: 'The environments that are enabled for this project', }, - features: { + featureTypeCounts: { type: 'array', items: { - $ref: '#/components/schemas/featureSchema', + $ref: '#/components/schemas/featureTypeCountSchema', }, description: - 'The full list of features in this project (excluding archived features)', + 'The number of features of each type that are in this project', }, updatedAt: { type: 'string', @@ -144,6 +145,7 @@ export const projectOverviewSchema = { variantSchema, projectStatsSchema, createFeatureNamingPatternSchema, + featureTypeCountSchema, }, }, } as const; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index abc4873cf7ed..6cef58f37cde 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -20,7 +20,7 @@ import UserAdminController from './user-admin'; import EmailController from './email'; import UserFeedbackController from './user-feedback'; import UserSplashController from './user-splash'; -import ProjectApi from './project'; +import ProjectApi from './project/project-api'; import { EnvironmentsController } from './environments'; import ConstraintsController from './constraints'; import PatController from './user/pat'; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/project-api.ts similarity index 78% rename from src/lib/routes/admin-api/project/index.ts rename to src/lib/routes/admin-api/project/project-api.ts index 51b3fe6f292b..9d2d6d13fc01 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/project-api.ts @@ -17,10 +17,11 @@ import { createResponseSchema, ProjectDoraMetricsSchema, projectDoraMetricsSchema, - ProjectOverviewSchema, - projectOverviewSchema, + DeprecatedProjectOverviewSchema, + deprecatedProjectOverviewSchema, projectsSchema, ProjectsSchema, + projectOverviewSchema, } from '../../../openapi'; import { getStandardResponses } from '../../../openapi/util/standard-responses'; import { OpenApiService, SettingService } from '../../../services'; @@ -31,6 +32,7 @@ import { createKnexTransactionStarter } from '../../../db/transaction'; import { Db } from '../../../db/db'; import { InvalidOperationError } from '../../../error'; import DependentFeaturesController from '../../../features/dependent-features/dependent-features-controller'; +import { ProjectOverviewSchema } from 'lib/openapi/spec/project-overview-schema'; export default class ProjectApi extends Controller { private projectService: ProjectService; @@ -68,6 +70,29 @@ export default class ProjectApi extends Controller { this.route({ method: 'get', path: '/:projectId', + handler: this.getDeprecatedProjectOverview, + permission: NONE, + middleware: [ + services.openApiService.validPath({ + tags: ['Projects'], + operationId: 'getDeprecatedProjectOverview', + summary: 'Get an overview of a project. (deprecated)', + deprecated: true, + description: + 'This endpoint returns an overview of the specified projects stats, project health, number of members, which environments are configured, and the features in the project.', + responses: { + 200: createResponseSchema( + 'deprecatedProjectOverviewSchema', + ), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + + this.route({ + method: 'get', + path: '/:projectId/overview', handler: this.getProjectOverview, permission: NONE, middleware: [ @@ -76,7 +101,7 @@ export default class ProjectApi extends Controller { operationId: 'getProjectOverview', summary: 'Get an overview of a project.', description: - 'This endpoint returns an overview of the specified projects stats, project health, number of members, which environments are configured, and the features in the project.', + 'This endpoint returns an overview of the specified projects stats, project health, number of members, which environments are configured, and the features types in the project.', responses: { 200: createResponseSchema('projectOverviewSchema'), ...getStandardResponses(401, 403, 404), @@ -148,6 +173,27 @@ export default class ProjectApi extends Controller { ); } + async getDeprecatedProjectOverview( + req: IAuthRequest, + res: Response, + ): Promise { + const { projectId } = req.params; + const { archived } = req.query; + const { user } = req; + const overview = await this.projectService.getProjectHealth( + projectId, + archived, + user.id, + ); + + this.openApiService.respondWithValidation( + 200, + res, + deprecatedProjectOverviewSchema.$id, + serializeDates(overview), + ); + } + async getProjectOverview( req: IAuthRequest, res: Response, diff --git a/src/lib/services/project-health-service.ts b/src/lib/services/project-health-service.ts index f03fbe5ff703..bdee134713b4 100644 --- a/src/lib/services/project-health-service.ts +++ b/src/lib/services/project-health-service.ts @@ -47,7 +47,7 @@ export default class ProjectHealthService { ): Promise { const featureTypes = await this.featureTypeStore.getAll(); - const overview = await this.projectService.getProjectOverview( + const overview = await this.projectService.getProjectHealth( projectId, false, undefined, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 78d69b70a132..724934712387 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -40,6 +40,7 @@ import { IFeatureNaming, CreateProject, IProjectUpdate, + IProjectHealth, } from '../types'; import { IProjectQuery, @@ -1100,11 +1101,11 @@ export default class ProjectService { }; } - async getProjectOverview( + async getProjectHealth( projectId: string, archived: boolean = false, userId?: number, - ): Promise { + ): Promise { const [ project, environments, @@ -1149,6 +1150,55 @@ export default class ProjectService { }; } + async getProjectOverview( + projectId: string, + archived: boolean = false, + userId?: number, + ): Promise { + const [ + project, + environments, + featureTypeCounts, + members, + favorite, + projectStats, + ] = await Promise.all([ + this.projectStore.get(projectId), + this.projectStore.getEnvironmentsForProject(projectId), + this.featureToggleService.getFeatureTypeCounts({ + projectId, + archived, + userId, + }), + this.projectStore.getMembersCountByProject(projectId), + userId + ? this.favoritesService.isFavoriteProject({ + project: projectId, + userId, + }) + : Promise.resolve(false), + 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, + favorite: favorite, + updatedAt: project.updatedAt, + createdAt: project.createdAt, + environments, + featureTypeCounts, + members, + version: 1, + }; + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types removeModeForNonEnterprise(data): any { if (this.isEnterprise) { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 4b3b45c7663f..670863b0a709 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -212,6 +212,11 @@ export interface IFeatureOverview { environments: IEnvironmentOverview[]; } +export interface IFeatureTypeCount { + type: string; + count: number; +} + export type ProjectMode = 'open' | 'protected' | 'private'; export interface IFeatureNaming { @@ -220,7 +225,7 @@ export interface IFeatureNaming { description?: string | null; } -export interface IProjectOverview { +export interface IProjectHealth { name: string; description: string; environments: ProjectEnvironment[]; @@ -238,7 +243,25 @@ export interface IProjectOverview { defaultStickiness: string; } -export interface IProjectHealthReport extends IProjectOverview { +export interface IProjectOverview { + name: string; + description: string; + environments: ProjectEnvironment[]; + featureTypeCounts: IFeatureTypeCount[]; + members: number; + version: number; + health: number; + favorite?: boolean; + updatedAt?: Date; + createdAt: Date | undefined; + stats?: IProjectStats; + mode: ProjectMode; + featureLimit?: number; + featureNaming?: IFeatureNaming; + defaultStickiness: string; +} + +export interface IProjectHealthReport extends IProjectHealth { staleCount: number; potentiallyStaleCount: number; activeCount: number; diff --git a/src/test/e2e/api/admin/project/projects.e2e.test.ts b/src/test/e2e/api/admin/project/projects.e2e.test.ts index a5d8cd5e325f..0ae6f6ad0466 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -144,6 +144,24 @@ test('response for default project should include created_at', async () => { expect(body.createdAt).toBeDefined(); }); +test('response for project overview should include feature type counts', async () => { + await app.createFeature({ name: 'my-new-release-toggle', type: 'release' }); + await app.createFeature({ + name: 'my-new-development-toggle', + type: 'development', + }); + const { body } = await app.request + .get('/api/admin/projects/default/overview') + .expect('Content-Type', /json/) + .expect(200); + expect(body).toMatchObject({ + featureTypeCounts: [ + { type: 'development', count: 1 }, + { type: 'release', count: 1 }, + ], + }); +}); + test('response should include last seen at per environment', async () => { await app.createFeature('my-new-feature-toggle');