diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 9e6099efb9..fa85597fbd 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -50,6 +50,7 @@ import { FeatureStrategiesReadModel } from '../features/feature-toggle/feature-s import { FeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model'; import { LargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model'; import { IntegrationEventsStore } from '../features/integration-events/integration-events-store'; +import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model'; export const createStores = ( config: IUnleashConfig, @@ -175,6 +176,7 @@ export const createStores = ( ), largestResourcesReadModel: new LargestResourcesReadModel(db), integrationEventsStore: new IntegrationEventsStore(db, { eventBus }), + featureCollaboratorsReadModel: new FeatureCollaboratorsReadModel(db), }; }; diff --git a/src/lib/features/feature-toggle/fake-feature-collaborators-read-model.ts b/src/lib/features/feature-toggle/fake-feature-collaborators-read-model.ts new file mode 100644 index 0000000000..8d3d3c8ca2 --- /dev/null +++ b/src/lib/features/feature-toggle/fake-feature-collaborators-read-model.ts @@ -0,0 +1,14 @@ +import type { + Collaborator, + IFeatureCollaboratorsReadModel, +} from './types/feature-collaborators-read-model-type'; + +export class FakeFeatureCollaboratorsReadModel + implements IFeatureCollaboratorsReadModel +{ + async getFeatureCollaborators( + feature: string, + ): Promise> { + return []; + } +} diff --git a/src/lib/features/feature-toggle/feature-collaborators-read-model.ts b/src/lib/features/feature-toggle/feature-collaborators-read-model.ts new file mode 100644 index 0000000000..a84df74203 --- /dev/null +++ b/src/lib/features/feature-toggle/feature-collaborators-read-model.ts @@ -0,0 +1,49 @@ +import type { Db } from '../../db/db'; +import type { + Collaborator, + IFeatureCollaboratorsReadModel, +} from './types/feature-collaborators-read-model-type'; +import { generateImageUrl } from '../../util'; + +export class FeatureCollaboratorsReadModel + implements IFeatureCollaboratorsReadModel +{ + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getFeatureCollaborators( + feature: string, + ): Promise> { + const query = this.db + .with('recent_events', (queryBuilder) => { + queryBuilder + .select('created_by_user_id') + .max('created_at as max_created_at') + .from('events') + .where('feature_name', feature) + .groupBy('created_by_user_id'); + }) + .select('users.id', 'users.email', 'users.username', 'users.name') + .from('recent_events') + .join('users', 'recent_events.created_by_user_id', 'users.id') + .orderBy('recent_events.max_created_at', 'desc'); + + const rows = await query; + + return rows.map((row) => { + const name = row.name || row.username || row.email || 'unknown'; + return { + id: row.id, + name: name, + imageUrl: generateImageUrl({ + id: row.id, + email: row.email, + username: name, + }), + }; + }); + } +} diff --git a/src/lib/features/feature-toggle/tests/feature-collaborators-read-model.test.ts b/src/lib/features/feature-toggle/tests/feature-collaborators-read-model.test.ts new file mode 100644 index 0000000000..18fc07773c --- /dev/null +++ b/src/lib/features/feature-toggle/tests/feature-collaborators-read-model.test.ts @@ -0,0 +1,71 @@ +import type { + IEventStore, + IFeatureCollaboratorsReadModel, + IUnleashStores, + IUserStore, +} from '../../../types'; +import getLogger from '../../../../test/fixtures/no-logger'; +import dbInit, { + type ITestDb, +} from '../../../../test/e2e/helpers/database-init'; + +let stores: IUnleashStores; +let db: ITestDb; +let eventStore: IEventStore; +let usersStore: IUserStore; +let featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; + +beforeAll(async () => { + db = await dbInit('feature_collaborators_read_model', getLogger); + stores = db.stores; + eventStore = stores.eventStore; + usersStore = stores.userStore; + featureCollaboratorsReadModel = stores.featureCollaboratorsReadModel; +}); + +afterAll(async () => { + await db.destroy(); +}); + +test('Should return collaborators according to their activity order', async () => { + const user1 = await usersStore.insert({ + name: 'User One', + email: 'user1@example.com', + }); + const user2 = await usersStore.insert({ + name: 'User Two', + email: 'user2@example.com', + }); + // first event on our feature + await eventStore.store({ + featureName: 'featureA', + createdByUserId: user1.id, + type: 'feature-created', + createdBy: 'irrelevant', + ip: '::1', + }); + // first event on another feature + await eventStore.store({ + featureName: 'featureB', + createdByUserId: user1.id, + type: 'feature-created', + createdBy: 'irrelevant', + ip: '::1', + }); + // second event on our feature + await eventStore.store({ + featureName: 'featureA', + createdByUserId: user2.id, + type: 'feature-updated', + createdBy: 'irrelevant', + ip: '::1', + }); + + const collaborators = + await featureCollaboratorsReadModel.getFeatureCollaborators('featureA'); + + expect(collaborators).toMatchObject([ + { id: 2, name: 'User Two', imageUrl: expect.any(String) }, + { id: 1, name: 'User One', imageUrl: expect.any(String) }, + ]); +}); diff --git a/src/lib/features/feature-toggle/types/feature-collaborators-read-model-type.ts b/src/lib/features/feature-toggle/types/feature-collaborators-read-model-type.ts new file mode 100644 index 0000000000..6fff345d8c --- /dev/null +++ b/src/lib/features/feature-toggle/types/feature-collaborators-read-model-type.ts @@ -0,0 +1,9 @@ +export type Collaborator = { + id: number; + name: string; + imageUrl: string; +}; + +export interface IFeatureCollaboratorsReadModel { + getFeatureCollaborators(feature: string): Promise>; +} diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 4bd089335c..eaf56c5a4c 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -47,6 +47,7 @@ import { IFeatureStrategiesReadModel } from '../features/feature-toggle/types/fe import { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type'; import { ILargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model-type'; import type { IntegrationEventsStore } from '../features/integration-events/integration-events-store'; +import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -98,6 +99,7 @@ export interface IUnleashStores { featureLifecycleReadModel: IFeatureLifecycleReadModel; largestResourcesReadModel: ILargestResourcesReadModel; integrationEventsStore: IntegrationEventsStore; + featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; } export { @@ -147,5 +149,6 @@ export { IFeatureStrategiesReadModel, IFeatureLifecycleReadModel, ILargestResourcesReadModel, + IFeatureCollaboratorsReadModel, type IntegrationEventsStore, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 44dcb9a46e..1c5b59d024 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -50,6 +50,7 @@ import { FakeProjectFlagCreatorsReadModel } from '../../lib/features/project/fak import { FakeFeatureStrategiesReadModel } from '../../lib/features/feature-toggle/fake-feature-strategies-read-model'; import { FakeFeatureLifecycleReadModel } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-read-model'; import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model'; +import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model'; const db = { select: () => ({ @@ -109,6 +110,7 @@ const createStores: () => IUnleashStores = () => { featureLifecycleReadModel: new FakeFeatureLifecycleReadModel(), largestResourcesReadModel: new FakeLargestResourcesReadModel(), integrationEventsStore: {} as IntegrationEventsStore, + featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(), }; };