diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index cd5550dd74..8b3894ede8 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -327,7 +327,9 @@ export class CodeRepository extends BaseRepository { project_role_id as id, name FROM project_role - WHERE record_end_date is null; + WHERE record_end_date is null + ORDER BY + CASE WHEN name = 'Coordinator' THEN 0 ELSE 1 END; `; const response = await this.connection.sql(sqlStatement, ICode); diff --git a/api/src/services/project-participation-service.test.ts b/api/src/services/project-participation-service.test.ts index 7601c46ac4..f76b9625c7 100644 --- a/api/src/services/project-participation-service.test.ts +++ b/api/src/services/project-participation-service.test.ts @@ -944,7 +944,7 @@ describe('ProjectParticipationService', () => { }); }); - describe('doProjectParticipantsHaveARole', () => { + describe('_doProjectParticipantsHaveARole', () => { it('should return true if one project user has a specified role', () => { const projectUsers: PostParticipantData[] = [ { @@ -962,7 +962,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -984,7 +984,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -1006,7 +1006,97 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + + expect(result).to.be.false; + }); + }); + + describe('_doProjectParticipantsHaveOneRole', () => { + it('should return true if one project user has one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return true if multiple project users have one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return false if a participant has multiple specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.false; + }); + + it('should return false if a participant has multiple specified roles in the same record', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER, PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); expect(result).to.be.false; }); @@ -1058,6 +1148,10 @@ describe('ProjectParticipationService', () => { project_participation_id: 12, project_role_names: [PROJECT_ROLE.COORDINATOR] // Existing user to be updated }, + { + system_user_id: 33, + project_role_names: [PROJECT_ROLE.COLLABORATOR] // Existing user to be unaffected + }, { system_user_id: 44, project_role_names: [PROJECT_ROLE.OBSERVER] // New user @@ -1086,6 +1180,25 @@ describe('ProjectParticipationService', () => { user_guid: '123-456-789-1', user_identifier: 'testuser1' }, + { + project_participation_id: 6, // Existing user to be unaffected + project_id: 1, + system_user_id: 33, + project_role_ids: [2], + project_role_names: [PROJECT_ROLE.COLLABORATOR], + project_role_permissions: ['Permission1'], + agency: null, + display_name: 'test user 1', + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + record_end_date: null, + role_ids: [2], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], + user_guid: '123-456-789-1', + user_identifier: 'testuser1' + }, { project_participation_id: 23, // Existing user to be removed project_id: 1, @@ -1121,6 +1234,8 @@ describe('ProjectParticipationService', () => { expect(getProjectParticipantsStub).to.have.been.calledOnceWith(projectId); expect(deleteProjectParticipationRecordStub).to.have.been.calledWith(1, 23); expect(updateProjectParticipationRoleStub).to.have.been.calledOnceWith(12, PROJECT_ROLE.COORDINATOR); + expect(updateProjectParticipationRoleStub).to.not.have.been.calledWith(6, PROJECT_ROLE.COLLABORATOR); + expect(postProjectParticipantStub).to.not.have.been.calledWith(projectId, 6, PROJECT_ROLE.COLLABORATOR); expect(postProjectParticipantStub).to.have.been.calledOnceWith(projectId, 44, PROJECT_ROLE.OBSERVER); }); }); diff --git a/api/src/services/project-participation-service.ts b/api/src/services/project-participation-service.ts index 4b008caa4b..faa4e29cac 100644 --- a/api/src/services/project-participation-service.ts +++ b/api/src/services/project-participation-service.ts @@ -314,50 +314,128 @@ export class ProjectParticipationService extends DBService { return true; } - doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { + /** + * Internal function for validating that all Project members have a role + * + * @param {PostParticipantData[]} participants + * @param {PROJECT_ROLE} roleToCheck + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { return participants.some((item) => item.project_role_names.some((role) => role === roleToCheck)); } - async upsertProjectParticipantData(projectId: number, participants: PostParticipantData[]): Promise { - if (!this.doProjectParticipantsHaveARole(participants, PROJECT_ROLE.COORDINATOR)) { + /** + * Internal function for validating that all project participants have one unique role. + * + * @param {PostParticipantData[]} participants + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveOneRole(participants: PostParticipantData[]): boolean { + // Map of system_user_id to set of unique role names + const participantUniqueRoles = new Map>(); + + for (const participant of participants) { + const system_user_id = participant.system_user_id; + const project_role_names = participant.project_role_names; + + // Get the set of unique role names, or initialize a new set if the user is not in the map + const uniqueRoleNamesForParticipant = participantUniqueRoles.get(system_user_id) ?? new Set(); + + for (const role of project_role_names) { + // Add the role names to the set, converting to lowercase to ensure case-insensitive comparison + uniqueRoleNamesForParticipant.add(role.toLowerCase()); + } + + // Update the map with the new set of unique role names + participantUniqueRoles.set(system_user_id, uniqueRoleNamesForParticipant); + } + + // Returns true if all participants have one unique role + return Array.from(participantUniqueRoles.values()).every((roleNames) => roleNames.size === 1); + } + + /** + * Updates existing participants, deletes those participants not in the incoming list, and inserts new participants. + * + * @param {number} projectId + * @param {PostParticipantData[]} incomingParticipants + * @return {*} {Promise} + * @throws ApiGeneralError If no participant has a coordinator role or if any participant has multiple roles. + * @memberof ProjectParticipationService + */ + async upsertProjectParticipantData(projectId: number, incomingParticipants: PostParticipantData[]): Promise { + // Confirm that at least one participant has a coordinator role + if (!this._doProjectParticipantsHaveARole(incomingParticipants, PROJECT_ROLE.COORDINATOR)) { throw new ApiGeneralError( `Projects require that at least one participant has a ${PROJECT_ROLE.COORDINATOR} role.` ); } - // all actions to take - const promises: Promise[] = []; + // Check for multiple roles for any participant + if (!this._doProjectParticipantsHaveOneRole(incomingParticipants)) { + throw new ApiGeneralError( + 'Users can only have one role per Project but multiple roles were specified for at least one user.' + ); + } - // get the existing participants for a project + // Fetch existing participants for the project const existingParticipants = await this.projectParticipationRepository.getProjectParticipants(projectId); - // Compare incoming with existing to find any outliers to delete + // Prepare promises for all database operations + const promises: Promise[] = []; + + // Identify participants to delete const participantsToDelete = existingParticipants.filter( - (item) => !participants.find((incoming) => incoming.system_user_id === item.system_user_id) + (existingParticipant) => + !incomingParticipants.some( + (incomingParticipant) => incomingParticipant.system_user_id === existingParticipant.system_user_id + ) ); - // delete - participantsToDelete.forEach((item) => { + // Delete participants not present in the incoming payload + participantsToDelete.forEach((participantToDelete) => { promises.push( - this.projectParticipationRepository.deleteProjectParticipationRecord(projectId, item.project_participation_id) + this.projectParticipationRepository.deleteProjectParticipationRecord( + projectId, + participantToDelete.project_participation_id + ) ); }); - participants.forEach((item) => { - if (item.project_participation_id) { + // Upsert participants based on conditions + incomingParticipants.forEach((incomingParticipant) => { + const existingParticipant = existingParticipants.find( + (existingParticipant) => existingParticipant.system_user_id === incomingParticipant.system_user_id + ); + + if (existingParticipant) { + // Update existing participant's role + if ( + !existingParticipant.project_role_names.some((existingRole) => + incomingParticipant.project_role_names.includes(existingRole as PROJECT_ROLE) + ) + ) { + promises.push( + this.projectParticipationRepository.updateProjectParticipationRole( + incomingParticipant.project_participation_id ?? existingParticipant.project_participation_id, + incomingParticipant.project_role_names[0] + ) + ); + } + } else if (!existingParticipant) { + // Insert new participant if the user does not already exist in the project, otherwise triggers database constraint error promises.push( - this.projectParticipationRepository.updateProjectParticipationRole( - item.project_participation_id, - item.project_role_names[0] + this.projectParticipationRepository.postProjectParticipant( + projectId, + incomingParticipant.system_user_id, + incomingParticipant.project_role_names[0] ) ); - } else { - this.projectParticipationRepository.postProjectParticipant( - projectId, - item.system_user_id, - item.project_role_names[0] - ); } + // If the participant already exists with the desired role, do nothing }); await Promise.all(promises); diff --git a/app/src/components/user/UserRoleSelector.tsx b/app/src/components/user/UserRoleSelector.tsx index 122533cf93..fbfcf2adf1 100644 --- a/app/src/components/user/UserRoleSelector.tsx +++ b/app/src/components/user/UserRoleSelector.tsx @@ -1,11 +1,13 @@ import { mdiClose } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import { grey } from '@mui/material/colors'; +import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; import Select from '@mui/material/Select'; +import Typography from '@mui/material/Typography'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ICode } from 'interfaces/useCodesApi.interface'; import { IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import { IGetSurveyParticipant } from 'interfaces/useSurveyApi.interface'; @@ -64,11 +66,27 @@ const UserRoleSelector: React.FC = (props) => { if (!selected) { return props.label; } - return selected; + return ( + + {selected} + {PROJECT_ROLE_ICONS[selected] && ( + <> +   + + + )} + + ); }}> {roles.map((item) => ( {item.name} + {PROJECT_ROLE_ICONS[item.name] && ( + <> +   + + + )} ))} diff --git a/app/src/constants/roles.ts b/app/src/constants/roles.ts index dd329207e9..ea1110d987 100644 --- a/app/src/constants/roles.ts +++ b/app/src/constants/roles.ts @@ -1,3 +1,5 @@ +import { mdiAccountEdit, mdiCrown } from '@mdi/js'; + /** * System level roles. * @@ -33,3 +35,14 @@ export enum PROJECT_PERMISSION { COLLABORATOR = 'Collaborator', OBSERVER = 'Observer' } + +/** + * Project role icons + * + * @export + */ +export const PROJECT_ROLE_ICONS: Record = { + Coordinator: mdiCrown, + Collaborator: mdiAccountEdit, + Observer: undefined +}; diff --git a/app/src/features/projects/components/ProjectUserForm.tsx b/app/src/features/projects/components/ProjectUserForm.tsx index a98c716593..6baa68ebb4 100644 --- a/app/src/features/projects/components/ProjectUserForm.tsx +++ b/app/src/features/projects/components/ProjectUserForm.tsx @@ -244,7 +244,12 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { {values.participants.map((user: ISystemUser | IGetProjectParticipant, index: number) => { const error = rowItemError(index); return ( - + { Project Details - + Project Objectives - + @@ -80,7 +80,7 @@ const ProjectDetails = () => { Team Members - + @@ -89,7 +89,7 @@ const ProjectDetails = () => { IUCN Classification - + */} diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index b90c081535..08b47ebba7 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -1,9 +1,10 @@ -import { mdiAccountEdit, mdiCrown } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import assert from 'assert'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; import { useContext, useMemo } from 'react'; import { getRandomHexColor } from 'utils/Utils'; @@ -55,30 +56,34 @@ const TeamMembers = () => { return ( {projectTeamMembers.map((member) => { - const isCoordinator = member.roles.includes('Coordinator'); - const isCollaborator = member.roles.includes('Collaborator'); return ( + {/* Avatar Box */} + sx={{ + height: '35px', + width: '35px', + minWidth: '35px', + borderRadius: '50%', + bgcolor: member.avatarColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mr: 1 + }}> {member.initials} - + + {/* Member Display Name and Roles */} + {member.display_name} - {(isCoordinator || isCollaborator) && ( - - )} + + {/* Roles with Icons */} + {member.roles.map((role) => ( + + + + ))} ); diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index a0600e8a6c..22c3ee7f2b 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -137,7 +137,6 @@ export default function useDataLoader} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH = biohub,biohub_dapi_v1; + + DROP VIEW IF EXISTS biohub_dapi_v1.project_participation; + + ---------------------------------------------------------------------------------------- + -- Add constraint to ensure a user can only have one role within a Project + ---------------------------------------------------------------------------------------- + + ALTER TABLE biohub.project_participation + ADD CONSTRAINT project_participation_uk2 UNIQUE (system_user_id, project_id); + + ---------------------------------------------------------------------------------------- + -- Update view + ---------------------------------------------------------------------------------------- + + CREATE OR REPLACE VIEW biohub_dapi_v1.project_participation AS SELECT * FROM biohub.project_participation; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}