diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 8ef3c8800f..918c6f87a7 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -12,6 +12,8 @@ const EVENT_COLUMNS = [ 'created_at', 'data', 'tags', + 'project', + 'environment', ]; export interface IEventTable { @@ -20,6 +22,8 @@ export interface IEventTable { created_by: string; created_at: Date; data: any; + project?: string; + environment?: string; tags: []; } @@ -126,6 +130,19 @@ class EventStore extends EventEmitter implements IEventStore { } } + async getEventsFilterByProject(project: string): Promise { + try { + const rows = await this.db + .select(EVENT_COLUMNS) + .from(TABLE) + .where({ project }) + .orderBy('created_at', 'desc'); + return rows.map(this.rowToEvent); + } catch (err) { + return []; + } + } + rowToEvent(row: IEventTable): IEvent { return { id: row.id, @@ -134,6 +151,8 @@ class EventStore extends EventEmitter implements IEventStore { createdAt: row.created_at, data: row.data, tags: row.tags || [], + project: row.project, + environment: row.environment, }; } @@ -143,6 +162,8 @@ class EventStore extends EventEmitter implements IEventStore { created_by: e.createdBy, data: e.data, tags: JSON.stringify(e.tags), + project: e.project, + environment: e.environment, }; } } diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index e2baf5bc78..247ad788d5 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -24,7 +24,14 @@ export default class EventController extends Controller { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async getEvents(req, res): Promise { - const events = await this.eventService.getEvents(); + let events; + if (req.query?.project) { + events = await this.eventService.getEventsForProject( + req.query.project, + ); + } else { + events = await this.eventService.getEvents(); + } eventDiffer.addDiffs(events); res.json({ version, events }); } diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index bc123acb4c..337832c082 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -174,6 +174,7 @@ class FeatureController extends Controller { s, createdFeature.project, createdFeature.name, + userName, ), ), ); @@ -219,6 +220,7 @@ class FeatureController extends Controller { s, projectId, featureName, + userName, ), ), ); diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index 65a24c6ded..3ff54148de 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -220,14 +220,16 @@ export default class ProjectFeaturesController extends Controller { } async addStrategy( - req: Request, + req: IAuthRequest, res: Response, ): Promise { const { projectId, featureName, environment } = req.params; + const userName = extractUsername(req); const featureStrategy = await this.featureService.createStrategy( req.body, projectId, featureName, + userName, environment, ); res.status(200).json(featureStrategy); @@ -248,34 +250,42 @@ export default class ProjectFeaturesController extends Controller { } async updateStrategy( - req: Request, + req: IAuthRequest, res: Response, ): Promise { - const { strategyId } = req.params; + const { strategyId, environment, projectId } = req.params; + const userName = extractUsername(req); const updatedStrategy = await this.featureService.updateStrategy( strategyId, + environment, + projectId, + userName, req.body, ); res.status(200).json(updatedStrategy); } async patchStrategy( - req: Request, + req: IAuthRequest, res: Response, ): Promise { - const { strategyId } = req.params; + const { strategyId, projectId, environment } = req.params; + const userName = extractUsername(req); const patch = req.body; const strategy = await this.featureService.getStrategy(strategyId); const { newDocument } = applyPatch(strategy, patch); const updatedStrategy = await this.featureService.updateStrategy( strategyId, + environment, + projectId, + userName, newDocument, ); res.status(200).json(updatedStrategy); } async getStrategy( - req: Request, + req: IAuthRequest, res: Response, ): Promise { this.logger.info('Getting strategy'); @@ -286,18 +296,25 @@ export default class ProjectFeaturesController extends Controller { } async deleteStrategy( - req: Request, + req: IAuthRequest, res: Response, ): Promise { this.logger.info('Deleting strategy'); + const { environment, projectId } = req.params; + const userName = extractUsername(req); const { strategyId } = req.params; this.logger.info(strategyId); - const strategy = await this.featureService.deleteStrategy(strategyId); + const strategy = await this.featureService.deleteStrategy( + strategyId, + userName, + projectId, + environment, + ); res.status(200).json(strategy); } async updateStrategyParameter( - req: Request< + req: IAuthRequest< StrategyIdParams, any, { name: string; value: string | number }, @@ -305,7 +322,8 @@ export default class ProjectFeaturesController extends Controller { >, res: Response, ): Promise { - const { strategyId } = req.params; + const { strategyId, environment, projectId } = req.params; + const userName = extractUsername(req); const { name, value } = req.body; const updatedStrategy = @@ -313,6 +331,9 @@ export default class ProjectFeaturesController extends Controller { strategyId, name, value, + userName, + projectId, + environment, ); res.status(200).json(updatedStrategy); } diff --git a/src/lib/services/event-service.ts b/src/lib/services/event-service.ts index 8bf3e75308..7ebbb8e184 100644 --- a/src/lib/services/event-service.ts +++ b/src/lib/services/event-service.ts @@ -28,6 +28,10 @@ export default class EventService { (e: IEvent) => e.type !== FEATURE_METADATA_UPDATED, ); } + + async getEventsForProject(project: string): Promise { + return this.eventStore.getEventsFilterByProject(project); + } } module.exports = EventService; diff --git a/src/lib/services/feature-toggle-service-v2.ts b/src/lib/services/feature-toggle-service-v2.ts index a7d7d816d4..85ebca88e1 100644 --- a/src/lib/services/feature-toggle-service-v2.ts +++ b/src/lib/services/feature-toggle-service-v2.ts @@ -10,11 +10,14 @@ import { FEATURE_ARCHIVED, FEATURE_CREATED, FEATURE_DELETED, + FEATURE_METADATA_UPDATED, FEATURE_REVIVED, FEATURE_STALE_OFF, FEATURE_STALE_ON, + FEATURE_STRATEGY_ADD, + FEATURE_STRATEGY_REMOVE, + FEATURE_STRATEGY_UPDATE, FEATURE_UPDATED, - FEATURE_METADATA_UPDATED, } from '../types/events'; import { GLOBAL_ENV } from '../types/environment'; import NotFoundError from '../error/notfound-error'; @@ -88,16 +91,11 @@ class FeatureToggleServiceV2 { this.featureEnvironmentStore = featureEnvironmentStore; } - /* - TODO after 4.1.0 release: - - add FEATURE_STRATEGY_ADD event - - add FEATURE_STRATEGY_REMOVE event - - add FEATURE_STRATEGY_UPDATE event - */ async createStrategy( strategyConfig: Omit, projectId: string, featureName: string, + userName: string, environment: string = GLOBAL_ENV, ): Promise { try { @@ -111,12 +109,20 @@ class FeatureToggleServiceV2 { featureName, environment, }); - return { + const data = { id: newFeatureStrategy.id, name: newFeatureStrategy.strategyName, constraints: newFeatureStrategy.constraints, parameters: newFeatureStrategy.parameters, }; + await this.eventStore.store({ + type: FEATURE_STRATEGY_ADD, + project: projectId, + createdBy: userName, + environment, + data, + }); + return data; } catch (e) { if (e.code === FOREIGN_KEY_VIOLATION) { throw new BadDataError( @@ -126,6 +132,12 @@ class FeatureToggleServiceV2 { throw e; } } + /* + TODO after 4.1.0 release: + - add FEATURE_STRATEGY_ADD event + - add FEATURE_STRATEGY_REMOVE event + - add FEATURE_STRATEGY_UPDATE event + */ /** * PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ? @@ -139,6 +151,9 @@ class FeatureToggleServiceV2 { // TODO: verify projectId is not changed from URL! async updateStrategy( id: string, + environment: string, + project: string, + userName: string, updates: Partial, ): Promise { const existingStrategy = await this.featureStrategiesStore.get(id); @@ -147,12 +162,20 @@ class FeatureToggleServiceV2 { id, updates, ); - return { + const data = { id: strategy.id, name: strategy.strategyName, constraints: strategy.constraints || [], parameters: strategy.parameters, }; + await this.eventStore.store({ + type: FEATURE_STRATEGY_UPDATE, + project, + environment, + createdBy: userName, + data, + }); + return data; } throw new NotFoundError(`Could not find strategy with id ${id}`); } @@ -162,6 +185,9 @@ class FeatureToggleServiceV2 { id: string, name: string, value: string | number, + userName: string, + project: string, + environment: string, ): Promise { const existingStrategy = await this.featureStrategiesStore.get(id); if (existingStrategy.id === id) { @@ -170,12 +196,20 @@ class FeatureToggleServiceV2 { id, existingStrategy, ); - return { + const data = { id: strategy.id, name: strategy.strategyName, constraints: strategy.constraints || [], parameters: strategy.parameters, }; + await this.eventStore.store({ + type: FEATURE_STRATEGY_UPDATE, + project, + environment, + createdBy: userName, + data, + }); + return data; } throw new NotFoundError(`Could not find strategy with id ${id}`); } @@ -188,8 +222,22 @@ class FeatureToggleServiceV2 { * @param id * @param updates */ - async deleteStrategy(id: string): Promise { - return this.featureStrategiesStore.delete(id); + async deleteStrategy( + id: string, + userName: string, + project: string = 'default', + environment: string = GLOBAL_ENV, + ): Promise { + await this.featureStrategiesStore.delete(id); + await this.eventStore.store({ + type: FEATURE_STRATEGY_REMOVE, + project, + environment, + createdBy: userName, + data: { + id, + }, + }); } async getStrategiesForEnvironment( @@ -309,6 +357,7 @@ class FeatureToggleServiceV2 { await this.eventStore.store({ type: FEATURE_CREATED, createdBy: userName, + project: projectId, data, }); @@ -341,6 +390,7 @@ class FeatureToggleServiceV2 { type: FEATURE_METADATA_UPDATED, createdBy: userName, data: featureToggle, + project: projectId, tags, }); return featureToggle; @@ -455,12 +505,13 @@ class FeatureToggleServiceV2 { createdBy: userName, data, tags, + project: feature.project, }); return feature; } async archiveToggle(name: string, userName: string): Promise { - await this.featureToggleStore.get(name); + const feature = await this.featureToggleStore.get(name); await this.featureToggleStore.archive(name); const tags = (await this.featureTagStore.getAllTagsForFeature(name)) || []; @@ -468,6 +519,7 @@ class FeatureToggleServiceV2 { type: FEATURE_ARCHIVED, createdBy: userName, data: { name }, + project: feature.project, tags, }); } @@ -514,6 +566,8 @@ class FeatureToggleServiceV2 { createdBy: userName, data, tags, + project: projectId, + environment, }); return feature; } @@ -583,6 +637,7 @@ class FeatureToggleServiceV2 { type: event || FEATURE_UPDATED, createdBy: userName, data, + project: data.project, tags, }); return feature; @@ -612,6 +667,7 @@ class FeatureToggleServiceV2 { type: FEATURE_REVIVED, createdBy: userName, data, + project: data.project, tags, }); } diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 59fd21b7f5..1ec6c8777d 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -131,6 +131,7 @@ export default class ProjectService { type: PROJECT_CREATED, createdBy: getCreatedBy(user), data, + project: newProject.id, }); return data; @@ -146,6 +147,7 @@ export default class ProjectService { type: PROJECT_UPDATED, createdBy: getCreatedBy(user), data: project, + project: project.id, }); } @@ -211,10 +213,11 @@ export default class ProjectService { await this.eventStore.store({ type: PROJECT_DELETED, createdBy: getCreatedBy(user), + project: id, data: { id }, }); - this.accessService.removeDefaultProjectRoles(user, id); + await this.accessService.removeDefaultProjectRoles(user, id); } async validateId(id: string): Promise { diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 0a0b1f8f99..5facf26d35 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -9,6 +9,9 @@ export const FEATURE_REVIVED = 'feature-revived'; export const FEATURE_IMPORT = 'feature-import'; export const FEATURE_TAGGED = 'feature-tagged'; export const FEATURE_TAG_IMPORT = 'feature-tag-import'; +export const FEATURE_STRATEGY_UPDATE = 'feature-strategy-update'; +export const FEATURE_STRATEGY_ADD = 'feature-strategy-add'; +export const FEATURE_STRATEGY_REMOVE = 'feature-strategy-remove'; export const DROP_FEATURE_TAGS = 'drop-feature-tags'; export const FEATURE_UNTAGGED = 'feature-untagged'; export const FEATURE_STALE_ON = 'feature-stale-on'; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 67d0d6c38b..25a218509e 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -197,6 +197,8 @@ export interface IAddonConfig { export interface ICreateEvent { type: string; createdBy: string; + project?: string; + environment?: string; data?: any; tags?: ITag[]; } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 3f3965ac31..e537c59bc1 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -7,4 +7,5 @@ export interface IEventStore extends Store, EventEmitter { batchStore(events: ICreateEvent[]): Promise; getEvents(): Promise; getEventsFilterByType(name: string): Promise; + getEventsFilterByProject(project: string): Promise; } diff --git a/src/migrations/20210915122001-add-project-and-environment-columns-to-events.js b/src/migrations/20210915122001-add-project-and-environment-columns-to-events.js new file mode 100644 index 0000000000..abe5713530 --- /dev/null +++ b/src/migrations/20210915122001-add-project-and-environment-columns-to-events.js @@ -0,0 +1,29 @@ +'use strict'; + +exports.up = function (db, cb) { + db.runSql( + ` + ALTER TABLE events + ADD COLUMN project TEXT; + ALTER TABLE events + ADD COLUMN environment TEXT; + CREATE INDEX events_project_idx ON events(project); + CREATE INDEX events_environment_idx ON events(environment); + `, + cb, + ); +}; + +exports.down = function (db, cb) { + db.runSql( + ` + DROP INDEX events_environment_idx; + DROP INDEX events_project_idx; + ALTER TABLE events + DROP COLUMN environment; + ALTER TABLE events + DROP COLUMN project; + `, + cb, + ); +}; diff --git a/src/test/e2e/api/admin/event.e2e.test.ts b/src/test/e2e/api/admin/event.e2e.test.ts index ad50354761..bbee66737a 100644 --- a/src/test/e2e/api/admin/event.e2e.test.ts +++ b/src/test/e2e/api/admin/event.e2e.test.ts @@ -1,13 +1,17 @@ -import { setupApp } from '../../helpers/test-helper'; -import dbInit from '../../helpers/database-init'; +import { IUnleashTest, setupApp } from '../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../helpers/database-init'; import getLogger from '../../../fixtures/no-logger'; +import { FEATURE_CREATED } from '../../../../lib/types/events'; +import { IEventStore } from '../../../../lib/types/stores/event-store'; -let app; -let db; +let app: IUnleashTest; +let db: ITestDb; +let eventStore: IEventStore; beforeAll(async () => { db = await dbInit('event_api_serial', getLogger); app = await setupApp(db.stores); + eventStore = db.stores.eventStore; }); afterAll(async () => { @@ -30,3 +34,29 @@ test('returns events given a name', async () => { .expect('Content-Type', /json/) .expect(200); }); + +test('Can filter by project', async () => { + await eventStore.store({ + type: FEATURE_CREATED, + project: 'something-else', + data: { id: 'some-other-feature' }, + tags: [], + createdBy: 'test-user', + environment: 'test', + }); + await eventStore.store({ + type: FEATURE_CREATED, + project: 'default', + data: { id: 'feature' }, + tags: [], + createdBy: 'test-user', + environment: 'test', + }); + await app.request + .get('/api/admin/events?project=default') + .expect(200) + .expect((res) => { + expect(res.body.events).toHaveLength(1); + expect(res.body.events[0].data.id).toEqual('feature'); + }); +}); diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 6604cd585b..f9c72bc350 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -32,6 +32,7 @@ beforeAll(async () => { strategy, projectId, toggle.name, + username, ); }; @@ -273,6 +274,7 @@ test('can not toggle of feature that does not exist', async () => { test('can toggle a feature that does exist', async () => { expect.assertions(0); const featureName = 'existing.feature'; + const username = 'toggle-feature'; const feature = await app.services.featureToggleServiceV2.createFeatureToggle( 'default', @@ -285,6 +287,7 @@ test('can toggle a feature that does exist', async () => { defaultStrategy, 'default', featureName, + username, ); return app.request .post(`/api/admin/features/${feature.name}/toggle`) diff --git a/src/test/e2e/api/client/feature.token.access.e2e.test.ts b/src/test/e2e/api/client/feature.token.access.e2e.test.ts index 119ec44a0d..26b1c9f833 100644 --- a/src/test/e2e/api/client/feature.token.access.e2e.test.ts +++ b/src/test/e2e/api/client/feature.token.access.e2e.test.ts @@ -57,6 +57,7 @@ beforeAll(async () => { }, project, feature1, + username, ); await featureToggleServiceV2.createStrategy( { @@ -66,6 +67,7 @@ beforeAll(async () => { }, project, feature1, + username, environment, ); @@ -85,6 +87,7 @@ beforeAll(async () => { }, project, feature2, + username, environment, ); @@ -104,6 +107,7 @@ beforeAll(async () => { }, project2, feature3, + username, environment, ); }); @@ -135,7 +139,7 @@ test('returns feature toggle with :global: config', async () => { }); }); -test('returns feature toggle with :global: config', async () => { +test('returns feature toggle with testing environment config', async () => { const token = await apiTokenService.createApiToken({ type: ApiTokenType.CLIENT, username, diff --git a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts index 834bd76d23..43b764417d 100644 --- a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts +++ b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts @@ -27,6 +27,8 @@ afterAll(async () => { }); test('Should create feature toggle strategy configuration', async () => { + const projectId = 'default'; + const username = 'feature-toggle'; const config: Omit = { name: 'default', constraints: [], @@ -43,8 +45,9 @@ test('Should create feature toggle strategy configuration', async () => { const createdConfig = await service.createStrategy( config, - 'default', + projectId, 'Demo', + username, ); expect(createdConfig.name).toEqual('default'); @@ -52,6 +55,8 @@ test('Should create feature toggle strategy configuration', async () => { }); test('Should be able to update existing strategy configuration', async () => { + const projectId = 'default'; + const username = 'existing-strategy'; const config: Omit = { name: 'default', constraints: [], @@ -59,7 +64,7 @@ test('Should be able to update existing strategy configuration', async () => { }; await service.createFeatureToggle( - 'default', + projectId, { name: 'update-existing-strategy', }, @@ -70,11 +75,18 @@ test('Should be able to update existing strategy configuration', async () => { config, 'default', 'update-existing-strategy', + username, ); expect(createdConfig.name).toEqual('default'); - const updatedConfig = await service.updateStrategy(createdConfig.id, { - parameters: { b2b: true }, - }); + const updatedConfig = await service.updateStrategy( + createdConfig.id, + GLOBAL_ENV, + projectId, + username, + { + parameters: { b2b: true }, + }, + ); expect(createdConfig.id).toEqual(updatedConfig.id); expect(updatedConfig.parameters).toEqual({ b2b: true }); }); @@ -96,7 +108,7 @@ test('Should include legacy props in event log when updating strategy configurat userName, ); - await service.createStrategy(config, 'default', featureName); + await service.createStrategy(config, 'default', featureName, userName); await service.updateEnabled( 'default', featureName, @@ -112,6 +124,7 @@ test('Should include legacy props in event log when updating strategy configurat }); test('Should be able to get strategy by id', async () => { + const userName = 'strategy'; const config: Omit = { name: 'default', constraints: [], @@ -130,6 +143,7 @@ test('Should be able to get strategy by id', async () => { config, 'default', 'Demo', + userName, ); const fetchedConfig = await service.getStrategy(createdConfig.id); expect(fetchedConfig).toEqual(createdConfig); diff --git a/src/test/e2e/stores/user-feedback-store.e2e.test.ts b/src/test/e2e/stores/user-feedback-store.e2e.test.ts index 7e6fd5bae3..78836c55f8 100644 --- a/src/test/e2e/stores/user-feedback-store.e2e.test.ts +++ b/src/test/e2e/stores/user-feedback-store.e2e.test.ts @@ -10,7 +10,7 @@ let userStore: IUserStore; let currentUser; beforeAll(async () => { - db = await dbInit('project_store_serial', getLogger); + db = await dbInit('user_feedback_store', getLogger); stores = db.stores; userFeedbackStore = stores.userFeedbackStore; userStore = stores.userStore; diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index e55b47fec9..050233cb2e 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -57,6 +57,10 @@ class FakeEventStore extends EventEmitter implements IEventStore { async getEventsFilterByType(type: string): Promise { return this.events.filter((e) => e.type === type); } + + async getEventsFilterByProject(project: string): Promise { + return this.events.filter((e) => e.project === project); + } } module.exports = FakeEventStore;