diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 0f7d16351c0c..82af198625e0 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -79,6 +79,7 @@ exports[`should create default config 1`] = ` "caseInsensitiveInOperators": false, "celebrateUnleash": false, "changeRequestConflictHandling": false, + "createdByUserIdDataMigration": true, "customRootRolesKillSwitch": false, "demo": false, "detectSegmentUsageInChangeRequests": false, diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts index fab6579f8d7f..27773ca3db3f 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts @@ -328,4 +328,8 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { ): Promise { throw new Error('Method not implemented.'); } + + setCreatedByUserId(batchSize: number): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 76bad81cd12f..496a1b6170cb 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -2402,6 +2402,10 @@ class FeatureToggleService { ); } } + + async setFeatureCreatedByUserIdFromEvents(): Promise { + await this.featureToggleStore.setCreatedByUserId(100); + } } export default FeatureToggleService; diff --git a/src/lib/features/feature-toggle/feature-toggle-store.ts b/src/lib/features/feature-toggle/feature-toggle-store.ts index 154049494846..29f048dbd7cc 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -18,7 +18,11 @@ import { DEFAULT_ENV } from '../../../lib/util'; import { FeatureToggleListBuilder } from './query-builders/feature-toggle-list-builder'; import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type'; -import { IFeatureTypeCount, IFlagResolver } from '../../../lib/types'; +import { + ADMIN_TOKEN_USER, + IFeatureTypeCount, + IFlagResolver, +} from '../../../lib/types'; import { FeatureToggleRowConverter } from './converters/feature-toggle-row-converter'; import { IFeatureProjectUserParams } from './feature-toggle-controller'; @@ -718,6 +722,40 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return result?.potentially_stale ?? false; } + + async setCreatedByUserId(batchSize: number): Promise { + const EVENTS_TABLE = 'events'; + const USERS_TABLE = 'users'; + const API_TOKEN_TABLE = 'api_tokens'; + + if (!this.flagResolver.isEnabled('createdByUserIdDataMigration')) { + return; + } + const toUpdate = await this.db(`${TABLE} as f`) + .joinRaw(`JOIN ${EVENTS_TABLE} AS ev ON ev.feature_name = f.name`) + .joinRaw( + `LEFT OUTER JOIN ${USERS_TABLE} AS u on ev.created_by = u.username OR ev.created_by = u.email`, + ) + .joinRaw( + `LEFT OUTER JOIN ${API_TOKEN_TABLE} AS t on ev.created_by = t.username`, + ) + .whereRaw( + `f.created_by_user_id IS null AND ev.type = 'feature-created'`, + ) + .orderBy('f.created_at', 'asc') + .limit(batchSize) + .select(['f.*', 'ev.created_by', 'u.id', 't.username']); + + toUpdate + .filter((row) => row.id || row.username) + .forEach(async (row) => { + const id = row.id || ADMIN_TOKEN_USER.id; + + await this.db(TABLE) + .update({ created_by_user_id: id }) + .where({ name: row.name }); + }); + } } module.exports = FeatureToggleStore; diff --git a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts index 9bd97655da83..106d9d184a53 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts @@ -103,4 +103,6 @@ export interface IFeatureToggleStore extends Store { getFeatureTypeCounts( params: IFeatureProjectUserParams, ): Promise; + + setCreatedByUserId(batchSize: number): Promise; } diff --git a/src/lib/features/scheduler/schedule-services.ts b/src/lib/features/scheduler/schedule-services.ts index 0e276270c902..52cd59bd5327 100644 --- a/src/lib/features/scheduler/schedule-services.ts +++ b/src/lib/features/scheduler/schedule-services.ts @@ -150,4 +150,12 @@ export const scheduleServices = async ( minutesToMilliseconds(3), 'updateAccountLastSeen', ); + + schedulerService.schedule( + featureToggleService.setFeatureCreatedByUserIdFromEvents.bind( + featureToggleService, + ), + minutesToMilliseconds(15), + 'setFeatureCreatedByUserIdFromEvents', + ); }; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index f55a6587585e..db00c234ae59 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -46,7 +46,8 @@ export type IFlagKey = | 'adminTokenKillSwitch' | 'changeRequestConflictHandling' | 'executiveDashboard' - | 'feedbackComments'; + | 'feedbackComments' + | 'createdByUserIdDataMigration'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -222,6 +223,10 @@ const flags: IFlags = { '', }, }, + createdByUserIdDataMigration: parseEnvVarBoolean( + process.env.CREATED_BY_USERID_DATA_MIGRATION, + true, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/test/e2e/features-created-by-user-id-data-migration.e2e.test.ts b/src/test/e2e/features-created-by-user-id-data-migration.e2e.test.ts new file mode 100644 index 000000000000..e756e2c8e777 --- /dev/null +++ b/src/test/e2e/features-created-by-user-id-data-migration.e2e.test.ts @@ -0,0 +1,195 @@ +import { createFeatureToggleService } from '../../lib/features'; +import { EventService, FeatureToggleService } from '../../lib/services'; +import { + ADMIN_TOKEN_USER, + IUnleashConfig, + IUnleashStores, +} from '../../lib/types'; +import { createTestConfig } from '../config/test-config'; +import dbInit, { ITestDb } from './helpers/database-init'; + +let stores: IUnleashStores; +let db: ITestDb; +let service: FeatureToggleService; +let eventService: EventService; +let unleashConfig: IUnleashConfig; + +beforeAll(async () => { + const config = createTestConfig(); + db = await dbInit( + 'features_created_by_user_id_migration', + config.getLogger, + ); + unleashConfig = config; + stores = db.stores; + + service = createFeatureToggleService(db.rawDatabase, config); + + eventService = new EventService(stores, config); +}); + +afterAll(async () => { + await db.rawDatabase('features').del(); + await db.rawDatabase('events').del(); + await db.rawDatabase('users').del(); + await db.destroy(); +}); + +beforeEach(async () => { + await db.rawDatabase('features').del(); + await db.rawDatabase('events').del(); + await db.rawDatabase('users').del(); +}); + +test('should set created_by_user_id on features', async () => { + for (let i = 0; i < 100; i++) { + await db.rawDatabase('features').insert({ + name: `feature${i}`, + type: 'release', + project: 'default', + description: '--created_by_test--', + }); + } + + await db.rawDatabase('users').insert({ + username: 'test1', + }); + await db.rawDatabase('users').insert({ + username: 'test2', + }); + await db.rawDatabase('users').insert({ + username: 'test3', + }); + await db.rawDatabase('users').insert({ + username: 'test4', + }); + + for (let i = 0; i < 25; i++) { + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'test1', + feature_name: `feature${i}`, + data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + } + + for (let i = 25; i < 50; i++) { + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'test2', + feature_name: `feature${i}`, + data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + } + + for (let i = 50; i < 75; i++) { + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'test3', + feature_name: `feature${i}`, + data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + } + + for (let i = 75; i < 100; i++) { + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'test4', + feature_name: `feature${i}`, + data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + } + + await stores.featureToggleStore.setCreatedByUserId(200); + + const features = await db.rawDatabase('features').select('*'); + const notSet = features.filter( + (f) => !f.created_by_user_id && f.description === '--created_by_test--', + ); + const test1 = features.filter((f) => f.created_by_user_id === 1); + const test2 = features.filter((f) => f.created_by_user_id === 2); + const test3 = features.filter((f) => f.created_by_user_id === 3); + const test4 = features.filter((f) => f.created_by_user_id === 4); + expect(notSet).toHaveLength(0); + expect(test1).toHaveLength(25); + expect(test2).toHaveLength(25); + expect(test3).toHaveLength(25); + expect(test4).toHaveLength(25); +}); + +test('admin tokens get populated to admin token user', async () => { + for (let i = 0; i < 5; i++) { + await db.rawDatabase('features').insert({ + name: `feature${i}`, + type: 'release', + project: 'default', + description: '--created_by_test--', + }); + } + + await db.rawDatabase('users').insert({ + username: 'input1', + }); + + await db.rawDatabase('api_tokens').insert({ + secret: 'token1', + username: 'adm-token', + type: 'admin', + environment: 'default', + token_name: 'admin-token', + }); + + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'input1', + feature_name: 'feature0', + data: `{"name":"feature0","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'input1', + feature_name: 'feature1', + data: `{"name":"feature1","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'adm-token', + feature_name: 'feature2', + data: `{"name":"feature2","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'deleted-user', + feature_name: 'feature3', + data: `{"name":"feature3","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + + await db.rawDatabase('events').insert({ + type: 'feature-created', + created_by: 'adm-token', + feature_name: 'feature4', + data: `{"name":"feature4","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`, + }); + + await stores.featureToggleStore.setCreatedByUserId(200); + + const user = await db + .rawDatabase('users') + .where({ username: 'input1' }) + .first('id'); + + const features = await db.rawDatabase('features').select('*'); + const notSet = features.filter( + (f) => !f.created_by_user_id && f.description === '--created_by_test--', + ); + const test1 = features.filter((f) => f.created_by_user_id === user.id); + const test2 = features.filter( + (f) => f.created_by_user_id === ADMIN_TOKEN_USER.id, + ); + expect(notSet).toHaveLength(1); + expect(test1).toHaveLength(2); + expect(test2).toHaveLength(2); +});