diff --git a/apps/backend/db_patches/0213_ActivateExperimentSafetyReviewTemplate.sql b/apps/backend/db_patches/0213_ActivateExperimentSafetyReviewTemplate.sql new file mode 100644 index 0000000000..53dfc141e8 --- /dev/null +++ b/apps/backend/db_patches/0213_ActivateExperimentSafetyReviewTemplate.sql @@ -0,0 +1,37 @@ +DO +$$ +DECLARE + exp_safety_review_template_id_var int; +BEGIN + IF register_patch( + 'ActivateExperimentSafetyReviewTemplate.sql', + 'Yoganandan Pandiyan', + 'Mark an experiment safety review template as active when none is active.', + '2026-06-08' + ) THEN + + SELECT template_id + INTO exp_safety_review_template_id_var + FROM templates + WHERE group_id = 'EXPERIMENT_SAFETY_REVIEW_TEMPLATE' + AND NOT EXISTS ( + SELECT 1 + FROM active_templates + WHERE group_id = 'EXPERIMENT_SAFETY_REVIEW_TEMPLATE' + ) + ORDER BY template_id + LIMIT 1; + + IF exp_safety_review_template_id_var IS NOT NULL THEN + INSERT INTO active_templates(group_id, template_id) + VALUES ( + 'EXPERIMENT_SAFETY_REVIEW_TEMPLATE', + exp_safety_review_template_id_var + ) + ON CONFLICT (group_id) DO NOTHING; + END IF; + + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql b/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql index eeee49dd34..6b34a3645e 100644 --- a/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql +++ b/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql @@ -80,7 +80,7 @@ BEGIN , comment_for_management , comment_for_user , notified - , management_decision_submitted + , management_decision_submitted , submitted ) VALUES @@ -98,11 +98,11 @@ BEGIN , NULL , NULL , true - , true - , true + , true + , true ); - INSERT INTO instrument_has_proposals(instrument_id, proposal_pk) VALUES (1, 1); + INSERT INTO instrument_has_proposals(instrument_id, proposal_pk, management_time_allocation) VALUES (1, 1, 100); INSERT INTO technical_review(proposal_pk, comment, time_allocation, status, public_comment, reviewer_id, technical_review_assignee_id, instrument_id, questionary_id) VALUES (1, '', 2, 0, '', 0, 0, 1, technical_review_questionary_id_var); @@ -122,7 +122,7 @@ BEGIN , comment_for_management , comment_for_user , notified - , management_decision_submitted + , management_decision_submitted , submitted ) VALUES @@ -140,11 +140,11 @@ BEGIN , NULL , NULL , true - , true - , true + , true + , true ); - INSERT INTO instrument_has_proposals(instrument_id, proposal_pk) VALUES (2, 2); + INSERT INTO instrument_has_proposals(instrument_id, proposal_pk, management_time_allocation) VALUES (2, 2, 100); END; $DO$ diff --git a/apps/backend/db_patches/db_seeds/0004_Experiments.sql b/apps/backend/db_patches/db_seeds/0004_Experiments.sql index 985275549a..e77a31d8e3 100644 --- a/apps/backend/db_patches/db_seeds/0004_Experiments.sql +++ b/apps/backend/db_patches/db_seeds/0004_Experiments.sql @@ -7,6 +7,14 @@ DECLARE instrument_id_1_var int; instrument_id_2_var int; experiment_workflow_id_var int; + awaiting_esf_workflow_status_id_var int; + esf_is_review_workflow_status_id_var int; + esf_esr_review_workflow_status_id_var int; + esf_approved_workflow_status_id_var int; + esf_rejected_workflow_status_id_var int; + workflow_status_connection_id_var int; + email_action_id_var int; + rabbitmq_action_id_var int; BEGIN -- Get instrument ids SELECT instrument_id INTO instrument_id_1_var FROM instruments WHERE name='Instrument 1' limit 1; @@ -18,8 +26,277 @@ DECLARE UPDATE call SET experiment_workflow_id = experiment_workflow_id_var; - INSERT INTO workflow_has_statuses(workflow_id, status_id) - VALUES (experiment_workflow_id_var, (SELECT status_id FROM statuses WHERE status_id='AWAITING_ESF')); + SELECT workflow_status_action_id + INTO email_action_id_var + FROM workflow_status_actions + WHERE type = 'EMAIL' + LIMIT 1; + + SELECT workflow_status_action_id + INTO rabbitmq_action_id_var + FROM workflow_status_actions + WHERE type = 'RABBITMQ' + LIMIT 1; + + INSERT INTO workflow_has_statuses(workflow_id, status_id, pos_x, pos_y) + VALUES (experiment_workflow_id_var, 'AWAITING_ESF', -191, -117) + RETURNING workflow_status_id INTO awaiting_esf_workflow_status_id_var; + + INSERT INTO workflow_has_statuses(workflow_id, status_id, pos_x, pos_y) + VALUES (experiment_workflow_id_var, 'ESF_IS_REVIEW', -190, 5) + RETURNING workflow_status_id INTO esf_is_review_workflow_status_id_var; + + INSERT INTO workflow_has_statuses(workflow_id, status_id, pos_x, pos_y) + VALUES (experiment_workflow_id_var, 'ESF_ESR_REVIEW', -189, 189) + RETURNING workflow_status_id INTO esf_esr_review_workflow_status_id_var; + + INSERT INTO workflow_has_statuses(workflow_id, status_id, pos_x, pos_y) + VALUES (experiment_workflow_id_var, 'ESF_APPROVED', 303, 135) + RETURNING workflow_status_id INTO esf_approved_workflow_status_id_var; + + INSERT INTO workflow_has_statuses(workflow_id, status_id, pos_x, pos_y) + VALUES (experiment_workflow_id_var, 'ESF_REJECTED', -187, 361) + RETURNING workflow_status_id INTO esf_rejected_workflow_status_id_var; + + INSERT INTO workflow_status_connections( + workflow_id, + prev_workflow_status_id, + next_workflow_status_id, + source_handle, + target_handle + ) + VALUES ( + experiment_workflow_id_var, + awaiting_esf_workflow_status_id_var, + esf_is_review_workflow_status_id_var, + 'bottom-source', + 'top-target' + ) + RETURNING workflow_status_connection_id INTO workflow_status_connection_id_var; + + INSERT INTO workflow_status_connection_has_events( + workflow_status_connection_id, + status_changing_event + ) + VALUES (workflow_status_connection_id_var, 'EXPERIMENT_ESF_SUBMITTED'); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + email_action_id_var, + experiment_workflow_id_var, + '{"recipientsWithEmailTemplate":[{"recipient":{"name":"INSTRUMENT_SCIENTISTS","description":"Instrument scientists including the manager on the instrument related to the proposal"},"emailTemplate":{"id":"instrument-scientist-esf-review","name":"Instrument Scientist ESF Review"}}]}' + ); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + rabbitmq_action_id_var, + experiment_workflow_id_var, + '{"exchanges":["user_office_backend.fanout"]}' + ); + + INSERT INTO workflow_status_connections( + workflow_id, + prev_workflow_status_id, + next_workflow_status_id, + source_handle, + target_handle + ) + VALUES ( + experiment_workflow_id_var, + esf_is_review_workflow_status_id_var, + esf_approved_workflow_status_id_var, + 'right-source', + 'top-target' + ) + RETURNING workflow_status_connection_id INTO workflow_status_connection_id_var; + + INSERT INTO workflow_status_connection_has_events( + workflow_status_connection_id, + status_changing_event + ) + VALUES (workflow_status_connection_id_var, 'EXPERIMENT_ESF_APPROVED_BY_IS'); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + email_action_id_var, + experiment_workflow_id_var, + '{"recipientsWithEmailTemplate":[{"recipient":{"name":"PI","description":"Principal investigator on the proposal"},"emailTemplate":{"id":"esf-approved","name":"ESF Approved"}}]}' + ); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + rabbitmq_action_id_var, + experiment_workflow_id_var, + '{"exchanges":["user_office_backend.fanout"]}' + ); + + INSERT INTO workflow_status_connections( + workflow_id, + prev_workflow_status_id, + next_workflow_status_id, + source_handle, + target_handle + ) + VALUES ( + experiment_workflow_id_var, + esf_esr_review_workflow_status_id_var, + esf_rejected_workflow_status_id_var, + 'bottom-source', + 'top-target' + ) + RETURNING workflow_status_connection_id INTO workflow_status_connection_id_var; + + INSERT INTO workflow_status_connection_has_events( + workflow_status_connection_id, + status_changing_event + ) + VALUES (workflow_status_connection_id_var, 'EXPERIMENT_ESF_REJECTED_BY_ESR'); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + email_action_id_var, + experiment_workflow_id_var, + '{"recipientsWithEmailTemplate":[{"recipient":{"name":"PI","description":"Principal investigator on the proposal"},"emailTemplate":{"id":"esf-rejected","name":"ESF Rejected"}}]}' + ); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + rabbitmq_action_id_var, + experiment_workflow_id_var, + '{"exchanges":["user_office_backend.fanout"]}' + ); + + INSERT INTO workflow_status_connections( + workflow_id, + prev_workflow_status_id, + next_workflow_status_id, + source_handle, + target_handle + ) + VALUES ( + experiment_workflow_id_var, + esf_is_review_workflow_status_id_var, + esf_esr_review_workflow_status_id_var, + 'bottom-source', + 'top-target' + ) + RETURNING workflow_status_connection_id INTO workflow_status_connection_id_var; + + INSERT INTO workflow_status_connection_has_events( + workflow_status_connection_id, + status_changing_event + ) + VALUES (workflow_status_connection_id_var, 'EXPERIMENT_ESF_REJECTED_BY_IS'); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + email_action_id_var, + experiment_workflow_id_var, + '{"recipientsWithEmailTemplate":[{"recipient":{"name":"EXPERIMENT_SAFETY_REVIEWERS","description":"The Experiment Safety email address"},"emailTemplate":{"id":"esr-esf-review","name":"ESR ESF Review"}}]}' + ); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + rabbitmq_action_id_var, + experiment_workflow_id_var, + '{"exchanges":["user_office_backend.fanout"]}' + ); + + INSERT INTO workflow_status_connections( + workflow_id, + prev_workflow_status_id, + next_workflow_status_id, + source_handle, + target_handle + ) + VALUES ( + experiment_workflow_id_var, + esf_esr_review_workflow_status_id_var, + esf_is_review_workflow_status_id_var, + 'right-source', + 'right-target' + ) + RETURNING workflow_status_connection_id INTO workflow_status_connection_id_var; + + INSERT INTO workflow_status_connection_has_events( + workflow_status_connection_id, + status_changing_event + ) + VALUES (workflow_status_connection_id_var, 'EXPERIMENT_ESF_APPROVED_BY_ESR'); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + email_action_id_var, + experiment_workflow_id_var, + '{"recipientsWithEmailTemplate":[{"recipient":{"name":"INSTRUMENT_SCIENTISTS","description":"Instrument scientists including the manager on the instrument related to the proposal"},"combineEmails":null,"emailTemplate":{"id":"instrument-scientist-esf-review","name":"Instrument Scientist ESF Review"},"otherRecipientEmails":null}]}' + ); + + INSERT INTO workflow_status_connection_has_actions( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + VALUES ( + workflow_status_connection_id_var, + rabbitmq_action_id_var, + experiment_workflow_id_var, + '{"exchanges":["user_office_backend.fanout"]}' + ); INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, local_contact_id, instrument_id) @@ -47,6 +324,10 @@ DECLARE INTO exp_safety_review_template_id_var from templates where group_id = 'EXPERIMENT_SAFETY_REVIEW_TEMPLATE' limit 1; + + INSERT INTO active_templates(group_id, template_id) + VALUES ('EXPERIMENT_SAFETY_REVIEW_TEMPLATE', exp_safety_review_template_id_var) + ON CONFLICT (group_id) DO NOTHING; select topics.topic_id INTO exp_safety_review_template_topic_id_var diff --git a/apps/backend/src/auth/ExperimentSafetyAuthorization.ts b/apps/backend/src/auth/ExperimentSafetyAuthorization.ts index adb6727614..0ca7e1ad62 100644 --- a/apps/backend/src/auth/ExperimentSafetyAuthorization.ts +++ b/apps/backend/src/auth/ExperimentSafetyAuthorization.ts @@ -4,6 +4,8 @@ import { ProposalAuthorization } from './ProposalAuthorization'; import { UserAuthorization } from './UserAuthorization'; import { Tokens } from '../config/Tokens'; import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; +import { StatusDataSource } from '../datasources/StatusDataSource'; +import { ExperimentSafetyWorkflowStatusCodes } from '../models/Status'; import { UserWithRole } from '../models/User'; import { ExperimentSafety } from '../resolvers/types/ExperimentSafety'; @@ -14,7 +16,8 @@ export class ExperimentSafetyAuthorization { @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, @inject(Tokens.ExperimentDataSource) - private experimentDataSource: ExperimentDataSource + private experimentDataSource: ExperimentDataSource, + @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource ) {} private async resolveExperimentSafety( @@ -98,6 +101,11 @@ export class ExperimentSafetyAuthorization { if (!experimentSafety) { return false; } + const experimentSafetyStatus = + await this.statusDataSource.getStatusByWorkflowStatusId( + experimentSafety.workflowStatusId + ); + const experiment = await this.experimentDataSource.getExperiment( experimentSafety.experimentPk ); @@ -106,6 +114,8 @@ export class ExperimentSafetyAuthorization { } if ( experimentSafety.esiQuestionarySubmittedAt !== null && + experimentSafetyStatus?.id !== + ExperimentSafetyWorkflowStatusCodes.AWAITING_ESF && this.userAuth.isUserOfficer(agent) === false ) { return false; diff --git a/apps/backend/src/auth/StfcUserAuthorization.ts b/apps/backend/src/auth/StfcUserAuthorization.ts index 49ddb0eb92..89eb7c594d 100644 --- a/apps/backend/src/auth/StfcUserAuthorization.ts +++ b/apps/backend/src/auth/StfcUserAuthorization.ts @@ -4,6 +4,7 @@ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { injectable, container } from 'tsyringe'; +import { UserAuthorization } from './UserAuthorization'; import { BasicPersonDetailsDTO, LoginDTO, @@ -15,6 +16,7 @@ import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { StfcBasicPersonDetails, toStfcBasicPersonDetails, + StfcUserDataSource, } from '../datasources/stfc/StfcUserDataSource'; import { createUOWSClient } from '../datasources/stfc/UOWSClient'; import { Institution } from '../models/Institution'; @@ -24,8 +26,6 @@ import { Role, Roles } from '../models/Role'; import { AuthJwtPayload, User, UserWithRole } from '../models/User'; import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation'; import { Cache } from '../utils/Cache'; -import { StfcUserDataSource } from './../datasources/stfc/StfcUserDataSource'; -import { UserAuthorization } from './UserAuthorization'; const UOWSClient = createUOWSClient(); diff --git a/apps/backend/src/datasources/ExperimentDataSource.ts b/apps/backend/src/datasources/ExperimentDataSource.ts index 2467334165..5adfb4ab14 100644 --- a/apps/backend/src/datasources/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/ExperimentDataSource.ts @@ -98,4 +98,8 @@ export interface ExperimentDataSource { getExperimentSafetyEvents( experimentPk: number ): Promise; + changeExperimentSafetyWorkflowStatus( + workflowStatusId: number, + experimentSafetyPks: number[] + ): Promise; } diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index c0de40cde3..d0fc3fc7c7 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -23,6 +23,9 @@ export interface WorkflowDataSource { getWorkflowConnection( connectionId: WorkflowConnection['id'] ): Promise; + getWorkflowConnectionByNextStatusId( + nextWorkflowStatusId: number + ): Promise; getWorkflowConnections( workflowId: WorkflowConnection['workflowId'] ): Promise; diff --git a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts index 090ddd971e..98cc9ae155 100644 --- a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts @@ -416,4 +416,21 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { return experimentSafety; } + + async changeExperimentSafetyWorkflowStatus( + workflowStatusId: number, + experimentSafetyPks: number[] + ): Promise { + const updatedSafeties: ExperimentSafety[] = []; + + for (const pk of experimentSafetyPks) { + const updatedSafety = await this.updateExperimentSafetyStatus( + pk, + workflowStatusId + ); + updatedSafeties.push(updatedSafety); + } + + return updatedSafeties; + } } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 7c86693090..76456eb735 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -334,15 +334,17 @@ export class ProposalDataSourceMock implements ProposalDataSource { workflowStatusId: number, proposalPks: number[] ): Promise { - const proposals = allProposals.map((p) => { - return { - ...p, - workflowStatusId, - statusId: dummyWorkflowStatuses.find( - (ws) => ws.workflowStatusId === workflowStatusId - )?.statusId as string, - }; - }); + const proposals = allProposals + .filter((proposal) => proposalPks.includes(proposal.primaryKey)) + .map((p) => { + return { + ...p, + workflowStatusId, + statusId: dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + )?.statusId as string, + }; + }); return { proposals: proposals }; } diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index f29c0bd46f..8f451a6756 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -111,6 +111,12 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return dummyWorkflowConnections; } + async getWorkflowConnectionByNextStatusId( + nextWorkflowStatusId: number + ): Promise { + return dummyWorkflowConnections[0]; + } + async getWorkflowStatuses(workflowId: number): Promise { return dummyWorkflowStatuses; } diff --git a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts index 39a68e01fa..0832d4af67 100644 --- a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts @@ -734,4 +734,22 @@ export default class PostgresExperimentDataSource return result; } + + async changeExperimentSafetyWorkflowStatus( + workflowStatusId: number, + experimentSafetyPks: number[] + ): Promise { + const result = await database('experiment_safety') + .update({ + workflow_status_id: workflowStatusId, + }) + .whereIn('experiment_safety_pk', experimentSafetyPks) + .returning('*'); + + if (!result || result.length === 0) { + throw new Error('Could not update experiment safety status'); + } + + return result.map((item) => createExperimentSafetyObject(item)); + } } diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 03bf5264ec..c44752552d 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -3,7 +3,11 @@ import { injectable } from 'tsyringe'; import database from './database'; import { StatusRecord } from './records'; -import { Status } from '../../models/Status'; +import { + Status, + ProposalStatusDefaultShortCodes, + ExperimentSafetyWorkflowStatusCodes, +} from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; import { UpdateStatusInput } from '../../resolvers/mutations/settings/UpdateStatusMutation'; import { StatusDataSource } from '../StatusDataSource'; @@ -110,7 +114,9 @@ export default class PostgresStatusDataSource implements StatusDataSource { entityType: Status['entityType'] ): Promise { const statusId = - entityType === WorkflowType.PROPOSAL ? 'DRAFT' : 'AWAITING_ESF'; + entityType === WorkflowType.PROPOSAL + ? ProposalStatusDefaultShortCodes.DRAFT + : ExperimentSafetyWorkflowStatusCodes.AWAITING_ESF; const status: StatusRecord = await database .select() diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 8e7374ed36..8bba8d59ab 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -20,7 +20,6 @@ import { WorkflowStatusRecord, WorkflowStructure, } from './records'; -import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; @injectable() export default class PostgresWorkflowDataSource implements WorkflowDataSource { @@ -248,9 +247,26 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ? this.createWorkflowConnectionObject(workflowConnection) : null; } - async addStatusToWorkflow( - newWorkflowStatusInput: AddStatusToWorkflowInput - ): Promise { + + async getWorkflowConnectionByNextStatusId( + nextWorkflowStatusId: number + ): Promise { + const workflowConnections: WorkflowConnectionRecord[] = await database + .select('*') + .from('workflow_status_connections') + .where('next_workflow_status_id', nextWorkflowStatusId); + + return workflowConnections.length > 0 + ? this.createWorkflowConnectionObject(workflowConnections[0]) + : null; + } + + async addStatusToWorkflow(newWorkflowStatusInput: { + workflowId: number; + statusId: string; + posX: number; + posY: number; + }): Promise { const workflow = await this.getWorkflow(newWorkflowStatusInput.workflowId); if (!workflow) { @@ -481,7 +497,6 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { if (!workflow) { throw new GraphQLError(`Workflow not found with id: ${workflowId}`); } - const cacheEntry = this.workflowStructureCache.get(workflowId); if ( cacheEntry && @@ -524,7 +539,6 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { statusChangingEvents: string[]; } >(); - workflowConnections.forEach((wc) => { const existingConnection = normalizedWorkflowConnectionsMap.get( wc.workflowStatusConnectionId diff --git a/apps/backend/src/eventHandlers/customHandler.ts b/apps/backend/src/eventHandlers/customHandler.ts index db4298a80a..5bcc80620c 100644 --- a/apps/backend/src/eventHandlers/customHandler.ts +++ b/apps/backend/src/eventHandlers/customHandler.ts @@ -1,7 +1,8 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; -import { handleWorkflowEngineChange } from './proposalWorkflow'; +import proposalWorkflowEntity from './workflowEntities/proposal'; +import { startWorkflow } from './workflowHandler'; import { Tokens } from '../config/Tokens'; import { FapDataSource } from '../datasources/FapDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; @@ -380,7 +381,7 @@ export default function createCustomHandler() { const proposalPks = allProposalsOnCall.proposalViews.map( (proposal) => proposal.primaryKey ); - handleWorkflowEngineChange(event, proposalPks); + await startWorkflow(event, proposalPks, proposalWorkflowEntity); } } catch (error) { logger.logException( diff --git a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts deleted file mode 100644 index b4bd1c14de..0000000000 --- a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../config/Tokens'; -import { StatusDataSourceMock } from '../datasources/mockups/StatusDataSource'; -import * as eventBusModule from '../events'; -import createExperimentSafetyWorkflowHandler, { - handleWorkflowEngineChange, -} from './experimentSafetyWorkflow'; -import * as workflowEngineModule from './experimentSafetyWorkflow'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { - ExperimentWorkflowEngine, - WorkflowEngineExperimentType, -} from '../workflowEngine/experiment'; - -const mockPublish = jest.fn(); - -let spyMarkEvent: jest.SpyInstance; -let spyResolveEventBus: jest.SpyInstance; -let mockStatusDataSource: StatusDataSourceMock; - -beforeAll(() => { - spyMarkEvent = jest - .spyOn(workflowEngineModule, 'handleWorkflowEngineChange') - .mockResolvedValue(); - - spyResolveEventBus = jest - .spyOn(eventBusModule, 'resolveApplicationEventBus') - .mockReturnValue({ publish: mockPublish } as any); -}); - -afterAll(() => { - spyMarkEvent.mockRestore(); - spyResolveEventBus.mockRestore(); -}); - -beforeEach(() => { - jest.clearAllMocks(); - - spyResolveEventBus.mockReturnValue({ publish: mockPublish } as any); - - mockStatusDataSource = container.resolve(Tokens.StatusDataSource); - jest.spyOn(mockStatusDataSource, 'getStatus'); -}); - -const createMockEvent = ( - overrides: Partial = {} -): ApplicationEvent => - ({ - type: Event.EXPERIMENT_ESF_SUBMITTED, - isRejection: false, - key: 'experimentsafety', - loggedInUserId: null, - experimentsafety: { experimentPk: 42 }, - ...overrides, - }) as unknown as ApplicationEvent; - -const createMockUpdatedExperiment = ( - overrides: Partial = {} -): WorkflowEngineExperimentType => - ({ - experimentSafetyPk: 1, - experimentPk: 42, - statusId: 10, - prevStatusId: 5, - workflowId: 1, - callShortCode: 'CALL-1', - ...overrides, - }) as WorkflowEngineExperimentType; - -describe('experimentSafetyWorkflowHandler', () => { - describe('Early exit conditions', () => { - test('should return early if event.isRejection is true', async () => { - const handler = createExperimentSafetyWorkflowHandler(); - const event = createMockEvent({ isRejection: true }); - - await handler(event); - - expect(spyMarkEvent).not.toHaveBeenCalled(); - }); - - test('should return early if event has no experimentPk anywhere', async () => { - const handler = createExperimentSafetyWorkflowHandler(); - const event = { - type: Event.PROPOSAL_CREATED, - isRejection: false, - key: 'proposal', - loggedInUserId: null, - proposal: { proposalPk: 1, title: 'Test Proposal' }, - } as unknown as ApplicationEvent; - - await handler(event); - - expect(spyMarkEvent).not.toHaveBeenCalled(); - }); - }); - - describe('Extracting experimentPk from events', () => { - test('should find experimentPk in a flat event object (e.g., experiment key)', async () => { - const handler = createExperimentSafetyWorkflowHandler(); - const event = createMockEvent({ - type: Event.EXPERIMENT_ESF_SUBMITTED, - experimentsafety: { experimentPk: 100 }, - } as unknown as Partial); - - await handler(event); - - expect(spyMarkEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: Event.EXPERIMENT_ESF_SUBMITTED, - experimentsafety: expect.objectContaining({ experimentPk: 100 }), - }), - 100 - ); - }); - - test('should find experimentPk in a nested object (e.g., experimentsafety)', async () => { - const handler = createExperimentSafetyWorkflowHandler(); - const event = { - type: Event.EXPERIMENT_ESF_SUBMITTED, - isRejection: false, - key: 'experimentsafety', - loggedInUserId: null, - experimentsafety: { experimentPk: 77, experimentSafetyPk: 1 }, - } as unknown as ApplicationEvent; - - await handler(event); - - expect(spyMarkEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: Event.EXPERIMENT_ESF_SUBMITTED, - experimentsafety: expect.objectContaining({ experimentPk: 77 }), - }), - 77 - ); - }); - }); - - describe('Non-experiment events are ignored', () => { - test('should not call workflow engine for events with no experiment info', async () => { - const handler = createExperimentSafetyWorkflowHandler(); - const event = { - type: Event.PROPOSAL_UPDATED, - isRejection: false, - key: 'proposal', - loggedInUserId: null, - proposal: { proposalPk: 5, title: 'Another' }, - } as unknown as ApplicationEvent; - - await handler(event); - - expect(spyMarkEvent).not.toHaveBeenCalled(); - }); - }); -}); - -describe('handleWorkflowEngineChange', () => { - test('should wrap a single pk into an array and call workflow engine', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - - await handleWorkflowEngineChange(event, 42); - - expect(spyMarkEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: Event.EXPERIMENT_ESF_SUBMITTED, - experimentsafety: expect.objectContaining({ experimentPk: 42 }), - }), - 42 - ); - }); - - describe('publishExperimentSafetyStatusChange', () => { - test('should publish status change events when workflow engine returns updated experiments', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - const updatedExperiment = createMockUpdatedExperiment({ - workflowStatusId: 10, - prevWorkflowStatusId: 5, - } as Partial); - - spyMarkEvent.mockRestore(); - - const runSpy = jest - .spyOn(ExperimentWorkflowEngine.prototype, 'run') - .mockResolvedValueOnce([updatedExperiment]); - - await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - - expect(mockPublish).toHaveBeenCalledTimes(1); - - runSpy.mockRestore(); - spyMarkEvent = jest - .spyOn(workflowEngineModule, 'handleWorkflowEngineChange') - .mockResolvedValue(); - }); - - test('should NOT publish status change when event type is EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER', async () => { - const event = createMockEvent({ - type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER, - experimentsafety: { experimentPk: 42 }, - } as unknown as Partial); - - const updatedExperiment = createMockUpdatedExperiment(); - spyMarkEvent.mockResolvedValue([updatedExperiment]); - - await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - - expect(mockPublish).not.toHaveBeenCalled(); - }); - - test('should not publish if workflow engine returns an empty array', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - spyMarkEvent.mockResolvedValue([]); - - await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - - expect(mockPublish).not.toHaveBeenCalled(); - }); - - test('should not publish if updated experiment has no statusId', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - const updatedExperiment = createMockUpdatedExperiment({ - workflowStatusId: 1, - }); - - spyMarkEvent.mockResolvedValue([updatedExperiment]); - - await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - - expect(mockPublish).not.toHaveBeenCalled(); - }); - - test('should not publish if workflow engine returns null', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - spyMarkEvent.mockResolvedValue(null); - - await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - - expect(mockPublish).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts deleted file mode 100644 index 74d35749dd..0000000000 --- a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../config/Tokens'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; -import { resolveApplicationEventBus } from '../events'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { searchObjectByKey } from '../utils/helperFunctions'; -import { - WorkflowEngineExperimentType, - ExperimentWorkflowEngine, -} from '../workflowEngine/experiment'; - -enum ExperimentInformationKeys { - ExperimentPk = 'experimentPk', -} - -export const publishExperimentSafetyStatusChange = async ( - updatedExperimentSafeties: WorkflowEngineExperimentType[] -) => { - const eventBus = resolveApplicationEventBus(); - - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource - ); - updatedExperimentSafeties.map(async (updatedExperimentSafety) => { - if (updatedExperimentSafety && updatedExperimentSafety.workflowStatusId) { - const experimentSafetyStatus = await workflowDataSource.getWorkflowStatus( - updatedExperimentSafety.workflowStatusId - ); - const previousExperimentStatus = - await workflowDataSource.getWorkflowStatus( - updatedExperimentSafety.prevWorkflowStatusId - ); - - return eventBus.publish({ - type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, - experimentsafety: updatedExperimentSafety, - isRejection: false, - key: 'experimentsafety', - loggedInUserId: null, - description: `From "${previousExperimentStatus?.statusId}" to "${experimentSafetyStatus?.statusId}"`, - }); - } - }); -}; - -export const experimentSafetyWorkflowHelpers = { - publishExperimentSafetyStatusChange, -}; - -export const handleWorkflowEngineChange = async ( - event: ApplicationEvent, - experimentPks: number[] | number -) => { - const isArray = Array.isArray(experimentPks); - - const workflowEngine = container.resolve(ExperimentWorkflowEngine); - const updatedExperimentSafeties = await workflowEngine.run({ - event: event.type, - experimentPks: isArray ? experimentPks : [experimentPks], - }); - - if ( - event.type !== Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER && - updatedExperimentSafeties?.length - ) { - // publish event EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW to the EventBus - await experimentSafetyWorkflowHelpers.publishExperimentSafetyStatusChange( - updatedExperimentSafeties - ); - } -}; - -const extractExperimentInformationFromEvent = (event: ApplicationEvent) => { - let experimentInformationObject, experimentInformationKey; - const experimentKeysToLookFor = Object.values(ExperimentInformationKeys); - // NOTE: Go through the event object and try to find some of the ExperimentInformationKeys. - for (const key of experimentKeysToLookFor) { - experimentInformationObject = searchObjectByKey(event, key); - - if ( - experimentInformationObject && - experimentInformationObject[key as keyof object] - ) { - experimentInformationKey = key; - - break; - } - } - - return { experimentInformationObject, experimentInformationKey }; -}; - -export default function createExperimentSafetyWorkflowHandler() { - // Handler to align input for workflowEngine - return async function experimentSafetyWorkflowHandler( - event: ApplicationEvent - ) { - // if the original method failed - // there is no point of moving forward in the workflow - if (event.isRejection) { - return; - } - const { experimentInformationObject, experimentInformationKey } = - extractExperimentInformationFromEvent(event); - - // If none of the ExperimentInformationKeys found then it is not a proposal event - if (!experimentInformationObject || !experimentInformationKey) { - return; - } - - const experimentInformationValue = - experimentInformationObject?.[experimentInformationKey as keyof object]; - if (experimentInformationValue) { - switch (experimentInformationKey) { - case ExperimentInformationKeys.ExperimentPk: - handleWorkflowEngineChange(event, experimentInformationValue); - break; - default: - break; - } - } - }; -} diff --git a/apps/backend/src/eventHandlers/index.ts b/apps/backend/src/eventHandlers/index.ts index 4397bac545..05cdb7278b 100644 --- a/apps/backend/src/eventHandlers/index.ts +++ b/apps/backend/src/eventHandlers/index.ts @@ -1,8 +1,7 @@ import { container } from 'tsyringe'; import createCustomHandler from './customHandler'; -import createExperimentSafetyWorkflowHandler from './experimentSafetyWorkflow'; -import createProposalWorkflowHandler from './proposalWorkflow'; +import workflowHandler from './workflowHandler'; import { Tokens } from '../config/Tokens'; import { ApplicationEvent } from '../events/applicationEvents'; @@ -23,8 +22,7 @@ export default function createEventHandlers() { emailHandler, loggingHandler, postToQueueHandler, - createProposalWorkflowHandler(), - createExperimentSafetyWorkflowHandler(), + workflowHandler(), createCustomHandler(), ]; } diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index fe862dcd64..7ae3025dd6 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -32,7 +32,8 @@ import { Proposal } from '../models/Proposal'; import { Sample } from '../models/Sample'; import { Visit } from '../models/Visit'; import { VisitRegistrationStatus } from '../models/VisitRegistration'; -import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; +import { WorkflowEngine } from '../workflowEngine'; +import proposalWorkflowEntity from './workflowEntities/proposal'; export const QUEUE_NAME = (process.env.RABBITMQ_CORE_QUEUE_NAME as Queue) || @@ -556,7 +557,7 @@ export async function createListenToRabbitMQHandler() { Tokens.VisitDataSource ); - const workflowEngine = container.resolve(ProposalWorkflowEngine); + const workflowEngine = container.resolve(WorkflowEngine); const handleProposalWorkflowEngineChange = async ( eventType: Event, @@ -566,10 +567,13 @@ export async function createListenToRabbitMQHandler() { throw new Error('Proposal id not found in the message'); } - await workflowEngine.run({ - event: eventType, - proposalPks: [proposalPk], - }); + await workflowEngine.run( + { + event: eventType, + entities: [proposalPk], + }, + proposalWorkflowEntity + ); }; const cancelVisit = async (visit: Visit) => { diff --git a/apps/backend/src/eventHandlers/proposalWorkflow.ts b/apps/backend/src/eventHandlers/proposalWorkflow.ts deleted file mode 100644 index 49223dd3af..0000000000 --- a/apps/backend/src/eventHandlers/proposalWorkflow.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../config/Tokens'; -import { CallDataSource } from '../datasources/CallDataSource'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; -import { resolveApplicationEventBus } from '../events'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { Proposal } from '../models/Proposal'; -import { searchObjectByKey } from '../utils/helperFunctions'; -import { - ProposalWorkflowEngine, - WorkflowEngineProposalType, -} from '../workflowEngine/proposal'; - -enum ProposalInformationKeys { - Proposal = 'proposal', - ProposalPk = 'proposalPk', - ProposalPKs = 'proposalPks', -} - -const publishProposalStatusChange = async ( - updatedProposals: WorkflowEngineProposalType[] -) => { - const eventBus = resolveApplicationEventBus(); - - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource - ); - updatedProposals.map(async (updatedProposal) => { - if (updatedProposal) { - const proposalStatus = await workflowDataSource.getWorkflowStatus( - updatedProposal.workflowStatusId - ); - const previousProposalStatus = await workflowDataSource.getWorkflowStatus( - updatedProposal.prevStatusId - ); - - return eventBus.publish({ - type: Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - proposal: updatedProposal, - isRejection: false, - key: 'proposal', - loggedInUserId: null, - description: `From "${previousProposalStatus?.statusId}" to "${proposalStatus?.statusId}"`, - }); - } - }); -}; - -const handleSubmittedProposalsAfterCallEnded = async ( - updatedProposals: WorkflowEngineProposalType[] -) => { - const callDataSource = container.resolve( - Tokens.CallDataSource - ); - - const workflowEngine = container.resolve(ProposalWorkflowEngine); - - const notEndedInternalCalls = await callDataSource - .getCalls({ - isEnded: true, - isEndedInternal: false, - }) - .then((calls) => calls.map((call) => call.id)); - - const proposalPks = updatedProposals - .filter( - (proposal) => proposal && notEndedInternalCalls.includes(proposal.callId) - ) - .map((proposal) => proposal && proposal.primaryKey) as number[]; - - if (proposalPks.length <= 0) { - return; - } - - const updatedSubmittedProposals = await workflowEngine.run({ - event: Event.CALL_ENDED, - proposalPks, - }); - if (updatedSubmittedProposals) { - await publishProposalStatusChange(updatedSubmittedProposals); - } -}; - -export const handleWorkflowEngineChange = async ( - event: ApplicationEvent, - proposalPks: number[] | number -) => { - const isArray = Array.isArray(proposalPks); - - const workflowEngine = container.resolve(ProposalWorkflowEngine); - const updatedProposals = await workflowEngine.run({ - event: event.type, - proposalPks: isArray ? proposalPks : [proposalPks], - }); - - if ( - event.type !== Event.PROPOSAL_STATUS_CHANGED_BY_USER && - updatedProposals?.length - ) { - // publish PROPOSAL_STATUS_CHANGED_BY_WORKFLOW event to the EventBus - await publishProposalStatusChange(updatedProposals); - if (event.type === Event.PROPOSAL_SUBMITTED) { - await handleSubmittedProposalsAfterCallEnded(updatedProposals); - } - } -}; - -const extractProposalInformationFromEvent = (event: ApplicationEvent) => { - let proposalInformationObject, proposalInformationKey; - const proposalKeysToLookFor = Object.values(ProposalInformationKeys); - - // NOTE: Go through the event object and try to find some of the ProposalInformationKeys. - for (const key of proposalKeysToLookFor) { - proposalInformationObject = searchObjectByKey(event, key); - - if ( - proposalInformationObject && - proposalInformationObject[key as keyof object] - ) { - proposalInformationKey = key; - - break; - } - } - - return { proposalInformationObject, proposalInformationKey }; -}; - -export default function createHandler() { - // Handler to align input for workflowEngine - return async function proposalWorkflowHandler(event: ApplicationEvent) { - // if the original method failed - // there is no point of moving forward in the workflow - if (event.isRejection) { - return; - } - - const { proposalInformationObject, proposalInformationKey } = - extractProposalInformationFromEvent(event); - - // If none of the ProposalInformationKeys found then it is not a proposal event - if (!proposalInformationObject || !proposalInformationKey) { - return; - } - - const proposalInformationValue = - proposalInformationObject?.[proposalInformationKey as keyof object]; - - if (proposalInformationValue) { - switch (proposalInformationKey) { - case ProposalInformationKeys.ProposalPKs: - case ProposalInformationKeys.ProposalPk: - handleWorkflowEngineChange(event, proposalInformationValue); - - break; - case ProposalInformationKeys.Proposal: - handleWorkflowEngineChange( - event, - (proposalInformationValue as Proposal).primaryKey - ); - - break; - default: - break; - } - } - }; -} diff --git a/apps/backend/src/eventHandlers/workflowEntities/experiment/emailActionHandler.ts b/apps/backend/src/eventHandlers/workflowEntities/experiment/emailActionHandler.ts new file mode 100644 index 0000000000..3b7a525ff6 --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/experiment/emailActionHandler.ts @@ -0,0 +1,292 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { + constructExperimentSafetyStatusChangeEvent, + EmailReadyType, + getExperimentSafetyReviewersAndFormatOutputForEmailSending, + getInstrumentScientistsAndFormatOutputForEmailSending, + getPIAndFormatOutputForEmailSending, +} from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { AdminDataSource } from '../../../datasources/AdminDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { ExperimentSafety } from '../../../models/Experiment'; +import { SettingsId } from '../../../models/Settings'; +import { ConnectionHasStatusAction } from '../../../models/StatusAction'; +import { + EmailActionConfig, + EmailStatusActionRecipients, + EmailStatusActionRecipientsWithTemplate, +} from '../../../resolvers/types/StatusActionConfig'; +import { MailService } from '../../MailService/MailService'; + +export const emailActionHandler = async ( + statusAction: ConnectionHasStatusAction, + experimentSafeties: ExperimentSafety[], + options?: { + statusActionsLogId?: number; + loggedInUserId?: number; + statusActionRecipients?: EmailStatusActionRecipients; + } +) => { + const { loggedInUserId, statusActionsLogId, statusActionRecipients } = { + loggedInUserId: null, + statusActionsLogId: null, + statusActionRecipients: null, + ...options, + }; + const config = statusAction.config as EmailActionConfig; + if (!config.recipientsWithEmailTemplate?.length) { + return; + } + if (statusActionRecipients) { + const recipientWithTemplate = config.recipientsWithEmailTemplate.find( + (value) => value.recipient.name === statusActionRecipients + ); + + if (!recipientWithTemplate) { + throw new Error( + `Provided status action recipient does not match any available email recipients + ${statusActionRecipients}` + ); + } + + await Promise.all( + experimentSafeties.map((experimentSafety) => + emailStatusActionRecipient( + recipientWithTemplate, + experimentSafety, + statusActionsLogId, + loggedInUserId + ) + ) + ); + + return; + } + + await Promise.all( + experimentSafeties.flatMap((experimentSafety) => + config.recipientsWithEmailTemplate.map((recipientWithTemplate) => + emailStatusActionRecipient( + recipientWithTemplate, + experimentSafety, + statusActionsLogId, + loggedInUserId + ) + ) + ) + ); +}; + +export const emailStatusActionRecipient = async ( + recipientWithTemplate: EmailStatusActionRecipientsWithTemplate, + experimentSafety: ExperimentSafety, + statusActionsLogId?: number | null, + loggedInUserId?: number | null +) => { + const emailTemplateId = recipientWithTemplate.emailTemplate.id; + const successfulMessage = statusActionsLogId + ? 'Email successfully sent on status action replay' + : 'Email successfully sent'; + const failMessage = statusActionsLogId + ? 'Email(s) could not be sent on status action replay' + : 'Email(s) could not be sent'; + switch (recipientWithTemplate.recipient.name) { + case EmailStatusActionRecipients.PI: { + const PI = await getPIAndFormatOutputForEmailSending( + experimentSafety, + recipientWithTemplate + ); + + if (!PI) { + logger.logError( + `Could not send email to the PI because no PI was found for experiment safety with pk ${experimentSafety.experimentSafetyPk}`, + { experimentSafetyPk: experimentSafety.experimentSafetyPk } + ); + break; + } + + await sendMail( + [PI], + async () => {}, + successfulMessage, + failMessage, + emailTemplateId, + loggedInUserId + ); + + break; + } + + case EmailStatusActionRecipients.INSTRUMENT_SCIENTISTS: { + const ISs = await getInstrumentScientistsAndFormatOutputForEmailSending( + experimentSafety, + recipientWithTemplate + ); + + if (!ISs?.length) { + logger.logError( + `Could not send email to the Instrument Scientists because no Instrument Scientists were found for experiment safety with pk ${experimentSafety.experimentSafetyPk}`, + { experimentSafetyPk: experimentSafety.experimentSafetyPk } + ); + break; + } + + await sendMail( + ISs, + async () => {}, + successfulMessage, + failMessage, + emailTemplateId, + loggedInUserId + ); + + break; + } + + case EmailStatusActionRecipients.EXPERIMENT_SAFETY_REVIEWERS: { + const adminDataSource = container.resolve( + Tokens.AdminDataSource + ); + + const experimentSafetyEmail = ( + await adminDataSource.getSetting( + SettingsId.EXPERIMENT_SAFETY_REVIEW_EMAIL + ) + )?.settingsValue; + + if (!experimentSafetyEmail) { + logger.logError( + 'Could not send email(s) to the Experiment Safety team as the setting (EXPERIMENT_SAFETY_REVIEW_EMAIL) is not set.', + { experimentSafetyEmailsSkipped: experimentSafety } + ); + + break; + } + + const experimentSafetyRecipients = + await getExperimentSafetyReviewersAndFormatOutputForEmailSending( + experimentSafety, + recipientWithTemplate + ); + + if (!experimentSafetyRecipients?.length) { + logger.logError( + 'Could not send email to the Experiment Safety Reviewers because no recipients were found.', + { experimentSafetyPk: experimentSafety.experimentSafetyPk } + ); + break; + } + + await sendMail( + experimentSafetyRecipients, + async () => {}, + successfulMessage, + failMessage, + emailTemplateId, + loggedInUserId + ); + + break; + } + + default: + break; + } +}; + +const sendMail = async ( + recipientsWithData: EmailReadyType[], + statusActionLogger: ( + actionSuccessful: boolean, + message: string + ) => Promise, + successfulMessage: string, + failMessage: string, + emailTemplateId: string, + loggedInUserId?: number | null +) => { + const mailService = container.resolve(Tokens.MailService); + const loggingHandler = container.resolve< + (event: ApplicationEvent) => Promise + >(Tokens.LoggingHandler); + const emailEventHandler = container.resolve< + (event: ApplicationEvent) => Promise + >(Tokens.EmailEventHandler); + + if (!recipientsWithData.length) { + logger.logInfo('Could not send email(s) because there are no recipients.', { + recipientsWithData, + }); + + return; + } + + try { + const mailServiceResponse = await Promise.all( + recipientsWithData.map(async (recipientWithData) => { + try { + const res = await mailService.sendMail({ + content: { + template: emailTemplateId, + }, + substitution_data: { + experimentSafety: recipientWithData.experimentSafety, + pi: recipientWithData.pi, + firstName: recipientWithData.firstName, + lastName: recipientWithData.lastName, + preferredName: recipientWithData.preferredName, + }, + recipients: [{ address: recipientWithData.email }], + }); + logger.logInfo('Email sent:', { + result: res, + }); + + const evt = constructExperimentSafetyStatusChangeEvent( + recipientWithData.experimentSafety, + loggedInUserId || null, + `${successfulMessage} template: ${emailTemplateId} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + undefined + ); + emailEventHandler(evt); + loggingHandler(evt); + + return res; + } catch (err) { + logger.logError('Could not send email', { + error: err, + }); + + const evt = constructExperimentSafetyStatusChangeEvent( + recipientWithData.experimentSafety, + loggedInUserId || null, + `${failMessage} template: ${emailTemplateId} to: ${recipientWithData.email} recipient: ${recipientWithData.id}`, + undefined + ); + emailEventHandler(evt); + loggingHandler(evt); + throw err; + } + }) + ); + if ( + !!mailServiceResponse.length && + !mailServiceResponse.some( + (result) => result.results.total_rejected_recipients !== 0 + ) + ) { + await statusActionLogger(true, successfulMessage); + + return; + } + await statusActionLogger(false, failMessage); + } catch (err) { + logger.logInfo('Status action email(s) not sent:', { + err, + }); + await statusActionLogger(false, failMessage); + } +}; diff --git a/apps/backend/src/eventHandlers/workflowEntities/experiment/index.ts b/apps/backend/src/eventHandlers/workflowEntities/experiment/index.ts new file mode 100644 index 0000000000..1c996fda51 --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/experiment/index.ts @@ -0,0 +1,146 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { experimentSafetyStatusActionEngine } from './statusActionEngine'; +import { Tokens } from '../../../config/Tokens'; +import { CallDataSource } from '../../../datasources/CallDataSource'; +import { ExperimentDataSource } from '../../../datasources/ExperimentDataSource'; +import { ProposalDataSource } from '../../../datasources/ProposalDataSource'; +import { WorkflowDataSource } from '../../../datasources/WorkflowDataSource'; +import { resolveApplicationEventBus } from '../../../events'; +import { Event } from '../../../events/event.enum'; +import { ExperimentSafety } from '../../../models/Experiment'; +import { WorkflowEngineType } from '../../../workflowEngine'; +import { WorkFlowEntity } from '../../workflowHandler'; + +const experimentSafetyWorkflowEntity: WorkFlowEntity = { + name: 'ExperimentSafety', + exemptedEvents: [], + extractionEntityKeys: ['experimentSafetyPk'], + resolveWorkflowId: async (entityId: number) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = + await experimentDataSource.getExperimentSafety(entityId); + + if (!experimentSafety) { + logger.logError('Experiment safety not found', { entityId }); + + return; + } + + const experiment = await experimentDataSource.getExperiment( + experimentSafety.experimentPk + ); + + if (!experiment) { + logger.logError('Experiment not found for experiment safety', { + entityId, + experimentPk: experimentSafety.experimentPk, + }); + + return; + } + const proposal = await proposalDataSource.get(experiment.proposalPk); + + if (!proposal) { + logger.logError('Proposal not found', { + experimentSafetyPk: entityId, + proposalPk: experiment.proposalPk, + }); + + return; + } + + const workflowId = ( + await callDataSource.getExperimentWorkflowByCall(proposal.callId) + )?.id; + + if (!workflowId) { + logger.logError('Experiment workflow not found for call', { + experimentSafetyPk: entityId, + callId: proposal.callId, + }); + + return; + } + + return workflowId; + }, + resolveCurrentStatusId: async (entityId: number) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + const experimentSafety = + await experimentDataSource.getExperimentSafety(entityId); + + return experimentSafety?.workflowStatusId; + }, + updateWorkflowStatus: async (entityId: number, workflowStatusId: number) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + await experimentDataSource.updateExperimentSafetyStatus( + entityId, + workflowStatusId + ); + }, + onWorkflowStatusChange: async (entities: WorkflowEngineType[]) => { + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + const eventBus = resolveApplicationEventBus(); + + const experimentSafeties = await Promise.all( + entities.map(async (entity) => { + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.entityId + ); + + if (!experimentSafety) { + logger.logError('Experiment not found for workflow status change', { + entityId: entity.entityId, + }); + + return; + } + + const previousWorkflowStatus = + await workflowDataSource.getWorkflowStatus(entity.prevStatusId); + const nextWorkflowStatus = await workflowDataSource.getWorkflowStatus( + entity.nextStatusId + ); + + await eventBus.publish({ + type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, + experimentsafety: experimentSafety, + isRejection: false, + key: 'experimentsafety', + loggedInUserId: null, + description: `From "${previousWorkflowStatus?.statusId}" to "${nextWorkflowStatus?.statusId}"`, + }); + + return experimentSafety; + }) + ); + + const validExperimentSafeties = experimentSafeties.filter( + (item): item is ExperimentSafety => item != null + ); + await experimentSafetyStatusActionEngine(validExperimentSafeties); + }, +}; + +export default experimentSafetyWorkflowEntity; diff --git a/apps/backend/src/eventHandlers/workflowEntities/experiment/rabbitMQHandler.ts b/apps/backend/src/eventHandlers/workflowEntities/experiment/rabbitMQHandler.ts new file mode 100644 index 0000000000..c2139b2e4b --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/experiment/rabbitMQHandler.ts @@ -0,0 +1,44 @@ +import { container } from 'tsyringe'; + +import { constructExperimentSafetyStatusChangeEvent } from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { ExperimentSafety } from '../../../models/Experiment'; +import { ConnectionHasStatusAction } from '../../../models/StatusAction'; +import { RabbitMQActionConfig } from '../../../resolvers/types/StatusActionConfig'; + +export const rabbitMQActionHandler = async ( + statusAction: ConnectionHasStatusAction, + experimentSafeties: ExperimentSafety[] +) => { + const postToMessageQueue = await container.resolve< + Promise<(event: ApplicationEvent) => Promise> + >(Tokens.PostToMessageQueue); + + const loggingHandler = container.resolve< + (event: ApplicationEvent) => Promise + >(Tokens.LoggingHandler); + + const config = statusAction.config as RabbitMQActionConfig; + if (!config.exchanges?.length) { + return; + } + + const messageDescription = + 'Experiment event successfully sent to the message broker'; + + return await Promise.all( + config.exchanges.map(async (exchange) => { + for (const experimentSafety of experimentSafeties) { + const evt = constructExperimentSafetyStatusChangeEvent( + experimentSafety, + null, + messageDescription, + exchange + ); + postToMessageQueue(evt); + loggingHandler(evt); + } + }) + ); +}; diff --git a/apps/backend/src/eventHandlers/workflowEntities/experiment/statusActionEngine.ts b/apps/backend/src/eventHandlers/workflowEntities/experiment/statusActionEngine.ts new file mode 100644 index 0000000000..6a3b56f0a3 --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/experiment/statusActionEngine.ts @@ -0,0 +1,69 @@ +import { container } from 'tsyringe'; + +import { emailActionHandler } from './emailActionHandler'; +import { rabbitMQActionHandler } from './rabbitMQHandler'; +import { groupExperimentSafetiesByProperties } from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { StatusActionsDataSource } from '../../../datasources/StatusActionsDataSource'; +import { WorkflowDataSource } from '../../../datasources/WorkflowDataSource'; +import { ExperimentSafety } from '../../../models/Experiment'; +import { StatusActionType } from '../../../models/StatusAction'; + +export const experimentSafetyStatusActionEngine = async ( + experimentSafeties: ExperimentSafety[] +) => { + const statusActionsDataSource: StatusActionsDataSource = container.resolve( + Tokens.StatusActionsDataSource + ); + + const workflowDataSource: WorkflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + + const groupByProperties = ['workflowStatusId']; + const groupResult = groupExperimentSafetiesByProperties( + experimentSafeties, + groupByProperties + ); + Promise.all( + groupResult.map(async (groupedExperimentSafeties) => { + const [{ workflowStatusId }] = groupedExperimentSafeties; + const currentConnection = + await workflowDataSource.getWorkflowConnectionByNextStatusId( + workflowStatusId + ); + if (!currentConnection) { + return; + } + + const statusActions = + await statusActionsDataSource.getConnectionStatusActions( + currentConnection.id + ); + if (!statusActions?.length) { + return; + } + + Promise.all( + statusActions.map(async (statusAction) => { + if (!statusAction.actionId || !statusAction.type) { + return; + } + + switch (statusAction.type) { + case StatusActionType.EMAIL: + emailActionHandler(statusAction, groupedExperimentSafeties); + break; + + case StatusActionType.RABBITMQ: + rabbitMQActionHandler(statusAction, groupedExperimentSafeties); + break; + + default: + break; + } + }) + ); + }) + ); +}; diff --git a/apps/backend/src/eventHandlers/workflowEntities/experiment/utils.ts b/apps/backend/src/eventHandlers/workflowEntities/experiment/utils.ts new file mode 100644 index 0000000000..76633d442f --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/experiment/utils.ts @@ -0,0 +1,258 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../../config/Tokens'; +import { ExperimentDataSource } from '../../../datasources/ExperimentDataSource'; +import { InstrumentDataSource } from '../../../datasources/InstrumentDataSource'; +import { ProposalDataSource } from '../../../datasources/ProposalDataSource'; +import { UserDataSource } from '../../../datasources/UserDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; +import { ExperimentSafety } from '../../../models/Experiment'; +import { BasicUserDetails, UserRole } from '../../../models/User'; +import { + EmailStatusActionRecipients, + EmailStatusActionRecipientsWithTemplate, +} from '../../../resolvers/types/StatusActionConfig'; + +export const groupExperimentSafetiesByProperties = ( + experimentSafeties: ExperimentSafety[], + props: string[] +): ExperimentSafety[][] => { + const getExperimentSafetyGroups = (item: ExperimentSafety) => { + const groupItemsArray = []; + for (let i = 0; i < props.length; i++) { + groupItemsArray.push(item[props[i] as keyof ExperimentSafety]); + } + + return groupItemsArray; + }; + + const experimentSafetyGroups: Record = {}; + + for (let i = 0; i < experimentSafeties.length; i++) { + const item = experimentSafeties[i]; + const experimentSafetyGroup = JSON.stringify( + getExperimentSafetyGroups(item) + ); + experimentSafetyGroups[experimentSafetyGroup] = + experimentSafetyGroups[experimentSafetyGroup] || []; + experimentSafetyGroups[experimentSafetyGroup].push(item); + } + + return Object.keys(experimentSafetyGroups).map((group) => { + return experimentSafetyGroups[group]; + }); +}; + +export type EmailReadyType = { + id: EmailStatusActionRecipients; + experimentSafety: ExperimentSafety; + template: string; + email: string; + firstName?: string; + lastName?: string; + preferredName?: string; + pi?: BasicUserDetails | null; +}; + +export const getPIAndFormatOutputForEmailSending = async ( + experimentSafety: ExperimentSafety, + recipientWithTemplate: EmailStatusActionRecipientsWithTemplate +) => { + const usersDataSource: UserDataSource = container.resolve( + Tokens.UserDataSource + ); + const proposalDataSource: ProposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const experimentDataSource: ExperimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experiment = await experimentDataSource.getExperiment( + experimentSafety.experimentPk + ); + if (!experiment) { + logger.logError('Experiment not found for experiment safety', { + experimentSafety, + }); + + return; + } + const proposal = await proposalDataSource.get(experiment.proposalPk); + if (!proposal) { + logger.logError('Proposal not found for experiment safety', { + experimentSafety, + experiment, + }); + + return; + } + + const PI = await usersDataSource.getBasicUserInfo(proposal.proposerId); + if (!PI) { + logger.logError('PI not found for proposal', { + proposal, + }); + + return; + } + + return { + id: recipientWithTemplate.recipient.name, + experimentSafety: experimentSafety, + template: recipientWithTemplate.emailTemplate.id, + email: PI.email, + firstName: PI.firstname, + lastName: PI.lastname, + preferredName: PI.preferredname, + }; +}; + +export const getInstrumentScientistsAndFormatOutputForEmailSending = async ( + experimentSafety: ExperimentSafety, + recipientWithTemplate: EmailStatusActionRecipientsWithTemplate +) => { + const instrumentDataSource: InstrumentDataSource = container.resolve( + Tokens.InstrumentDataSource + ); + const usersDataSource: UserDataSource = container.resolve( + Tokens.UserDataSource + ); + + const proposalDataSource: ProposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const experimentDataSource: ExperimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experiment = await experimentDataSource.getExperiment( + experimentSafety.experimentPk + ); + + if (!experiment) { + logger.logError('Experiment not found for instrument scientists', { + experimentSafety, + }); + + return; + } + const proposal = await proposalDataSource.get(experiment.proposalPk); + if (!proposal) { + logger.logError('Proposal not found for instrument scientists', { + experimentSafety, + experiment, + }); + + return; + } + + const proposalInstruments = + await instrumentDataSource.getInstrumentsByProposalPk(proposal.primaryKey); + + if (!proposalInstruments?.length) { + logger.logError('No instruments found for proposal', { + experimentSafety, + proposalPk: proposal.primaryKey, + }); + + return; + } + + const instrumentsPeople = await Promise.all( + proposalInstruments.map(async (proposalInstrument) => { + const instrumentContact = await usersDataSource.getBasicUserInfo( + proposalInstrument.managerUserId + ); + + if (!instrumentContact) { + logger.logError('Instrument manager user not found', { + experimentSafety, + instrumentId: proposalInstrument.id, + managerUserId: proposalInstrument.managerUserId, + }); + + return; + } + + const instrumentScientists = + await instrumentDataSource.getInstrumentScientists( + proposalInstrument.id + ); + + return [instrumentContact, ...instrumentScientists]; + }) + ); + + const filteredInstrumentPeople = instrumentsPeople + .flat() + .filter( + (user, i, array): user is BasicUserDetails => + !!user && + array.findIndex((comparedUser) => comparedUser?.id === user?.id) === i + ); + + return filteredInstrumentPeople.map((IS) => ({ + id: recipientWithTemplate.recipient.name, + experimentSafety, + template: recipientWithTemplate.emailTemplate.id, + email: IS.email, + firstName: IS.firstname, + lastName: IS.lastname, + preferredName: IS.preferredname, + })); +}; + +export const getExperimentSafetyReviewersAndFormatOutputForEmailSending = + async ( + experimentSafety: ExperimentSafety, + recipientWithTemplate: EmailStatusActionRecipientsWithTemplate + ) => { + const usersDataSource: UserDataSource = container.resolve( + Tokens.UserDataSource + ); + + const experimentSafetyReviewers = await usersDataSource.getUsers({ + userRole: UserRole.EXPERIMENT_SAFETY_REVIEWER, + }); + + if (!experimentSafetyReviewers?.users?.length) { + logger.logError( + 'Could not send email to the Experiment Safety Reviewers because no users with the Experiment Safety Reviewer role were found.', + { experimentSafetyReviewers } + ); + + return; + } + + return experimentSafetyReviewers.users.map((ESR) => ({ + id: recipientWithTemplate.recipient.name, + experimentSafety, + template: recipientWithTemplate.emailTemplate.id, + email: ESR.email, + firstName: ESR.firstname, + lastName: ESR.lastname, + preferredName: ESR.preferredname, + })); + }; + +export const constructExperimentSafetyStatusChangeEvent = ( + experimentSafety: ExperimentSafety, + loggedInUserId: number | null, + messageDescription: string, + exchange?: string +) => { + const event = { + type: Event.EXPERIMENT_SAFETY_STATUS_ACTION_EXECUTED, + experimentSafety: experimentSafety, + key: 'experimentSafety', + loggedInUserId, + isRejection: false, + description: messageDescription, + exchange: exchange, + } as ApplicationEvent; + + return event; +}; diff --git a/apps/backend/src/statusActionEngine/emailActionHandler.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/emailActionHandler.ts similarity index 84% rename from apps/backend/src/statusActionEngine/emailActionHandler.ts rename to apps/backend/src/eventHandlers/workflowEntities/proposal/emailActionHandler.ts index ca346d0347..4c0eba0e58 100644 --- a/apps/backend/src/statusActionEngine/emailActionHandler.ts +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/emailActionHandler.ts @@ -12,25 +12,25 @@ import { getPIAndFormatOutputForEmailSending, getTechniqueScientistsAndFormatOutputForEmailSending, statusActionLogger, -} from './statusActionUtils'; -import { Tokens } from '../config/Tokens'; -import { AdminDataSource } from '../datasources/AdminDataSource'; -import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; -import { UserDataSource } from '../datasources/UserDataSource'; -import { MailService } from '../eventHandlers/MailService/MailService'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { SettingsId } from '../models/Settings'; -import { ConnectionHasStatusAction } from '../models/StatusAction'; +} from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { AdminDataSource } from '../../../datasources/AdminDataSource'; +import { InstrumentDataSource } from '../../../datasources/InstrumentDataSource'; +import { UserDataSource } from '../../../datasources/UserDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Proposal } from '../../../models/Proposal'; +import { SettingsId } from '../../../models/Settings'; +import { ConnectionHasStatusAction } from '../../../models/StatusAction'; import { EmailActionConfig, EmailStatusActionRecipients, EmailStatusActionRecipientsWithTemplate, -} from '../resolvers/types/StatusActionConfig'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; +} from '../../../resolvers/types/StatusActionConfig'; +import { MailService } from '../../MailService/MailService'; export const emailActionHandler = async ( statusAction: ConnectionHasStatusAction, - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], options?: { statusActionsLogId?: number; loggedInUserId?: number; @@ -85,7 +85,7 @@ export const emailActionHandler = async ( export const emailStatusActionRecipient = async ( recipientWithTemplate: EmailStatusActionRecipientsWithTemplate, statusAction: ConnectionHasStatusAction, - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], statusActionsLogId?: number | null, loggedInUserId?: number | null ) => { @@ -320,65 +320,6 @@ export const emailStatusActionRecipient = async ( break; } - case EmailStatusActionRecipients.EXPERIMENT_SAFETY_REVIEWERS: { - const adminDataSource = container.resolve( - Tokens.AdminDataSource - ); - - const experimentSafetyEmail = ( - await adminDataSource.getSetting( - SettingsId.EXPERIMENT_SAFETY_REVIEW_EMAIL - ) - )?.settingsValue; - - if (!experimentSafetyEmail) { - logger.logError( - 'Could not send email(s) to the Experiment Safety team as the setting (EXPERIMENT_SAFETY_REVIEW_EMAIL) is not set.', - { proposalEmailsSkipped: proposals } - ); - - break; - } - - let experimentSafetyRecipients: EmailReadyType[]; - - if (recipientWithTemplate.combineEmails) { - experimentSafetyRecipients = [ - { - id: recipientWithTemplate.recipient.name, - email: experimentSafetyEmail, - proposals: proposals, - template: recipientWithTemplate.emailTemplate.id, - }, - ]; - } else { - experimentSafetyRecipients = - await getOtherAndFormatOutputForEmailSending( - proposals, - recipientWithTemplate, - experimentSafetyEmail - ); - } - - await sendMail( - experimentSafetyRecipients, - statusActionLogger({ - connectionId: statusAction.connectionId, - actionId: statusAction.actionId, - statusActionsLogId, - emailStatusActionRecipient: - EmailStatusActionRecipients.EXPERIMENT_SAFETY_REVIEWERS, - proposalPks, - }), - successfulMessage, - failMessage, - emailTemplateId, - loggedInUserId - ); - - break; - } - case EmailStatusActionRecipients.OTHER: { if (!recipientWithTemplate.otherRecipientEmails?.length) { logger.logError( diff --git a/apps/backend/src/eventHandlers/workflowEntities/proposal/index.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/index.ts new file mode 100644 index 0000000000..0f6d4a6b71 --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/index.ts @@ -0,0 +1,107 @@ +import { logger } from '@user-office-software/duo-logger'; +import { container } from 'tsyringe'; + +import { proposalStatusActionEngine } from './statusActionEngine'; +import { Tokens } from '../../../config/Tokens'; +import { CallDataSource } from '../../../datasources/CallDataSource'; +import { ProposalDataSource } from '../../../datasources/ProposalDataSource'; +import { WorkflowDataSource } from '../../../datasources/WorkflowDataSource'; +import { resolveApplicationEventBus } from '../../../events'; +import { Event } from '../../../events/event.enum'; +import { WorkflowEngineType } from '../../../workflowEngine'; +import { WorkFlowEntity } from '../../workflowHandler'; + +const proposalWorkflowEntity: WorkFlowEntity = { + name: 'Proposal', + extractionEntityKeys: ['primaryKey', 'proposalPk', 'proposalPks'], + exemptedEvents: [Event.PROPOSAL_DELETED], + resolveWorkflowId: async (entityId: number) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + + const proposal = await proposalDataSource.get(entityId); + + if (!proposal) { + logger.logError('Proposal not found', { entityId }); + + return; + } + + const workflowId = ( + await callDataSource.getProposalWorkflowByCall(proposal.callId) + )?.id; + + return workflowId; + }, + resolveCurrentStatusId: async (entityId: number) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entityId); + + return proposal?.workflowStatusId; + }, + updateWorkflowStatus: async ( + entityId: number, + newWorkflowStatusId: number + ) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + await proposalDataSource.changeProposalsWorkflowStatus( + newWorkflowStatusId, + [entityId] + ); + }, + onWorkflowStatusChange: async (entities: WorkflowEngineType[]) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + const eventBus = resolveApplicationEventBus(); + + const allProposals = await Promise.all( + entities.map(async (entity) => { + const { entityId, prevStatusId, nextStatusId } = entity; + + const proposal = await proposalDataSource.get(entityId); + + if (!proposal) { + logger.logError('Proposal not found for workflow status change', { + entityId, + }); + + return null; + } + + const previousWorkflowStatus = + await workflowDataSource.getWorkflowStatus(prevStatusId); + const nextWorkflowStatus = + await workflowDataSource.getWorkflowStatus(nextStatusId); + + await eventBus.publish({ + type: Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, + proposal, + isRejection: false, + key: 'proposal', + loggedInUserId: null, + description: `From "${previousWorkflowStatus?.statusId}" to "${nextWorkflowStatus?.statusId}"`, + }); + + return proposal; + }) + ); + const validProposals = allProposals.filter( + (proposal): proposal is NonNullable => proposal != null + ); + await proposalStatusActionEngine(validProposals); + }, +}; + +export default proposalWorkflowEntity; diff --git a/apps/backend/src/statusActionEngine/proposalDownloadActionHandler.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/pdfDownloadActionHandler.ts similarity index 91% rename from apps/backend/src/statusActionEngine/proposalDownloadActionHandler.ts rename to apps/backend/src/eventHandlers/workflowEntities/proposal/pdfDownloadActionHandler.ts index b6f7dbb0f0..a198c9bada 100644 --- a/apps/backend/src/statusActionEngine/proposalDownloadActionHandler.ts +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/pdfDownloadActionHandler.ts @@ -7,20 +7,20 @@ import { container } from 'tsyringe'; import { constructProposalStatusChangeEvent, statusActionLogger, -} from './statusActionUtils'; -import { Tokens } from '../config/Tokens'; -import { FileDataSource } from '../datasources/FileDataSource'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { ProposalPDFData } from '../factory/pdf/proposal'; -import { FileMetadata } from '../models/Blob'; -import { ConnectionHasStatusAction } from '../models/StatusAction'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; +} from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { FileDataSource } from '../../../datasources/FileDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import type { ProposalPDFData } from '../../../factory/pdf/proposal'; +import { FileMetadata } from '../../../models/Blob'; +import { Proposal } from '../../../models/Proposal'; +import { ConnectionHasStatusAction } from '../../../models/StatusAction'; const FACTORY_ENDPOINT = process.env.USER_OFFICE_FACTORY_ENDPOINT; -export const proposalDownloadActionHandler = async ( +export const pdfDownloadActionHandler = async ( statusAction: ConnectionHasStatusAction, - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], options?: { statusActionsLogId?: number; loggedInUserId?: number; @@ -67,7 +67,7 @@ export const proposalDownloadActionHandler = async ( ); const { collectProposalPDFDataTokenAccess } = await import( - '../factory/pdf/proposal' + '../../../factory/pdf/proposal' ); let proposalPDFData: ProposalPDFData; diff --git a/apps/backend/src/statusActionEngine/rabbitMQHandler.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/rabbitMQHandler.ts similarity index 68% rename from apps/backend/src/statusActionEngine/rabbitMQHandler.ts rename to apps/backend/src/eventHandlers/workflowEntities/proposal/rabbitMQHandler.ts index 8445254ec0..47b232c82b 100644 --- a/apps/backend/src/statusActionEngine/rabbitMQHandler.ts +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/rabbitMQHandler.ts @@ -1,15 +1,15 @@ import { container } from 'tsyringe'; -import { constructProposalStatusChangeEvent } from './statusActionUtils'; -import { Tokens } from '../config/Tokens'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { ConnectionHasStatusAction } from '../models/StatusAction'; -import { RabbitMQActionConfig } from '../resolvers/types/StatusActionConfig'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; +import { constructProposalStatusChangeEvent } from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Proposal } from '../../../models/Proposal'; +import { ConnectionHasStatusAction } from '../../../models/StatusAction'; +import { RabbitMQActionConfig } from '../../../resolvers/types/StatusActionConfig'; export const rabbitMQActionHandler = async ( statusAction: ConnectionHasStatusAction, - proposals: WorkflowEngineProposalType[] + proposals: Proposal[] ) => { const postToMessageQueue = await container.resolve< Promise<(event: ApplicationEvent) => Promise> diff --git a/apps/backend/src/statusActionEngine/proposal.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/statusActionEngine.ts similarity index 68% rename from apps/backend/src/statusActionEngine/proposal.ts rename to apps/backend/src/eventHandlers/workflowEntities/proposal/statusActionEngine.ts index 616d8b18a6..d5259c5a7b 100644 --- a/apps/backend/src/statusActionEngine/proposal.ts +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/statusActionEngine.ts @@ -1,18 +1,16 @@ import { container } from 'tsyringe'; import { emailActionHandler } from './emailActionHandler'; -import { proposalDownloadActionHandler } from './proposalDownloadActionHandler'; +import { pdfDownloadActionHandler } from './pdfDownloadActionHandler'; import { rabbitMQActionHandler } from './rabbitMQHandler'; -import { groupProposalsByProperties } from './statusActionUtils'; -import { Tokens } from '../config/Tokens'; -import { StatusActionsDataSource } from '../datasources/StatusActionsDataSource'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; -import { StatusActionType } from '../models/StatusAction'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; +import { groupProposalsByProperties } from './utils'; +import { Tokens } from '../../../config/Tokens'; +import { StatusActionsDataSource } from '../../../datasources/StatusActionsDataSource'; +import { WorkflowDataSource } from '../../../datasources/WorkflowDataSource'; +import { Proposal } from '../../../models/Proposal'; +import { StatusActionType } from '../../../models/StatusAction'; -export const proposalStatusActionEngine = async ( - proposals: WorkflowEngineProposalType[] -) => { +export const proposalStatusActionEngine = async (proposals: Proposal[]) => { const statusActionsDataSource: StatusActionsDataSource = container.resolve( Tokens.StatusActionsDataSource ); @@ -25,25 +23,21 @@ export const proposalStatusActionEngine = async ( const groupByProperties = ['workflowId', 'statusId']; // NOTE: Here the result is something like: [[proposalsWithWorkflowStatusIdCombination1], [proposalsWithWorkflowStatusIdCombination2]...] const groupResult = groupProposalsByProperties(proposals, groupByProperties); - Promise.all( groupResult.map(async (groupedProposals) => { // NOTE: We get the needed ids from the first proposal in the group. - const [{ workflowStatusConnectionId }] = groupedProposals; - - const currentConnection = await workflowDataSource.getWorkflowConnection( - workflowStatusConnectionId - ); - + const [{ workflowStatusId }] = groupedProposals; + const currentConnection = + await workflowDataSource.getWorkflowConnectionByNextStatusId( + workflowStatusId + ); if (!currentConnection) { return; } - const statusActions = await statusActionsDataSource.getConnectionStatusActions( currentConnection.id ); - if (!statusActions?.length) { return; } @@ -64,7 +58,7 @@ export const proposalStatusActionEngine = async ( break; case StatusActionType.PROPOSALDOWNLOAD: - proposalDownloadActionHandler(statusAction, groupedProposals); + pdfDownloadActionHandler(statusAction, groupedProposals); break; default: diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/eventHandlers/workflowEntities/proposal/utils.ts similarity index 88% rename from apps/backend/src/statusActionEngine/statusActionUtils.ts rename to apps/backend/src/eventHandlers/workflowEntities/proposal/utils.ts index c6b8f146e2..0cdda2918a 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/eventHandlers/workflowEntities/proposal/utils.ts @@ -2,42 +2,42 @@ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql/error/GraphQLError'; import { container } from 'tsyringe'; -import { Tokens } from '../config/Tokens'; -import { CallDataSource } from '../datasources/CallDataSource'; -import { FapDataSource } from '../datasources/FapDataSource'; -import { GenericTemplateDataSource } from '../datasources/GenericTemplateDataSource'; -import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; -import StatusActionsLogsDataSource from '../datasources/postgres/StatusActionsLogsDataSource'; -import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; -import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; -import { TemplateDataSource } from '../datasources/TemplateDataSource'; -import { UserDataSource } from '../datasources/UserDataSource'; -import { ApplicationEvent } from '../events/applicationEvents'; -import { Event } from '../events/event.enum'; -import { InstrumentWithManagementTime } from '../models/Instrument'; -import { Answer } from '../models/Questionary'; -import { Technique } from '../models/Technique'; -import { DataType } from '../models/Template'; -import { BasicUserDetails, User } from '../models/User'; -import { StatusActionsLogsArgs } from '../resolvers/queries/StatusActionsLogsQuery'; +import { Tokens } from '../../../config/Tokens'; +import { CallDataSource } from '../../../datasources/CallDataSource'; +import { FapDataSource } from '../../../datasources/FapDataSource'; +import { GenericTemplateDataSource } from '../../../datasources/GenericTemplateDataSource'; +import { InstrumentDataSource } from '../../../datasources/InstrumentDataSource'; +import StatusActionsLogsDataSource from '../../../datasources/postgres/StatusActionsLogsDataSource'; +import { QuestionaryDataSource } from '../../../datasources/QuestionaryDataSource'; +import { TechniqueDataSource } from '../../../datasources/TechniqueDataSource'; +import { TemplateDataSource } from '../../../datasources/TemplateDataSource'; +import { UserDataSource } from '../../../datasources/UserDataSource'; +import { ApplicationEvent } from '../../../events/applicationEvents'; +import { Event } from '../../../events/event.enum'; +import { InstrumentWithManagementTime } from '../../../models/Instrument'; +import { Proposal } from '../../../models/Proposal'; +import { Answer } from '../../../models/Questionary'; +import { Technique } from '../../../models/Technique'; +import { DataType } from '../../../models/Template'; +import { BasicUserDetails, User } from '../../../models/User'; +import { StatusActionsLogsArgs } from '../../../resolvers/queries/StatusActionsLogsQuery'; import { EmailStatusActionRecipients, EmailStatusActionRecipientsWithTemplate, -} from '../resolvers/types/StatusActionConfig'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; +} from '../../../resolvers/types/StatusActionConfig'; interface GroupedObjectType { - [key: string]: WorkflowEngineProposalType[]; + [key: string]: Proposal[]; } export const groupProposalsByProperties = ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], props: string[] ) => { - const getProposalGroups = (item: WorkflowEngineProposalType) => { + const getProposalGroups = (item: Proposal) => { const groupItemsArray = []; for (let i = 0; i < props.length; i++) { - groupItemsArray.push(item[props[i] as keyof WorkflowEngineProposalType]); + groupItemsArray.push(item[props[i] as keyof Proposal]); } return groupItemsArray; @@ -59,7 +59,7 @@ export const groupProposalsByProperties = ( export type EmailReadyType = { id: EmailStatusActionRecipients; - proposals: WorkflowEngineProposalType[]; + proposals: Proposal[]; template: string; email: string; firstName?: string; @@ -141,7 +141,7 @@ async function stepAnswers( export const getEmailReadyArrayOfUsersAndProposals = async ( emailReadyUsersWithProposals: EmailReadyType[], recipientUsers: BasicUserDetails[] | User[], - proposal: WorkflowEngineProposalType, + proposal: Proposal, recipientsWithEmailTemplate: EmailStatusActionRecipientsWithTemplate ) => { const usersDataSource: UserDataSource = container.resolve( @@ -254,7 +254,7 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( }; export const getPIAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const usersDataSource: UserDataSource = container.resolve( @@ -279,7 +279,7 @@ export const getPIAndFormatOutputForEmailSending = async ( }; export const getCoProposersAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const usersDataSource: UserDataSource = container.resolve( @@ -303,13 +303,15 @@ export const getCoProposersAndFormatOutputForEmailSending = async ( }; export const getFapReviewersAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const fapDataSource: FapDataSource = container.resolve(Tokens.FapDataSource); const FRs: EmailReadyType[] = []; for (const proposal of proposals) { + if (!proposal) continue; + const allFapReviewers = await fapDataSource.getFapUsersByProposalPkAndCallId( proposal.primaryKey, @@ -328,7 +330,7 @@ export const getFapReviewersAndFormatOutputForEmailSending = async ( }; export const getFapChairSecretariesAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const fapDataSource: FapDataSource = container.resolve(Tokens.FapDataSource); @@ -361,7 +363,7 @@ export const getFapChairSecretariesAndFormatOutputForEmailSending = async ( }; export const getInstrumentScientistsAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const instrumentDataSource: InstrumentDataSource = container.resolve( @@ -420,7 +422,7 @@ export const getInstrumentScientistsAndFormatOutputForEmailSending = async ( }; export const getTechniqueScientistsAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate ) => { const techniqueDataSource: TechniqueDataSource = container.resolve( @@ -468,7 +470,7 @@ export const getTechniqueScientistsAndFormatOutputForEmailSending = async ( }; export const getOtherAndFormatOutputForEmailSending = async ( - proposals: WorkflowEngineProposalType[], + proposals: Proposal[], recipientWithTemplate: EmailStatusActionRecipientsWithTemplate, otherEmail: string ) => { @@ -500,7 +502,7 @@ export const getOtherAndFormatOutputForEmailSending = async ( }; export const constructProposalStatusChangeEvent = ( - proposal: WorkflowEngineProposalType, + proposal: Proposal, loggedInUserId: number | null, messageDescription: string, exchange?: string diff --git a/apps/backend/src/eventHandlers/workflowHandler.ts b/apps/backend/src/eventHandlers/workflowHandler.ts new file mode 100644 index 0000000000..c47baa34a1 --- /dev/null +++ b/apps/backend/src/eventHandlers/workflowHandler.ts @@ -0,0 +1,108 @@ +import { container } from 'tsyringe'; + +import { ApplicationEvent } from '../events/applicationEvents'; +import { Event } from '../events/event.enum'; +import { searchObjectByKey } from '../utils/helperFunctions'; +import { WorkflowEngine, WorkflowEngineType } from '../workflowEngine'; +import experimentSafetyWorkflowEntity from './workflowEntities/experiment'; +import proposalWorkflowEntity from './workflowEntities/proposal'; + +type EntityKey = string; + +export interface WorkFlowEntity { + name: string; + extractionEntityKeys: EntityKey[]; + exemptedEvents: Event[]; + resolveWorkflowId: (entityId: number) => Promise; + resolveCurrentStatusId: (entityId: number) => Promise; + updateWorkflowStatus: ( + entityId: number, + workflowStatusId: number + ) => Promise; + onWorkflowStatusChange: ( + updatedEntities: WorkflowEngineType[] + ) => Promise; +} + +const extractEntityFromEvent = ( + event: ApplicationEvent, + entityKeys: EntityKey[] +) => { + let entity, entityKey; + + for (const key of entityKeys) { + entity = searchObjectByKey(event, key); + if (entity && entity[key as keyof object]) { + entityKey = key; + + return { entity, entityKey }; + } + } + + return null; +}; + +export const startWorkflow = async ( + event: ApplicationEvent, + entityIdentifier: number | number[], + workflowEntity: WorkFlowEntity +) => { + const isArray = Array.isArray(entityIdentifier); + + const workflowEngine = container.resolve(WorkflowEngine); + const updatedEntities = await workflowEngine.run( + { + event: event.type, + entities: isArray ? entityIdentifier : [entityIdentifier], + }, + workflowEntity + ); + await workflowEntity.onWorkflowStatusChange(updatedEntities); + + return updatedEntities; +}; + +function isValidWorkflowEntityValue( + value: unknown +): value is number | number[] { + if (!value) return false; + if (Array.isArray(value)) { + if (value.length === 0) return false; + + return value.every((item) => typeof item === 'number'); + } + + return typeof value === 'number'; +} + +export default function workflowHandler() { + const entities = [proposalWorkflowEntity, experimentSafetyWorkflowEntity]; + + return async (event: ApplicationEvent) => { + if (event.isRejection) { + return; + } + for (const entity of entities) { + if (entity.exemptedEvents.includes(event.type)) { + continue; + } + const { extractionEntityKeys } = entity; + const extractedEntity = extractEntityFromEvent( + event, + extractionEntityKeys + ); + if (extractedEntity) { + const { entity: extractedEntityObject, entityKey: extractedEntityKey } = + extractedEntity; + + const extractedEntityValue = extractedEntityObject[extractedEntityKey]; + if ( + extractedEntityValue && + isValidWorkflowEntityValue(extractedEntityValue) + ) { + await startWorkflow(event, extractedEntityValue, entity); + } + } + } + }; +} diff --git a/apps/backend/src/events/applicationEvents.ts b/apps/backend/src/events/applicationEvents.ts index 78af21d208..d6faea92c3 100644 --- a/apps/backend/src/events/applicationEvents.ts +++ b/apps/backend/src/events/applicationEvents.ts @@ -194,6 +194,11 @@ interface ProposalStatusActionExecutedEvent extends GeneralEvent { proposal: Proposal; } +interface ExperimentSafetyStatusActionExecutedEvent extends GeneralEvent { + type: Event.EXPERIMENT_SAFETY_STATUS_ACTION_EXECUTED; + experimentSafety: ExperimentSafety; +} + interface ProposalStatusChangedByUserEvent extends GeneralEvent { type: Event.PROPOSAL_STATUS_CHANGED_BY_USER; proposals: Proposals; @@ -541,6 +546,7 @@ export type ApplicationEvent = | InstrumentDeletedEvent | FapReviewerNotified | ProposalStatusActionExecutedEvent + | ExperimentSafetyStatusActionExecutedEvent | TechniqueCreatedEvent | TechniqueUpdatedEvent | TechniqueDeletedEvent diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 04013d83e4..49d89c76fa 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,3 +1,10 @@ +import { + isExperimentESFApprovedByESR, + isExperimentESFApprovedByIS, + isExperimentESFRejectedByESR, + isExperimentESFRejectedByIS, + isExperimentESFSubmitted, +} from '../workflowEngine/guards'; import { isCallEndedGuard } from '../workflowEngine/guards/isCallEndedGuard'; import { isCallEndedInternalGuard } from '../workflowEngine/guards/isCallEndedInternalGuard'; import { isCallFapReviewEndedGuard } from '../workflowEngine/guards/isCallFapReviewEndedGuard'; @@ -87,6 +94,7 @@ export enum Event { PROPOSAL_STATUS_ACTION_EXECUTED = 'PROPOSAL_STATUS_ACTION_EXECUTED', PROPOSAL_STATUS_CHANGED_BY_WORKFLOW = 'PROPOSAL_STATUS_CHANGED_BY_WORKFLOW', PROPOSAL_STATUS_CHANGED_BY_USER = 'PROPOSAL_STATUS_CHANGED_BY_USER', + EXPERIMENT_SAFETY_STATUS_ACTION_EXECUTED = 'EXPERIMENT_SAFETY_STATUS_ACTION_EXECUTED', TOPIC_ANSWERED = 'TOPIC_ANSWERED', PROPOSAL_BOOKING_TIME_SLOT_ADDED = 'PROPOSAL_BOOKING_TIME_SLOT_ADDED', PROPOSAL_BOOKING_TIME_SLOTS_REMOVED = 'PROPOSAL_BOOKING_TIME_SLOTS_REMOVED', @@ -445,6 +453,13 @@ export const EventMetadataByEvent = new Map([ 'Event occurs when the proposal status action is being executed in the status engine', }, ], + [ + Event.EXPERIMENT_SAFETY_STATUS_ACTION_EXECUTED, + { + label: + 'Event occurs when the experiment safety status action is being executed in the status engine', + }, + ], [ Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, { @@ -630,23 +645,38 @@ export const EventMetadataByEvent = new Map([ ], [ Event.EXPERIMENT_ESF_SUBMITTED, - { label: 'Event occurs when experiment ESF is submitted' }, + { + label: 'Event occurs when experiment ESF is submitted', + guard: isExperimentESFSubmitted, + }, ], [ Event.EXPERIMENT_ESF_APPROVED_BY_IS, - { label: 'Event occurs when experiment ESF is approved by IS' }, + { + label: 'Event occurs when experiment ESF is approved by IS', + guard: isExperimentESFApprovedByIS, + }, ], [ Event.EXPERIMENT_ESF_REJECTED_BY_IS, - { label: 'Event occurs when experiment ESF is rejected by IS' }, + { + label: 'Event occurs when experiment ESF is rejected by IS', + guard: isExperimentESFRejectedByIS, + }, ], [ Event.EXPERIMENT_ESF_APPROVED_BY_ESR, - { label: 'Event occurs when experiment ESF is approved by ESR' }, + { + label: 'Event occurs when experiment ESF is approved by ESR', + guard: isExperimentESFApprovedByESR, + }, ], [ Event.EXPERIMENT_ESF_REJECTED_BY_ESR, - { label: 'Event occurs when experiment ESF is rejected by ESR' }, + { + label: 'Event occurs when experiment ESF is rejected by ESR', + guard: isExperimentESFRejectedByESR, + }, ], [ Event.DATA_ACCESS_USERS_UPDATED, diff --git a/apps/backend/src/models/Status.ts b/apps/backend/src/models/Status.ts index 419695fe05..3b01246bae 100644 --- a/apps/backend/src/models/Status.ts +++ b/apps/backend/src/models/Status.ts @@ -16,6 +16,15 @@ export enum ProposalStatusDefaultShortCodes { FAP_AND_FEASIBILITY_REVIEW = 'FAP_AND_FEASIBILITY_REVIEW', } +// Default experiment safety workflow status short codes +export enum ExperimentSafetyWorkflowStatusCodes { + AWAITING_ESF = 'AWAITING_ESF', + ESF_IS_REVIEW = 'ESF_IS_REVIEW', + ESF_ESR_REVIEW = 'ESF_ESR_REVIEW', + ESF_REJECTED = 'ESF_REJECTED', + ESF_APPROVED = 'ESF_APPROVED', +} + export class Status { constructor( public id: string, diff --git a/apps/backend/src/mutations/ExperimentMutation.ts b/apps/backend/src/mutations/ExperimentMutation.ts index 72d0e491e7..9810962b34 100644 --- a/apps/backend/src/mutations/ExperimentMutation.ts +++ b/apps/backend/src/mutations/ExperimentMutation.ts @@ -1,3 +1,4 @@ +import { logger } from '@user-office-software/duo-logger'; import { container, inject, injectable } from 'tsyringe'; import { ExperimentSafetyAuthorization } from '../auth/ExperimentSafetyAuthorization'; @@ -11,6 +12,7 @@ import { SampleDataSource } from '../datasources/SampleDataSource'; import { TemplateDataSource } from '../datasources/TemplateDataSource'; import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Authorized, EventBus } from '../decorators'; +import { experimentSafetyStatusActionEngine } from '../eventHandlers/workflowEntities/experiment/statusActionEngine'; import { Event } from '../events/event.enum'; import { ExperimentHasSample, @@ -31,6 +33,7 @@ import { ExperimentSafety } from '../resolvers/types/ExperimentSafety'; import { SampleDeclarationConfig } from '../resolvers/types/FieldConfig'; import { CloneUtils } from '../utils/CloneUtils'; import { ProposalAuthorization } from './../auth/ProposalAuthorization'; +import { ChangeExperimentsSafetyStatusInput } from '../resolvers/mutations/ChangeExperimentsSafetyStatusMutation'; @injectable() export default class ExperimentMutations { @@ -299,7 +302,6 @@ export default class ExperimentMutations { if (!experiment) { return rejection('No experiment found'); } - const proposal = await this.proposalDataSource.get(experiment.proposalPk); if (!proposal) { return rejection('No proposal found', { args }); @@ -320,7 +322,6 @@ export default class ExperimentMutations { if (!experimentSafety) { return rejection('No experiment safety found', { args }); } - const hasAccessRights = this.userAuth.isApiToken(agent) || (await this.experimentSafetyAuth.hasWriteRights( @@ -350,19 +351,16 @@ export default class ExperimentMutations { if (!questionTemplateRel) { return rejection('No question found', { args }); } - const templateId = (questionTemplateRel.config as SampleDeclarationConfig) .esiTemplateId; if (!templateId) { return rejection('Esi template is not defined', { args }); } - // Creating a new Questionary for the Sample using the ESI Template const newQuestionary = await this.questionaryDataSource.create( agent!.id, templateId! ); - // Copying the answers from the Sample Questionary to the new Sample ESI Questionary await this.questionaryDataSource.copyAnswers( sample.questionaryId, @@ -509,4 +507,116 @@ export default class ExperimentMutations { }, }); } + + private async processExperimentsSafetyStatusChange( + agent: UserWithRole | null, + args: ChangeExperimentsSafetyStatusInput + ): Promise { + const { workflowStatusId: statusId, experimentSafetyPks } = args; + + const result = await this.dataSource.changeExperimentSafetyWorkflowStatus( + statusId, + experimentSafetyPks + ); + + if (result.length === experimentSafetyPks.length) { + const fullExperimentsSafety = await Promise.all( + experimentSafetyPks.map(async (experimentSafetyPk) => { + const fullExperimentSafety = result.find( + (updateExperimentSafety) => + updateExperimentSafety.experimentSafetyPk === experimentSafetyPk + ); + + if (!fullExperimentSafety) { + logger.logError( + 'Full experiment safety not found after status change', + { experimentSafetyPk, statusId } + ); + + return null; + } + + const experiment = await this.dataSource.getExperiment( + fullExperimentSafety.experimentPk + ); + + if (!experiment) { + logger.logError('Experiment not found for experiment safety', { + experimentSafetyPk, + experimentPk: fullExperimentSafety.experimentPk, + }); + + return null; + } + + const proposal = await this.proposalDataSource.get( + experiment.proposalPk + ); + + if (!proposal) { + logger.logError('Proposal not found for experiment', { + experimentSafetyPk, + experimentPk: experiment.experimentPk, + proposalPk: experiment.proposalPk, + }); + + return null; + } + + const experimentSafetyWorkflow = + await this.callDataSource.getExperimentWorkflowByCall( + proposal.callId + ); + + if (!experimentSafetyWorkflow) { + return rejection( + `No experiment workflow found for the specific proposal call with id: ${proposal.callId}`, + { + agent, + args, + } + ); + } + + return fullExperimentSafety; + }) + ); + + const statusEngineReadyExperimentsSafety = fullExperimentsSafety.filter( + (item): item is ExperimentSafety => !!item + ); + + if ( + statusEngineReadyExperimentsSafety.length !== experimentSafetyPks.length + ) { + return rejection( + 'Could not fetch required data for all experiment safeties after status change', + { experimentSafetyPks, fullExperimentsSafety } + ); + } + + // NOTE: After experiment safety status change we need to run the status engine and execute the actions on the selected status. + experimentSafetyStatusActionEngine(statusEngineReadyExperimentsSafety); + } else { + return rejection( + 'Could not change statuses to all of the selected experiment safeties', + { + result, + } + ); + } + + return ( + result || rejection('Can not change experiment safety status', { result }) + ); + } + + @EventBus(Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER) + @Authorized([Roles.USER_OFFICER]) + async changeExperimentsSafetyStatus( + agent: UserWithRole | null, + args: ChangeExperimentsSafetyStatusInput + ): Promise { + return this.processExperimentsSafetyStatusChange(agent, args); + } } diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index 1522e458f5..b2f68ff7b5 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -12,8 +12,12 @@ import { } from '@user-office-software/duo-validation'; import { container, inject, injectable } from 'tsyringe'; +import FapMutations from './FapMutations'; +import InstrumentMutations from './InstrumentMutations'; +import { ProposalAuthorization } from '../auth/ProposalAuthorization'; import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; +import { CallDataSource } from '../datasources/CallDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { GenericTemplateDataSource } from '../datasources/GenericTemplateDataSource'; import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; @@ -25,6 +29,7 @@ import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Authorized, EventBus, ValidateArgs } from '../decorators'; +import { proposalStatusActionEngine } from '../eventHandlers/workflowEntities/proposal/statusActionEngine'; import { Event } from '../events/event.enum'; import { Call } from '../models/Call'; import { Proposal, ProposalEndStatus, Proposals } from '../models/Proposal'; @@ -40,13 +45,7 @@ import { NotifyProposalArgs } from '../resolvers/mutations/NotifyProposalMutatio import { UpdateProposalArgs } from '../resolvers/mutations/UpdateProposalMutation'; import { UpdateProposalScientistCommentArgs } from '../resolvers/mutations/UpdateProposalScientistCommentMutation'; import { ProposalScientistComment } from '../resolvers/types/ProposalView'; -import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; -import { ProposalAuthorization } from './../auth/ProposalAuthorization'; -import { CallDataSource } from './../datasources/CallDataSource'; -import { CloneUtils } from './../utils/CloneUtils'; -import FapMutations from './FapMutations'; -import InstrumentMutations from './InstrumentMutations'; +import { CloneUtils } from '../utils/CloneUtils'; @injectable() export default class ProposalMutations { @@ -166,8 +165,13 @@ export default class ProposalMutations { ): Promise { const { proposalPk, title, abstract, users, proposerId, created } = args; - proposal.title = title; - proposal.abstract = abstract; + if (title !== undefined) { + proposal.title = title; + } + + if (abstract !== undefined) { + proposal.abstract = abstract; + } if (created !== undefined) { proposal.created = created; @@ -581,15 +585,18 @@ export default class ProposalMutations { ); const statusEngineReadyProposals = fullProposals.filter( - (item): item is WorkflowEngineProposalType => !!item + (item): item is Proposal => !!item ); // NOTE: After proposal status change we need to run the status engine and execute the actions on the selected status. proposalStatusActionEngine(statusEngineReadyProposals); } else { - rejection('Could not change statuses to all of the selected proposals', { - result, - }); + return rejection( + 'Could not change statuses to all of the selected proposals', + { + result, + } + ); } return result || rejection('Can not change proposal status', { result }); @@ -835,12 +842,12 @@ export default class ProposalMutations { true ); - const defaultWorkflowStatus = + const defaultWfStatus = await this.workflowDataSource.getInitialWorkflowStatus( call.proposalWorkflowId ); - if (!defaultWorkflowStatus) { + if (!defaultWfStatus) { return rejection( 'Could not clone the proposal because the call has misconfigured workflow statuses', { call, sourceProposal } @@ -853,7 +860,7 @@ export default class ProposalMutations { title: `Copy of ${clonedProposal.title}`, abstract: clonedProposal.abstract, proposerId: sourceProposal.proposerId, - workflowStatusId: defaultWorkflowStatus.workflowStatusId, + workflowStatusId: defaultWfStatus.workflowStatusId, created: new Date(), updated: new Date(), proposalId: clonedProposal.proposalId, diff --git a/apps/backend/src/mutations/StatusActionsLogsMutations.ts b/apps/backend/src/mutations/StatusActionsLogsMutations.ts index ff36654237..88af872091 100644 --- a/apps/backend/src/mutations/StatusActionsLogsMutations.ts +++ b/apps/backend/src/mutations/StatusActionsLogsMutations.ts @@ -7,6 +7,8 @@ import ProposalDataSource from '../datasources/postgres/ProposalDataSource'; import StatusActionsDataSource from '../datasources/postgres/StatusActionsDataSource'; import StatusActionsLogsDataSource from '../datasources/postgres/StatusActionsLogsDataSource'; import { Authorized } from '../decorators'; +import { emailActionHandler } from '../eventHandlers/workflowEntities/proposal/emailActionHandler'; +import { pdfDownloadActionHandler } from '../eventHandlers/workflowEntities/proposal/pdfDownloadActionHandler'; import { Proposal } from '../models/Proposal'; import { Rejection, rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; @@ -18,9 +20,6 @@ import { ReplayStatusLogFailure, } from '../resolvers/mutations/ReplayStatusActionsLogMutation'; import { EmailStatusActionRecipients } from '../resolvers/types/StatusActionConfig'; -import { emailActionHandler } from '../statusActionEngine/emailActionHandler'; -import { proposalDownloadActionHandler } from '../statusActionEngine/proposalDownloadActionHandler'; -import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; @injectable() export default class StatusActionsLogsMutations { @@ -37,7 +36,7 @@ export default class StatusActionsLogsMutations { private async getStatusEngineReadyProposals( proposals: Proposal[] - ): Promise { + ): Promise { if (proposals.length < 1) { return []; } @@ -58,9 +57,9 @@ export default class StatusActionsLogsMutations { }) ); - return fullProposals.filter( - (item): item is WorkflowEngineProposalType => !!item - ); + const filteredProposals = fullProposals.filter((item) => item !== null); + + return filteredProposals as Proposal[]; } private async executeStatusActionsLog( statusActionsLog: StatusActionsLog, @@ -118,14 +117,10 @@ export default class StatusActionsLogsMutations { break; case StatusActionType.PROPOSALDOWNLOAD: - await proposalDownloadActionHandler( - statusAction, - workflowEngineProposals, - { - statusActionsLogId: statusActionsLog.statusActionsLogId, - loggedInUserId: agent?.id, - } - ); + await pdfDownloadActionHandler(statusAction, workflowEngineProposals, { + statusActionsLogId: statusActionsLog.statusActionsLogId, + loggedInUserId: agent?.id, + }); break; default: diff --git a/apps/backend/src/resolvers/mutations/ChangeExperimentsSafetyStatusMutation.ts b/apps/backend/src/resolvers/mutations/ChangeExperimentsSafetyStatusMutation.ts new file mode 100644 index 0000000000..74fccaaa51 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/ChangeExperimentsSafetyStatusMutation.ts @@ -0,0 +1,39 @@ +import { + Ctx, + Mutation, + Resolver, + Arg, + Int, + Field, + InputType, +} from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { isRejection } from '../../models/Rejection'; + +@InputType() +export class ChangeExperimentsSafetyStatusInput { + @Field(() => Int) + public workflowStatusId: number; + + @Field(() => [Int]) + public experimentSafetyPks: number[]; +} + +@Resolver() +export class ChangeExperimentsSafetyStatusMutation { + @Mutation(() => Boolean) + async changeExperimentsSafetyStatus( + @Arg('changeExperimentsSafetyStatusInput') + changeExperimentsSafetyStatusInput: ChangeExperimentsSafetyStatusInput, + @Ctx() context: ResolverContext + ) { + const result = + await context.mutations.experiment.changeExperimentsSafetyStatus( + context.user, + changeExperimentsSafetyStatusInput + ); + + return isRejection(result) ? result : true; + } +} diff --git a/apps/backend/src/utils/helperFunctions.ts b/apps/backend/src/utils/helperFunctions.ts index 66f9521beb..5006256bca 100644 --- a/apps/backend/src/utils/helperFunctions.ts +++ b/apps/backend/src/utils/helperFunctions.ts @@ -52,9 +52,9 @@ export const checkAllReviewsSubmittedOnProposal = ( }; export const searchObjectByKey = ( - object: object, + object: Record, originalKey: string -): object | null => { +): Record | null => { if (object !== null) { for (const key of Object.keys(object)) { if (key === originalKey) { diff --git a/apps/backend/src/workflowEngine/experiment.ts b/apps/backend/src/workflowEngine/experiment.ts index f7b54a8da3..657361f112 100644 --- a/apps/backend/src/workflowEngine/experiment.ts +++ b/apps/backend/src/workflowEngine/experiment.ts @@ -72,7 +72,7 @@ export class ExperimentWorkflowEngine { ); const validExperiments = experimentsWithChangedStatuses.filter( - (exp): exp is WorkflowEngineExperimentType => !!exp + (p): p is WorkflowEngineExperimentType => !!p ); return validExperiments; @@ -87,7 +87,6 @@ export class ExperimentWorkflowEngine { ): Promise { const experiment = await this.experimentDataSource.getExperiment(experimentPk); - if (!experiment) { logger.logError('Experiment not found', { experimentPk }); @@ -149,23 +148,23 @@ export class ExperimentWorkflowEngine { { id: experimentSafety.experimentSafetyPk }, currentExperimentState ); - const currentWorkflowStatus = actor.getState(); + const currentWfStatus = actor.getState(); const { nextStateValue, connectionId } = await actor.event( event.toUpperCase() ); - if (nextStateValue !== currentWorkflowStatus) { + if (nextStateValue !== currentWfStatus) { const meta = machine.schema.states[nextStateValue]?.meta as | WorkflowStateMeta | undefined; - const nextWorkflowStatusId = meta?.workflowStatusId; + const nextWfStatusId = meta?.workflowStatusId; - if (nextWorkflowStatusId) { + if (nextWfStatusId) { const updatedExperimentSafety = await this.experimentDataSource.updateExperimentSafetyStatus( experimentSafety.experimentSafetyPk, - nextWorkflowStatusId + nextWfStatusId ); return { diff --git a/apps/backend/src/workflowEngine/guards/index.ts b/apps/backend/src/workflowEngine/guards/index.ts new file mode 100644 index 0000000000..746361e2aa --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/index.ts @@ -0,0 +1,35 @@ +// Call Guards +export { isCallEndedGuard } from './isCallEndedGuard'; +export { isCallEndedInternalGuard } from './isCallEndedInternalGuard'; +export { isCallFapReviewEndedGuard } from './isCallFapReviewEndedGuard'; +export { isCallReviewEndedGuard } from './isCallReviewEndedGuard'; + +// Proposal Guards +export { isProposalAcceptedGuard } from './isProposalAcceptedGuard'; +export { isProposalAllReviewsSubmittedGuard } from './isProposalAllReviewsSubmittedGuard'; +export { isProposalAssignedToTechniquesGuard } from './isProposalAssignedToTechniquesGuard'; +export { isProposalBookingTimeActivatedGuard } from './isProposalBookingTimeActivatedGuard'; +export { isProposalBookingTimeCompletedGuard } from './isProposalBookingTimeCompletedGuard'; +export { isProposalFapMeetingInstrumentSubmittedGuard } from './isProposalFapMeetingInstrumentSubmittedGuard'; +export { isProposalFapMeetingInstrumentUnsubmittedGuard } from './isProposalFapMeetingInstrumentUnsubmittedGuard'; +export { isProposalFapReviewSubmittedGuard } from './isProposalFapReviewSubmittedGuard'; +export { isProposalFapsSelectedGuard } from './isProposalFapsSelectedGuard'; +export { isProposalFeasibilityReviewFeasibleGuard } from './isProposalFeasibilityReviewFeasibleGuard'; +export { isProposalFeasibilityReviewSubmittedGuard } from './isProposalFeasibilityReviewSubmittedGuard'; +export { isProposalFeasibilityReviewUnfeasibleGuard } from './isProposalFeasibilityReviewUnfeasibleGuard'; +export { isProposalInstrumentsSelectedGuard } from './isProposalInstrumentsSelectedGuard'; +export { isProposalManagementDecisionSubmittedGuard } from './isProposalManagementDecisionSubmittedGuard'; +export { isProposalNotifiedGuard } from './isProposalNotifiedGuard'; +export { isProposalRejectedGuard } from './isProposalRejectedGuard'; +export { isProposalReservedGuard } from './isProposalReservedGuard'; +export { isProposalSampleReviewSubmittedGuard } from './isProposalSampleReviewSubmittedGuard'; +export { isProposalSampleSafeGuard } from './isProposalSampleSafeGuard'; +export { isProposalSubmittedGuard } from './isProposalSubmittedGuard'; +export { isProposalTechnicalReviewsSubmittedGuard } from './isProposalTechnicalReviewsSubmittedGuard'; + +// Experiment Guards +export { isExperimentESFApprovedByESR } from './isExperimentESFApprovedByESR'; +export { isExperimentESFApprovedByIS } from './isExperimentESFApprovedByIS'; +export { isExperimentESFRejectedByESR } from './isExperimentESFRejectedByESR'; +export { isExperimentESFRejectedByIS } from './isExperimentESFRejectedByIS'; +export { isExperimentESFSubmitted } from './isExperimentESFSubmitted'; diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.spec.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.spec.ts new file mode 100644 index 0000000000..20c839bfb9 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isExperimentESFApprovedByESR } from './isExperimentESFApprovedByESR'; +import { Tokens } from '../../config/Tokens'; +import { ExperimentSafetyReviewerDecisionEnum } from '../../models/Experiment'; + +describe('isExperimentESFApprovedByESR', () => { + const mockExperimentDataSource = { + getExperimentSafety: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if experiment safety not found', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue(null); + const result = await isExperimentESFApprovedByESR({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if experimentSafetyReviewerDecision is not ACCEPTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + experimentSafetyReviewerDecision: + ExperimentSafetyReviewerDecisionEnum.REJECTED, + }); + const result = await isExperimentESFApprovedByESR({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if experimentSafetyReviewerDecision is ACCEPTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + experimentSafetyReviewerDecision: + ExperimentSafetyReviewerDecisionEnum.ACCEPTED, + }); + const result = await isExperimentESFApprovedByESR({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.ts new file mode 100644 index 0000000000..fb5d866abb --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByESR.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { ExperimentSafetyReviewerDecisionEnum } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isExperimentESFApprovedByESR: GuardFn = async (entity: Entity) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.id + ); + + return ( + experimentSafety?.experimentSafetyReviewerDecision === + ExperimentSafetyReviewerDecisionEnum.ACCEPTED + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.spec.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.spec.ts new file mode 100644 index 0000000000..886ccbcf07 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isExperimentESFApprovedByIS } from './isExperimentESFApprovedByIS'; +import { Tokens } from '../../config/Tokens'; +import { InstrumentScientistDecisionEnum } from '../../models/Experiment'; + +describe('isExperimentESFApprovedByIS', () => { + const mockExperimentDataSource = { + getExperimentSafety: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if experiment safety not found', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue(null); + const result = await isExperimentESFApprovedByIS({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if instrumentScientistDecision is not ACCEPTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + instrumentScientistDecision: InstrumentScientistDecisionEnum.REJECTED, + }); + const result = await isExperimentESFApprovedByIS({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if instrumentScientistDecision is ACCEPTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + instrumentScientistDecision: InstrumentScientistDecisionEnum.ACCEPTED, + }); + const result = await isExperimentESFApprovedByIS({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.ts new file mode 100644 index 0000000000..9d2af1ce50 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFApprovedByIS.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { InstrumentScientistDecisionEnum } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isExperimentESFApprovedByIS: GuardFn = async (entity: Entity) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.id + ); + + return ( + experimentSafety?.instrumentScientistDecision === + InstrumentScientistDecisionEnum.ACCEPTED + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.spec.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.spec.ts new file mode 100644 index 0000000000..e3be29f1b0 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isExperimentESFRejectedByESR } from './isExperimentESFRejectedByESR'; +import { Tokens } from '../../config/Tokens'; +import { ExperimentSafetyReviewerDecisionEnum } from '../../models/Experiment'; + +describe('isExperimentESFRejectedByESR', () => { + const mockExperimentDataSource = { + getExperimentSafety: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if experiment safety not found', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue(null); + const result = await isExperimentESFRejectedByESR({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if experimentSafetyReviewerDecision is not REJECTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + experimentSafetyReviewerDecision: + ExperimentSafetyReviewerDecisionEnum.ACCEPTED, + }); + const result = await isExperimentESFRejectedByESR({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if experimentSafetyReviewerDecision is REJECTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + experimentSafetyReviewerDecision: + ExperimentSafetyReviewerDecisionEnum.REJECTED, + }); + const result = await isExperimentESFRejectedByESR({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.ts new file mode 100644 index 0000000000..a01c49a0d8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByESR.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { ExperimentSafetyReviewerDecisionEnum } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isExperimentESFRejectedByESR: GuardFn = async (entity: Entity) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.id + ); + + return ( + experimentSafety?.experimentSafetyReviewerDecision === + ExperimentSafetyReviewerDecisionEnum.REJECTED + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.spec.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.spec.ts new file mode 100644 index 0000000000..730d80163a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isExperimentESFRejectedByIS } from './isExperimentESFRejectedByIS'; +import { Tokens } from '../../config/Tokens'; +import { InstrumentScientistDecisionEnum } from '../../models/Experiment'; + +describe('isExperimentESFRejectedByIS', () => { + const mockExperimentDataSource = { + getExperimentSafety: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if experiment safety not found', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue(null); + const result = await isExperimentESFRejectedByIS({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if instrumentScientistDecision is not REJECTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + instrumentScientistDecision: InstrumentScientistDecisionEnum.ACCEPTED, + }); + const result = await isExperimentESFRejectedByIS({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if instrumentScientistDecision is REJECTED', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + instrumentScientistDecision: InstrumentScientistDecisionEnum.REJECTED, + }); + const result = await isExperimentESFRejectedByIS({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.ts new file mode 100644 index 0000000000..87961acc30 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFRejectedByIS.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { InstrumentScientistDecisionEnum } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isExperimentESFRejectedByIS: GuardFn = async (entity: Entity) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.id + ); + + return ( + experimentSafety?.instrumentScientistDecision === + InstrumentScientistDecisionEnum.REJECTED + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.spec.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.spec.ts new file mode 100644 index 0000000000..41ce44a3e5 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isExperimentESFSubmitted } from './isExperimentESFSubmitted'; +import { Tokens } from '../../config/Tokens'; + +describe('isExperimentESFSubmitted', () => { + const mockExperimentDataSource = { + getExperimentSafety: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if experiment safety not found', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue(null); + const result = await isExperimentESFSubmitted({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if esiQuestionarySubmittedAt is not set', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + esiQuestionarySubmittedAt: null, + }); + const result = await isExperimentESFSubmitted({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if esiQuestionarySubmittedAt is set', async () => { + mockExperimentDataSource.getExperimentSafety.mockResolvedValue({ + esiQuestionarySubmittedAt: new Date('2024-01-01'), + }); + const result = await isExperimentESFSubmitted({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.ts b/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.ts new file mode 100644 index 0000000000..afb572b84b --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isExperimentESFSubmitted.ts @@ -0,0 +1,17 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isExperimentESFSubmitted: GuardFn = async (entity: Entity) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experimentSafety = await experimentDataSource.getExperimentSafety( + entity.id + ); + + return !!experimentSafety?.esiQuestionarySubmittedAt; +}; diff --git a/apps/backend/src/workflowEngine/index.ts b/apps/backend/src/workflowEngine/index.ts new file mode 100644 index 0000000000..2b6f025cb0 --- /dev/null +++ b/apps/backend/src/workflowEngine/index.ts @@ -0,0 +1,133 @@ +//todo: file name rename +import { injectable } from 'tsyringe'; + +import { WorkFlowEntity } from '../eventHandlers/workflowHandler'; +import { Event } from '../events/event.enum'; +import { createWorkflowMachine } from './stateMachine/createWorkflowMachine'; +import { createActor } from './stateMachine/stateMachine'; + +type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; + +export type WorkflowEngineType = { + entityId: number; + prevStatusId: number; + nextStatusId: number; + workflowStatusConnectionId: number; +}; + +type WorkflowRunSingleInput = { + entity: number; + currentEvent: Event; +}; + +type WorkflowRunBatchInput = { + entities: number[]; + event: Event; +}; + +export type WorkflowRunInput = + | WorkflowRunSingleInput + | WorkflowRunSingleInput[] + | WorkflowRunBatchInput; + +const isBatchWorkflowInput = ( + input: WorkflowRunInput +): input is WorkflowRunBatchInput => { + return Array.isArray((input as WorkflowRunBatchInput).entities); +}; + +@injectable() +export class WorkflowEngine { + async run( + input: WorkflowRunInput, + workflowEntity: WorkFlowEntity + ): Promise> { + let normalizedInput: WorkflowRunSingleInput[]; + + if (Array.isArray(input)) { + normalizedInput = input; + } else if (isBatchWorkflowInput(input)) { + normalizedInput = input.entities.map((entity) => ({ + entity, + currentEvent: input.event, + })); + } else { + normalizedInput = [input]; + } + const entitiesWithChangedStatuses = await Promise.all( + normalizedInput.map(async ({ entity, currentEvent }) => { + const workflowId = await workflowEntity.resolveWorkflowId(entity); + if (!workflowId) { + return; + } + const currentStatusId = + await workflowEntity.resolveCurrentStatusId(entity); + + if (!currentStatusId) { + return; + } + + return await this.runOne( + entity, + workflowId, + currentStatusId, + currentEvent, + async (newWorkflowStatusId: number) => { + await workflowEntity.updateWorkflowStatus( + entity, + newWorkflowStatusId + ); + } + ); + }) + ); + + const validEntities = entitiesWithChangedStatuses.filter( + (p): p is WorkflowEngineType => !!p + ); + + return validEntities; + } + + /** + * Internal method to run the workflow engine for a single entity and event. + */ + private async runOne( + entityId: number, + workflowId: number, + currentStatusId: number, + event: Event, + handleEntityWorkflowChange: (newWorkflowStatus: number) => Promise + ): Promise { + const machine = await createWorkflowMachine(workflowId); + const currentEntityState = Object.entries(machine.schema.states).find( + ([, state]) => { + return ( + (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === + currentStatusId + ); + } + )?.[0]; + + const actor = createActor(machine, { id: entityId }, currentEntityState); + const { nextStateValue, connectionId, transitionPerformed } = + await actor.event(event.toUpperCase()); + if (transitionPerformed) { + const meta = machine.schema.states[nextStateValue]?.meta as + | WorkflowStateMeta + | undefined; + const nextWfStatusId = meta?.workflowStatusId; + + if (nextWfStatusId) { + await handleEntityWorkflowChange(nextWfStatusId); + + return { + entityId, + prevStatusId: currentStatusId, + nextStatusId: nextWfStatusId, + workflowStatusConnectionId: connectionId, + }; + } + } + } +} diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts deleted file mode 100644 index 38f09fd4d4..0000000000 --- a/apps/backend/src/workflowEngine/proposal.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { logger } from '@user-office-software/duo-logger'; -import { inject, injectable } from 'tsyringe'; - -import { Tokens } from '../config/Tokens'; -import { CallDataSource } from '../datasources/CallDataSource'; -import { ProposalDataSource } from '../datasources/ProposalDataSource'; -import { Event } from '../events/event.enum'; -import { Proposal } from '../models/Proposal'; -import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; -import { createWorkflowMachine } from './stateMachine/createWorkflowMachine'; -import { createActor } from './stateMachine/stateMachine'; - -type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; - -export type WorkflowEngineProposalType = Proposal & { - prevStatusId: number; - workflowStatusConnectionId: number; -}; - -type WorkflowRunSingleInput = { - proposalPk: number; - currentEvent: Event; -}; - -type WorkflowRunBatchInput = { - proposalPks: number[]; - event: Event; -}; - -export type WorkflowRunInput = - | WorkflowRunSingleInput - | WorkflowRunSingleInput[] - | WorkflowRunBatchInput; - -const isBatchWorkflowInput = ( - input: WorkflowRunInput -): input is WorkflowRunBatchInput => { - return Array.isArray((input as WorkflowRunBatchInput).proposalPks); -}; - -@injectable() -export class ProposalWorkflowEngine { - constructor( - @inject(Tokens.ProposalDataSource) - private readonly proposalDataSource: ProposalDataSource, - @inject(Tokens.CallDataSource) - private readonly callDataSource: CallDataSource - ) {} - - async run( - input: WorkflowRunInput - ): Promise> { - let normalizedInput: WorkflowRunSingleInput[]; - - if (Array.isArray(input)) { - normalizedInput = input; - } else if (isBatchWorkflowInput(input)) { - normalizedInput = input.proposalPks.map((proposalPk) => ({ - proposalPk, - currentEvent: input.event, - })); - } else { - normalizedInput = [input]; - } - - const proposalsWithChangedStatuses = await Promise.all( - normalizedInput.map(({ proposalPk, currentEvent }) => - this.runOne(proposalPk, currentEvent) - ) - ); - - const validProposals = proposalsWithChangedStatuses.filter( - (p): p is WorkflowEngineProposalType => !!p - ); - - if (validProposals.length > 0) { - await proposalStatusActionEngine(validProposals); - } - - return validProposals; - } - - /** - * Internal method to run the workflow engine for a single proposal and event. - */ - private async runOne( - proposalPk: number, - event: Event - ): Promise { - if (event === Event.PROPOSAL_DELETED) { - return; - } - - const proposal = await this.proposalDataSource.get(proposalPk); - - if (!proposal) { - logger.logError('Proposal not found', { proposalPk }); - - return; - } - - const proposalWorkflowId = ( - await this.callDataSource.getProposalWorkflowByCall(proposal.callId) - )?.id; - - if (!proposalWorkflowId) { - logger.logError('Workflow not found for proposal', { proposalPk }); - - return; - } - - const machine = await createWorkflowMachine(proposalWorkflowId); - - const currentProposalState = Object.entries(machine.schema.states).find( - ([, state]) => { - return ( - (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === - proposal.workflowStatusId - ); - } - )?.[0]; // find the state matching proposalWorkflowStatusId in the state machine - - const actor = createActor( - machine, - { id: proposal.primaryKey }, - currentProposalState - ); - - const { nextStateValue, connectionId, transitionPerformed } = - await actor.event(event.toUpperCase()); - - if (transitionPerformed) { - const meta = machine.schema.states[nextStateValue]?.meta as - | WorkflowStateMeta - | undefined; - const nextWorkflowStatusId = meta?.workflowStatusId; - - if (nextWorkflowStatusId) { - const { proposals } = - await this.proposalDataSource.changeProposalsWorkflowStatus( - nextWorkflowStatusId, - [proposalPk] - ); - - const updatedProposal = proposals[0]; - - return { - ...updatedProposal, - prevStatusId: proposal.workflowStatusId, - workflowStatusConnectionId: connectionId, - }; - } - } - } -} diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index 995152e645..12119ad6b8 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -481,7 +481,7 @@ context('Fap reviews tests', () => { cy.get('[data-cy="fap-assignments-table"] thead').contains('Deviation'); }); - it.only('Table selection and parameters should be saved between tab navigation', () => { + it('Table selection and parameters should be saved between tab navigation', () => { for (let index = 0; index < 6; index++) { cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const createdProposal = result.createProposal; diff --git a/apps/e2e/cypress/e2e/experiments.cy.ts b/apps/e2e/cypress/e2e/experiments.cy.ts index 4d81b90118..1d4fb9ba7e 100644 --- a/apps/e2e/cypress/e2e/experiments.cy.ts +++ b/apps/e2e/cypress/e2e/experiments.cy.ts @@ -292,5 +292,276 @@ context('Experiments tests', () => { } ); }); + + it('Should display error when trying to change status of experiment without experimentSafety', () => { + // NOTE: The beforeEach creates experimentSafety only for the "upcoming" experiment + // This test should find an experiment without experimentSafety and try to change its status + + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + // Remove date filter to show all experiments + cy.get('[value=NONE]').click(); + cy.finishedLoading(); + + // Find the table and count rows - should have multiple experiments + cy.get('[data-cy=experiments-table] table tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Get all experiment rows and find one that shows "ESF Not Started" status + // These are experiments without experimentSafety + cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { + // Look for a row with "ESF Not Started" status + let selectedRowIndex = -1; + $rows.each((index, row) => { + const cells = Cypress.$(row).find('td'); + const statusCell = cells.eq(5); // Experiment Safety Status column + if (statusCell.text().includes('ESF Not Started')) { + selectedRowIndex = index; + + return false; // break + } + }); + + // If we found a row with ESF Not Started, select it + if (selectedRowIndex !== -1) { + cy.get('[data-cy=experiments-table] input[type="checkbox"]') + .eq(selectedRowIndex + 1) // +1 because first row is header + .check({ force: true }); + cy.finishedLoading(); + + // Click on the change status button + cy.get('[data-cy=change-experiment-safety-status]').click(); + cy.finishedLoading(); + + // Check for the error notification + cy.notification({ + variant: 'error', + text: /Cannot change status\. Some or all selected experiments do not have required safety information\./, + }); + } else { + // If no experiment without ESF was found, skip this test + cy.log('No experiment without experimentSafety found'); + } + }); + }); + + it('User officer should see experiment status as a clickable link', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + + // Find the status column cell that contains ESF text and verify it's clickable + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .should('exist') + .and('include.text', 'ESF'); + }); + + it('Instrument scientist should see experiment status as plain text, not clickable', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Find the status column cell and verify it has no clickable elements + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Should contain ESF text + cy.wrap($cell).should('include.text', 'ESF'); + // Should NOT have any clickable links or buttons + cy.wrap($cell).find('a, button').should('not.exist'); + }); + }); + + it('User officer should be able to open and change experiment safety status', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + + // Click on the status cell (which should be clickable for officer) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .click(); + cy.finishedLoading(); + + // Verify the modal opened with the workflow tree + cy.get('[role="presentation"]').should( + 'contain', + 'Change experiment(s) safety status' + ); + cy.get('.MuiDialogContent-root').should('exist'); + + // Verify the status selection dropdown exists and is enabled + cy.get('#selectedWorkflowStatusId-input').should( + 'not.have.class', + 'Mui-disabled' + ); + }); + + it('Instrument scientist should not be able to click on status to open change status modal', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // The status cell should be plain text (no clickable elements) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Verify it's not a button or link + cy.wrap($cell).find('a, button').should('not.exist'); + }); + + // Verify the change status modal is NOT present (check for specific modal title) + cy.contains('Change experiment(s) safety status').should('not.exist'); + }); + + it('Instrument scientist should not see change status button in toolbar', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Non-officers don't have checkboxes, so the change status button should not exist + cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); + }); + + it('Experiment safety reviewer should see experiment status as plain text, not clickable', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Find the status column cell and verify it has no clickable elements + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Should contain ESF text + cy.wrap($cell).should('include.text', 'ESF'); + // Should NOT have any clickable links or buttons + cy.wrap($cell).find('a, button').should('not.exist'); + }); + }); + + it('Experiment safety reviewer should not be able to click on status to open change status modal', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // The status cell should be plain text (no clickable elements) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Verify it's not a button or link + cy.wrap($cell).find('a, button').should('not.exist'); + }); + + // Verify the change status modal is NOT present (check for specific modal title) + cy.contains('Change experiment(s) safety status').should('not.exist'); + }); + + it('Experiment safety reviewer should not see change status button in toolbar', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Non-officers don't have checkboxes, so the change status button should not exist + cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); + }); + + it('User officer should see status action logs after changing experiment status', () => { + cy.login('officer'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Click the status link to open the change status modal + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .click({ force: true }); + cy.finishedLoading(); + + // Modal for changing status should appear + cy.contains('Change experiment(s) safety status').should('exist'); + + // Click the status selection dropdown and select the second status (which has status actions) + cy.get('[data-cy=status-selection]').should('exist'); + + // Find the input field within the autocomplete component and click it + cy.get('[data-cy=status-selection] input').click(); + + // Wait for the dropdown to appear and select the second option + cy.get('[role="listbox"]').should('be.visible'); + cy.get('[role="option"]').eq(1).click(); + + // Click submit button to change status + cy.get('[data-cy=submit-experiment-status-change]').click(); + cy.finishedLoading(); + + // Verify success notification + cy.notification({ + variant: 'success', + text: 'status changed successfully', + }); + + // Wait for modal to close + cy.contains('Change experiment(s) safety status').should('not.exist'); + cy.finishedLoading(); + + // Now open experiment details to check the logs + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .find('[data-cy=view-experiment]') + .click(); + cy.finishedLoading(); + + // Experiment details modal should be open + cy.get('[role="presentation"]').should('exist'); + + // Click on the Logs tab + cy.get('[role="tab"]').contains(/Logs/i).should('exist').click(); + cy.finishedLoading(); + + // Verify that event logs table is displayed + cy.get('[data-cy="event-logs-table"]').should('exist'); + + // Verify the logs are not empty and contain status action entries + cy.get('[data-cy="event-logs-table"] tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Verify the logs contain the expected status action messages + cy.get('[data-cy="event-logs-table"]') + .invoke('text') + .then((tableText) => { + // Should contain email status action log entries + // The logs show: Email successfully sent template: [template-name] to: [email] recipient: [recipient-type] + expect(tableText).to.contain('Email successfully sent template'); + // Should also contain RabbitMQ action log entries + // The logs show: RabbitMQ message successfully sent + expect(tableText).to.contain( + 'Experiment event successfully sent to the message broker' + ); + }); + }); }); }); diff --git a/apps/e2e/cypress/e2e/instruments.cy.ts b/apps/e2e/cypress/e2e/instruments.cy.ts index 14775bd3c9..67f22f5cc6 100644 --- a/apps/e2e/cypress/e2e/instruments.cy.ts +++ b/apps/e2e/cypress/e2e/instruments.cy.ts @@ -4,7 +4,6 @@ import { TechnicalReviewStatus, FeatureId, SettingsId, - WorkflowType, } from '@user-office-software-libs/shared-types'; import featureFlags from '../support/featureFlags'; @@ -62,18 +61,6 @@ context('Instrument tests', () => { cy.resetDB(); cy.getAndStoreFeaturesEnabled(); - cy.createStatus({ - id: 'FEASIBILITY', - name: 'Feasibility', - description: 'Feasibility status', - entityType: WorkflowType.PROPOSAL, - }); - - cy.addStatusToWorkflow({ - workflowId: initialDBData.workflows.defaultWorkflow.id, - statusId: 'FEASIBILITY', - }); - if ( settings .getEnabledSettings() diff --git a/apps/e2e/cypress/e2e/settings.cy.ts b/apps/e2e/cypress/e2e/settings.cy.ts index 888d522966..db39cf244c 100644 --- a/apps/e2e/cypress/e2e/settings.cy.ts +++ b/apps/e2e/cypress/e2e/settings.cy.ts @@ -149,7 +149,7 @@ context('Settings tests', () => { managerUserId: initialDBData.users.user1.id, }; let createdWorkflowId: number; - let createdDraftWorkflowStatusId: number; + let createdDraftWfStatusId: number; let createdEsiTemplateId: number; let createdInstrumentId: number; @@ -171,7 +171,7 @@ context('Settings tests', () => { cy.addStatusToWorkflow({ statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }) .then((result) => { const connection = result.createWorkflowConnection; @@ -183,14 +183,14 @@ context('Settings tests', () => { }) .then(() => result.addStatusToWorkflow.workflowStatusId); }) - .then((feasibilityReviewWorkflowStatusId) => { + .then((feasibilityReviewWfStatusId) => { // Add FAP_SELECTION return cy.addStatusToWorkflow({ statusId: statuses.fapSelection.id, workflowId: createdWorkflowId, posX: 0, posY: 200, - prevId: feasibilityReviewWorkflowStatusId, + prevId: feasibilityReviewWfStatusId, }); }) .then((result) => { @@ -206,14 +206,14 @@ context('Settings tests', () => { }) .then(() => result.addStatusToWorkflow.workflowStatusId); }) - .then((fapSelectionWorkflowStatusId) => { + .then((fapSelectionWfStatusId) => { // Add FAP_REVIEW return cy.addStatusToWorkflow({ statusId: statuses.fapReview.id, workflowId: createdWorkflowId, posX: 0, posY: 300, - prevId: fapSelectionWorkflowStatusId, + prevId: fapSelectionWfStatusId, }); }) .then((result) => { @@ -226,14 +226,14 @@ context('Settings tests', () => { }) .then(() => result.addStatusToWorkflow.workflowStatusId); }) - .then((fapReviewWorkflowStatusId) => { + .then((fapReviewWfStatusId) => { // Add FAP_MEETING return cy.addStatusToWorkflow({ statusId: statuses.fapMeeting.id, workflowId: createdWorkflowId, posX: 0, posY: 400, - prevId: fapReviewWorkflowStatusId, + prevId: fapReviewWfStatusId, }); }) .then((result) => { @@ -252,10 +252,9 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 0, posY: 100, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }).then((result) => { - const createdFeasibilityReviewWorkflowStatus = - result.addStatusToWorkflow; + const createdFeasibilityReviewWfStatus = result.addStatusToWorkflow; const connection = result.createWorkflowConnection; cy.setStatusChangingEventsOnConnection({ @@ -269,7 +268,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: -100, posY: 200, - prevId: createdFeasibilityReviewWorkflowStatus.workflowStatusId, + prevId: createdFeasibilityReviewWfStatus.workflowStatusId, }).then((result) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.createWorkflowConnection.id, @@ -283,7 +282,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 100, posY: 200, - prevId: createdFeasibilityReviewWorkflowStatus.workflowStatusId, + prevId: createdFeasibilityReviewWfStatus.workflowStatusId, }).then((result) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.createWorkflowConnection.id, @@ -306,7 +305,7 @@ context('Settings tests', () => { const workflow = result.createWorkflow; if (workflow) { createdWorkflowId = workflow.id; - createdDraftWorkflowStatusId = workflow.statuses[0].workflowStatusId; + createdDraftWfStatusId = workflow.statuses[0].workflowStatusId; cy.createTemplate({ name: 'default esi template', @@ -337,7 +336,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 0, posY: 200, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }).then((result) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.createWorkflowConnection.id, @@ -420,7 +419,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 0, posY: 200, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }).then((result) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.createWorkflowConnection.id, @@ -511,7 +510,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 0, posY: 200, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }).then((result) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.createWorkflowConnection.id, @@ -592,11 +591,11 @@ context('Settings tests', () => { cy.addStatusToWorkflow({ statusId: statuses.editableSubmitted.id, workflowId: createdWorkflowId, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }).then( ({ createWorkflowConnection: connectionFromDraft, - addStatusToWorkflow: editableSubmittedWorkflowStatus, + addStatusToWorkflow: editableSubmittedWfStatus, }) => { cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connectionFromDraft.id, @@ -606,7 +605,7 @@ context('Settings tests', () => { cy.addStatusToWorkflow({ statusId: statuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, - prevId: editableSubmittedWorkflowStatus.workflowStatusId, + prevId: editableSubmittedWfStatus.workflowStatusId, }).then( ({ createWorkflowConnection: connectionFromSubmittedInternal }) => { cy.setStatusChangingEventsOnConnection({ @@ -757,7 +756,7 @@ context('Settings tests', () => { workflowId: createdWorkflowId, posX: 0, posY: 150, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, }); cy.login('officer'); cy.visit('/'); @@ -990,6 +989,85 @@ context('Settings tests', () => { cy.contains('FAP_REVIEW'); }); + it.skip('Proposal status should update multiple times if conditions are met', () => { + addWorkflowWithChangingEvents(); + cy.createInstrument(instrument1).then((result) => { + if (result.createInstrument) { + createdInstrumentId = result.createInstrument.id; + + cy.assignInstrumentToCall({ + callId: initialDBData.call.id, + instrumentFapIds: [ + { + instrumentId: createdInstrumentId, + fapId: initialDBData.fap.id, + }, + ], + }); + } + }); + cy.createProposal({ callId: initialDBData.call.id }).then((result) => { + const proposal = result.createProposal; + if (proposal) { + cy.updateProposal({ + proposalPk: proposal.primaryKey, + title: proposalTitle, + abstract: proposalAbstract, + proposerId: initialDBData.users.user1.id, + }); + + cy.submitProposal({ proposalPk: proposal.primaryKey }); + } + }); + cy.login('officer'); + cy.visit('/'); + + cy.finishedLoading(); + + cy.get('[type="checkbox"]').first().check(); + + cy.get('[data-cy="assign-remove-instrument"]').click(); + + cy.get('[data-cy="proposals-instrument-assignment"]') + .contains('Loading...') + .should('not.exist'); + + cy.get('#selectedInstrumentIds-input').first().click(); + + cy.get('[data-cy="instrument-selection-options"] li') + .contains(instrument1.name) + .click(); + + cy.get('[data-cy="submit-assign-remove-instrument"]').click(); + + cy.get('[data-cy="proposals-instrument-assignment"]').should('not.exist'); + + cy.get('[data-cy="view-proposal"]').first().click(); + cy.get('[role="dialog"]').contains('Technical review').click(); + + cy.get('[data-cy="timeAllocation"] input').clear().type('20'); + + cy.get('[data-cy="technical-review-status"]').click(); + cy.get('[data-cy="technical-review-status-options"]') + .contains('Feasible') + .click(); + + cy.get('[data-cy="save-and-continue-button"]').focus().click(); + cy.get('[data-cy="is-review-submitted"]').click(); + cy.get('[data-cy="save-button"]').focus().click(); + + cy.notification({ + variant: 'success', + text: 'Updated', + }); + + cy.closeNotification(); + cy.closeModal(); + + cy.should('not.contain', 'FAP_SELECTION'); + cy.contains('FAP_REVIEW'); + }); + it('Proposal status should update immediately after all Fap reviews submitted', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.FAP_REVIEW)) { this.skip(); @@ -1206,90 +1284,6 @@ context('Settings tests', () => { ); }); - it.skip('User Officer should be able to create proposal workflow with branches', () => { - // Skipping due to flakiness, enable once CI performance is improved - cy.login('officer'); - cy.visit(`/ProposalWorkflowEditor/${createdWorkflowId}`); - - cy.dragStatusIntoWorkflow(statuses.feasibilityReview, { - clientX: 600, - clientY: 400, - }); - cy.finishedLoading(); - cy.notification({ - variant: 'success', - text: 'Workflow status added successfully', - }); - cy.get('[data-cy="workflow_status_FEASIBILITY_REVIEW"]').should( - 'contain.text', - 'FEASIBILITY_REVIEW' - ); - - cy.dragStatusIntoWorkflow(statuses.fapSelection, { - clientX: 300, - clientY: 800, - }); - cy.finishedLoading(); - cy.notification({ - variant: 'success', - text: 'Workflow status added successfully', - }); - cy.get('[data-cy="workflow_status_FAP_SELECTION"]').should( - 'contain.text', - 'FAP_SELECTION' - ); - - cy.dragStatusIntoWorkflow(statuses.notFeasible, { - clientX: 900, - clientY: 800, - }); - cy.finishedLoading(); - cy.notification({ - variant: 'success', - text: 'Workflow status added successfully', - }); - cy.get('[data-cy="workflow_status_NOT_FEASIBLE"]').should( - 'contain.text', - 'NOT_FEASIBLE' - ); - - cy.finishedLoading(); - - cy.get('[title="fit view"]').click(); - - cy.connectReactFlowNodes( - statuses.feasibilityReview, - statuses.fapSelection, - { - force: true, - } - ); - cy.finishedLoading(); - cy.get( - `[aria-label="Edge from FEASIBILITY_REVIEW to FAP_SELECTION"]` - ).should('exist'); - - cy.connectReactFlowNodes( - statuses.feasibilityReview, - statuses.notFeasible, - { - force: true, - } - ); - cy.finishedLoading(); - cy.get( - `[aria-label="Edge from FEASIBILITY_REVIEW to NOT_FEASIBLE"]` - ).should('exist'); - - cy.connectReactFlowNodes(statuses.draft, statuses.feasibilityReview, { - force: true, - }); - cy.finishedLoading(); - cy.get('[aria-label="Edge from DRAFT to FEASIBILITY_REVIEW"]').should( - 'exist' - ); - }); - it('Proposal should follow workflow with branches', function () { if (!featureFlags.getEnabledFeatures().get(FeatureId.TECHNICAL_REVIEW)) { this.skip(); @@ -1444,7 +1438,7 @@ context('Settings tests', () => { cy.addStatusToWorkflow({ statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, - prevId: createdDraftWorkflowStatusId, + prevId: createdDraftWfStatusId, posX: 0, posY: 200, }).then((result) => { diff --git a/apps/frontend/src/components/experiment/ChangeExperimentSafetyStatus.tsx b/apps/frontend/src/components/experiment/ChangeExperimentSafetyStatus.tsx new file mode 100644 index 0000000000..fbe3bdcde7 --- /dev/null +++ b/apps/frontend/src/components/experiment/ChangeExperimentSafetyStatus.tsx @@ -0,0 +1,217 @@ +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import { Form, Formik } from 'formik'; +import React, { useMemo } from 'react'; +import * as yup from 'yup'; + +import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; +import WorkflowView from 'components/settings/workflow/WorkflowView'; +import { Experiment, WorkflowStatus, WorkflowType } from 'generated/sdk'; +import { useWorkflowStatusesData } from 'hooks/settings/useWorkflowStatusesData'; + +const ChangeExperimentSafetyStatusValidationSchema = yup.object().shape({ + selectedWorkflowStatusId: yup + .string() + .required('You must select experiment safety status'), +}); + +type ChangeExperimentSafetyStatusProps = { + close: () => void; + changeStatusOnExperiments: (workflowStatus: WorkflowStatus) => Promise; + selectedExperiments: Experiment[]; +}; + +const ChangeExperimentSafetyStatus = ({ + close, + changeStatusOnExperiments, + selectedExperiments, +}: ChangeExperimentSafetyStatusProps) => { + const selectedExperimentStatuses = selectedExperiments.map( + (experiment) => experiment.experimentSafety?.workflowStatusId + ); + const selectedExperimentsWorkflowIds = selectedExperiments + .map((experiment) => experiment.proposal.call?.experimentWorkflowId) + .filter((id): id is number => !!id); + const { + statuses: experimentStatuses, + loadingStatuses: loadingExperimentStatuses, + } = useWorkflowStatusesData(selectedExperimentsWorkflowIds[0]); + + const allSelectedExperimentsHaveSameWorkflowStatus = + selectedExperimentStatuses.every( + (workflowStatusId) => workflowStatusId === selectedExperimentStatuses[0] + ); + + const allExperimentsHaveSameWorkflow = selectedExperimentsWorkflowIds.every( + (workflowId) => workflowId === selectedExperimentsWorkflowIds[0] + ); + + const selectedExperimentsWorkflowStatus = + allSelectedExperimentsHaveSameWorkflowStatus + ? selectedExperimentStatuses[0] + : null; + + const highlightedNodes = useMemo(() => { + const counts = selectedExperiments.reduce( + (acc, experiment) => { + const workflowStatus = experimentStatuses.find( + (ws) => + ws.workflowStatusId === + experiment.experimentSafety?.workflowStatusId + ); + if (workflowStatus) { + const id = workflowStatus.status.id; + if (!acc[id]) { + acc[id] = []; + } + acc[id].push(experiment.experimentId); + } + + return acc; + }, + {} as Record + ); + + return Object.entries(counts).map(([statusId, entities]) => ({ + statusId, + entities, + })); + }, [selectedExperiments, experimentStatuses]); + + if (!allExperimentsHaveSameWorkflow) { + return ( + + + All selected experiments must belong to the same workflow in order to + change their status. + + + + ); + } + + return ( + + => { + const selectedStatus = experimentStatuses.find( + (status) => + status.workflowStatusId === values.selectedWorkflowStatusId + ); + + if (!selectedStatus) { + actions.setFieldError('selectedWorkflowStatusId', 'Required'); + + return; + } + + await changeStatusOnExperiments(selectedStatus); + close(); + }} + validationSchema={ChangeExperimentSafetyStatusValidationSchema} + > + {({ isSubmitting, values, setFieldValue }): JSX.Element => ( +
+ + + + Change experiment(s) safety status + + + + +
+ + s.workflowStatusId === + (values.selectedWorkflowStatusId || + selectedExperimentsWorkflowStatus) + )?.status.id + } + onNodeClicked={(statusId, workflowStatusId) => { + setFieldValue( + 'selectedWorkflowStatusId', + workflowStatusId + ); + }} + /> +
+
+ + + + + ({ + value: status.workflowStatusId, + text: status.status.name, + }))} + required + disabled={isSubmitting} + data-cy="status-selection" + /> + + + + {!values.selectedWorkflowStatusId && ( + + Be aware that selected experiments have different + statuses and changing status will affect all of them. + + )} + + + + +
+
+ )} +
+
+ ); +}; + +export default ChangeExperimentSafetyStatus; diff --git a/apps/frontend/src/components/experiment/ExperimentReviewContent.tsx b/apps/frontend/src/components/experiment/ExperimentReviewContent.tsx index 63357fd6b1..6770f465e6 100644 --- a/apps/frontend/src/components/experiment/ExperimentReviewContent.tsx +++ b/apps/frontend/src/components/experiment/ExperimentReviewContent.tsx @@ -15,12 +15,14 @@ import { useTranslation } from 'react-i18next'; import SimpleTabs from 'components/common/SimpleTabs'; import StyledDialog from 'components/common/StyledDialog'; import UOLoader from 'components/common/UOLoader'; +import EventLogList from 'components/eventLog/EventLogList'; import ExperimentSafetyReview from 'components/experimentSafetyReview/ExperimentSafetyReview'; import GeneralInformation from 'components/proposal/GeneralInformation'; import { AnswersTable } from 'components/questionary/AnswersTable'; import QuestionaryDetails from 'components/questionary/QuestionaryDetails'; -import { SettingsId } from 'generated/sdk'; +import { SettingsId, UserRole } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; +import { useCheckAccess } from 'hooks/common/useCheckAccess'; import { useExperiment } from 'hooks/experiment/useExperiment'; import { useExperimentSafety } from 'hooks/experimentSafety/useExperimentSafety'; import { useProposalData } from 'hooks/proposal/useProposalData'; @@ -33,6 +35,7 @@ export enum EXPERIMENT_MODAL_TAB_NAMES { EXPERIMENT_SAFETY_FORM = 'Experiment Safety Form', EXPERIMENT_SAFETY_REVIEW = 'Experiment Safety Review', VISIT = 'Visit', + LOGS = 'Logs', } type ExperimentReviewContentProps = { @@ -229,6 +232,8 @@ const ExperimentReviewContent = ({ settingsFormatToUse: SettingsId.DATE_FORMAT, }); + const isUserOfficer = useCheckAccess([UserRole.USER_OFFICER]); + if (experimentLoading) { return ; } @@ -275,6 +280,13 @@ const ExperimentReviewContent = ({ ); + const EventLogsTab = isUserOfficer && ( + + ); + const tabsContent = tabNames.map((tab, index) => { switch (tab) { case EXPERIMENT_MODAL_TAB_NAMES.EXPERIMENT_INFORMATION: @@ -313,6 +325,8 @@ const ExperimentReviewContent = ({ ); + case EXPERIMENT_MODAL_TAB_NAMES.LOGS: + return {EventLogsTab}; default: return null; } diff --git a/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx b/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx index 319bc6e40b..2a3fcb0a5e 100644 --- a/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentVisitsTable.tsx @@ -15,7 +15,7 @@ import { SettingsContext } from 'context/SettingsContextProvider'; import { SettingsId, VisitRegistrationStatus, - GetExperimentsQuery, + GetAllExperimentsQuery, GetExperimentQuery, } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; @@ -26,7 +26,7 @@ import withConfirm, { WithConfirmProps } from 'utils/withConfirm'; type RowType = NonNullable< NonNullable< - NonNullable['experiments'] + NonNullable['experiments'] >[0]['visit'] >['registrations'][0]; diff --git a/apps/frontend/src/components/experiment/ExperimentsTable.tsx b/apps/frontend/src/components/experiment/ExperimentsTable.tsx index 37efceaf96..44a9343ee0 100644 --- a/apps/frontend/src/components/experiment/ExperimentsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentsTable.tsx @@ -1,18 +1,36 @@ import MaterialTable, { + Action, Column, Query, QueryResult, } from '@material-table/core'; import { Visibility } from '@mui/icons-material'; -import { IconButton, Tooltip, Typography } from '@mui/material'; -import React, { useRef, useState } from 'react'; +import { + Dialog, + DialogContent, + IconButton, + Link, + Tooltip, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import React, { useCallback, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Experiment, PaginationSortDirection, SettingsId } from 'generated/sdk'; +import ListStatusIcon from 'components/common/icons/ListStatusIcon'; +import { + Experiment, + PaginationSortDirection, + SettingsId, + UserRole, + WorkflowStatus, +} from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; +import { useCheckAccess } from 'hooks/common/useCheckAccess'; import { setSortDirectionOnSortField } from 'utils/helperFunctions'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import ChangeExperimentSafetyStatus from './ChangeExperimentSafetyStatus'; import ExperimentReviewContent, { EXPERIMENT_MODAL_TAB_NAMES, } from './ExperimentReviewContent'; @@ -45,16 +63,35 @@ const RowActionButtons = (rowData: Experiment) => { ); }; +const ChangeExperimentSafetyStatusIcon = (): JSX.Element => ( + +); + export default function ExperimentsTable({ experimentsFilter, }: ExperimentsTableProps) { const tableRef = React.useRef>(); const { api } = useDataApiWithFeedback(); + const { enqueueSnackbar } = useSnackbar(); const [searchParams, setSearchParams] = useSearchParams(); const [tableData, setTableData] = useState([]); const [selectedExperiment, setSelectedExperiment] = useState(); + const [ + openChangeExperimentSafetyStatus, + setOpenChangeExperimentSafetyStatus, + ] = useState(false); + const isUserOfficer = useCheckAccess([UserRole.USER_OFFICER]); + + const getSelectedExperimentsData = useCallback( + () => + tableData.filter((item) => + searchParams.getAll('selection').includes(item.experimentPk.toString()) + ), + [tableData, searchParams] + ); + const selectedExperimentsData = getSelectedExperimentsData(); const search = searchParams.get('search'); const page = searchParams.get('page'); const pageSize = searchParams.get('pageSize'); @@ -102,7 +139,7 @@ export default function ExperimentsTable({ experimentEndDate, } = experimentsFilter; api() - .getExperiments({ + .getAllExperiments({ filter: { callId, instrumentId, @@ -160,6 +197,17 @@ export default function ExperimentsTable({ } }); + const tableActions: Action[] = [ + { + icon: ChangeExperimentSafetyStatusIcon, + tooltip: 'Change experiment safety status', + onClick: () => { + setOpenChangeExperimentSafetyStatus(true); + }, + position: 'toolbarOnSelect', + }, + ]; + let columns: Column[] = [ { title: 'Experiment ID', @@ -216,6 +264,43 @@ export default function ExperimentsTable({ : undefined ); + // Make the status column clickable to open the workflow tree (only for user officers) + columns = columns.map((column) => { + if (column.field === 'experimentSafety.status.name') { + return { + ...column, + render: (rowData: Experiment) => + isUserOfficer ? ( + { + e.preventDefault(); + setSearchParams((searchParams) => { + searchParams.delete('selection'); + searchParams.append( + 'selection', + rowData.experimentPk.toString() + ); + + return searchParams; + }); + setOpenChangeExperimentSafetyStatus(true); + }} + > + {rowData.experimentSafety?.status?.name ?? 'ESF Not Started'} + + ) : ( + + {rowData.experimentSafety?.status?.name ?? 'ESF Not Started'} + + ), + }; + } + + return column; + }); + const experimentReviewTabs = [ EXPERIMENT_MODAL_TAB_NAMES.EXPERIMENT_INFORMATION, EXPERIMENT_MODAL_TAB_NAMES.PROPOSAL_INFORMATION, @@ -224,8 +309,58 @@ export default function ExperimentsTable({ EXPERIMENT_MODAL_TAB_NAMES.VISIT, ]; + if (isUserOfficer) { + experimentReviewTabs.push(EXPERIMENT_MODAL_TAB_NAMES.LOGS); + } + + const changeStatusOnExperimentSafety = async ( + workflowStatus: WorkflowStatus + ) => { + const selectedExperimentsData = getSelectedExperimentsData(); + const experimentSafetyPks = selectedExperimentsData + .map((experiment) => experiment.experimentSafety?.experimentSafetyPk) + .filter((pk): pk is number => pk !== undefined); + + if (experimentSafetyPks.length !== selectedExperimentsData.length) { + enqueueSnackbar( + 'Cannot change status. Some or all selected experiments do not have required safety information.', + { variant: 'error' } + ); + + return; + } + + if (workflowStatus?.workflowStatusId && experimentSafetyPks.length) { + const shouldAddPluralLetter = experimentSafetyPks.length > 1 ? 's' : ''; + await api({ + toastSuccessMessage: `Experiment Safety${shouldAddPluralLetter} status changed successfully!`, + }).changeExperimentsSafetyStatus({ + experimentSafetyPks: experimentSafetyPks, + workflowStatusId: workflowStatus.workflowStatusId, + }); + refreshTableData(); + } + }; + return (
+ setOpenChangeExperimentSafetyStatus(false)} + fullWidth + > + + setOpenChangeExperimentSafetyStatus(false)} + selectedExperiments={selectedExperimentsData} + /> + + + { setSearchParams((searchParams) => { searchParams.set('pageSize', pageSize.toString()); @@ -262,6 +399,27 @@ export default function ExperimentsTable({ return searchParams; }); }} + onSelectionChange={(selectedItems) => { + if (selectedItems.length) { + setSearchParams((searchParams) => { + searchParams.delete('selection'); + selectedItems.map((selectedItem) => + searchParams.append( + 'selection', + selectedItem.experimentPk.toString() + ) + ); + + return searchParams; + }); + } else { + setSearchParams((searchParams) => { + searchParams.delete('selection'); + + return searchParams; + }); + } + }} onPageChange={(page) => { setSearchParams((searchParams) => { searchParams.set('page', page.toString()); diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafety.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafety.tsx index 56cb0ef39f..b3e39292db 100644 --- a/apps/frontend/src/components/experimentSafety/ExperimentSafety.tsx +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafety.tsx @@ -3,9 +3,11 @@ import React, { useEffect, useState } from 'react'; import UOLoader from 'components/common/UOLoader'; import { GetExperimentSafetyQuery } from 'generated/sdk'; +import { StyledPaper } from 'styles/StyledComponents'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import ExperimentSafetyContainer from './ExperimentSafetyContainer'; +import ExperimentSafetyNotification from './ExperimentSafetyNotification'; interface ExperimentSafetyProps { experimentSafetyPk: number; @@ -36,7 +38,14 @@ function ExperimentSafety({ experimentSafetyPk }: ExperimentSafetyProps) { return ; } - return ; + return ( + <> + + + + + + ); } export default ExperimentSafety; diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx index 42a77d93d5..a1541733bd 100644 --- a/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx @@ -25,6 +25,7 @@ export function createExperimentSafetyStub( return { experimentSafetyPk: 0, experimentPk: 0, + workflowStatusId: 0, esiQuestionaryId: 0, esiQuestionarySubmittedAt: 0, createdBy: 0, diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafetyNotification.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafetyNotification.tsx new file mode 100644 index 0000000000..ca91bd7fed --- /dev/null +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafetyNotification.tsx @@ -0,0 +1,221 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import InfoIcon from '@mui/icons-material/Info'; +import WarningIcon from '@mui/icons-material/Warning'; +import Box from '@mui/material/Box'; +import { alpha, Theme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import React from 'react'; + +import { + ExperimentSafetyReviewerDecisionEnum, + InstrumentScientistDecisionEnum, + GetExperimentSafetyQuery, +} from 'generated/sdk'; +import { StyledPaper } from 'styles/StyledComponents'; + +interface ExperimentSafetyNotificationProps { + experimentSafety: GetExperimentSafetyQuery['experimentSafety']; +} + +const ExperimentSafetyNotification = ({ + experimentSafety, +}: ExperimentSafetyNotificationProps) => { + if (!experimentSafety) { + return null; + } + + const statusId = experimentSafety.statusId; + const instrumentScientistDecision = + experimentSafety.instrumentScientistDecision; + const experimentSafetyReviewerDecision = + experimentSafety.experimentSafetyReviewerDecision; + const instrumentScientistComment = + experimentSafety.instrumentScientistComment; + const experimentSafetyReviewerComment = + experimentSafety.experimentSafetyReviewerComment; + + // Don't show notification for fresh AWAITING_ESF applications + if ( + statusId === 'AWAITING_ESF' && + instrumentScientistDecision !== InstrumentScientistDecisionEnum.REJECTED && + experimentSafetyReviewerDecision !== + ExperimentSafetyReviewerDecisionEnum.REJECTED + ) { + return null; + } + + // Determine the notification type and message + let notificationType: 'success' | 'error' | 'warning' | 'info' = 'info'; + let title: string = ''; + let message: string = ''; + let icon: React.ReactNode = null; + let showResubmitInfo: boolean = false; + const comments: string[] = []; + + if (statusId === 'ESF_REJECTED') { + // Final rejection - cannot resubmit + notificationType = 'error'; + title = 'Experiment Safety Review Rejected'; + message = + 'Your Experiment Safety Review has been rejected and cannot be resubmitted.'; + icon = ; + + if ( + instrumentScientistDecision === + InstrumentScientistDecisionEnum.REJECTED && + instrumentScientistComment + ) { + comments.push(instrumentScientistComment); + } + if ( + experimentSafetyReviewerDecision === + ExperimentSafetyReviewerDecisionEnum.REJECTED && + experimentSafetyReviewerComment + ) { + comments.push(experimentSafetyReviewerComment); + } + } else if ( + statusId === 'AWAITING_ESF' && + (instrumentScientistDecision === InstrumentScientistDecisionEnum.REJECTED || + experimentSafetyReviewerDecision === + ExperimentSafetyReviewerDecisionEnum.REJECTED) + ) { + // Rejection with resubmit opportunity + notificationType = 'warning'; + title = 'Experiment Safety Review Rejected'; + + if ( + instrumentScientistDecision === + InstrumentScientistDecisionEnum.REJECTED && + instrumentScientistComment + ) { + comments.push(instrumentScientistComment); + } + if ( + experimentSafetyReviewerDecision === + ExperimentSafetyReviewerDecisionEnum.REJECTED && + experimentSafetyReviewerComment + ) { + comments.push(experimentSafetyReviewerComment); + } + + message = + comments.length > 0 + ? 'Your Experiment Safety Review has been rejected. You have the opportunity to revise and resubmit your form.' + : 'Your Experiment Safety Review has been rejected. Please reach out to the user officer for further guidance.'; + icon = ; + showResubmitInfo = true; + } else if (statusId === 'ESF_APPROVED') { + // Approved + notificationType = 'success'; + title = 'Experiment Safety Review Approved'; + message = 'Your Experiment Safety Review has been approved successfully.'; + icon = ; + } else { + // Processing/Other states + notificationType = 'info'; + title = 'Experiment Safety Review Under Review'; + message = + 'Your Experiment Safety Review is currently being processed. Please check back later for updates.'; + icon = ; + } + + // Map notification type to colors + const colorMap: Record< + 'success' | 'error' | 'warning' | 'info', + { + bg: (theme: Theme) => string; + border: (theme: Theme) => string; + text: string; + } + > = { + success: { + bg: (theme: Theme) => alpha(theme.palette.success.main, 0.12), + border: (theme: Theme) => + `1px solid ${alpha(theme.palette.success.main, 0.5)}`, + text: 'success.main', + }, + error: { + bg: (theme: Theme) => alpha(theme.palette.error.main, 0.12), + border: (theme: Theme) => + `1px solid ${alpha(theme.palette.error.main, 0.5)}`, + text: 'error.main', + }, + warning: { + bg: (theme: Theme) => alpha(theme.palette.warning.main, 0.12), + border: (theme: Theme) => + `1px solid ${alpha(theme.palette.warning.main, 0.5)}`, + text: 'warning.main', + }, + info: { + bg: (theme: Theme) => alpha(theme.palette.info.main, 0.12), + border: (theme: Theme) => + `1px solid ${alpha(theme.palette.info.main, 0.5)}`, + text: 'info.main', + }, + }; + + const colors = colorMap[notificationType]; + + return ( + + + {icon} + + + + {title} + + + {message} + + {showResubmitInfo && ( + + {comments.length > 0 + ? 'Please review the feedback provided and make the necessary corrections before resubmitting.' + : 'In case of any queries, please reach out to the user officer for further guidance.'} + + )} + {comments.length > 0 && ( + + + Feedback: + + {comments.map((comment, index) => ( + + • {comment} + + ))} + + )} + + + ); +}; + +export default ExperimentSafetyNotification; diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafetyPage.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafetyPage.tsx index 826c7a7341..2f91145ab2 100644 --- a/apps/frontend/src/components/experimentSafety/ExperimentSafetyPage.tsx +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafetyPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import NotFound from 'components/common/NotFound'; -import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; +import { StyledContainer } from 'styles/StyledComponents'; import ExperimentSafety from './ExperimentSafety'; @@ -15,9 +15,7 @@ function ExperimentSafetyPage() { return ( - - - + ); } diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafetyReview.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafetyReview.tsx index 5cb6c23b72..8dd27fb693 100644 --- a/apps/frontend/src/components/experimentSafety/ExperimentSafetyReview.tsx +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafetyReview.tsx @@ -6,10 +6,6 @@ import { createMissingContextErrorMessage, QuestionaryContext, } from 'components/questionary/QuestionaryContext'; -import { - ExperimentSafetyReviewerDecisionEnum, - InstrumentScientistDecisionEnum, -} from 'generated/sdk'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { FunctionType } from 'utils/utilTypes'; import withConfirm, { WithConfirmType } from 'utils/withConfirm'; @@ -34,30 +30,13 @@ function ExperimentSafetyReview({ confirm }: ExperimentSafetyReviewProps) { const isSubmitted = state.experimentSafety.esiQuestionarySubmittedAt !== null; - // Check if any decision has been made (not null, not undefined, not UNSET) - const hasInstrumentScientistDecision = - state.experimentSafety.instrumentScientistDecision !== null && - state.experimentSafety.instrumentScientistDecision !== undefined && - state.experimentSafety.instrumentScientistDecision !== - InstrumentScientistDecisionEnum.UNSET; - - const hasExperimentSafetyReviewerDecision = - state.experimentSafety.experimentSafetyReviewerDecision !== null && - state.experimentSafety.experimentSafetyReviewerDecision !== undefined && - state.experimentSafety.experimentSafetyReviewerDecision !== - ExperimentSafetyReviewerDecisionEnum.UNSET; - - const hasDecision = - hasInstrumentScientistDecision || hasExperimentSafetyReviewerDecision; - - const isDisabled = isSubmitted || hasDecision; + const isDisabled = + isSubmitted && state.experimentSafety.statusId !== 'AWAITING_ESF'; // Create appropriate button label let buttonLabel = 'Submit'; - if (isSubmitted) { + if (isDisabled) { buttonLabel = '✔ Submitted'; - } else if (hasDecision) { - buttonLabel = '✔ Decision Made'; } return ( diff --git a/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx b/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx index 015858a726..6bde98af4f 100644 --- a/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx +++ b/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx @@ -6,7 +6,7 @@ import ListSubheader from '@mui/material/ListSubheader'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import TextField from '@mui/material/TextField'; -import React, { useContext, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import { NavigButton } from 'components/common/NavigButton'; import NavigationFragment from 'components/questionary/NavigationFragment'; @@ -86,6 +86,32 @@ function ExperimentSafetyReviewSummary({ const downloadExperimentSafety = useDownloadPDFExperimentSafety(); + // Poll API every 5 seconds to check if experiment safety review is approved + useEffect(() => { + if (!state?.experimentSafety.experimentSafetyPk) { + return; + } + + const pollInterval = setInterval(async () => { + try { + const { experimentSafety } = await api().getExperimentSafety({ + experimentSafetyPk: state.experimentSafety.experimentSafetyPk, + }); + + if (experimentSafety) { + setIsDownloadEnabled(experimentSafety.status?.id === 'ESF_APPROVED'); + } + } catch (error) { + // Silently fail on polling errors to not interrupt user experience + api().addClientLog({ + error: `Error polling experiment safety status: ${String(error)}`, + }); + } + }, 5000); + + return () => clearInterval(pollInterval); + }, [state?.experimentSafety.experimentSafetyPk, api]); + if (!state?.experimentSafety) { throw new Error('Experiment safety review not found'); } diff --git a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx index 3164dd87a5..12175e5cb2 100644 --- a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx +++ b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx @@ -57,6 +57,11 @@ const ChangeProposalStatus = ({ (id) => id === selectedProposalsWorkflowIds[0] ); + const selectedProposalsWorkflowStatus = + allSelectedProposalsHaveSameWorkflowStatus + ? selectedProposalStatuses[0] + : null; + const highlightedNodes = useMemo(() => { const counts = selectedProposals.reduce( (acc, proposal) => { @@ -64,11 +69,11 @@ const ChangeProposalStatus = ({ (ws) => ws.workflowStatusId === proposal.workflowStatusId ); if (workflowStatus) { - const statusId = workflowStatus.status.id; - if (!acc[statusId]) { - acc[statusId] = []; + const id = workflowStatus.status.id; + if (!acc[id]) { + acc[id] = []; } - acc[statusId].push(proposal.proposalId); + acc[id].push(proposal.proposalId); } return acc; @@ -103,11 +108,6 @@ const ChangeProposalStatus = ({ ); } - const selectedProposalsWorkflowStatus = - allSelectedProposalsHaveSameWorkflowStatus - ? selectedProposalStatuses[0] - : null; - return ( - {entityType == WorkflowType.PROPOSAL && ( - { - dispatch({ - type: EventType.ADD_STATUS_ACTION_REQUESTED, - payload: { - statusActions, - workflowConnection: workflowConnection!, - }, - }); - }} - connectionStatusActions={workflowConnection?.statusActions} - statusName={workflowConnection?.nextStatus.status.name} - isLoading={isLoading} - /> - )} + { + dispatch({ + type: EventType.ADD_STATUS_ACTION_REQUESTED, + payload: { + statusActions, + workflowConnection: workflowConnection!, + }, + }); + }} + connectionStatusActions={workflowConnection?.statusActions} + statusName={workflowConnection?.nextStatus.status.name} + isLoading={isLoading} + /> diff --git a/apps/frontend/src/graphql/experiment/changeExperimentsSafetyStatus.graphql b/apps/frontend/src/graphql/experiment/changeExperimentsSafetyStatus.graphql new file mode 100644 index 0000000000..86b97bf324 --- /dev/null +++ b/apps/frontend/src/graphql/experiment/changeExperimentsSafetyStatus.graphql @@ -0,0 +1,11 @@ +mutation changeExperimentsSafetyStatus( + $experimentSafetyPks: [Int!]! + $workflowStatusId: Int! +) { + changeExperimentsSafetyStatus( + changeExperimentsSafetyStatusInput: { + experimentSafetyPks: $experimentSafetyPks + workflowStatusId: $workflowStatusId + } + ) +} diff --git a/apps/frontend/src/graphql/experiment/fragment.experimentSafety.graphql b/apps/frontend/src/graphql/experiment/fragment.experimentSafety.graphql index f5abd1f6ab..62ba53016e 100644 --- a/apps/frontend/src/graphql/experiment/fragment.experimentSafety.graphql +++ b/apps/frontend/src/graphql/experiment/fragment.experimentSafety.graphql @@ -5,6 +5,7 @@ fragment experimentSafety on ExperimentSafety { esiQuestionarySubmittedAt createdBy statusId + workflowStatusId safetyReviewQuestionaryId reviewedBy createdAt diff --git a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql index 8b0a14c634..e9385964ca 100644 --- a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql +++ b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql @@ -1,4 +1,4 @@ -query getExperiments( +query getAllExperiments( $filter: ExperimentsFilter $first: Int $offset: Int @@ -21,6 +21,11 @@ query getExperiments( proposer { ...basicUserDetails } + call { + id + shortCode + experimentWorkflowId + } } experimentSafety { ...experimentSafety diff --git a/documentation/docs/developer-guide/rabbitmq.md b/documentation/docs/developer-guide/rabbitmq.md index ea674df793..ea7c0d1dcf 100644 --- a/documentation/docs/developer-guide/rabbitmq.md +++ b/documentation/docs/developer-guide/rabbitmq.md @@ -34,7 +34,7 @@ The `rabbitMQActionHandler` function in the backend is responsible for sending m export const rabbitMQActionHandler = async ( statusAction: ConnectionHasStatusAction, - proposals: WorkflowEngineProposalType[] + proposals: Proposal[] ) => { const config = statusAction.config as RabbitMQActionConfig; if (!config.exchanges?.length) {