diff --git a/src/lib/routes/admin-api/archive.ts b/src/lib/features/feature-toggle/archive-feature-toggle-controller.ts similarity index 95% rename from src/lib/routes/admin-api/archive.ts rename to src/lib/features/feature-toggle/archive-feature-toggle-controller.ts index 360a8846a9db..ba530f5d5303 100644 --- a/src/lib/routes/admin-api/archive.ts +++ b/src/lib/features/feature-toggle/archive-feature-toggle-controller.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types'; -import Controller from '../controller'; +import Controller from '../../routes/controller'; import { extractUsername } from '../../util/extract-user'; import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions'; -import FeatureToggleService from '../../features/feature-toggle/feature-toggle-service'; -import { IAuthRequest } from '../unleash-types'; +import FeatureToggleService from './feature-toggle-service'; +import { IAuthRequest } from '../../routes/unleash-types'; import { featuresSchema, FeaturesSchema, @@ -140,7 +140,7 @@ export default class ArchiveController extends Controller { res: Response, ): Promise { const { user } = req; - const features = await this.featureService.getMetadataForAllFeatures( + const features = await this.featureService.getAllArchivedFeatures( true, user.id, ); @@ -158,7 +158,7 @@ export default class ArchiveController extends Controller { ): Promise { const { projectId } = req.params; const features = - await this.featureService.getMetadataForAllFeaturesByProjectId( + await this.featureService.getArchivedFeaturesByProjectId( true, projectId, ); 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 ff5065218eb5..d2e9c52f19ad 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 @@ -88,7 +88,7 @@ export class FeatureToggleRowConverter { const newEnvironment = { name: row.last_seen_at_env, lastSeenAt: row.env_last_seen_at, - enabled: row.enabled, + enabled: row.enabled || false, }; feature.environments.push(newEnvironment); @@ -253,4 +253,32 @@ export class FeatureToggleRowConverter { return this.formatToggles(result); }; + + buildArchivedFeatureToggleListFromRows = ( + rows: any[], + ): IFeatureToggleListItem[] => { + const result = rows.reduce((acc, row) => { + const feature: PartialDeep = + acc[row.name] ?? {}; + + feature.name = row.name; + feature.description = row.description; + feature.type = row.type; + feature.project = row.project; + feature.stale = row.stale; + feature.createdAt = row.created_at; + feature.impressionData = row.impression_data; + feature.lastSeenAt = row.last_seen_at; + feature.archivedAt = row.archived_at; + + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + this.addLastSeenByEnvironment(feature, row); + } + + acc[row.name] = feature; + return acc; + }, {}); + + return Object.values(result); + }; } 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 df9c857d7d9e..56ec62bcb5c7 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 @@ -163,6 +163,10 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return this.features.filter((feature) => feature.archived !== archived); } + async getArchivedFeatures(project: string): Promise { + return this.features.filter((feature) => feature.archived === true); + } + async getPlaygroundFeatures( query?: IFeatureToggleQuery, ): Promise { diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 85fb56b7a68a..aa22b3ff0dca 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -2032,11 +2032,18 @@ class FeatureToggleService { ); } - async getMetadataForAllFeatures( + async getAllArchivedFeatures( archived: boolean, userId: number, ): Promise { - const features = await this.featureToggleStore.getAll({ archived }); + let features; + + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + features = await this.featureToggleStore.getArchivedFeatures(); + } else { + features = await this.featureToggleStore.getAll({ archived }); + } + if (this.flagResolver.isEnabled('privateProjects')) { const projectAccess = await this.privateProjectChecker.getUserAccessibleProjects( @@ -2053,11 +2060,15 @@ class FeatureToggleService { return features; } - async getMetadataForAllFeaturesByProjectId( + async getArchivedFeaturesByProjectId( archived: boolean, project: string, ): Promise { - return this.featureToggleStore.getAll({ archived, project }); + if (this.flagResolver.isEnabled('useLastSeenRefactor')) { + return this.featureToggleStore.getArchivedFeatures(project); + } else { + return this.featureToggleStore.getAll({ archived, project }); + } } async getProjectId(name: 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 1deb717200b8..437360f998ed 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -20,7 +20,6 @@ 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[] }; @@ -53,6 +52,17 @@ interface VariantDTO { variants: IVariant[]; } +const commonSelectColumns = [ + 'features.name as name', + 'features.description as description', + 'features.type as type', + 'features.project as project', + 'features.stale as stale', + 'features.impression_data as impression_data', + 'features.last_seen_at as last_seen_at', + 'features.created_at as created_at', +]; + const TABLE = 'features'; const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments'; @@ -117,7 +127,22 @@ export default class FeatureToggleStore implements IFeatureToggleStore { } private getBaseFeatureQuery = (archived: boolean, environment: string) => { - const builder = new FeatureToggleListBuilder(this.db); + const builder = new FeatureToggleListBuilder(this.db, [ + ...commonSelectColumns, + 'fe.variants as variants', + 'fe.enabled as enabled', + 'fe.environment as environment', + 'fs.id as strategy_id', + 'fs.strategy_name as strategy_name', + 'fs.title as strategy_title', + 'fs.disabled as strategy_disabled', + 'fs.parameters as parameters', + 'fs.constraints as constraints', + 'fs.sort_order as sort_order', + 'fs.variants as strategy_variants', + 'segments.id as segment_id', + 'segments.constraints as segment_constraints', + ]); builder .query('features') @@ -227,9 +252,43 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .from(TABLE) .where(rest) .modify(FeatureToggleStore.filterByArchived, archived); + return rows.map(this.rowToFeature); } + async getArchivedFeatures(project?: string): Promise { + const builder = new FeatureToggleListBuilder(this.db, [ + ...commonSelectColumns, + 'features.archived_at as archived_at', + ]); + + builder.query('features').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', + ); + + let rows; + + if (project) { + rows = await builder.internalQuery + .select(builder.getSelectColumns()) + .where({ project }) + .whereNotNull('archived_at'); + } else { + rows = await builder.internalQuery + .select(builder.getSelectColumns()) + .whereNotNull('archived_at'); + } + + return this.featureToggleRowConverter.buildArchivedFeatureToggleListFromRows( + rows, + ); + } + async getAllByNames(names: string[]): Promise { const query = this.db(TABLE).orderBy('name', 'asc'); query.whereIn('name', names); 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 0fa22b4e90a7..27b3c6bf21cd 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 @@ -8,31 +8,9 @@ export class FeatureToggleListBuilder { private selectColumns: (string | Knex.Raw)[]; - constructor(db) { + constructor(db, selectColumns) { this.db = db; - this.selectColumns = [ - 'features.name as name', - 'features.description as description', - 'features.type as type', - 'features.project as project', - 'features.stale as stale', - 'features.impression_data as impression_data', - 'features.last_seen_at as last_seen_at', - 'features.created_at as created_at', - 'fe.variants as variants', - 'fe.enabled as enabled', - 'fe.environment as environment', - 'fs.id as strategy_id', - 'fs.strategy_name as strategy_name', - 'fs.title as strategy_title', - 'fs.disabled as strategy_disabled', - 'fs.parameters as parameters', - 'fs.constraints as constraints', - 'fs.sort_order as sort_order', - 'fs.variants as strategy_variants', - 'segments.id as segment_id', - 'segments.constraints as segment_constraints', - ] as (string | Knex.Raw)[]; + this.selectColumns = selectColumns; } getSelectColumns = () => { diff --git a/src/test/e2e/api/admin/archive.test.ts b/src/lib/features/feature-toggle/tests/archive-feature-toggles.e2e.test.ts similarity index 96% rename from src/test/e2e/api/admin/archive.test.ts rename to src/lib/features/feature-toggle/tests/archive-feature-toggles.e2e.test.ts index 02be9c33b7bd..4449ac02ebbd 100644 --- a/src/test/e2e/api/admin/archive.test.ts +++ b/src/lib/features/feature-toggle/tests/archive-feature-toggles.e2e.test.ts @@ -1,9 +1,9 @@ -import dbInit, { ITestDb } from '../../helpers/database-init'; +import dbInit, { ITestDb } from '../../../../test/e2e/helpers/database-init'; import { IUnleashTest, setupAppWithCustomConfig, -} from '../../helpers/test-helper'; -import getLogger from '../../../fixtures/no-logger'; +} from '../../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../../test/fixtures/no-logger'; let app: IUnleashTest; let db: ITestDb; 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 index 67b09c53e4e6..3ff5c7adb12f 100644 --- 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 @@ -9,6 +9,14 @@ import getLogger from '../../../../test/fixtures/no-logger'; let app: IUnleashTest; let db: ITestDb; +const setupLastSeenAtTest = async (featureName: string) => { + await app.createFeature(featureName); + + await insertLastSeenAt(featureName, db.rawDatabase, 'default'); + await insertLastSeenAt(featureName, db.rawDatabase, 'development'); + await insertLastSeenAt(featureName, db.rawDatabase, 'production'); +}; + beforeAll(async () => { const config = { experimental: { @@ -27,33 +35,7 @@ beforeAll(async () => { 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', @@ -76,25 +58,36 @@ test('response should include last seen at per environment for multiple environm 'default', 'production', ); +}); - await app.createFeature('multiple-environment-last-seen-at'); +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); - await insertLastSeenAt( - 'multiple-environment-last-seen-at', - db.rawDatabase, - 'default', - ); - await insertLastSeenAt( - 'multiple-environment-last-seen-at', - db.rawDatabase, - 'development', +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', ); - await insertLastSeenAt( - 'multiple-environment-last-seen-at', - db.rawDatabase, - 'production', + + expect(found.environments[0].lastSeenAt).toEqual( + '2023-10-01T12:34:56.000Z', ); +}); + +test('response should include last seen at per environment for multiple environments in /api/admin/features', async () => { + const featureName = 'multiple-environment-last-seen-at'; + await setupLastSeenAtTest(featureName); const { body } = await app.request .get('/api/admin/features') .expect('Content-Type', /json/) @@ -113,3 +106,51 @@ test('response should include last seen at per environment for multiple environm expect(production.name).toBe('production'); expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z'); }); + +test('response should include last seen at per environment for multiple environments in /api/admin/archive/features', async () => { + const featureName = 'multiple-environment-last-seen-at-archived'; + await setupLastSeenAtTest(featureName); + + await app.request + .delete(`/api/admin/projects/default/features/${featureName}`) + .expect(202); + + const { body } = await app.request.get(`/api/admin/archive/features`); + + const featureEnvironments = body.features[0].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'); +}); + +test('response should include last seen at per environment for multiple environments in /api/admin/archive/features/:projectId', async () => { + const featureName = 'multiple-environment-last-seen-at-archived-project'; + await setupLastSeenAtTest(featureName); + + await app.request + .delete(`/api/admin/projects/default/features/${featureName}`) + .expect(202); + + const { body } = await app.request.get( + `/api/admin/archive/features/default`, + ); + + const featureEnvironments = body.features[0].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/types/feature-toggle-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts index 223cdd82add0..e94630c48ec2 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 @@ -37,6 +37,7 @@ export interface IFeatureToggleStore extends Store { userId?: number, archived?: boolean, ): Promise; + getArchivedFeatures(project?: string): Promise; getPlaygroundFeatures( featureQuery?: IFeatureToggleQuery, ): Promise; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index f3fde9e99ad2..d995461ab1c8 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -2,7 +2,7 @@ import Controller from '../controller'; import { IUnleashServices, IUnleashConfig } from '../../types'; import FeatureController from '../../features/feature-toggle/legacy/feature-toggle-legacy-controller'; import { FeatureTypeController } from './feature-type'; -import ArchiveController from './archive'; +import ArchiveController from '../../features/feature-toggle/archive-feature-toggle-controller'; import StrategyController from './strategy'; import EventController from './event'; import PlaygroundController from '../../features/playground/playground';