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');