From 138ac9809477a70d9db08b0467fb7405efbfd286 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Thu, 16 Mar 2023 09:51:18 +0200 Subject: [PATCH] feat: bulk revive features (#3321) --- src/lib/db/feature-toggle-store.ts | 8 ++++ .../admin-api/project/project-archive.ts | 38 ++++++++++++++++++- src/lib/services/feature-toggle-service.ts | 36 ++++++++++++++++++ src/lib/types/stores/feature-toggle-store.ts | 1 + .../e2e/api/admin/feature-archive.e2e.test.ts | 38 ++++++++++++++++++- .../__snapshots__/openapi.e2e.test.ts.snap | 34 +++++++++++++++++ .../fixtures/fake-feature-toggle-store.ts | 10 +++++ 7 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index bd80180a545..29613d30281 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -316,6 +316,14 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return this.rowToFeature(row[0]); } + async batchRevive(names: string[]): Promise { + const rows = await this.db(TABLE) + .whereIn('name', names) + .update({ archived_at: null }) + .returning(FEATURE_COLUMNS); + return rows.map((row) => this.rowToFeature(row)); + } + async getVariants(featureName: string): Promise { if (!(await this.exists(featureName))) { throw new NotFoundError('No feature toggle found'); diff --git a/src/lib/routes/admin-api/project/project-archive.ts b/src/lib/routes/admin-api/project/project-archive.ts index 21efe55024e..61aa2cff9a3 100644 --- a/src/lib/routes/admin-api/project/project-archive.ts +++ b/src/lib/routes/admin-api/project/project-archive.ts @@ -1,6 +1,11 @@ import { Response } from 'express'; import { IUnleashConfig } from '../../../types/option'; -import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types'; +import { + IFlagResolver, + IProjectParam, + IUnleashServices, + UPDATE_FEATURE, +} from '../../../types'; import { Logger } from '../../../logger'; import { extractUsername } from '../../../util/extract-user'; import { DELETE_FEATURE } from '../../../types/permissions'; @@ -14,6 +19,7 @@ import Controller from '../../controller'; const PATH = '/:projectId/archive'; const PATH_DELETE = `${PATH}/delete`; +const PATH_REVIVE = `${PATH}/revive`; export default class ProjectArchiveController extends Controller { private readonly logger: Logger; @@ -52,6 +58,22 @@ export default class ProjectArchiveController extends Controller { }), ], }); + + this.route({ + method: 'post', + path: PATH_REVIVE, + acceptAnyContentType: true, + handler: this.reviveFeatures, + permission: UPDATE_FEATURE, + middleware: [ + openApiService.validPath({ + tags: ['Archive'], + operationId: 'reviveFeatures', + requestBody: createRequestSchema('batchFeaturesSchema'), + responses: { 200: emptyResponse }, + }), + ], + }); } async deleteFeatures( @@ -67,6 +89,20 @@ export default class ProjectArchiveController extends Controller { await this.featureService.deleteFeatures(features, projectId, user); res.status(200).end(); } + + async reviveFeatures( + req: IAuthRequest, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('bulkOperations')) { + throw new NotFoundError('Bulk operations are not enabled'); + } + const { projectId } = req.params; + const { features } = req.body; + const user = extractUsername(req); + await this.featureService.reviveFeatures(features, projectId, user); + res.status(200).end(); + } } module.exports = ProjectArchiveController; diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 9551802e851..f7340ac4d2c 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -1374,6 +1374,42 @@ class FeatureToggleService { ); } + async reviveFeatures( + featureNames: string[], + projectId: string, + createdBy: string, + ): Promise { + await this.validateFeaturesContext(featureNames, projectId); + + const features = await this.featureToggleStore.getAllByNames( + featureNames, + ); + const eligibleFeatures = features.filter( + (toggle) => toggle.archivedAt !== null, + ); + const eligibleFeatureNames = eligibleFeatures.map( + (toggle) => toggle.name, + ); + const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames); + await this.featureToggleStore.batchRevive(eligibleFeatureNames); + await this.eventStore.batchStore( + eligibleFeatures.map( + (feature) => + new FeatureRevivedEvent({ + featureName: feature.name, + createdBy, + project: feature.project, + tags: tags + .filter((tag) => tag.featureName === feature.name) + .map((tag) => ({ + value: tag.tagValue, + type: tag.tagType, + })), + }), + ), + ); + } + // TODO: add project id. async reviveToggle(featureName: string, createdBy: string): Promise { const toggle = await this.featureToggleStore.revive(featureName); diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 847755febc2..f6e1ad6326c 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -21,6 +21,7 @@ export interface IFeatureToggleStore extends Store { stale: boolean, ): Promise; batchDelete(featureNames: string[]): Promise; + batchRevive(featureNames: string[]): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; getAllByNames(names: string[]): Promise; diff --git a/src/test/e2e/api/admin/feature-archive.e2e.test.ts b/src/test/e2e/api/admin/feature-archive.e2e.test.ts index e9d51166f41..8597da2ab89 100644 --- a/src/test/e2e/api/admin/feature-archive.e2e.test.ts +++ b/src/test/e2e/api/admin/feature-archive.e2e.test.ts @@ -1,6 +1,7 @@ import { setupAppWithCustomConfig } from '../../helpers/test-helper'; import dbInit from '../../helpers/database-init'; import getLogger from '../../../fixtures/no-logger'; +import { DEFAULT_PROJECT } from '../../../../lib/types'; let app; let db; @@ -203,8 +204,13 @@ test('can bulk delete features and recreate after', async () => { }) .set('Content-Type', 'application/json') .expect(201); - await app.request.delete(`/api/admin/features/${feature}`).expect(200); } + await app.request + .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`) + .send({ + features, + }) + .expect(202); await app.request .post('/api/admin/projects/default/archive/delete') .send({ features }) @@ -217,3 +223,33 @@ test('can bulk delete features and recreate after', async () => { .expect(200); } }); + +test('can bulk revive features', async () => { + const features = ['first.revive.issue', 'second.revive.issue']; + for (const feature of features) { + await app.request + .post('/api/admin/features') + .send({ + name: feature, + enabled: false, + strategies: [{ name: 'default' }], + }) + .set('Content-Type', 'application/json') + .expect(201); + } + await app.request + .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`) + .send({ + features, + }) + .expect(202); + await app.request + .post('/api/admin/projects/default/archive/revive') + .send({ features }) + .expect(200); + for (const feature of features) { + await app.request + .get(`/api/admin/projects/default/features/${feature}`) + .expect(200); + } +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index aef007d8c92..849f132f873 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -6114,6 +6114,40 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/archive/revive": { + "post": { + "operationId": "reviveFeatures", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/batchFeaturesSchema", + }, + }, + }, + "description": "batchFeaturesSchema", + "required": true, + }, + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Archive", + ], + }, + }, "/api/admin/projects/{projectId}/environments": { "post": { "operationId": "addEnvironmentToProject", diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index 1ce1e3b2674..5015766a4cc 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -54,6 +54,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return Promise.resolve(); } + async batchRevive(featureNames: string[]): Promise { + const features = this.features.filter((f) => + featureNames.includes(f.name), + ); + for (const feature of features) { + feature.archived = false; + } + return features; + } + async count(query: Partial): Promise { return this.features.filter(this.getFilterQuery(query)).length; }