mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: project owners in project service (#6935)
Schema and integrating into service and controller for project owners --------- Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
		
							parent
							
								
									7d01dbb748
								
							
						
					
					
						commit
						66ec9a2f2f
					
				| @ -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), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
							
								
								
									
										16
									
								
								src/lib/features/project/fake-project-owners-read-model.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/lib/features/project/fake-project-owners-read-model.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<IProjectWithCountAndOwners[]> { | ||||
|         return projects.map((project) => ({ | ||||
|             ...project, | ||||
|             owners: [{ ownerType: 'system' }], | ||||
|         })); | ||||
|     } | ||||
| } | ||||
| @ -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, | ||||
|  | ||||
| @ -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', () => { | ||||
|  | ||||
| @ -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<UserProjectOwner | GroupProjectOwner>; | ||||
| 
 | ||||
| export type ProjectOwnersDictionary = Record<string, ProjectOwners>; | ||||
| 
 | ||||
| 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<ProjectOwnersDictionary> { | ||||
|         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); | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										28
									
								
								src/lib/features/project/project-owners-read-model.type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/lib/features/project/project-owners-read-model.type.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<UserProjectOwner | GroupProjectOwner>; | ||||
| 
 | ||||
| export type ProjectOwnersDictionary = Record<string, ProjectOwners>; | ||||
| 
 | ||||
| export type IProjectWithCountAndOwners = IProjectWithCount & { | ||||
|     owners: ProjectOwners; | ||||
| }; | ||||
| 
 | ||||
| export interface IProjectOwnersReadModel { | ||||
|     addOwners( | ||||
|         projects: IProjectWithCount[], | ||||
|     ): Promise<IProjectWithCountAndOwners[]>; | ||||
| } | ||||
| @ -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<IProjectWithCount[]> { | ||||
|         return this.projectOwnersReadModel.addOwners(projects); | ||||
|     } | ||||
| 
 | ||||
|     async getProject(id: string): Promise<IProject> { | ||||
|         return this.projectStore.get(id); | ||||
|     } | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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, | ||||
| }; | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -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(), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user