mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-04 01:18:20 +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:
parent
b726a229d3
commit
6188079122
@ -1,5 +1,5 @@
|
||||
import type { Db } from '../../db/db';
|
||||
import type { IUnleashConfig } from '../../types';
|
||||
import type { IUnleashConfig, IUnleashStores } from '../../types';
|
||||
import { PersonalDashboardService } from './personal-dashboard-service';
|
||||
import { PersonalDashboardReadModel } from './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 { FeatureEventFormatterMd } from '../../addons/feature-event-formatter-md';
|
||||
import FakeEventStore from '../../../test/fixtures/fake-event-store';
|
||||
import { FakePrivateProjectChecker } from '../private-project/fakePrivateProjectChecker';
|
||||
import { PrivateProjectChecker } from '../private-project/privateProjectChecker';
|
||||
|
||||
export const createPersonalDashboardService = (
|
||||
db: Db,
|
||||
config: IUnleashConfig,
|
||||
stores: IUnleashStores,
|
||||
) => {
|
||||
return new PersonalDashboardService(
|
||||
new PersonalDashboardReadModel(db),
|
||||
@ -24,6 +27,7 @@ export const createPersonalDashboardService = (
|
||||
unleashUrl: config.server.unleashUrl,
|
||||
formatStyle: 'markdown',
|
||||
}),
|
||||
new PrivateProjectChecker(stores, config),
|
||||
);
|
||||
};
|
||||
|
||||
@ -37,5 +41,6 @@ export const createFakePersonalDashboardService = (config: IUnleashConfig) => {
|
||||
unleashUrl: config.server.unleashUrl,
|
||||
formatStyle: 'markdown',
|
||||
}),
|
||||
new FakePrivateProjectChecker(),
|
||||
);
|
||||
};
|
||||
|
@ -83,18 +83,17 @@ export default class PersonalDashboardController extends Controller {
|
||||
): Promise<void> {
|
||||
const user = req.user;
|
||||
|
||||
const flags = await this.personalDashboardService.getPersonalFeatures(
|
||||
user.id,
|
||||
);
|
||||
|
||||
const projects =
|
||||
await this.personalDashboardService.getPersonalProjects(user.id);
|
||||
const [flags, projects, projectOwners] = await Promise.all([
|
||||
this.personalDashboardService.getPersonalFeatures(user.id),
|
||||
this.personalDashboardService.getPersonalProjects(user.id),
|
||||
this.personalDashboardService.getProjectOwners(user.id),
|
||||
]);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
personalDashboardSchema.$id,
|
||||
{ projects, flags },
|
||||
{ projects, flags, projectOwners },
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
IPersonalDashboardReadModel,
|
||||
PersonalFeature,
|
||||
PersonalProject,
|
||||
} from './personal-dashboard-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 { FeatureEventFormatter } from '../../addons/feature-event-formatter-md';
|
||||
|
||||
@ -19,6 +23,8 @@ export class PersonalDashboardService {
|
||||
|
||||
private projectReadModel: IProjectReadModel;
|
||||
|
||||
private privateProjectChecker: IPrivateProjectChecker;
|
||||
|
||||
private eventStore: IEventStore;
|
||||
|
||||
private featureEventFormatter: FeatureEventFormatter;
|
||||
@ -29,12 +35,14 @@ export class PersonalDashboardService {
|
||||
projectReadModel: IProjectReadModel,
|
||||
eventStore: IEventStore,
|
||||
featureEventFormatter: FeatureEventFormatter,
|
||||
privateProjectChecker: IPrivateProjectChecker,
|
||||
) {
|
||||
this.personalDashboardReadModel = personalDashboardReadModel;
|
||||
this.projectOwnersReadModel = projectOwnersReadModel;
|
||||
this.projectReadModel = projectReadModel;
|
||||
this.eventStore = eventStore;
|
||||
this.featureEventFormatter = featureEventFormatter;
|
||||
this.privateProjectChecker = privateProjectChecker;
|
||||
}
|
||||
|
||||
getPersonalFeatures(userId: number): Promise<PersonalFeature[]> {
|
||||
@ -62,6 +70,18 @@ export class PersonalDashboardService {
|
||||
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(
|
||||
projectId: string,
|
||||
): Promise<PersonalProjectDetails> {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type {
|
||||
IProjectOwnersReadModel,
|
||||
UserProjectOwner,
|
||||
WithProjectOwners,
|
||||
} from './project-owners-read-model.type';
|
||||
|
||||
@ -12,4 +13,8 @@ export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel {
|
||||
owners: [{ ownerType: 'system' }],
|
||||
}));
|
||||
}
|
||||
|
||||
async getAllUserProjectOwners(): Promise<UserProjectOwner[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -135,7 +135,7 @@ afterEach(async () => {
|
||||
|
||||
describe('integration tests', () => {
|
||||
test('returns an empty object if there are no projects', async () => {
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toStrictEqual({});
|
||||
});
|
||||
@ -150,7 +150,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Owner Name' }),
|
||||
@ -168,7 +168,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: [
|
||||
@ -201,7 +201,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: [{ name: 'Owner Name' }],
|
||||
@ -219,7 +219,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: [
|
||||
@ -248,7 +248,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: [
|
||||
@ -299,7 +299,7 @@ describe('integration tests', () => {
|
||||
projectId,
|
||||
);
|
||||
|
||||
const owners = await readModel.getAllProjectOwners();
|
||||
const owners = await readModel.getProjectOwnersDictionary();
|
||||
|
||||
expect(owners).toMatchObject({
|
||||
[projectId]: [
|
||||
@ -361,4 +361,96 @@ describe('integration tests', () => {
|
||||
{ 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -104,7 +104,7 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
|
||||
return groupsDict;
|
||||
}
|
||||
|
||||
async getAllProjectOwners(): Promise<ProjectOwnersDictionary> {
|
||||
async getProjectOwnersDictionary(): Promise<ProjectOwnersDictionary> {
|
||||
const ownerRole = await this.db(T.ROLES)
|
||||
.where({ name: RoleName.OWNER })
|
||||
.first();
|
||||
@ -127,10 +127,33 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
|
||||
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 }>(
|
||||
projects: T[],
|
||||
): Promise<WithProjectOwners<T>> {
|
||||
const owners = await this.getAllProjectOwners();
|
||||
const owners = await this.getProjectOwnersDictionary();
|
||||
|
||||
return ProjectOwnersReadModel.addOwnerData(projects, owners);
|
||||
}
|
||||
|
@ -29,4 +29,8 @@ export interface IProjectOwnersReadModel {
|
||||
addOwners<T extends { id: string }>(
|
||||
projects: T[],
|
||||
): Promise<WithProjectOwners<T>>;
|
||||
|
||||
getAllUserProjectOwners(
|
||||
projects?: Set<string>,
|
||||
): Promise<UserProjectOwner[]>;
|
||||
}
|
||||
|
@ -7,6 +7,35 @@ export const personalDashboardSchema = {
|
||||
additionalProperties: false,
|
||||
required: ['projects', 'flags'],
|
||||
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: {
|
||||
type: 'array',
|
||||
items: {
|
||||
|
@ -407,7 +407,7 @@ export const createServices = (
|
||||
onboardingService.listen();
|
||||
|
||||
const personalDashboardService = db
|
||||
? createPersonalDashboardService(db, config)
|
||||
? createPersonalDashboardService(db, config, stores)
|
||||
: createFakePersonalDashboardService(config);
|
||||
|
||||
return {
|
||||
|
Loading…
Reference in New Issue
Block a user