Skip to content

Commit

Permalink
feat: bulk revive features (#3321)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Mar 16, 2023
1 parent 75d2930 commit 138ac98
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 2 deletions.
8 changes: 8 additions & 0 deletions src/lib/db/feature-toggle-store.ts
Expand Up @@ -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');
Expand Down
38 changes: 37 additions & 1 deletion 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';
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;
36 changes: 36 additions & 0 deletions src/lib/services/feature-toggle-service.ts
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/stores/feature-toggle-store.ts
Expand Up @@ -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[]>;
Expand Down
38 changes: 37 additions & 1 deletion 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;
Expand Down Expand Up @@ -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 })
Expand All @@ -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);
}
});
34 changes: 34 additions & 0 deletions src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/test/fixtures/fake-feature-toggle-store.ts
Expand Up @@ -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;
}
Expand Down

0 comments on commit 138ac98

Please sign in to comment.