diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 3fc41b3a99..5f8ede8ecf 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -43,6 +43,7 @@ import FeatureSearchStore from '../features/feature-search/feature-search-store' import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; import { SegmentReadModel } from '../features/segment/segment-read-model'; +import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; export const createStores = ( config: IUnleashConfig, @@ -148,6 +149,7 @@ export const createStores = ( inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), segmentReadModel: new SegmentReadModel(db), + projectOwnersReadModel: new ProjectOwnersReadModel(db), }; }; diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts index 23ca655d95..30df7e85b3 100644 --- a/src/lib/features/project/createProjectService.ts +++ b/src/lib/features/project/createProjectService.ts @@ -41,6 +41,8 @@ import { import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store'; 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'; export const createProjectService = ( db: Db, @@ -54,6 +56,7 @@ export const createProjectService = ( getLogger, flagResolver, ); + const projectOwnersReadModel = new ProjectOwnersReadModel(db); const groupStore = new GroupStore(db); const featureToggleStore = new FeatureToggleStore( db, @@ -115,6 +118,7 @@ export const createProjectService = ( featureTypeStore, accountStore, projectStatsStore, + projectOwnersReadModel, }, config, accessService, @@ -131,6 +135,7 @@ export const createFakeProjectService = ( ): ProjectService => { const { getLogger } = config; const eventStore = new FakeEventStore(); + const projectOwnersReadModel = new FakeProjectOwnersReadModel(); const projectStore = new FakeProjectStore(); const groupStore = new FakeGroupStore(); const featureToggleStore = new FakeFeatureToggleStore(); @@ -169,6 +174,7 @@ export const createFakeProjectService = ( return new ProjectService( { projectStore, + projectOwnersReadModel, eventStore, featureToggleStore, environmentStore, diff --git a/src/lib/features/project/fake-project-owners-read-model.ts b/src/lib/features/project/fake-project-owners-read-model.ts new file mode 100644 index 0000000000..85227b9ff5 --- /dev/null +++ b/src/lib/features/project/fake-project-owners-read-model.ts @@ -0,0 +1,16 @@ +import type { IProjectWithCount } from '../../types'; +import type { + IProjectOwnersReadModel, + IProjectWithCountAndOwners, +} from './project-owners-read-model.type'; + +export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel { + async addOwners( + projects: IProjectWithCount[], + ): Promise { + return projects.map((project) => ({ + ...project, + owners: [{ ownerType: 'system' }], + })); + } +} diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 66ad593d2b..f072e926a0 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -197,9 +197,19 @@ export default class ProjectController extends Controller { user.id, ); - // if (this.flagResolver.isEnabled('projectsListNewCards')) { - // TODO: get project owners and add to response - // } + if (this.flagResolver.isEnabled('projectsListNewCards')) { + const projectsWithOwners = + await this.projectService.addOwnersToProjects(projects); + + this.openApiService.respondWithValidation( + 200, + res, + projectsSchema.$id, + { version: 1, projects: serializeDates(projectsWithOwners) }, + ); + + return; + } this.openApiService.respondWithValidation( 200, diff --git a/src/lib/features/project/project-owners-read-model.test.ts b/src/lib/features/project/project-owners-read-model.test.ts index 0326cbbfcc..2d3f1e7aef 100644 --- a/src/lib/features/project/project-owners-read-model.test.ts +++ b/src/lib/features/project/project-owners-read-model.test.ts @@ -19,7 +19,10 @@ const mockProjectWithCounts = (name: string) => ({ describe('unit tests', () => { test('maps owners to projects', () => { - const projects = [{ name: 'project1' }, { name: 'project2' }] as any; + const projects = [ + { id: 'project1', name: 'Project one' }, + { id: 'project2', name: 'Project two' }, + ] as any; const owners = { project1: [{ ownerType: 'user' as const, name: 'Owner Name' }], @@ -32,13 +35,21 @@ describe('unit tests', () => { ); expect(projectsWithOwners).toMatchObject([ - { name: 'project1', owners: [{ name: 'Owner Name' }] }, - { name: 'project2', owners: [{ name: 'Owner Name' }] }, + { + id: 'project1', + name: 'Project one', + owners: [{ name: 'Owner Name' }], + }, + { + id: 'project2', + name: 'Project two', + owners: [{ name: 'Owner Name' }], + }, ]); }); test('returns "system" when a project has no owners', async () => { - const projects = [{ name: 'project1' }, { name: 'project2' }] as any; + const projects = [{ id: 'project1' }, { id: 'project2' }] as any; const owners = {}; @@ -48,8 +59,14 @@ describe('unit tests', () => { ); expect(projectsWithOwners).toMatchObject([ - { name: 'project1', owners: [{ ownerType: 'system' }] }, - { name: 'project2', owners: [{ ownerType: 'system' }] }, + { + id: 'project1', + owners: [{ ownerType: 'system' }], + }, + { + id: 'project2', + owners: [{ ownerType: 'system' }], + }, ]); }); }); @@ -66,7 +83,7 @@ let group2: IGroup; beforeAll(async () => { db = await dbInit('project_owners_read_model_serial', getLogger); - readModel = new ProjectOwnersReadModel(db.rawDatabase, db.stores.roleStore); + readModel = new ProjectOwnersReadModel(db.rawDatabase); ownerRoleId = (await db.stores.roleStore.getRoleByName(RoleName.OWNER)).id; const ownerData = { @@ -107,14 +124,7 @@ afterAll(async () => { }); afterEach(async () => { - if (db) { - const projects = await db.stores.projectStore.getAll(); - for (const project of projects) { - // Clean only project roles, not all roles - await db.stores.roleStore.removeRolesForProject(project.id); - } - await db.stores.projectStore.deleteAll(); - } + db.stores.roleStore; }); describe('integration tests', () => { diff --git a/src/lib/features/project/project-owners-read-model.ts b/src/lib/features/project/project-owners-read-model.ts index 3d5346dcc0..a2e63e5c1a 100644 --- a/src/lib/features/project/project-owners-read-model.ts +++ b/src/lib/features/project/project-owners-read-model.ts @@ -1,5 +1,12 @@ import type { Db } from '../../db/db'; -import { RoleName, type IProjectWithCount, type IRoleStore } from '../../types'; +import { RoleName, type IProjectWithCount } from '../../types'; +import type { + GroupProjectOwner, + IProjectOwnersReadModel, + IProjectWithCountAndOwners, + ProjectOwnersDictionary, + UserProjectOwner, +} from './project-owners-read-model.type'; const T = { ROLE_USER: 'role_user', @@ -8,34 +15,11 @@ const T = { USERS: 'users', }; -type SystemOwner = { ownerType: 'system' }; -type UserProjectOwner = { - ownerType: 'user'; - name: string; - email?: string; - imageUrl?: string; -}; -type GroupProjectOwner = { - ownerType: 'group'; - name: string; -}; -type ProjectOwners = - | [SystemOwner] - | Array; - -export type ProjectOwnersDictionary = Record; - -type IProjectWithCountAndOwners = IProjectWithCount & { - owners: ProjectOwners; -}; - -export class ProjectOwnersReadModel { +export class ProjectOwnersReadModel implements IProjectOwnersReadModel { private db: Db; - roleStore: IRoleStore; - constructor(db: Db, roleStore: IRoleStore) { + constructor(db: Db) { this.db = db; - this.roleStore = roleStore; } static addOwnerData( @@ -44,7 +28,7 @@ export class ProjectOwnersReadModel { ): IProjectWithCountAndOwners[] { return projects.map((project) => ({ ...project, - owners: owners[project.name] || [{ ownerType: 'system' }], + owners: owners[project.id] || [{ ownerType: 'system' }], })); } @@ -119,7 +103,9 @@ export class ProjectOwnersReadModel { } async getAllProjectOwners(): Promise { - const ownerRole = await this.roleStore.getRoleByName(RoleName.OWNER); + const ownerRole = await this.db(T.ROLES) + .where({ name: RoleName.OWNER }) + .first(); const usersDict = await this.getAllProjectUsersByRole(ownerRole.id); const groupsDict = await this.getAllProjectGroupsByRole(ownerRole.id); diff --git a/src/lib/features/project/project-owners-read-model.type.ts b/src/lib/features/project/project-owners-read-model.type.ts new file mode 100644 index 0000000000..0f1a71929f --- /dev/null +++ b/src/lib/features/project/project-owners-read-model.type.ts @@ -0,0 +1,28 @@ +import type { IProjectWithCount } from '../../types'; + +export type SystemOwner = { ownerType: 'system' }; +export type UserProjectOwner = { + ownerType: 'user'; + name: string; + email?: string; + imageUrl?: string; +}; +export type GroupProjectOwner = { + ownerType: 'group'; + name: string; +}; +type ProjectOwners = + | [SystemOwner] + | Array; + +export type ProjectOwnersDictionary = Record; + +export type IProjectWithCountAndOwners = IProjectWithCount & { + owners: ProjectOwners; +}; + +export interface IProjectOwnersReadModel { + addOwners( + projects: IProjectWithCount[], + ): Promise; +} diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index e1e55faeba..9879f07fa0 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -50,6 +50,7 @@ import { RoleName, SYSTEM_USER_ID, type ProjectCreated, + type IProjectOwnersReadModel, } from '../../types'; import type { IProjectAccessModel, @@ -77,8 +78,6 @@ import type { IProjectQuery, } from './project-store-type'; -const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; - type Days = number; type Count = number; @@ -112,6 +111,8 @@ function includes( export default class ProjectService { private projectStore: IProjectStore; + private projectOwnersReadModel: IProjectOwnersReadModel; + private accessService: AccessService; private eventStore: IEventStore; @@ -147,6 +148,7 @@ export default class ProjectService { constructor( { projectStore, + projectOwnersReadModel, eventStore, featureToggleStore, environmentStore, @@ -157,6 +159,7 @@ export default class ProjectService { }: Pick< IUnleashStores, | 'projectStore' + | 'projectOwnersReadModel' | 'eventStore' | 'featureToggleStore' | 'environmentStore' @@ -174,6 +177,7 @@ export default class ProjectService { privateProjectChecker: IPrivateProjectChecker, ) { this.projectStore = projectStore; + this.projectOwnersReadModel = projectOwnersReadModel; this.environmentStore = environmentStore; this.featureEnvironmentStore = featureEnvironmentStore; this.accessService = accessService; @@ -218,6 +222,12 @@ export default class ProjectService { return projects; } + async addOwnersToProjects( + projects: IProjectWithCount[], + ): Promise { + return this.projectOwnersReadModel.addOwners(projects); + } + async getProject(id: string): Promise { return this.projectStore.get(id); } diff --git a/src/lib/openapi/spec/project-schema.ts b/src/lib/openapi/spec/project-schema.ts index 5c86be88ab..96ca1b3c35 100644 --- a/src/lib/openapi/spec/project-schema.ts +++ b/src/lib/openapi/spec/project-schema.ts @@ -89,6 +89,74 @@ export const projectSchema = { description: 'The average time from when a feature was created to when it was enabled in the "production" environment during the current window', }, + owners: { + description: + 'The users and/or groups that have the "owner" role in this project. If no such users or groups exist, the list will contain the "system" owner instead.', + oneOf: [ + { + type: 'array', + minItems: 1, + items: { + anyOf: [ + { + type: 'object', + required: ['ownerType', 'name'], + properties: { + ownerType: { + type: 'string', + enum: ['user'], + }, + name: { + type: 'string', + example: 'User Name', + }, + imageUrl: { + type: 'string', + nullable: true, + example: + 'https://example.com/image.jpg', + }, + email: { + type: 'string', + nullable: true, + example: 'user@example.com', + }, + }, + }, + { + type: 'object', + required: ['ownerType', 'name'], + properties: { + ownerType: { + type: 'string', + enum: ['group'], + }, + name: { + type: 'string', + example: 'Group Name', + }, + }, + }, + ], + }, + }, + { + type: 'array', + minItems: 1, + maxItems: 1, + items: { + type: 'object', + required: ['ownerType'], + properties: { + ownerType: { + type: 'string', + enum: ['system'], + }, + }, + }, + }, + ], + }, }, components: {}, } as const; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 12d7f965ff..775ee7c75d 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -40,6 +40,7 @@ import { IFeatureSearchStore } from '../features/feature-search/feature-search-s import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users-store-type'; import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; +import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -84,6 +85,7 @@ export interface IUnleashStores { inactiveUsersStore: IInactiveUsersStore; trafficDataUsageStore: ITrafficDataUsageStore; segmentReadModel: ISegmentReadModel; + projectOwnersReadModel: IProjectOwnersReadModel; } export { @@ -127,4 +129,5 @@ export { IFeatureSearchStore, ITrafficDataUsageStore, ISegmentReadModel, + IProjectOwnersReadModel, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 9c5c7a7e71..0fd487d1e3 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -43,6 +43,7 @@ import FakeFeatureSearchStore from '../../lib/features/feature-search/fake-featu import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inactive-users-store'; import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store'; import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; +import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; const db = { select: () => ({ @@ -95,6 +96,7 @@ const createStores: () => IUnleashStores = () => { inactiveUsersStore: new FakeInactiveUsersStore(), trafficDataUsageStore: new FakeTrafficDataUsageStore(), segmentReadModel: new FakeSegmentReadModel(), + projectOwnersReadModel: new FakeProjectOwnersReadModel(), }; };