diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 9be55b7dfe97..66ad593d2bfe 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -197,6 +197,10 @@ export default class ProjectController extends Controller { user.id, ); + // if (this.flagResolver.isEnabled('projectsListNewCards')) { + // TODO: get project owners and add to response + // } + this.openApiService.respondWithValidation( 200, res, diff --git a/src/lib/features/project/project-owners-read-model.test.ts b/src/lib/features/project/project-owners-read-model.test.ts new file mode 100644 index 000000000000..2711bbcb6b07 --- /dev/null +++ b/src/lib/features/project/project-owners-read-model.test.ts @@ -0,0 +1,175 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { type IUser, RoleName, type IGroup } from '../../types'; +import { randomId } from '../../util'; +import { ProjectOwnersReadModel } from './project-owners-read-model'; + +describe('unit tests', () => { + test('maps owners to projects', () => {}); + test('returns "system" when a project has no owners', async () => { + // this is a mapping test; not an integration test + }); +}); + +let db: ITestDb; +let readModel: ProjectOwnersReadModel; + +let ownerRoleId: number; +let owner: IUser; +let member: IUser; +let group: IGroup; + +beforeAll(async () => { + db = await dbInit('project_owners_read_model_serial', getLogger); + readModel = new ProjectOwnersReadModel(db.rawDatabase, db.stores.roleStore); + ownerRoleId = (await db.stores.roleStore.getRoleByName(RoleName.OWNER)).id; + + const ownerData = { + name: 'Owner User', + username: 'owner', + email: 'owner@email.com', + imageUrl: 'image-url-1', + }; + const memberData = { + name: 'Member Name', + username: 'member', + email: 'member@email.com', + imageUrl: 'image-url-2', + }; + + // create users + owner = await db.stores.userStore.insert(ownerData); + member = await db.stores.userStore.insert(memberData); + + // create groups + group = await db.stores.groupStore.create({ name: 'Group Name' }); + await db.stores.groupStore.addUserToGroups(owner.id, [group.id]); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +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(); + } +}); + +describe('integration tests', () => { + test('name takes precedence over username', async () => { + const projectId = randomId(); + await db.stores.projectStore.create({ id: projectId, name: projectId }); + + await db.stores.accessStore.addUserToRole( + owner.id, + ownerRoleId, + projectId, + ); + + const owners = await readModel.getAllProjectOwners(); + expect(owners).toMatchObject({ + [projectId]: expect.arrayContaining([ + expect.objectContaining({ name: 'Owner User' }), + ]), + }); + }); + + test('gets project user owners', async () => { + const projectId = randomId(); + await db.stores.projectStore.create({ id: projectId, name: projectId }); + + await db.stores.accessStore.addUserToRole( + owner.id, + ownerRoleId, + projectId, + ); + + const owners = await readModel.getAllProjectOwners(); + + expect(owners).toMatchObject({ + [projectId]: [ + { + ownerType: 'user', + name: 'Owner User', + email: 'owner@email.com', + imageUrl: 'image-url-1', + }, + ], + }); + }); + + test('does not get regular project members', async () => { + const projectId = randomId(); + await db.stores.projectStore.create({ id: projectId, name: projectId }); + + const memberRole = await db.stores.roleStore.getRoleByName( + RoleName.MEMBER, + ); + await db.stores.accessStore.addUserToRole( + owner.id, + ownerRoleId, + projectId, + ); + + await db.stores.accessStore.addUserToRole( + member.id, + memberRole.id, + projectId, + ); + + const owners = await readModel.getAllProjectOwners(); + + expect(owners).toMatchObject({ + [projectId]: [{ name: 'Owner User' }], + }); + }); + + test('gets project group owners', async () => { + const projectId = randomId(); + await db.stores.projectStore.create({ id: projectId, name: projectId }); + + await db.stores.accessStore.addGroupToRole( + group.id, + ownerRoleId, + '', + projectId, + ); + + const owners = await readModel.getAllProjectOwners(); + + expect(owners).toMatchObject({ + [projectId]: [ + { + ownerType: 'group', + name: 'Group Name', + }, + ], + }); + }); + + test('users are listed before groups', async () => {}); + + test('owners (users and groups) are sorted by when they were added; oldest first', async () => {}); + + test('returns the system owner for the default project', async () => {}); + + test('returns an empty list if there are no projects', async () => { + const owners = await readModel.getAllProjectOwners(); + + expect(owners).toStrictEqual({}); + }); + + test('enriches fully', async () => { + const owners = await readModel.enrichWithOwners([]); + + expect(owners).toStrictEqual([]); + }); +}); diff --git a/src/lib/features/project/project-owners-read-model.ts b/src/lib/features/project/project-owners-read-model.ts new file mode 100644 index 000000000000..6f42522e999b --- /dev/null +++ b/src/lib/features/project/project-owners-read-model.ts @@ -0,0 +1,151 @@ +import type { Db } from '../../db/db'; +import { RoleName, type IProjectWithCount, type IRoleStore } from '../../types'; + +const T = { + ROLE_USER: 'role_user', + GROUP_ROLE: 'group_role', + ROLES: 'roles', + 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; + +export type ProjectOwnersDictionary = Record; + +type IProjectWithCountAndOwners = IProjectWithCount & { + owners: ProjectOwners; +}; + +export class ProjectOwnersReadModel { + private db: Db; + roleStore: IRoleStore; + + constructor(db: Db, roleStore: IRoleStore) { + this.db = db; + this.roleStore = roleStore; + } + + addOwnerData( + projects: IProjectWithCount[], + owners: ProjectOwnersDictionary, + ): IProjectWithCountAndOwners[] { + // const projectsWithOwners = projects.map((p) => ({ + // ...p, + // owners: projectOwners[p.id] || [], + // })); + return []; + } + + private async getAllProjectUsersByRole( + roleId: number, + ): Promise> { + const usersResult = await this.db + .select( + 'user.username', + 'user.name', + 'user.email', + 'user.image_url', + 'ru.created_at', + 'ru.project', + ) + .from(`${T.ROLE_USER} as ru`) + .join(`${T.ROLES} as r`, 'ru.role_id', 'r.id') + .where('r.id', roleId) + .join(`${T.USERS} as user`, 'ru.user_id', 'user.id'); + const usersDict: Record = {}; + + usersResult.forEach((user) => { + const project = user.project as string; + + const data: UserProjectOwner = { + ownerType: 'user', + name: user?.name || user?.username, + email: user?.email, + imageUrl: user?.image_url, + }; + + if (project in usersDict) { + usersDict[project] = [...usersDict[project], data]; + } else { + usersDict[project] = [data]; + } + }); + + return usersDict; + } + + private async getAllProjectGroupsByRole( + roleId: number, + ): Promise> { + const groupsResult = await this.db + .select('groups.name', 'gr.created_at', 'gr.project') + .from(`${T.GROUP_ROLE} as gr`) + .join(`${T.ROLES} as r`, 'gr.role_id', 'r.id') + .where('r.id', roleId) + .join('groups', 'gr.group_id', 'groups.id'); + + const groupsDict: Record = {}; + + groupsResult.forEach((group) => { + const project = group.project as string; + + const data: GroupProjectOwner = { + ownerType: 'group', + name: group?.name, + }; + + if (project in groupsDict) { + groupsDict[project] = [...groupsDict[project], data]; + } else { + groupsDict[project] = [data]; + } + }); + + return groupsDict; + } + + async getAllProjectOwners(): Promise { + const ownerRole = await this.roleStore.getRoleByName(RoleName.OWNER); + const usersDict = await this.getAllProjectUsersByRole(ownerRole.id); + const groupsDict = await this.getAllProjectGroupsByRole(ownerRole.id); + + const projects = [ + ...new Set([...Object.keys(usersDict), ...Object.keys(groupsDict)]), + ]; + + const dict = Object.fromEntries( + projects.map((project) => { + return [ + project, + [ + ...(usersDict[project] || []), + ...(groupsDict[project] || []), + ], + ]; + }), + ); + + return dict; + } + + async enrichWithOwners( + projects: IProjectWithCount[], + ): Promise { + const owners = await this.getAllProjectOwners(); + + return this.addOwnerData(projects, owners); + } +}