mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: bulk delete features (#3314)
This commit is contained in:
		
							parent
							
								
									ac1be475e4
								
							
						
					
					
						commit
						a5f1b89b4a
					
				| @ -301,6 +301,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore { | ||||
|             .del(); | ||||
|     } | ||||
| 
 | ||||
|     async batchDelete(names: string[]): Promise<void> { | ||||
|         await this.db(TABLE) | ||||
|             .whereIn('name', names) | ||||
|             .whereNotNull('archived_at') | ||||
|             .del(); | ||||
|     } | ||||
| 
 | ||||
|     async revive(name: string): Promise<FeatureToggle> { | ||||
|         const row = await this.db(TABLE) | ||||
|             .where({ name }) | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { | ||||
| import { IArchivedQuery, IProjectParam } from '../../../types/model'; | ||||
| import { ProjectApiTokenController } from './api-token'; | ||||
| import { SettingService } from '../../../services'; | ||||
| import ProjectArchiveController from './project-archive'; | ||||
| 
 | ||||
| const STICKINESS_KEY = 'stickiness'; | ||||
| const DEFAULT_STICKINESS = 'default'; | ||||
| @ -114,6 +115,7 @@ export default class ProjectApi extends Controller { | ||||
|         this.use('/', new ProjectHealthReport(config, services).router); | ||||
|         this.use('/', new VariantsController(config, services).router); | ||||
|         this.use('/', new ProjectApiTokenController(config, services).router); | ||||
|         this.use('/', new ProjectArchiveController(config, services).router); | ||||
|     } | ||||
| 
 | ||||
|     async getProjects( | ||||
|  | ||||
							
								
								
									
										72
									
								
								src/lib/routes/admin-api/project/project-archive.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/lib/routes/admin-api/project/project-archive.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | ||||
| import { Response } from 'express'; | ||||
| import { IUnleashConfig } from '../../../types/option'; | ||||
| import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types'; | ||||
| import { Logger } from '../../../logger'; | ||||
| import { extractUsername } from '../../../util/extract-user'; | ||||
| import { DELETE_FEATURE } from '../../../types/permissions'; | ||||
| import FeatureToggleService from '../../../services/feature-toggle-service'; | ||||
| import { IAuthRequest } from '../../unleash-types'; | ||||
| import { OpenApiService } from '../../../services/openapi-service'; | ||||
| import { emptyResponse } from '../../../openapi/util/standard-responses'; | ||||
| import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi'; | ||||
| import NotFoundError from '../../../error/notfound-error'; | ||||
| import Controller from '../../controller'; | ||||
| 
 | ||||
| const PATH = '/:projectId/archive'; | ||||
| const PATH_DELETE = `${PATH}/delete`; | ||||
| 
 | ||||
| export default class ProjectArchiveController extends Controller { | ||||
|     private readonly logger: Logger; | ||||
| 
 | ||||
|     private featureService: FeatureToggleService; | ||||
| 
 | ||||
|     private openApiService: OpenApiService; | ||||
| 
 | ||||
|     private flagResolver: IFlagResolver; | ||||
| 
 | ||||
|     constructor( | ||||
|         config: IUnleashConfig, | ||||
|         { | ||||
|             featureToggleServiceV2, | ||||
|             openApiService, | ||||
|         }: Pick<IUnleashServices, 'featureToggleServiceV2' | 'openApiService'>, | ||||
|     ) { | ||||
|         super(config); | ||||
|         this.logger = config.getLogger('/admin-api/archive.js'); | ||||
|         this.featureService = featureToggleServiceV2; | ||||
|         this.openApiService = openApiService; | ||||
|         this.flagResolver = config.flagResolver; | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: PATH_DELETE, | ||||
|             acceptAnyContentType: true, | ||||
|             handler: this.deleteFeatures, | ||||
|             permission: DELETE_FEATURE, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Archive'], | ||||
|                     operationId: 'deleteFeatures', | ||||
|                     requestBody: createRequestSchema('batchFeaturesSchema'), | ||||
|                     responses: { 200: emptyResponse }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     async deleteFeatures( | ||||
|         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.deleteFeatures(features, projectId, user); | ||||
|         res.status(200).end(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = ProjectArchiveController; | ||||
| @ -1089,6 +1089,7 @@ class FeatureToggleService { | ||||
|             featureNames, | ||||
|         ); | ||||
|         await this.featureToggleStore.batchArchive(featureNames); | ||||
|         const tags = await this.tagStore.getAllByFeatures(featureNames); | ||||
|         await this.eventStore.batchStore( | ||||
|             features.map( | ||||
|                 (feature) => | ||||
| @ -1096,6 +1097,12 @@ class FeatureToggleService { | ||||
|                         featureName: feature.name, | ||||
|                         createdBy, | ||||
|                         project: feature.project, | ||||
|                         tags: tags | ||||
|                             .filter((tag) => tag.featureName === feature.name) | ||||
|                             .map((tag) => ({ | ||||
|                                 value: tag.tagValue, | ||||
|                                 type: tag.tagType, | ||||
|                             })), | ||||
|                     }), | ||||
|             ), | ||||
|         ); | ||||
| @ -1115,10 +1122,11 @@ class FeatureToggleService { | ||||
|         const relevantFeatures = features.filter( | ||||
|             (feature) => feature.stale !== stale, | ||||
|         ); | ||||
|         await this.featureToggleStore.batchStale( | ||||
|             relevantFeatures.map((feature) => feature.name), | ||||
|             stale, | ||||
|         const relevantFeatureNames = relevantFeatures.map( | ||||
|             (feature) => feature.name, | ||||
|         ); | ||||
|         await this.featureToggleStore.batchStale(relevantFeatureNames, stale); | ||||
|         const tags = await this.tagStore.getAllByFeatures(relevantFeatureNames); | ||||
|         await this.eventStore.batchStore( | ||||
|             relevantFeatures.map( | ||||
|                 (feature) => | ||||
| @ -1127,6 +1135,12 @@ class FeatureToggleService { | ||||
|                         project: projectId, | ||||
|                         featureName: feature.name, | ||||
|                         createdBy, | ||||
|                         tags: tags | ||||
|                             .filter((tag) => tag.featureName === feature.name) | ||||
|                             .map((tag) => ({ | ||||
|                                 value: tag.tagValue, | ||||
|                                 type: tag.tagType, | ||||
|                             })), | ||||
|                     }), | ||||
|             ), | ||||
|         ); | ||||
| @ -1320,6 +1334,43 @@ class FeatureToggleService { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async deleteFeatures( | ||||
|         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.batchDelete(eligibleFeatureNames); | ||||
|         await this.eventStore.batchStore( | ||||
|             eligibleFeatures.map( | ||||
|                 (feature) => | ||||
|                     new FeatureDeletedEvent({ | ||||
|                         featureName: feature.name, | ||||
|                         createdBy, | ||||
|                         project: feature.project, | ||||
|                         preData: feature, | ||||
|                         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); | ||||
|  | ||||
| @ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent { | ||||
|         project: string; | ||||
|         featureName: string; | ||||
|         createdBy: string | IUser; | ||||
|         tags?: ITag[]; | ||||
|         tags: ITag[]; | ||||
|     }) { | ||||
|         super( | ||||
|             p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF, | ||||
| @ -330,7 +330,7 @@ export class FeatureArchivedEvent extends BaseEvent { | ||||
|         project: string; | ||||
|         featureName: string; | ||||
|         createdBy: string | IUser; | ||||
|         tags?: ITag[]; | ||||
|         tags: ITag[]; | ||||
|     }) { | ||||
|         super(FEATURE_ARCHIVED, p.createdBy, p.tags); | ||||
|         const { project, featureName } = p; | ||||
|  | ||||
| @ -20,6 +20,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> { | ||||
|         featureNames: string[], | ||||
|         stale: boolean, | ||||
|     ): Promise<FeatureToggle[]>; | ||||
|     batchDelete(featureNames: string[]): Promise<void>; | ||||
|     revive(featureName: string): Promise<FeatureToggle>; | ||||
|     getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>; | ||||
|     getAllByNames(names: string[]): Promise<FeatureToggle[]>; | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { setupApp } from '../../helpers/test-helper'; | ||||
| import { setupAppWithCustomConfig } from '../../helpers/test-helper'; | ||||
| import dbInit from '../../helpers/database-init'; | ||||
| import getLogger from '../../../fixtures/no-logger'; | ||||
| 
 | ||||
| @ -7,7 +7,14 @@ let db; | ||||
| 
 | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('archive_serial', getLogger); | ||||
|     app = await setupApp(db.stores); | ||||
|     app = await setupAppWithCustomConfig(db.stores, { | ||||
|         experimental: { | ||||
|             flags: { | ||||
|                 strictSchemaValidation: true, | ||||
|                 bulkOperations: true, | ||||
|             }, | ||||
|         }, | ||||
|     }); | ||||
|     await app.services.featureToggleServiceV2.createFeatureToggle( | ||||
|         'default', | ||||
|         { | ||||
| @ -183,3 +190,30 @@ test('Deleting an unarchived toggle should not take effect', async () => { | ||||
|         .set('Content-Type', 'application/json') | ||||
|         .expect(409); // because it still exists
 | ||||
| }); | ||||
| 
 | ||||
| test('can bulk delete features and recreate after', async () => { | ||||
|     const features = ['first.bulk.issue', 'second.bulk.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.delete(`/api/admin/features/${feature}`).expect(200); | ||||
|     } | ||||
|     await app.request | ||||
|         .post('/api/admin/projects/default/archive/delete') | ||||
|         .send({ features }) | ||||
|         .expect(200); | ||||
|     for (const feature of features) { | ||||
|         await app.request | ||||
|             .post('/api/admin/features/validate') | ||||
|             .send({ name: feature }) | ||||
|             .set('Content-Type', 'application/json') | ||||
|             .expect(200); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| @ -6044,6 +6044,40 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|     "/api/admin/projects/{projectId}/archive/delete": { | ||||
|       "post": { | ||||
|         "operationId": "deleteFeatures", | ||||
|         "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", | ||||
|  | ||||
| @ -47,6 +47,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { | ||||
|         return features; | ||||
|     } | ||||
| 
 | ||||
|     async batchDelete(featureNames: string[]): Promise<void> { | ||||
|         this.features = this.features.filter( | ||||
|             (feature) => !featureNames.includes(feature.name), | ||||
|         ); | ||||
|         return Promise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     async count(query: Partial<IFeatureToggleQuery>): Promise<number> { | ||||
|         return this.features.filter(this.getFilterQuery(query)).length; | ||||
|     } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user