diff --git a/src/lib/features/feature-toggle/converters/feature-toggle-row-converter.ts b/src/lib/features/feature-toggle/converters/feature-toggle-row-converter.ts index d3b4385d49..ff5065218e 100644 --- a/src/lib/features/feature-toggle/converters/feature-toggle-row-converter.ts +++ b/src/lib/features/feature-toggle/converters/feature-toggle-row-converter.ts @@ -2,10 +2,10 @@ import { PartialDeep, IFeatureToggleClient, IStrategyConfig, - FeatureToggle, IFeatureToggleQuery, ITag, IFlagResolver, + IFeatureToggleListItem, } from '../../../types'; import { mapValues, ensureStringValue } from '../../../util'; @@ -69,6 +69,31 @@ export class FeatureToggleRowConverter { strategy.segments.push(row.segment_id); }; + addLastSeenByEnvironment = ( + feature: PartialDeep, + row: Record, + ) => { + if (!feature.environments) { + feature.environments = []; + } + + const found = feature.environments.find( + (environment) => environment?.name === row.last_seen_at_env, + ); + + if (found) { + return; + } + + const newEnvironment = { + name: row.last_seen_at_env, + lastSeenAt: row.env_last_seen_at, + enabled: row.enabled, + }; + + feature.environments.push(newEnvironment); + }; + rowToStrategy = (row: Record): IStrategyConfig => { let strategy: IStrategyConfig; if (this.flagResolver.isEnabled('playgroundImprovements')) { @@ -166,9 +191,9 @@ export class FeatureToggleRowConverter { rows: any[], featureQuery?: IFeatureToggleQuery, includeDisabledStrategies?: boolean, - ): FeatureToggle[] => { + ): IFeatureToggleListItem[] => { const result = rows.reduce((acc, r) => { - let feature: PartialDeep = acc[r.name] ?? { + let feature: PartialDeep = acc[r.name] ?? { strategies: [], }; @@ -182,6 +207,10 @@ export class FeatureToggleRowConverter { feature.createdAt = r.created_at; feature.favorite = r.favorite; + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + this.addLastSeenByEnvironment(feature, r); + } + acc[r.name] = feature; return acc; }, {}); 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 979931cc1e..df9c857d7d 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 @@ -164,8 +164,6 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { } async getPlaygroundFeatures( - dependentFeaturesEnabled: boolean, - includeDisabledStrategies: boolean, query?: IFeatureToggleQuery, ): Promise { return this.features.filter( diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 5ef25b2cb0..bcc38d079e 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -1055,11 +1055,7 @@ class FeatureToggleService { const [featuresFromClientStore, featuresFromFeatureToggleStore] = await Promise.all([ await this.clientFeatureToggleStore.getPlayground(query || {}), - await this.featureToggleStore.getPlaygroundFeatures( - this.flagResolver.isEnabled('dependentFeatures'), - this.flagResolver.isEnabled('playgroundImprovements'), - query, - ), + await this.featureToggleStore.getPlaygroundFeatures(query), ]); const equal = isEqual( diff --git a/src/lib/features/feature-toggle/feature-toggle-store.ts b/src/lib/features/feature-toggle/feature-toggle-store.ts index 8157c16dc1..26d7d33cf5 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -20,6 +20,7 @@ import { FeatureToggleListBuilder } from './query-builders/feature-toggle-list-b import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type'; import { IFlagResolver } from '../../../lib/types'; import { FeatureToggleRowConverter } from './converters/feature-toggle-row-converter'; +import FlagResolver from 'lib/util/flag-resolver'; export type EnvironmentFeatureNames = { [key: string]: string[] }; @@ -77,6 +78,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore { this.featureToggleRowConverter = new FeatureToggleRowConverter( flagResolver, ); + this.flagResolver = flagResolver; this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, { store: 'feature-toggle', @@ -144,6 +146,16 @@ export default class FeatureToggleStore implements IFeatureToggleStore { builder.addSelectColumn('ft.tag_value as tag_value'); builder.addSelectColumn('ft.tag_type as tag_type'); + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + builder.withLastSeenByEnvironment(); + builder.addSelectColumn( + 'last_seen_at_metrics.last_seen_at as env_last_seen_at', + ); + builder.addSelectColumn( + 'last_seen_at_metrics.environment as last_seen_at_env', + ); + } + if (userId) { builder.withFavorites(userId); builder.addSelectColumn( @@ -165,8 +177,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore { } async getPlaygroundFeatures( - dependentFeaturesEnabled: boolean, - includeDisabledStrategies: boolean, featureQuery: IFeatureToggleQuery, ): Promise { const environment = featureQuery?.environment || DEFAULT_ENV; @@ -174,6 +184,12 @@ export default class FeatureToggleStore implements IFeatureToggleStore { const archived = false; const builder = this.getBaseFeatureQuery(archived, environment); + const dependentFeaturesEnabled = + this.flagResolver.isEnabled('dependentFeatures'); + const includeDisabledStrategies = this.flagResolver.isEnabled( + 'playgroundImprovements', + ); + if (dependentFeaturesEnabled) { builder.withDependentFeatureToggles(); diff --git a/src/lib/features/feature-toggle/query-builders/feature-toggle-list-builder.ts b/src/lib/features/feature-toggle/query-builders/feature-toggle-list-builder.ts index 99682205b1..da388a6db8 100644 --- a/src/lib/features/feature-toggle/query-builders/feature-toggle-list-builder.ts +++ b/src/lib/features/feature-toggle/query-builders/feature-toggle-list-builder.ts @@ -115,6 +115,12 @@ export class FeatureToggleListBuilder { return this; } + withLastSeenByEnvironment = () => { + this.internalQuery.leftJoin('last_seen_at_metrics', 'last_seen_at_metrics.feature_name', 'features.name'); + + return this; + } + withFavorites = (userId: number) => { this.internalQuery.leftJoin(`favorite_features`, function () { this.on('favorite_features.feature', 'features.name').andOnVal( diff --git a/src/lib/features/feature-toggle/tests/feature-toggle-last-seen-at.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggle-last-seen-at.e2e.test.ts new file mode 100644 index 0000000000..67b09c53e4 --- /dev/null +++ b/src/lib/features/feature-toggle/tests/feature-toggle-last-seen-at.e2e.test.ts @@ -0,0 +1,115 @@ +import dbInit, { ITestDb } from '../../../../test/e2e/helpers/database-init'; +import { + IUnleashTest, + insertLastSeenAt, + setupAppWithCustomConfig, +} from '../../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../../test/fixtures/no-logger'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + const config = { + experimental: { + flags: { + strictSchemaValidation: true, + dependentFeatures: true, + separateAdminClientApi: true, + useLastSeenRefactor: true, + }, + }, + }; + + db = await dbInit( + 'feature_toggles_last_seen_at_refactor', + getLogger, + config, + ); + app = await setupAppWithCustomConfig(db.stores, config, db.rawDatabase); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('should return last seen at per env for /api/admin/features', async () => { + await app.createFeature('lastSeenAtPerEnv'); + + await insertLastSeenAt('lastSeenAtPerEnv', db.rawDatabase, 'default'); + + const response = await app.request + .get('/api/admin/features') + .expect('Content-Type', /json/) + .expect(200); + + const found = await response.body.features.find( + (featureToggle) => featureToggle.name === 'lastSeenAtPerEnv', + ); + + expect(found.environments[0].lastSeenAt).toEqual( + '2023-10-01T12:34:56.000Z', + ); +}); + +test('response should include last seen at per environment for multiple environments', async () => { + await db.stores.environmentStore.create({ + name: 'development', + type: 'development', + sortOrder: 1, + enabled: true, + }); + + await db.stores.environmentStore.create({ + name: 'production', + type: 'production', + sortOrder: 2, + enabled: true, + }); + + await app.services.projectService.addEnvironmentToProject( + 'default', + 'development', + ); + await app.services.projectService.addEnvironmentToProject( + 'default', + 'production', + ); + + await app.createFeature('multiple-environment-last-seen-at'); + + await insertLastSeenAt( + 'multiple-environment-last-seen-at', + db.rawDatabase, + 'default', + ); + await insertLastSeenAt( + 'multiple-environment-last-seen-at', + db.rawDatabase, + 'development', + ); + await insertLastSeenAt( + 'multiple-environment-last-seen-at', + db.rawDatabase, + 'production', + ); + + const { body } = await app.request + .get('/api/admin/features') + .expect('Content-Type', /json/) + .expect(200); + + const featureEnvironments = body.features[1].environments; + + const [def, development, production] = featureEnvironments; + + expect(def.name).toBe('default'); + expect(def.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z'); + + expect(development.name).toBe('development'); + expect(development.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z'); + + expect(production.name).toBe('production'); + expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z'); +}); diff --git a/src/lib/features/feature-toggle/tests/feature-toggle-service.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggle-service.e2e.test.ts index 7ca87cd007..71ac357f80 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggle-service.e2e.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggle-service.e2e.test.ts @@ -15,9 +15,9 @@ import { ForbiddenError, PatternError, PermissionError } from '../../../error'; import { ISegmentService } from '../../../segments/segment-service-interface'; import { createFeatureToggleService, createSegmentService } from '../..'; import { - insertFeatureEnvironmentsLastSeen, insertLastSeenAt, -} from '../../../../test/e2e/api/admin/project/projects.e2e.test'; + insertFeatureEnvironmentsLastSeen, +} from '../../../../test/e2e/helpers/test-helper'; let stores: IUnleashStores; let db; diff --git a/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts b/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts index fb547c5937..5b8d3a4c66 100644 --- a/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts +++ b/src/lib/features/feature-toggle/tests/feature-toggles.e2e.test.ts @@ -1,6 +1,7 @@ import dbInit, { ITestDb } from '../../../../test/e2e/helpers/database-init'; import { IUnleashTest, + insertLastSeenAt, setupAppWithCustomConfig, } from '../../../../test/e2e/helpers/test-helper'; import getLogger from '../../../../test/fixtures/no-logger'; 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 b21395dc24..223cdd82ad 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 @@ -38,8 +38,6 @@ export interface IFeatureToggleStore extends Store { archived?: boolean, ): Promise; getPlaygroundFeatures( - dependentFeaturesEnabled: boolean, - includeDisabledStrategies: boolean, featureQuery?: IFeatureToggleQuery, ): Promise; countByDate(queryModifiers: { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 735b69c803..27d6d56800 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -67,6 +67,11 @@ export interface FeatureToggle extends FeatureToggleDTO { createdAt?: Date; } +export interface IFeatureToggleListItem extends FeatureToggle { + environments?: Partial[]; + favorite: boolean; +} + export interface IFeatureToggleClient { name: string; description: string; 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 4830f3191c..b700b10138 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -1,45 +1,19 @@ import dbInit, { ITestDb } from '../../../helpers/database-init'; import { IUnleashTest, + insertFeatureEnvironmentsLastSeen, + insertLastSeenAt, setupAppWithCustomConfig, } from '../../../helpers/test-helper'; import getLogger from '../../../../fixtures/no-logger'; import { IProjectStore } from 'lib/types'; -import { Knex } from 'knex'; let app: IUnleashTest; let db: ITestDb; let projectStore: IProjectStore; -export const insertLastSeenAt = async ( - featureName: string, - db: Knex, - environment: string = 'default', - date: string = '2023-10-01 12:34:56', -): Promise => { - await db.raw(`INSERT INTO last_seen_at_metrics (feature_name, environment, last_seen_at) - VALUES ('${featureName}', '${environment}', '${date}');`); - - return date; -}; - -export const insertFeatureEnvironmentsLastSeen = async ( - featureName: string, - db: Knex, - environment: string = 'default', - date: string = '2022-05-01 12:34:56', -): Promise => { - await db.raw(` - INSERT INTO feature_environments (feature_name, environment, last_seen_at, enabled) - VALUES ('${featureName}', '${environment}', '${date}', true) - ON CONFLICT (feature_name, environment) DO UPDATE SET last_seen_at = '${date}', enabled = true; - `); - - return date; -}; - beforeAll(async () => { db = await dbInit('projects_api_serial', getLogger); app = await setupAppWithCustomConfig( diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 8295a121e8..46f9e55bf9 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -16,6 +16,7 @@ import { CreateFeatureStrategySchema, ImportTogglesSchema, } from '../../../lib/openapi'; +import { Knex } from 'knex'; process.env.NODE_ENV = 'test'; @@ -300,3 +301,30 @@ export async function setupAppWithBaseUrl( }, }); } + +export const insertLastSeenAt = async ( + featureName: string, + db: Knex, + environment: string = 'default', + date: string = '2023-10-01 12:34:56', +): Promise => { + await db.raw(`INSERT INTO last_seen_at_metrics (feature_name, environment, last_seen_at) + VALUES ('${featureName}', '${environment}', '${date}');`); + + return date; +}; + +export const insertFeatureEnvironmentsLastSeen = async ( + featureName: string, + db: Knex, + environment: string = 'default', + date: string = '2022-05-01 12:34:56', +): Promise => { + await db.raw(` + INSERT INTO feature_environments (feature_name, environment, last_seen_at, enabled) + VALUES ('${featureName}', '${environment}', '${date}', true) + ON CONFLICT (feature_name, environment) DO UPDATE SET last_seen_at = '${date}', enabled = true; + `); + + return date; +};