1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-14 01:16:17 +02:00

feat: add project owners to personal dashboard (#8293)

This PR adds all user-type owners of projects that you have access to to
the personal dashboard payload. It adds the new `projectOwners` property
regardless of whether you have access to any projects or not because it
required less code and fewer conditionals, but we can do the filtering
if we want to.

To add the owners, it uses the private project checker to get accessible
projects before passing those to the project owner read model, which has
a new method to fetch user owners for projects.
This commit is contained in:
Thomas Heartman 2024-09-30 10:49:34 +02:00 committed by GitHub
parent b726a229d3
commit 6188079122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 196 additions and 19 deletions

View File

@ -1,5 +1,5 @@
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import type { IUnleashConfig } from '../../types'; import type { IUnleashConfig, IUnleashStores } from '../../types';
import { PersonalDashboardService } from './personal-dashboard-service'; import { PersonalDashboardService } from './personal-dashboard-service';
import { PersonalDashboardReadModel } from './personal-dashboard-read-model'; import { PersonalDashboardReadModel } from './personal-dashboard-read-model';
import { FakePersonalDashboardReadModel } from './fake-personal-dashboard-read-model'; import { FakePersonalDashboardReadModel } from './fake-personal-dashboard-read-model';
@ -10,10 +10,13 @@ import { FakeProjectReadModel } from '../project/fake-project-read-model';
import EventStore from '../../db/event-store'; import EventStore from '../../db/event-store';
import { FeatureEventFormatterMd } from '../../addons/feature-event-formatter-md'; import { FeatureEventFormatterMd } from '../../addons/feature-event-formatter-md';
import FakeEventStore from '../../../test/fixtures/fake-event-store'; import FakeEventStore from '../../../test/fixtures/fake-event-store';
import { FakePrivateProjectChecker } from '../private-project/fakePrivateProjectChecker';
import { PrivateProjectChecker } from '../private-project/privateProjectChecker';
export const createPersonalDashboardService = ( export const createPersonalDashboardService = (
db: Db, db: Db,
config: IUnleashConfig, config: IUnleashConfig,
stores: IUnleashStores,
) => { ) => {
return new PersonalDashboardService( return new PersonalDashboardService(
new PersonalDashboardReadModel(db), new PersonalDashboardReadModel(db),
@ -24,6 +27,7 @@ export const createPersonalDashboardService = (
unleashUrl: config.server.unleashUrl, unleashUrl: config.server.unleashUrl,
formatStyle: 'markdown', formatStyle: 'markdown',
}), }),
new PrivateProjectChecker(stores, config),
); );
}; };
@ -37,5 +41,6 @@ export const createFakePersonalDashboardService = (config: IUnleashConfig) => {
unleashUrl: config.server.unleashUrl, unleashUrl: config.server.unleashUrl,
formatStyle: 'markdown', formatStyle: 'markdown',
}), }),
new FakePrivateProjectChecker(),
); );
}; };

View File

@ -83,18 +83,17 @@ export default class PersonalDashboardController extends Controller {
): Promise<void> { ): Promise<void> {
const user = req.user; const user = req.user;
const flags = await this.personalDashboardService.getPersonalFeatures( const [flags, projects, projectOwners] = await Promise.all([
user.id, this.personalDashboardService.getPersonalFeatures(user.id),
); this.personalDashboardService.getPersonalProjects(user.id),
this.personalDashboardService.getProjectOwners(user.id),
const projects = ]);
await this.personalDashboardService.getPersonalProjects(user.id);
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,
res, res,
personalDashboardSchema.$id, personalDashboardSchema.$id,
{ projects, flags }, { projects, flags, projectOwners },
); );
} }

View File

@ -1,10 +1,14 @@
import type { IProjectOwnersReadModel } from '../project/project-owners-read-model.type'; import type {
IProjectOwnersReadModel,
UserProjectOwner,
} from '../project/project-owners-read-model.type';
import type { import type {
IPersonalDashboardReadModel, IPersonalDashboardReadModel,
PersonalFeature, PersonalFeature,
PersonalProject, PersonalProject,
} from './personal-dashboard-read-model-type'; } from './personal-dashboard-read-model-type';
import type { IProjectReadModel } from '../project/project-read-model-type'; import type { IProjectReadModel } from '../project/project-read-model-type';
import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
import type { IEventStore } from '../../types'; import type { IEventStore } from '../../types';
import type { FeatureEventFormatter } from '../../addons/feature-event-formatter-md'; import type { FeatureEventFormatter } from '../../addons/feature-event-formatter-md';
@ -19,6 +23,8 @@ export class PersonalDashboardService {
private projectReadModel: IProjectReadModel; private projectReadModel: IProjectReadModel;
private privateProjectChecker: IPrivateProjectChecker;
private eventStore: IEventStore; private eventStore: IEventStore;
private featureEventFormatter: FeatureEventFormatter; private featureEventFormatter: FeatureEventFormatter;
@ -29,12 +35,14 @@ export class PersonalDashboardService {
projectReadModel: IProjectReadModel, projectReadModel: IProjectReadModel,
eventStore: IEventStore, eventStore: IEventStore,
featureEventFormatter: FeatureEventFormatter, featureEventFormatter: FeatureEventFormatter,
privateProjectChecker: IPrivateProjectChecker,
) { ) {
this.personalDashboardReadModel = personalDashboardReadModel; this.personalDashboardReadModel = personalDashboardReadModel;
this.projectOwnersReadModel = projectOwnersReadModel; this.projectOwnersReadModel = projectOwnersReadModel;
this.projectReadModel = projectReadModel; this.projectReadModel = projectReadModel;
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureEventFormatter = featureEventFormatter; this.featureEventFormatter = featureEventFormatter;
this.privateProjectChecker = privateProjectChecker;
} }
getPersonalFeatures(userId: number): Promise<PersonalFeature[]> { getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
@ -62,6 +70,18 @@ export class PersonalDashboardService {
return normalizedProjects; return normalizedProjects;
} }
async getProjectOwners(userId: number): Promise<UserProjectOwner[]> {
const accessibleProjects =
await this.privateProjectChecker.getUserAccessibleProjects(userId);
const filter =
accessibleProjects.mode === 'all'
? undefined
: new Set(accessibleProjects.projects);
return this.projectOwnersReadModel.getAllUserProjectOwners(filter);
}
async getPersonalProjectDetails( async getPersonalProjectDetails(
projectId: string, projectId: string,
): Promise<PersonalProjectDetails> { ): Promise<PersonalProjectDetails> {

View File

@ -1,5 +1,6 @@
import type { import type {
IProjectOwnersReadModel, IProjectOwnersReadModel,
UserProjectOwner,
WithProjectOwners, WithProjectOwners,
} from './project-owners-read-model.type'; } from './project-owners-read-model.type';
@ -12,4 +13,8 @@ export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel {
owners: [{ ownerType: 'system' }], owners: [{ ownerType: 'system' }],
})); }));
} }
async getAllUserProjectOwners(): Promise<UserProjectOwner[]> {
return [];
}
} }

View File

@ -135,7 +135,7 @@ afterEach(async () => {
describe('integration tests', () => { describe('integration tests', () => {
test('returns an empty object if there are no projects', async () => { test('returns an empty object if there are no projects', async () => {
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toStrictEqual({}); expect(owners).toStrictEqual({});
}); });
@ -150,7 +150,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: expect.arrayContaining([ [projectId]: expect.arrayContaining([
expect.objectContaining({ name: 'Owner Name' }), expect.objectContaining({ name: 'Owner Name' }),
@ -168,7 +168,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: [ [projectId]: [
@ -201,7 +201,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: [{ name: 'Owner Name' }], [projectId]: [{ name: 'Owner Name' }],
@ -219,7 +219,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: [ [projectId]: [
@ -248,7 +248,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: [ [projectId]: [
@ -299,7 +299,7 @@ describe('integration tests', () => {
projectId, projectId,
); );
const owners = await readModel.getAllProjectOwners(); const owners = await readModel.getProjectOwnersDictionary();
expect(owners).toMatchObject({ expect(owners).toMatchObject({
[projectId]: [ [projectId]: [
@ -361,4 +361,96 @@ describe('integration tests', () => {
{ name: projectIdB, owners: [{ ownerType: 'user' }] }, { name: projectIdB, owners: [{ ownerType: 'user' }] },
]); ]);
}); });
test('filters out system and group owners when getting all user project owners', async () => {
const createProject = async () => {
const id = randomId();
return db.stores.projectStore.create({
id,
name: id,
});
};
const projectA = await createProject();
const projectB = await createProject();
const projectC = await createProject();
await createProject(); // <- no owner
await db.stores.accessStore.addUserToRole(
owner.id,
ownerRoleId,
projectA.id,
);
await db.stores.accessStore.addUserToRole(
owner2.id,
ownerRoleId,
projectB.id,
);
await db.stores.accessStore.addGroupToRole(
group.id,
ownerRoleId,
'',
projectC.id,
);
const userOwners = await readModel.getAllUserProjectOwners();
userOwners.sort((a, b) => a.name.localeCompare(b.name));
expect(userOwners).toMatchObject([
{
name: owner.name,
ownerType: 'user',
email: owner.email,
imageUrl: 'https://image-url-1',
},
{
name: owner2.name,
ownerType: 'user',
email: owner2.email,
imageUrl: 'https://image-url-3',
},
]);
});
test('only returns projects listed in the projects input if provided', async () => {
const createProject = async () => {
const id = randomId();
return db.stores.projectStore.create({
id,
name: id,
});
};
const projectA = await createProject();
const projectB = await createProject();
await db.stores.accessStore.addUserToRole(
owner.id,
ownerRoleId,
projectA.id,
);
await db.stores.accessStore.addUserToRole(
owner2.id,
ownerRoleId,
projectB.id,
);
const noOwners = await readModel.getAllUserProjectOwners(new Set());
expect(noOwners).toMatchObject([]);
const onlyProjectA = await readModel.getAllUserProjectOwners(
new Set([projectA.id]),
);
expect(onlyProjectA).toMatchObject([
{
name: owner.name,
ownerType: 'user',
email: owner.email,
imageUrl: 'https://image-url-1',
},
]);
});
}); });

View File

@ -104,7 +104,7 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
return groupsDict; return groupsDict;
} }
async getAllProjectOwners(): Promise<ProjectOwnersDictionary> { async getProjectOwnersDictionary(): Promise<ProjectOwnersDictionary> {
const ownerRole = await this.db(T.ROLES) const ownerRole = await this.db(T.ROLES)
.where({ name: RoleName.OWNER }) .where({ name: RoleName.OWNER })
.first(); .first();
@ -127,10 +127,33 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
return dict; return dict;
} }
async getAllUserProjectOwners(
projects?: Set<string>,
): Promise<UserProjectOwner[]> {
const allOwners = await this.getProjectOwnersDictionary();
const owners = projects
? Object.entries(allOwners)
.filter(([projectId]) => projects.has(projectId))
.map(([_, owners]) => owners)
: Object.values(allOwners);
const ownersDict = owners.flat().reduce(
(acc, owner) => {
if (owner.ownerType === 'user') {
acc[owner.email || owner.name] = owner;
}
return acc;
},
{} as Record<string, UserProjectOwner>,
);
return Object.values(ownersDict);
}
async addOwners<T extends { id: string }>( async addOwners<T extends { id: string }>(
projects: T[], projects: T[],
): Promise<WithProjectOwners<T>> { ): Promise<WithProjectOwners<T>> {
const owners = await this.getAllProjectOwners(); const owners = await this.getProjectOwnersDictionary();
return ProjectOwnersReadModel.addOwnerData(projects, owners); return ProjectOwnersReadModel.addOwnerData(projects, owners);
} }

View File

@ -29,4 +29,8 @@ export interface IProjectOwnersReadModel {
addOwners<T extends { id: string }>( addOwners<T extends { id: string }>(
projects: T[], projects: T[],
): Promise<WithProjectOwners<T>>; ): Promise<WithProjectOwners<T>>;
getAllUserProjectOwners(
projects?: Set<string>,
): Promise<UserProjectOwner[]>;
} }

View File

@ -7,6 +7,35 @@ export const personalDashboardSchema = {
additionalProperties: false, additionalProperties: false,
required: ['projects', 'flags'], required: ['projects', 'flags'],
properties: { properties: {
projectOwners: {
type: 'array',
description:
'Users with the project owner role in Unleash. Only contains owners of projects that are visible to the user.',
items: {
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',
},
},
},
},
projects: { projects: {
type: 'array', type: 'array',
items: { items: {

View File

@ -407,7 +407,7 @@ export const createServices = (
onboardingService.listen(); onboardingService.listen();
const personalDashboardService = db const personalDashboardService = db
? createPersonalDashboardService(db, config) ? createPersonalDashboardService(db, config, stores)
: createFakePersonalDashboardService(config); : createFakePersonalDashboardService(config);
return { return {