Skip to content

Commit

Permalink
feat: add project owners to personal dashboard (#8293)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
thomasheartman authored Sep 30, 2024
1 parent b726a22 commit 6188079
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
Expand All @@ -24,6 +27,7 @@ export const createPersonalDashboardService = (
unleashUrl: config.server.unleashUrl,
formatStyle: 'markdown',
}),
new PrivateProjectChecker(stores, config),
);
};

Expand All @@ -37,5 +41,6 @@ export const createFakePersonalDashboardService = (config: IUnleashConfig) => {
unleashUrl: config.server.unleashUrl,
formatStyle: 'markdown',
}),
new FakePrivateProjectChecker(),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,6 +23,8 @@ export class PersonalDashboardService {

private projectReadModel: IProjectReadModel;

private privateProjectChecker: IPrivateProjectChecker;

private eventStore: IEventStore;

private featureEventFormatter: FeatureEventFormatter;
Expand All @@ -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[]> {
Expand Down Expand Up @@ -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> {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/features/project/fake-project-owners-read-model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
IProjectOwnersReadModel,
UserProjectOwner,
WithProjectOwners,
} from './project-owners-read-model.type';

Expand All @@ -12,4 +13,8 @@ export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel {
owners: [{ ownerType: 'system' }],
}));
}

async getAllUserProjectOwners(): Promise<UserProjectOwner[]> {
return [];
}
}
106 changes: 99 additions & 7 deletions src/lib/features/project/project-owners-read-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
Expand All @@ -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' }),
Expand All @@ -168,7 +168,7 @@ describe('integration tests', () => {
projectId,
);

const owners = await readModel.getAllProjectOwners();
const owners = await readModel.getProjectOwnersDictionary();

expect(owners).toMatchObject({
[projectId]: [
Expand Down Expand Up @@ -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' }],
Expand All @@ -219,7 +219,7 @@ describe('integration tests', () => {
projectId,
);

const owners = await readModel.getAllProjectOwners();
const owners = await readModel.getProjectOwnersDictionary();

expect(owners).toMatchObject({
[projectId]: [
Expand Down Expand Up @@ -248,7 +248,7 @@ describe('integration tests', () => {
projectId,
);

const owners = await readModel.getAllProjectOwners();
const owners = await readModel.getProjectOwnersDictionary();

expect(owners).toMatchObject({
[projectId]: [
Expand Down Expand Up @@ -299,7 +299,7 @@ describe('integration tests', () => {
projectId,
);

const owners = await readModel.getAllProjectOwners();
const owners = await readModel.getProjectOwnersDictionary();

expect(owners).toMatchObject({
[projectId]: [
Expand Down Expand Up @@ -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',
},
]);
});
});
27 changes: 25 additions & 2 deletions src/lib/features/project/project-owners-read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/features/project/project-owners-read-model.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export interface IProjectOwnersReadModel {
addOwners<T extends { id: string }>(
projects: T[],
): Promise<WithProjectOwners<T>>;

getAllUserProjectOwners(
projects?: Set<string>,
): Promise<UserProjectOwner[]>;
}
29 changes: 29 additions & 0 deletions src/lib/openapi/spec/personal-dashboard-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]',
},
},
},
},
projects: {
type: 'array',
items: {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ export const createServices = (
onboardingService.listen();

const personalDashboardService = db
? createPersonalDashboardService(db, config)
? createPersonalDashboardService(db, config, stores)
: createFakePersonalDashboardService(config);

return {
Expand Down

0 comments on commit 6188079

Please sign in to comment.