1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-02 01:17:58 +02: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:
Tymoteusz Czech 2024-04-26 12:07:11 +02:00 committed by GitHub
parent 7d01dbb748
commit 66ec9a2f2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 189 additions and 48 deletions

View File

@ -43,6 +43,7 @@ import FeatureSearchStore from '../features/feature-search/feature-search-store'
import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; import { InactiveUsersStore } from '../users/inactive/inactive-users-store';
import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store';
import { SegmentReadModel } from '../features/segment/segment-read-model'; import { SegmentReadModel } from '../features/segment/segment-read-model';
import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -148,6 +149,7 @@ export const createStores = (
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger),
segmentReadModel: new SegmentReadModel(db), segmentReadModel: new SegmentReadModel(db),
projectOwnersReadModel: new ProjectOwnersReadModel(db),
}; };
}; };

View File

@ -41,6 +41,8 @@ import {
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store'; import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import FeatureTypeStore from '../../db/feature-type-store'; import FeatureTypeStore from '../../db/feature-type-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-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 = ( export const createProjectService = (
db: Db, db: Db,
@ -54,6 +56,7 @@ export const createProjectService = (
getLogger, getLogger,
flagResolver, flagResolver,
); );
const projectOwnersReadModel = new ProjectOwnersReadModel(db);
const groupStore = new GroupStore(db); const groupStore = new GroupStore(db);
const featureToggleStore = new FeatureToggleStore( const featureToggleStore = new FeatureToggleStore(
db, db,
@ -115,6 +118,7 @@ export const createProjectService = (
featureTypeStore, featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
projectOwnersReadModel,
}, },
config, config,
accessService, accessService,
@ -131,6 +135,7 @@ export const createFakeProjectService = (
): ProjectService => { ): ProjectService => {
const { getLogger } = config; const { getLogger } = config;
const eventStore = new FakeEventStore(); const eventStore = new FakeEventStore();
const projectOwnersReadModel = new FakeProjectOwnersReadModel();
const projectStore = new FakeProjectStore(); const projectStore = new FakeProjectStore();
const groupStore = new FakeGroupStore(); const groupStore = new FakeGroupStore();
const featureToggleStore = new FakeFeatureToggleStore(); const featureToggleStore = new FakeFeatureToggleStore();
@ -169,6 +174,7 @@ export const createFakeProjectService = (
return new ProjectService( return new ProjectService(
{ {
projectStore, projectStore,
projectOwnersReadModel,
eventStore, eventStore,
featureToggleStore, featureToggleStore,
environmentStore, environmentStore,

View 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' }],
}));
}
}

View File

@ -197,9 +197,19 @@ export default class ProjectController extends Controller {
user.id, user.id,
); );
// if (this.flagResolver.isEnabled('projectsListNewCards')) { if (this.flagResolver.isEnabled('projectsListNewCards')) {
// TODO: get project owners and add to response const projectsWithOwners =
// } await this.projectService.addOwnersToProjects(projects);
this.openApiService.respondWithValidation(
200,
res,
projectsSchema.$id,
{ version: 1, projects: serializeDates(projectsWithOwners) },
);
return;
}
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,

View File

@ -19,7 +19,10 @@ const mockProjectWithCounts = (name: string) => ({
describe('unit tests', () => { describe('unit tests', () => {
test('maps owners to projects', () => { 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 = { const owners = {
project1: [{ ownerType: 'user' as const, name: 'Owner Name' }], project1: [{ ownerType: 'user' as const, name: 'Owner Name' }],
@ -32,13 +35,21 @@ describe('unit tests', () => {
); );
expect(projectsWithOwners).toMatchObject([ 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 () => { 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 = {}; const owners = {};
@ -48,8 +59,14 @@ describe('unit tests', () => {
); );
expect(projectsWithOwners).toMatchObject([ 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 () => { beforeAll(async () => {
db = await dbInit('project_owners_read_model_serial', getLogger); 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; ownerRoleId = (await db.stores.roleStore.getRoleByName(RoleName.OWNER)).id;
const ownerData = { const ownerData = {
@ -107,14 +124,7 @@ afterAll(async () => {
}); });
afterEach(async () => { afterEach(async () => {
if (db) { db.stores.roleStore;
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();
}
}); });
describe('integration tests', () => { describe('integration tests', () => {

View File

@ -1,5 +1,12 @@
import type { Db } from '../../db/db'; 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 = { const T = {
ROLE_USER: 'role_user', ROLE_USER: 'role_user',
@ -8,34 +15,11 @@ const T = {
USERS: 'users', USERS: 'users',
}; };
type SystemOwner = { ownerType: 'system' }; export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
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 {
private db: Db; private db: Db;
roleStore: IRoleStore;
constructor(db: Db, roleStore: IRoleStore) { constructor(db: Db) {
this.db = db; this.db = db;
this.roleStore = roleStore;
} }
static addOwnerData( static addOwnerData(
@ -44,7 +28,7 @@ export class ProjectOwnersReadModel {
): IProjectWithCountAndOwners[] { ): IProjectWithCountAndOwners[] {
return projects.map((project) => ({ return projects.map((project) => ({
...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> { 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 usersDict = await this.getAllProjectUsersByRole(ownerRole.id);
const groupsDict = await this.getAllProjectGroupsByRole(ownerRole.id); const groupsDict = await this.getAllProjectGroupsByRole(ownerRole.id);

View 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[]>;
}

View File

@ -50,6 +50,7 @@ import {
RoleName, RoleName,
SYSTEM_USER_ID, SYSTEM_USER_ID,
type ProjectCreated, type ProjectCreated,
type IProjectOwnersReadModel,
} from '../../types'; } from '../../types';
import type { import type {
IProjectAccessModel, IProjectAccessModel,
@ -77,8 +78,6 @@ import type {
IProjectQuery, IProjectQuery,
} from './project-store-type'; } from './project-store-type';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
type Days = number; type Days = number;
type Count = number; type Count = number;
@ -112,6 +111,8 @@ function includes(
export default class ProjectService { export default class ProjectService {
private projectStore: IProjectStore; private projectStore: IProjectStore;
private projectOwnersReadModel: IProjectOwnersReadModel;
private accessService: AccessService; private accessService: AccessService;
private eventStore: IEventStore; private eventStore: IEventStore;
@ -147,6 +148,7 @@ export default class ProjectService {
constructor( constructor(
{ {
projectStore, projectStore,
projectOwnersReadModel,
eventStore, eventStore,
featureToggleStore, featureToggleStore,
environmentStore, environmentStore,
@ -157,6 +159,7 @@ export default class ProjectService {
}: Pick< }: Pick<
IUnleashStores, IUnleashStores,
| 'projectStore' | 'projectStore'
| 'projectOwnersReadModel'
| 'eventStore' | 'eventStore'
| 'featureToggleStore' | 'featureToggleStore'
| 'environmentStore' | 'environmentStore'
@ -174,6 +177,7 @@ export default class ProjectService {
privateProjectChecker: IPrivateProjectChecker, privateProjectChecker: IPrivateProjectChecker,
) { ) {
this.projectStore = projectStore; this.projectStore = projectStore;
this.projectOwnersReadModel = projectOwnersReadModel;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
this.featureEnvironmentStore = featureEnvironmentStore; this.featureEnvironmentStore = featureEnvironmentStore;
this.accessService = accessService; this.accessService = accessService;
@ -218,6 +222,12 @@ export default class ProjectService {
return projects; return projects;
} }
async addOwnersToProjects(
projects: IProjectWithCount[],
): Promise<IProjectWithCount[]> {
return this.projectOwnersReadModel.addOwners(projects);
}
async getProject(id: string): Promise<IProject> { async getProject(id: string): Promise<IProject> {
return this.projectStore.get(id); return this.projectStore.get(id);
} }

View File

@ -89,6 +89,74 @@ export const projectSchema = {
description: description:
'The average time from when a feature was created to when it was enabled in the "production" environment during the current window', '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: {}, components: {},
} as const; } as const;

View File

@ -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 type { IInactiveUsersStore } from '../users/inactive/types/inactive-users-store-type';
import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type';
import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; import { ISegmentReadModel } from '../features/segment/segment-read-model-type';
import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -84,6 +85,7 @@ export interface IUnleashStores {
inactiveUsersStore: IInactiveUsersStore; inactiveUsersStore: IInactiveUsersStore;
trafficDataUsageStore: ITrafficDataUsageStore; trafficDataUsageStore: ITrafficDataUsageStore;
segmentReadModel: ISegmentReadModel; segmentReadModel: ISegmentReadModel;
projectOwnersReadModel: IProjectOwnersReadModel;
} }
export { export {
@ -127,4 +129,5 @@ export {
IFeatureSearchStore, IFeatureSearchStore,
ITrafficDataUsageStore, ITrafficDataUsageStore,
ISegmentReadModel, ISegmentReadModel,
IProjectOwnersReadModel,
}; };

View File

@ -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 { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inactive-users-store';
import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-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 { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model';
import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model';
const db = { const db = {
select: () => ({ select: () => ({
@ -95,6 +96,7 @@ const createStores: () => IUnleashStores = () => {
inactiveUsersStore: new FakeInactiveUsersStore(), inactiveUsersStore: new FakeInactiveUsersStore(),
trafficDataUsageStore: new FakeTrafficDataUsageStore(), trafficDataUsageStore: new FakeTrafficDataUsageStore(),
segmentReadModel: new FakeSegmentReadModel(), segmentReadModel: new FakeSegmentReadModel(),
projectOwnersReadModel: new FakeProjectOwnersReadModel(),
}; };
}; };