diff --git a/frontend/src/hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi.ts b/frontend/src/hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi.ts index 7681beb3c2..62fd7f7d7c 100644 --- a/frontend/src/hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi.ts +++ b/frontend/src/hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi.ts @@ -82,6 +82,7 @@ export const useDependentFeaturesApi = (project: string) => { makeRequest, setToastData, formatUnknownError, + project, ]; return { addDependency: useCallback(addDependency, callbackDeps), diff --git a/package.json b/package.json index 373b6b1c52..0e54a2dd7c 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", - "unleash-client": "4.1.1", + "unleash-client": "4.2.0-beta.0", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/src/lib/features/dependent-features/createDependentFeaturesService.ts b/src/lib/features/dependent-features/createDependentFeaturesService.ts index 5855629bd6..016511720f 100644 --- a/src/lib/features/dependent-features/createDependentFeaturesService.ts +++ b/src/lib/features/dependent-features/createDependentFeaturesService.ts @@ -4,24 +4,53 @@ import { DependentFeaturesStore } from './dependent-features-store'; import { DependentFeaturesReadModel } from './dependent-features-read-model'; import { FakeDependentFeaturesStore } from './fake-dependent-features-store'; import { FakeDependentFeaturesReadModel } from './fake-dependent-features-read-model'; +import EventStore from '../../db/event-store'; +import { IUnleashConfig } from '../../types'; +import { EventService } from '../../services'; +import FeatureTagStore from '../../db/feature-tag-store'; +import FakeEventStore from '../../../test/fixtures/fake-event-store'; +import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store'; export const createDependentFeaturesService = ( db: Db, + config: IUnleashConfig, ): DependentFeaturesService => { + const { getLogger, eventBus } = config; + const eventStore = new EventStore(db, getLogger); + const featureTagStore = new FeatureTagStore(db, eventBus, getLogger); + const eventService = new EventService( + { + eventStore, + featureTagStore, + }, + config, + ); const dependentFeaturesStore = new DependentFeaturesStore(db); const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); return new DependentFeaturesService( dependentFeaturesStore, dependentFeaturesReadModel, + eventService, ); }; -export const createFakeDependentFeaturesService = - (): DependentFeaturesService => { - const dependentFeaturesStore = new FakeDependentFeaturesStore(); - const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); - return new DependentFeaturesService( - dependentFeaturesStore, - dependentFeaturesReadModel, - ); - }; +export const createFakeDependentFeaturesService = ( + config: IUnleashConfig, +): DependentFeaturesService => { + const eventStore = new FakeEventStore(); + const featureTagStore = new FakeFeatureTagStore(); + const eventService = new EventService( + { + eventStore, + featureTagStore, + }, + config, + ); + const dependentFeaturesStore = new FakeDependentFeaturesStore(); + const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); + return new DependentFeaturesService( + dependentFeaturesStore, + dependentFeaturesReadModel, + eventService, + ); +}; diff --git a/src/lib/features/dependent-features/dependent-features-controller.ts b/src/lib/features/dependent-features/dependent-features-controller.ts index 5a297cb17b..ccd6df2fb9 100644 --- a/src/lib/features/dependent-features/dependent-features-controller.ts +++ b/src/lib/features/dependent-features/dependent-features-controller.ts @@ -21,12 +21,17 @@ import { IAuthRequest } from '../../routes/unleash-types'; import { InvalidOperationError } from '../../error'; import { DependentFeaturesService } from './dependent-features-service'; import { TransactionCreator, UnleashTransaction } from '../../db/transaction'; +import { extractUsernameFromUser } from '../../util'; -interface FeatureParams { +interface ProjectParams { + projectId: string; +} + +interface FeatureParams extends ProjectParams { child: string; } -interface DeleteDependencyParams { +interface DeleteDependencyParams extends ProjectParams { child: string; parent: string; } @@ -167,18 +172,22 @@ export default class DependentFeaturesController extends Controller { req: IAuthRequest, res: Response, ): Promise { - const { child } = req.params; + const { child, projectId } = req.params; const { variants, enabled, feature } = req.body; if (this.config.flagResolver.isEnabled('dependentFeatures')) { await this.startTransaction(async (tx) => this.transactionalDependentFeaturesService( tx, - ).upsertFeatureDependency(child, { - variants, - enabled, - feature, - }), + ).upsertFeatureDependency( + { child, projectId }, + { + variants, + enabled, + feature, + }, + extractUsernameFromUser(req.user), + ), ); res.status(200).end(); } else { @@ -192,13 +201,17 @@ export default class DependentFeaturesController extends Controller { req: IAuthRequest, res: Response, ): Promise { - const { child, parent } = req.params; + const { child, parent, projectId } = req.params; if (this.config.flagResolver.isEnabled('dependentFeatures')) { - await this.dependentFeaturesService.deleteFeatureDependency({ - parent, - child, - }); + await this.dependentFeaturesService.deleteFeatureDependency( + { + parent, + child, + }, + projectId, + extractUsernameFromUser(req.user), + ); res.status(200).end(); } else { throw new InvalidOperationError( @@ -211,11 +224,13 @@ export default class DependentFeaturesController extends Controller { req: IAuthRequest, res: Response, ): Promise { - const { child } = req.params; + const { child, projectId } = req.params; if (this.config.flagResolver.isEnabled('dependentFeatures')) { await this.dependentFeaturesService.deleteFeatureDependencies( child, + projectId, + extractUsernameFromUser(req.user), ); res.status(200).end(); } else { diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index 4ee6ac3848..8d29e26ffb 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -3,23 +3,29 @@ import { CreateDependentFeatureSchema } from '../../openapi'; import { IDependentFeaturesStore } from './dependent-features-store-type'; import { FeatureDependency, FeatureDependencyId } from './dependent-features'; import { IDependentFeaturesReadModel } from './dependent-features-read-model-type'; +import { EventService } from '../../services'; export class DependentFeaturesService { private dependentFeaturesStore: IDependentFeaturesStore; private dependentFeaturesReadModel: IDependentFeaturesReadModel; + private eventService: EventService; + constructor( dependentFeaturesStore: IDependentFeaturesStore, dependentFeaturesReadModel: IDependentFeaturesReadModel, + eventService: EventService, ) { this.dependentFeaturesStore = dependentFeaturesStore; this.dependentFeaturesReadModel = dependentFeaturesReadModel; + this.eventService = eventService; } async upsertFeatureDependency( - child: string, + { child, projectId }: { child: string; projectId: string }, dependentFeature: CreateDependentFeatureSchema, + user: string, ): Promise { const { enabled, feature: parent, variants } = dependentFeature; @@ -46,16 +52,46 @@ export class DependentFeaturesService { variants, }; await this.dependentFeaturesStore.upsert(featureDependency); + await this.eventService.storeEvent({ + type: 'feature-dependency-added', + project: projectId, + featureName: child, + createdBy: user, + data: { + feature: parent, + enabled: featureDependency.enabled, + ...(variants !== undefined && { variants }), + }, + }); } async deleteFeatureDependency( dependency: FeatureDependencyId, + projectId: string, + user: string, ): Promise { await this.dependentFeaturesStore.delete(dependency); + await this.eventService.storeEvent({ + type: 'feature-dependency-removed', + project: projectId, + featureName: dependency.child, + createdBy: user, + data: { feature: dependency.parent }, + }); } - async deleteFeatureDependencies(feature: string): Promise { + async deleteFeatureDependencies( + feature: string, + projectId: string, + user: string, + ): Promise { await this.dependentFeaturesStore.deleteAll(feature); + await this.eventService.storeEvent({ + type: 'feature-dependencies-removed', + project: projectId, + featureName: feature, + createdBy: user, + }); } async getParentOptions(feature: string): Promise { diff --git a/src/lib/features/dependent-features/dependent.features.e2e.test.ts b/src/lib/features/dependent-features/dependent.features.e2e.test.ts index 37e8c97937..be378a58fa 100644 --- a/src/lib/features/dependent-features/dependent.features.e2e.test.ts +++ b/src/lib/features/dependent-features/dependent.features.e2e.test.ts @@ -6,9 +6,16 @@ import { } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; import { CreateDependentFeatureSchema } from '../../openapi'; +import { + FEATURE_DEPENDENCIES_REMOVED, + FEATURE_DEPENDENCY_ADDED, + FEATURE_DEPENDENCY_REMOVED, + IEventStore, +} from '../../types'; let app: IUnleashTest; let db: ITestDb; +let eventStore: IEventStore; beforeAll(async () => { db = await dbInit('dependent_features', getLogger); @@ -24,8 +31,14 @@ beforeAll(async () => { }, db.rawDatabase, ); + eventStore = db.stores.eventStore; }); +const getRecordedEventTypesForDependencies = async () => + (await eventStore.getEvents()) + .map((event) => event.type) + .filter((type) => type.includes('depend')); + afterAll(async () => { await app.destroy(); await db.destroy(); @@ -95,6 +108,13 @@ test('should add and delete feature dependencies', async () => { await deleteFeatureDependency(child, parent); // single await deleteFeatureDependencies(child); // all + + expect(await getRecordedEventTypesForDependencies()).toStrictEqual([ + FEATURE_DEPENDENCIES_REMOVED, + FEATURE_DEPENDENCY_REMOVED, + FEATURE_DEPENDENCY_ADDED, + FEATURE_DEPENDENCY_ADDED, + ]); }); test('should not allow to add a parent dependency to a feature that already has children', async () => { diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 7bb76e1d81..c81e21f768 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -328,10 +328,10 @@ export const createServices = ( const eventAnnouncerService = new EventAnnouncerService(stores, config); const dependentFeaturesService = db - ? createDependentFeaturesService(db) - : createFakeDependentFeaturesService(); + ? createDependentFeaturesService(db, config) + : createFakeDependentFeaturesService(config); const transactionalDependentFeaturesService = (txDb: Knex.Transaction) => - createDependentFeaturesService(txDb); + createDependentFeaturesService(txDb, config); return { accessService, diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 68f68a7ca4..0924485385 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -9,6 +9,10 @@ export const APPLICATION_CREATED = 'application-created' as const; export const FEATURE_CREATED = 'feature-created' as const; export const FEATURE_DELETED = 'feature-deleted' as const; export const FEATURE_UPDATED = 'feature-updated' as const; +export const FEATURE_DEPENDENCY_ADDED = 'feature-dependency-added' as const; +export const FEATURE_DEPENDENCY_REMOVED = 'feature-dependency-removed' as const; +export const FEATURE_DEPENDENCIES_REMOVED = + 'feature-dependencies-removed' as const; export const FEATURE_METADATA_UPDATED = 'feature-metadata-updated' as const; export const FEATURE_VARIANTS_UPDATED = 'feature-variants-updated' as const; export const FEATURE_ENVIRONMENT_VARIANTS_UPDATED = @@ -249,6 +253,9 @@ export const IEventTypes = [ SERVICE_ACCOUNT_DELETED, SERVICE_ACCOUNT_UPDATED, FEATURE_POTENTIALLY_STALE_ON, + FEATURE_DEPENDENCY_ADDED, + FEATURE_DEPENDENCY_REMOVED, + FEATURE_DEPENDENCIES_REMOVED, ] as const; export type IEventType = typeof IEventTypes[number]; diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 2eac2f01f9..ce150b078a 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -62,8 +62,9 @@ beforeAll(async () => { ); // depend on enabled feature with variant await app.services.dependentFeaturesService.upsertFeatureDependency( - 'featureY', + { child: 'featureY', projectId: 'default' }, { feature: 'featureX', variants: ['featureXVariant'] }, + 'test', ); await app.services.featureToggleServiceV2.archiveToggle( diff --git a/yarn.lock b/yarn.lock index 60f3d907f8..0c26b29be9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7899,10 +7899,10 @@ universalify@^0.1.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unleash-client@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-4.1.1.tgz#ad3e90853f98885bbb4746af813514e6d1e7dee9" - integrity sha512-cliJJ82unQauip8/7TQhJbvuHMgBIrM167672uV5RmeD7buluAHm1x0BmYjqsXMpE3MX06m05EzpRz62H90puQ== +unleash-client@4.2.0-beta.0: + version "4.2.0-beta.0" + resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-4.2.0-beta.0.tgz#62d4615d1e55255696c09938a12a02224262279c" + integrity sha512-Rhq1ahtXU47FyMZJ1f3Wrjr7rpU5V0noGwfxMj9+79NoksiA9NcmqnP2qeZF0hmE3trLDk8q3hj7NmVIR6RjPA== dependencies: ip "^1.1.8" make-fetch-happen "^10.2.1"