mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: bulk revive features (#3321)
This commit is contained in:
		
							parent
							
								
									75d2930bcd
								
							
						
					
					
						commit
						138ac98094
					
				| @ -316,6 +316,14 @@ export default class FeatureToggleStore implements IFeatureToggleStore { | ||||
|         return this.rowToFeature(row[0]); | ||||
|     } | ||||
| 
 | ||||
|     async batchRevive(names: string[]): Promise<FeatureToggle[]> { | ||||
|         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<IVariant[]> { | ||||
|         if (!(await this.exists(featureName))) { | ||||
|             throw new NotFoundError('No feature toggle found'); | ||||
|  | ||||
| @ -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<IProjectParam, any, BatchFeaturesSchema>, | ||||
|         res: Response<void>, | ||||
|     ): Promise<void> { | ||||
|         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; | ||||
|  | ||||
| @ -1374,6 +1374,42 @@ class FeatureToggleService { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async reviveFeatures( | ||||
|         featureNames: string[], | ||||
|         projectId: string, | ||||
|         createdBy: string, | ||||
|     ): Promise<void> { | ||||
|         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<void> { | ||||
|         const toggle = await this.featureToggleStore.revive(featureName); | ||||
|  | ||||
| @ -21,6 +21,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> { | ||||
|         stale: boolean, | ||||
|     ): Promise<FeatureToggle[]>; | ||||
|     batchDelete(featureNames: string[]): Promise<void>; | ||||
|     batchRevive(featureNames: string[]): Promise<FeatureToggle[]>; | ||||
|     revive(featureName: string): Promise<FeatureToggle>; | ||||
|     getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>; | ||||
|     getAllByNames(names: string[]): Promise<FeatureToggle[]>; | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
							
								
								
									
										10
									
								
								src/test/fixtures/fake-feature-toggle-store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								src/test/fixtures/fake-feature-toggle-store.ts
									
									
									
									
										vendored
									
									
								
							| @ -54,6 +54,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { | ||||
|         return Promise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     async batchRevive(featureNames: string[]): Promise<FeatureToggle[]> { | ||||
|         const features = this.features.filter((f) => | ||||
|             featureNames.includes(f.name), | ||||
|         ); | ||||
|         for (const feature of features) { | ||||
|             feature.archived = false; | ||||
|         } | ||||
|         return features; | ||||
|     } | ||||
| 
 | ||||
|     async count(query: Partial<IFeatureToggleQuery>): Promise<number> { | ||||
|         return this.features.filter(this.getFilterQuery(query)).length; | ||||
|     } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user