diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index e962b8b334..1b4281e68d 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -45,6 +45,7 @@ import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-da import { SegmentReadModel } from '../features/segment/segment-read-model'; import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store'; +import { ProjectFlagCreatorsReadModel } from '../features/project/project-flag-creators-read-model'; export const createStores = ( config: IUnleashConfig, @@ -156,6 +157,7 @@ export const createStores = ( trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), segmentReadModel: new SegmentReadModel(db), projectOwnersReadModel: new ProjectOwnersReadModel(db), + projectFlagCreatorsReadModel: new ProjectFlagCreatorsReadModel(db), featureLifecycleStore: new FeatureLifecycleStore(db), }; }; diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts index 30df7e85b3..261d17db10 100644 --- a/src/lib/features/project/createProjectService.ts +++ b/src/lib/features/project/createProjectService.ts @@ -43,6 +43,8 @@ import FeatureTypeStore from '../../db/feature-type-store'; import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store'; import { ProjectOwnersReadModel } from './project-owners-read-model'; import { FakeProjectOwnersReadModel } from './fake-project-owners-read-model'; +import { FakeProjectFlagCreatorsReadModel } from './fake-project-flag-creators-read-model'; +import { ProjectFlagCreatorsReadModel } from './project-flag-creators-read-model'; export const createProjectService = ( db: Db, @@ -57,6 +59,7 @@ export const createProjectService = ( flagResolver, ); const projectOwnersReadModel = new ProjectOwnersReadModel(db); + const projectFlagCreatorsReadModel = new ProjectFlagCreatorsReadModel(db); const groupStore = new GroupStore(db); const featureToggleStore = new FeatureToggleStore( db, @@ -119,6 +122,7 @@ export const createProjectService = ( accountStore, projectStatsStore, projectOwnersReadModel, + projectFlagCreatorsReadModel, }, config, accessService, @@ -136,6 +140,7 @@ export const createFakeProjectService = ( const { getLogger } = config; const eventStore = new FakeEventStore(); const projectOwnersReadModel = new FakeProjectOwnersReadModel(); + const projectFlagCreatorsReadModel = new FakeProjectFlagCreatorsReadModel(); const projectStore = new FakeProjectStore(); const groupStore = new FakeGroupStore(); const featureToggleStore = new FakeFeatureToggleStore(); @@ -175,6 +180,7 @@ export const createFakeProjectService = ( { projectStore, projectOwnersReadModel, + projectFlagCreatorsReadModel, eventStore, featureToggleStore, environmentStore, diff --git a/src/lib/features/project/fake-project-flag-creators-read-model.ts b/src/lib/features/project/fake-project-flag-creators-read-model.ts new file mode 100644 index 0000000000..4fd3309494 --- /dev/null +++ b/src/lib/features/project/fake-project-flag-creators-read-model.ts @@ -0,0 +1,11 @@ +import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type'; + +export class FakeProjectFlagCreatorsReadModel + implements IProjectFlagCreatorsReadModel +{ + async getFlagCreators( + project: string, + ): Promise<{ id: number; name: string }[]> { + return []; + } +} diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 8229c8fd30..cc191be216 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -44,6 +44,10 @@ import { normalizeQueryParams } from '../feature-search/search-utils'; import ProjectInsightsController from '../project-insights/project-insights-controller'; import FeatureLifecycleController from '../feature-lifecycle/feature-lifecycle-controller'; import type ClientInstanceService from '../metrics/instance/instance-service'; +import { + projectFlagCreatorsSchema, + type ProjectFlagCreatorsSchema, +} from '../../openapi/spec/project-flag-creators-schema'; export default class ProjectController extends Controller { private projectService: ProjectService; @@ -166,6 +170,26 @@ export default class ProjectController extends Controller { ], }); + this.route({ + method: 'get', + path: '/:projectId/flag-creators', + handler: this.getProjectFlagCreators, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getProjectFlagCreators', + summary: 'Get a list of all flag creators for a project.', + description: + 'This endpoint returns every user who created a flag in the project.', + responses: { + 200: createResponseSchema('projectFlagCreatorsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + this.route({ method: 'get', path: '/:projectId/sdks/outdated', @@ -327,6 +351,24 @@ export default class ProjectController extends Controller { serializeDates(applications), ); } + + async getProjectFlagCreators( + req: IAuthRequest, + res: Response, + ): Promise { + const { projectId } = req.params; + + const flagCreators = + await this.projectService.getProjectFlagCreators(projectId); + + this.openApiService.respondWithValidation( + 200, + res, + projectFlagCreatorsSchema.$id, + serializeDates(flagCreators), + ); + } + async getOutdatedProjectSdks( req: IAuthRequest, res: Response, diff --git a/src/lib/features/project/project-flag-creators-read-model.ts b/src/lib/features/project/project-flag-creators-read-model.ts new file mode 100644 index 0000000000..f6c8c89f26 --- /dev/null +++ b/src/lib/features/project/project-flag-creators-read-model.ts @@ -0,0 +1,31 @@ +import type { Db } from '../../db/db'; +import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type'; + +export class ProjectFlagCreatorsReadModel + implements IProjectFlagCreatorsReadModel +{ + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getFlagCreators( + project: string, + ): Promise> { + const result = await this.db('users') + .distinct('users.id') + .join('features', 'users.id', '=', 'features.created_by_user_id') + .where('features.project', project) + .select([ + 'users.id', + 'users.name', + 'users.username', + 'users.email', + ]); + return result.map((row) => ({ + id: Number(row.id), + name: String(row.name || row.username || row.email), + })); + } +} diff --git a/src/lib/features/project/project-flag-creators-read-model.type.ts b/src/lib/features/project/project-flag-creators-read-model.type.ts new file mode 100644 index 0000000000..57c2ac7381 --- /dev/null +++ b/src/lib/features/project/project-flag-creators-read-model.type.ts @@ -0,0 +1,5 @@ +export interface IProjectFlagCreatorsReadModel { + getFlagCreators( + project: string, + ): Promise>; +} diff --git a/src/lib/features/project/project-flag-creators.e2e.test.ts b/src/lib/features/project/project-flag-creators.e2e.test.ts new file mode 100644 index 0000000000..23ce401c76 --- /dev/null +++ b/src/lib/features/project/project-flag-creators.e2e.test.ts @@ -0,0 +1,61 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import { + type IUnleashTest, + setupAppWithAuth, +} from '../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../test/fixtures/no-logger'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('project_flag_creators', getLogger); + app = await setupAppWithAuth( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + }, + }, + }, + db.rawDatabase, + ); +}); + +afterEach(async () => { + await db.stores.featureToggleStore.deleteAll(); + await db.stores.userStore.deleteAll(); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('should return flag creators', async () => { + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user1@getunleash.io', + }) + .expect(200); + await app.createFeature('flag-name-1'); + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user2@getunleash.io', + }) + .expect(200); + await app.createFeature('flag-name-2'); + + const { body } = await app.request + .get('/api/admin/projects/default/flag-creators') + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toEqual([ + { id: 1, name: 'user1@getunleash.io' }, + { id: 2, name: 'user2@getunleash.io' }, + ]); +}); diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index efb06a0e11..0a9ce5721b 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -78,6 +78,7 @@ import type { IProjectEnterpriseSettingsUpdate, IProjectQuery, } from './project-store-type'; +import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type'; type Days = number; type Count = number; @@ -114,6 +115,8 @@ export default class ProjectService { private projectOwnersReadModel: IProjectOwnersReadModel; + private projectFlagCreatorsReadModel: IProjectFlagCreatorsReadModel; + private accessService: AccessService; private eventStore: IEventStore; @@ -150,6 +153,7 @@ export default class ProjectService { { projectStore, projectOwnersReadModel, + projectFlagCreatorsReadModel, eventStore, featureToggleStore, environmentStore, @@ -161,6 +165,7 @@ export default class ProjectService { IUnleashStores, | 'projectStore' | 'projectOwnersReadModel' + | 'projectFlagCreatorsReadModel' | 'eventStore' | 'featureToggleStore' | 'environmentStore' @@ -179,6 +184,7 @@ export default class ProjectService { ) { this.projectStore = projectStore; this.projectOwnersReadModel = projectOwnersReadModel; + this.projectFlagCreatorsReadModel = projectFlagCreatorsReadModel; this.environmentStore = environmentStore; this.featureEnvironmentStore = featureEnvironmentStore; this.accessService = accessService; @@ -1081,6 +1087,10 @@ export default class ProjectService { return applications; } + async getProjectFlagCreators(projectId: string) { + return this.projectFlagCreatorsReadModel.getFlagCreators(projectId); + } + async changeRole( projectId: string, roleId: number, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 90e255c40f..93c4318ae9 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -139,6 +139,7 @@ export * from './project-application-sdk-schema'; export * from './project-applications-schema'; export * from './project-dora-metrics-schema'; export * from './project-environment-schema'; +export * from './project-flag-creators-schema'; export * from './project-insights-schema'; export * from './project-overview-schema'; export * from './project-schema'; diff --git a/src/lib/openapi/spec/project-flag-creators-schema.ts b/src/lib/openapi/spec/project-flag-creators-schema.ts new file mode 100644 index 0000000000..5232a5ed12 --- /dev/null +++ b/src/lib/openapi/spec/project-flag-creators-schema.ts @@ -0,0 +1,33 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const projectFlagCreatorsSchema = { + $id: '#/components/schemas/projectFlagCreatorsSchema', + type: 'array', + description: 'A list of project flag creators', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + example: 50, + description: 'The user id.', + }, + name: { + description: + "Name of the user. If the user has no set name, the API falls back to using the user's username (if they have one) or email (if neither name or username is set).", + type: 'string', + example: 'User', + }, + }, + }, + + components: { + schemas: {}, + }, +} as const; + +export type ProjectFlagCreatorsSchema = FromSchema< + typeof projectFlagCreatorsSchema +>; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 8cde17e878..c98234d365 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -42,6 +42,7 @@ import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-d import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type'; +import { IProjectFlagCreatorsReadModel } from '../features/project/project-flag-creators-read-model.type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -87,6 +88,7 @@ export interface IUnleashStores { trafficDataUsageStore: ITrafficDataUsageStore; segmentReadModel: ISegmentReadModel; projectOwnersReadModel: IProjectOwnersReadModel; + projectFlagCreatorsReadModel: IProjectFlagCreatorsReadModel; featureLifecycleStore: IFeatureLifecycleStore; } @@ -133,4 +135,5 @@ export { ISegmentReadModel, IProjectOwnersReadModel, IFeatureLifecycleStore, + IProjectFlagCreatorsReadModel, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index ce51a043e6..83106c7cdf 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -45,6 +45,7 @@ import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store'; +import { FakeProjectFlagCreatorsReadModel } from '../../lib/features/project/fake-project-flag-creators-read-model'; const db = { select: () => ({ @@ -98,6 +99,7 @@ const createStores: () => IUnleashStores = () => { trafficDataUsageStore: new FakeTrafficDataUsageStore(), segmentReadModel: new FakeSegmentReadModel(), projectOwnersReadModel: new FakeProjectOwnersReadModel(), + projectFlagCreatorsReadModel: new FakeProjectFlagCreatorsReadModel(), featureLifecycleStore: new FakeFeatureLifecycleStore(), }; };