diff --git a/apps/backend/db_patches/0207_AddNewWorkflowDataStructures.sql b/apps/backend/db_patches/0207_AddNewWorkflowDataStructures.sql new file mode 100644 index 0000000000..b54e87075b --- /dev/null +++ b/apps/backend/db_patches/0207_AddNewWorkflowDataStructures.sql @@ -0,0 +1,146 @@ +DO +$$ +BEGIN + IF register_patch( + 'Add_new_workflow_data_structures', + 'Jekabs Karklins', + 'Add new workflow data structures for improved workflow management.', + '2025-12-20' + ) THEN + BEGIN + + -- =============================== + -- 0) Update workflows table + -- =============================== + ALTER TABLE workflows ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); + + -- =============================== + -- 1) workflow_has_statuses + -- =============================== + CREATE TABLE workflow_has_statuses ( + workflow_status_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + workflow_id INT NOT NULL, + status_id INT NOT NULL, + pos_x INT NOT NULL DEFAULT 0, + pos_y INT NOT NULL DEFAULT 0, + + CONSTRAINT fk_whs_workflow + FOREIGN KEY (workflow_id) REFERENCES workflows (workflow_id), + + CONSTRAINT fk_whs_status + FOREIGN KEY (status_id) REFERENCES statuses (status_id) + ); + + -- (1) Make (workflow_id, workflow_status_id) uniquely addressable so we can + -- use it as a composite FK target from transitions. + ALTER TABLE workflow_has_statuses + ADD CONSTRAINT uq_whs_workflow_and_state UNIQUE (workflow_id, workflow_status_id); + + + + -- =============================== + -- 2) workflow_status_connections + -- =============================== + CREATE TABLE workflow_status_connections ( + workflow_status_connection_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + workflow_id INT NOT NULL, + prev_workflow_status_id INT NOT NULL, + next_workflow_status_id INT NOT NULL, + source_handle VARCHAR(255) NOT NULL DEFAULT 'bottom-source', + target_handle VARCHAR(255) NOT NULL DEFAULT 'top-target', + + CONSTRAINT fk_wsc_workflow + FOREIGN KEY (workflow_id) REFERENCES workflows (workflow_id), + + -- (1) Enforce that prev/next states belong to the same workflow by referencing + -- the composite key on workflow_has_statuses. + CONSTRAINT fk_wsc_prev_state + FOREIGN KEY (workflow_id, prev_workflow_status_id) + REFERENCES workflow_has_statuses (workflow_id, workflow_status_id) + ON DELETE CASCADE, + + CONSTRAINT fk_wsc_next_state + FOREIGN KEY (workflow_id, next_workflow_status_id) + REFERENCES workflow_has_statuses (workflow_id, workflow_status_id) + ON DELETE CASCADE + ); + + -- (2) Prevent duplicate edges within a workflow. + ALTER TABLE workflow_status_connections + ADD CONSTRAINT uq_wsc_edge UNIQUE (workflow_id, prev_workflow_status_id, next_workflow_status_id); + + + -- ===================================================================== + -- 4) workflow_status_connection_has_events (edge→events) + -- ===================================================================== + CREATE TABLE workflow_status_connection_has_events ( + workflow_status_connection_id BIGINT NOT NULL, + status_changing_event TEXT NOT NULL, + + CONSTRAINT pk_wsc_has_events + PRIMARY KEY (workflow_status_connection_id, status_changing_event), + + CONSTRAINT fk_wsche_connection + FOREIGN KEY (workflow_status_connection_id) + REFERENCES workflow_status_connections (workflow_status_connection_id) + ON DELETE CASCADE + ); + + -- The composite PK already prevents duplicates for (connection, event). + + + -- Rename existing status_actions table to workflow_status_actions + ALTER TABLE status_actions RENAME TO workflow_status_actions; + ALTER table workflow_status_actions + RENAME COLUMN status_action_id TO workflow_status_action_id; + + -- ========================================================== + -- 5) workflow_status_connection_has_actions (edge→actions) + -- ========================================================== + CREATE TABLE workflow_status_connection_has_actions ( + workflow_status_connection_id INT NOT NULL, + workflow_status_action_id INT NOT NULL, + workflow_id INT NOT NULL, + config JSONB, + + CONSTRAINT pk_wsc_has_actions + PRIMARY KEY (workflow_status_connection_id, workflow_status_action_id), + + CONSTRAINT fk_wsca_workflow + FOREIGN KEY (workflow_id) + REFERENCES workflows (workflow_id), + + CONSTRAINT fk_wsca_connection + FOREIGN KEY (workflow_status_connection_id) + REFERENCES workflow_status_connections (workflow_status_connection_id) + ON DELETE CASCADE, + + CONSTRAINT fk_wsca_action + FOREIGN KEY (workflow_status_action_id) + REFERENCES workflow_status_actions (workflow_status_action_id) + ); + + -- ================================================================== + -- 7) Link proposals and experiment_safety to the new workflow graph + -- ================================================================== + ALTER TABLE proposals + ADD COLUMN workflow_status_id INT NULL; + + ALTER TABLE proposals + ADD CONSTRAINT fk_proposals_workflow_status + FOREIGN KEY (workflow_status_id) + REFERENCES workflow_has_statuses (workflow_status_id); + + ALTER TABLE experiment_safety + ADD COLUMN workflow_status_id INT NULL; + + ALTER TABLE experiment_safety + ADD CONSTRAINT fk_experiment_safety_workflow_status + FOREIGN KEY (workflow_status_id) + REFERENCES workflow_has_statuses (workflow_status_id); + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0208_MigrateOldWorkflowsToNewDataStructures.sql b/apps/backend/db_patches/0208_MigrateOldWorkflowsToNewDataStructures.sql new file mode 100644 index 0000000000..92b7647d85 --- /dev/null +++ b/apps/backend/db_patches/0208_MigrateOldWorkflowsToNewDataStructures.sql @@ -0,0 +1,317 @@ +DO +$$ +DECLARE + v_node_count BIGINT; + v_workflow_status_id INT; + v_prev_workflow_status_id INT; + v_next_workflow_status_id INT; + v_connection_id INT; + v_unmapped_actions BIGINT := 0; + v_unmapped_events BIGINT := 0; + v_logs_updated BIGINT := 0; + v_unmapped_logs BIGINT := 0; + has_status_actions_logs BOOLEAN := FALSE; + v_proposals_updated BIGINT := 0; + v_unmapped_proposals BIGINT := 0; + node_rec RECORD; + edge_rec RECORD; +BEGIN + IF register_patch( + 'Migrate_workflow', + 'Jekabs Karklins', + 'Migrate workflow data to new structures.', + '2026-01-05' + ) THEN + BEGIN + + + + -- Pre-migration validation: abort if any proposal has a status_id that + -- is not present as a node in the workflow the proposal belongs to. + -- Such proposals would end up with workflow_status_id = NULL, which is invalid. + DECLARE + v_validation_rec RECORD; + v_abort_msg TEXT := ''; + BEGIN + CREATE TEMP TABLE tmp_pre_val_call_workflow ( + call_id INT PRIMARY KEY, + workflow_id INT + ) ON COMMIT DROP; + + INSERT INTO tmp_pre_val_call_workflow (call_id, workflow_id) + SELECT call_id, proposal_workflow_id + FROM call + WHERE proposal_workflow_id IS NOT NULL; + + FOR v_validation_rec IN + SELECT array_agg(p.proposal_pk ORDER BY p.proposal_pk) AS proposal_pks, + p.status_id, + cw.workflow_id, + w.name AS workflow_name + FROM proposals p + JOIN tmp_pre_val_call_workflow cw ON cw.call_id = p.call_id + JOIN workflows w ON w.workflow_id = cw.workflow_id + WHERE p.status_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM workflow_connections wc + WHERE wc.workflow_id = cw.workflow_id + AND wc.status_id = p.status_id + ) + GROUP BY p.status_id, cw.workflow_id, w.name + LOOP + v_abort_msg := v_abort_msg || format( + E'\n Proposal(s) %s have status_id=%s which is not part of workflow (id=%s, name=''%s'').', + v_validation_rec.proposal_pks::TEXT, + v_validation_rec.status_id, + v_validation_rec.workflow_id, + v_validation_rec.workflow_name + ); + END LOOP; + + IF v_abort_msg <> '' THEN + RAISE EXCEPTION E'Migration aborted:%\nPlease ensure that each proposal''s status_id exists as a node in its assigned workflow before running this migration.', + v_abort_msg; + END IF; + + DROP TABLE IF EXISTS tmp_pre_val_call_workflow; + END; + + ALTER TABLE status_actions_logs + DROP CONSTRAINT IF EXISTS status_actions_logs_connection_id_action_id_fkey, + DROP CONSTRAINT IF EXISTS status_actions_logs_connection_id_fkey, + DROP CONSTRAINT IF EXISTS status_actions_logs_action_id_fkey; + + CREATE TEMP TABLE tmp_workflow_status_map ( + workflow_connection_id INT PRIMARY KEY, + workflow_status_id INT NOT NULL + ) ON COMMIT DROP; + + CREATE TEMP TABLE tmp_workflow_edge_map ( + workflow_connection_id INT PRIMARY KEY, + workflow_status_connection_id INT NOT NULL + ) ON COMMIT DROP; + + FOR node_rec IN + SELECT workflow_connection_id, + workflow_id, + status_id, + COALESCE(pos_x, 0) AS pos_x, + COALESCE(pos_y, 0) AS pos_y + FROM workflow_connections + ORDER BY workflow_connection_id + LOOP + INSERT INTO workflow_has_statuses (workflow_id, status_id, pos_x, pos_y) + VALUES (node_rec.workflow_id, node_rec.status_id, node_rec.pos_x, node_rec.pos_y) + RETURNING workflow_status_id INTO v_workflow_status_id; + + INSERT INTO tmp_workflow_status_map (workflow_connection_id, workflow_status_id) + VALUES (node_rec.workflow_connection_id, v_workflow_status_id); + END LOOP; + + FOR edge_rec IN + SELECT workflow_connection_id, + workflow_id, + prev_connection_id + FROM workflow_connections + WHERE prev_connection_id IS NOT NULL + ORDER BY workflow_connection_id + LOOP + SELECT workflow_status_id + INTO STRICT v_prev_workflow_status_id + FROM tmp_workflow_status_map + WHERE workflow_connection_id = edge_rec.prev_connection_id; + + SELECT workflow_status_id + INTO STRICT v_next_workflow_status_id + FROM tmp_workflow_status_map + WHERE workflow_connection_id = edge_rec.workflow_connection_id; + + v_connection_id := NULL; + + INSERT INTO workflow_status_connections (workflow_id, prev_workflow_status_id, next_workflow_status_id) + VALUES (edge_rec.workflow_id, v_prev_workflow_status_id, v_next_workflow_status_id) + ON CONFLICT (workflow_id, prev_workflow_status_id, next_workflow_status_id) DO NOTHING + RETURNING workflow_status_connection_id INTO v_connection_id; + + IF v_connection_id IS NULL THEN + SELECT workflow_status_connection_id + INTO STRICT v_connection_id + FROM workflow_status_connections + WHERE workflow_id = edge_rec.workflow_id + AND prev_workflow_status_id = v_prev_workflow_status_id + AND next_workflow_status_id = v_next_workflow_status_id; + END IF; + + INSERT INTO tmp_workflow_edge_map (workflow_connection_id, workflow_status_connection_id) + VALUES (edge_rec.workflow_connection_id, v_connection_id) + ON CONFLICT (workflow_connection_id) DO UPDATE + SET workflow_status_connection_id = EXCLUDED.workflow_status_connection_id; + END LOOP; + + INSERT INTO workflow_status_connection_has_actions ( + workflow_status_connection_id, + workflow_status_action_id, + workflow_id, + config + ) + SELECT edge_map.workflow_status_connection_id, + action.action_id, + COALESCE(action.workflow_id, conn.workflow_id), + COALESCE(action.config, '{}'::jsonb) + FROM workflow_connection_has_actions action + JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = action.connection_id + JOIN workflow_connections conn + ON conn.workflow_connection_id = action.connection_id; + + SELECT COUNT(*) INTO v_unmapped_actions + FROM workflow_connection_has_actions action + LEFT JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = action.connection_id + WHERE edge_map.workflow_connection_id IS NULL; + + IF v_unmapped_actions > 0 THEN + RAISE WARNING USING MESSAGE = format('%s workflow actions could not be migrated because their connection does not reference a previous node.', v_unmapped_actions); + END IF; + + INSERT INTO workflow_status_connection_has_events ( + workflow_status_connection_id, + status_changing_event + ) + SELECT edge_map.workflow_status_connection_id, + events.status_changing_event::text + FROM status_changing_events events + JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = events.workflow_connection_id; + + SELECT COUNT(*) INTO v_unmapped_events + FROM status_changing_events events + LEFT JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = events.workflow_connection_id + WHERE edge_map.workflow_connection_id IS NULL; + + IF v_unmapped_events > 0 THEN + RAISE WARNING USING MESSAGE = format('%s workflow events could not be migrated because their connection does not reference a previous node.', v_unmapped_events); + END IF; + + IF has_status_actions_logs THEN + UPDATE status_actions_logs sal + SET connection_id = edge_map.workflow_status_connection_id + FROM workflow_connection_has_actions action + JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = action.connection_id + WHERE sal.connection_id = action.connection_id + AND sal.action_id = action.action_id; + + GET DIAGNOSTICS v_logs_updated = ROW_COUNT; + + SELECT COUNT(*) INTO v_unmapped_logs + FROM status_actions_logs sal + LEFT JOIN workflow_connection_has_actions action + ON action.connection_id = sal.connection_id + AND action.action_id = sal.action_id + LEFT JOIN tmp_workflow_edge_map edge_map + ON edge_map.workflow_connection_id = action.connection_id + WHERE edge_map.workflow_connection_id IS NULL; + + IF v_unmapped_logs > 0 THEN + RAISE WARNING USING MESSAGE = format('%s status action logs could not be remapped because their workflow connection is missing a previous node.', v_unmapped_logs); + END IF; + + ALTER TABLE status_actions_logs + ADD CONSTRAINT status_actions_logs_connection_id_fkey + FOREIGN KEY (connection_id) + REFERENCES workflow_status_connections (workflow_status_connection_id) + ON DELETE CASCADE, + ADD CONSTRAINT status_actions_logs_action_id_fkey + FOREIGN KEY (action_id) + REFERENCES workflow_status_actions (workflow_status_action_id) + ON DELETE CASCADE; + END IF; + + CREATE TEMP TABLE tmp_call_workflow_map ( + call_id INT PRIMARY KEY, + workflow_id INT + ) ON COMMIT DROP; + + INSERT INTO tmp_call_workflow_map (call_id, workflow_id) + SELECT call_id, proposal_workflow_id + FROM call + WHERE proposal_workflow_id IS NOT NULL; + + CREATE TEMP TABLE tmp_workflow_status_lookup ( + workflow_id INT NOT NULL, + status_id INT NOT NULL, + workflow_status_id INT NOT NULL, + PRIMARY KEY (workflow_id, status_id) + ) ON COMMIT DROP; + + INSERT INTO tmp_workflow_status_lookup (workflow_id, status_id, workflow_status_id) + SELECT workflow_id, + status_id, + MIN(workflow_status_id) AS workflow_status_id + FROM workflow_has_statuses + GROUP BY workflow_id, status_id; + + WITH proposal_status_matches AS ( + SELECT p.proposal_pk, + lookup.workflow_status_id + FROM proposals p + JOIN tmp_call_workflow_map call_map + ON call_map.call_id = p.call_id + JOIN tmp_workflow_status_lookup lookup + ON lookup.workflow_id = call_map.workflow_id + AND lookup.status_id = p.status_id + ) + UPDATE proposals p + SET workflow_status_id = matches.workflow_status_id + FROM proposal_status_matches matches + WHERE matches.proposal_pk = p.proposal_pk; + + GET DIAGNOSTICS v_proposals_updated = ROW_COUNT; + + SELECT COUNT(*) + INTO v_unmapped_proposals + FROM proposals p + LEFT JOIN tmp_call_workflow_map call_map + ON call_map.call_id = p.call_id + LEFT JOIN tmp_workflow_status_lookup lookup + ON lookup.workflow_id = call_map.workflow_id + AND lookup.status_id = p.status_id + WHERE p.workflow_status_id IS NULL + AND call_map.workflow_id IS NOT NULL + AND p.status_id IS NOT NULL; + + IF v_proposals_updated = 0 THEN + RAISE NOTICE 'No proposals required workflow_status_id backfill.'; + ELSE + RAISE NOTICE USING MESSAGE = format('%s proposals updated with workflow_status_id.', v_proposals_updated); + END IF; + + IF v_unmapped_proposals > 0 THEN + RAISE WARNING USING MESSAGE = format('%s proposals could not be mapped to workflow_status_id during migration.', v_unmapped_proposals); + END IF; + + ALTER TABLE proposals ALTER COLUMN workflow_status_id SET NOT NULL; + + DROP VIEW IF EXISTS proposal_table_view; + DROP VIEW IF EXISTS review_data; + ALTER TABLE proposals DROP CONSTRAINT IF EXISTS proposals_proposal_statuses_id_fkey; + ALTER TABLE proposals DROP COLUMN status_id; + + ALTER TABLE experiment_safety DROP COLUMN IF EXISTS status_id; + + DROP TABLE IF EXISTS workflow_connection_has_actions; + DROP TABLE IF EXISTS status_changing_events; + DROP TABLE IF EXISTS workflow_connections; + DROP SEQUENCE IF EXISTS proposal_workflow_connections_proposal_workflow_connection__seq; + DROP SEQUENCE IF EXISTS next_status_events_next_status_event_id_seq; + + DROP TABLE IF EXISTS proposal_events; + DROP TABLE IF EXISTS experiment_safety_events; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/db_patches/0209_RenameShortCodeToStatusId.sql b/apps/backend/db_patches/0209_RenameShortCodeToStatusId.sql new file mode 100644 index 0000000000..3cd0792fcf --- /dev/null +++ b/apps/backend/db_patches/0209_RenameShortCodeToStatusId.sql @@ -0,0 +1,308 @@ +DO +$$ +DECLARE + -- Variable to hold count of affected rows for logging/verification + v_count bigint; +BEGIN + IF register_patch( + 'RenameShortCodeToStatusId', + 'Jekabs Karklins', + 'Make short_code to be the primary key (renaming it to status_id) and update references.', + '2026-01-12' + ) THEN + BEGIN + + -- ========================================== + -- 0. Drop Dependent Views + -- ========================================== + -- 'proposal_table_view' depends on proposals.status_id and statuses.status_id + -- 'review_data' depends on proposals.status_id + -- We drop them now and recreate them at the end with the new schema types. + DROP VIEW IF EXISTS proposal_table_view; + DROP VIEW IF EXISTS review_data; + + + -- ========================================== + -- 1. Prepare 'statuses' table + -- ========================================== + + -- Drop the existing primary key constraint on statuses.status_id + -- We will re-add it later on the new text column + -- CASCADE should drop FKs from other tables referencing this PK + ALTER TABLE statuses DROP CONSTRAINT proposal_statuses_pkey CASCADE; + + -- Rename existing columns to prepare for the swap + -- status_id (int) -> old_id + ALTER TABLE statuses RENAME COLUMN status_id TO old_id; + + -- short_code (varchar) -> status_id + ALTER TABLE statuses RENAME COLUMN short_code TO status_id; + + -- Ensure the new status_id is not null and unique (it should be if it's becoming PK) + ALTER TABLE statuses ALTER COLUMN status_id SET NOT NULL; + + -- Make it the Primary Key + ALTER TABLE statuses ADD PRIMARY KEY (status_id); + + + -- ========================================== + -- 2. Migrate 'workflow_has_statuses' + -- ========================================== + + -- Add temporary column + ALTER TABLE workflow_has_statuses ADD COLUMN new_status_id VARCHAR(50); + + -- Populate new column using the join on the old ID + UPDATE workflow_has_statuses whs + SET new_status_id = s.status_id + FROM statuses s + WHERE whs.status_id = s.old_id; + + -- Drop old column + ALTER TABLE workflow_has_statuses DROP COLUMN status_id; + + -- Rename new column + ALTER TABLE workflow_has_statuses RENAME COLUMN new_status_id TO status_id; + ALTER TABLE workflow_has_statuses ALTER COLUMN status_id SET NOT NULL; + + -- Add Foreign Key + ALTER TABLE workflow_has_statuses + ADD CONSTRAINT fk_whs_status FOREIGN KEY (status_id) REFERENCES statuses (status_id) ON UPDATE CASCADE ON DELETE CASCADE; + + + -- ========================================== + -- 3. Skip 'experiment_safety' (no status_id column) + -- ========================================== + -- experiment_safety no longer uses direct status_id column. + -- Status is looked up via workflow_status_id → workflow_has_statuses → status_id + -- Foreign key constraint already added in 0207_AddNewWorkflowDataStructures.sql + + + -- ========================================== + -- 5. Migrate 'workflow_status_actions' + -- ========================================== + -- Checking if table exists and column exists (it should based on schema history) + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'workflow_status_actions') THEN + + -- Check for 'proposal_status_id' (legacy name) + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'workflow_status_actions' AND column_name = 'proposal_status_id') THEN + + ALTER TABLE workflow_status_actions ADD COLUMN new_status_id VARCHAR(50); + + UPDATE workflow_status_actions wsa + SET new_status_id = s.status_id + FROM statuses s + WHERE wsa.proposal_status_id = s.old_id; + + ALTER TABLE workflow_status_actions DROP COLUMN proposal_status_id; + + -- Standardize on 'status_id' + ALTER TABLE workflow_status_actions RENAME COLUMN new_status_id TO status_id; + ALTER TABLE workflow_status_actions ALTER COLUMN status_id SET NOT NULL; + + ALTER TABLE workflow_status_actions + ADD CONSTRAINT fk_wsa_status FOREIGN KEY (status_id) REFERENCES statuses (status_id) ON UPDATE CASCADE ON DELETE CASCADE; + + -- Check if it was already 'status_id' + ELSIF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'workflow_status_actions' AND column_name = 'status_id') THEN + + ALTER TABLE workflow_status_actions ADD COLUMN new_status_id VARCHAR(50); + + UPDATE workflow_status_actions wsa + SET new_status_id = s.status_id + FROM statuses s + WHERE wsa.status_id = s.old_id; + + ALTER TABLE workflow_status_actions DROP COLUMN status_id; + ALTER TABLE workflow_status_actions RENAME COLUMN new_status_id TO status_id; + ALTER TABLE workflow_status_actions ALTER COLUMN status_id SET NOT NULL; + + ALTER TABLE workflow_status_actions + ADD CONSTRAINT fk_wsa_status FOREIGN KEY (status_id) REFERENCES statuses (status_id) ON UPDATE CASCADE ON DELETE CASCADE; + + END IF; + END IF; + + + -- ========================================== + -- 6. Cleanup 'statuses' + -- ========================================== + + -- Drop the old integer ID column + ALTER TABLE statuses DROP COLUMN old_id; + + + -- ========================================== + -- 7. Recreate 'proposal_table_view' + -- ========================================== + -- Copied from 0184_AddMultipleTechReviewsEnabledToInstInPropsalTableView.sql + + CREATE VIEW proposal_table_view AS + SELECT + p.proposal_pk, + p.title, + p.proposer_id AS principal_investigator, + p.workflow_status_id AS proposal_workflow_status_id, + whs.status_id AS proposal_status_id, + s.name AS proposal_status_name, + s.description AS proposal_status_description, + p.proposal_id, + p.final_status, + p.notified, + p.questionary_id, + p.submitted, + p.submitted_date, + t.technical_reviews, + ihp.instruments, + fp.faps, + fp.fap_instruments, + c.call_short_code, + c.allocation_time_unit, + c.call_id, + c.proposal_workflow_id + FROM proposals p + LEFT JOIN workflow_has_statuses whs ON whs.workflow_status_id = p.workflow_status_id + LEFT JOIN statuses s ON s.status_id = whs.status_id + LEFT JOIN call c ON c.call_id = p.call_id + LEFT JOIN ( + SELECT + fp_1.proposal_pk, + jsonb_agg( + jsonb_build_object( + 'id', f.fap_id, + 'code', f.code + ) ORDER BY fp_1.fap_proposal_id ASC + ) AS faps, + jsonb_agg( + jsonb_build_object( + 'instrumentId', fp_1.instrument_id, + 'fapId', f.fap_id + ) + ) AS fap_instruments + FROM fap_proposals fp_1 + JOIN faps f ON f.fap_id = fp_1.fap_id + GROUP BY fp_1.proposal_pk + ) fp ON fp.proposal_pk = p.proposal_pk + LEFT JOIN ( + SELECT + t_1.proposal_pk, + jsonb_agg( + jsonb_build_object( + 'id', t_1.technical_review_id, + 'timeAllocation', t_1.time_allocation, + 'technicalReviewAssignee', ( + SELECT jsonb_build_object( + 'id', u.user_id, + 'firstname', u.firstname, + 'lastname', u.lastname + ) + FROM users u + WHERE u.user_id = t_1.technical_review_assignee_id + ), + 'status', t_1.status, + 'submitted', t_1.submitted, + 'internalReviewers', ( + SELECT jsonb_agg(jsonb_build_object('id', ir.reviewer_id)) + FROM internal_reviews ir + WHERE ir.technical_review_id = t_1.technical_review_id + ), + 'instrumentId', t_1.instrument_id + ) ORDER BY t_1.technical_review_id ASC + ) AS technical_reviews + FROM technical_review t_1 + GROUP BY t_1.proposal_pk + ) t ON t.proposal_pk = p.proposal_pk + LEFT JOIN ( + SELECT + ihp_1.proposal_pk, + jsonb_agg( + jsonb_build_object( + 'id', ihp_1.instrument_id, + 'name', i.name, + 'managerUserId', i.manager_user_id, + 'managementTimeAllocation', ihp_1.management_time_allocation, + 'multipleTechReviewsEnabled', i.multiple_tech_reviews_enabled, + 'scientists', ( + SELECT jsonb_agg(jsonb_build_object('id', ihs.user_id)) + FROM instrument_has_scientists ihs + WHERE ihs.instrument_id = ihp_1.instrument_id + ) + ) ORDER BY ihp_1.instrument_has_proposals_id ASC + ) AS instruments + FROM instrument_has_proposals ihp_1 + JOIN instruments i ON i.instrument_id = ihp_1.instrument_id + GROUP BY ihp_1.proposal_pk + ) ihp ON ihp.proposal_pk = p.proposal_pk + ORDER BY p.proposal_pk; + + + + -- ========================================== + -- 8. Recreate 'review_data' + -- ========================================== + -- Copied from 0200_ChangeFapGradeToString.sql + -- Updated WHERE clause for status_id strings (9->EXPIRED, 1->DRAFT) + + CREATE VIEW review_data AS + SELECT + proposal.proposal_pk, + proposal.proposal_id, + proposal.title, + proposal.instrument_name, + proposal.availability_time, + proposal.time_allocation, + proposal.fap_id, + proposal.rank_order, + proposal.call_id, + proposal.proposer_id, + proposal.instrument_id, + proposal.fap_time_allocation, + proposal.questionary_id, + grade.avg AS average_grade, + proposal.public_comment AS comment + FROM ( + SELECT + fp.proposal_pk, + p.proposal_id, + p.title, + i.name AS instrument_name, + chi.availability_time, + tr.time_allocation, + f.fap_id, + fmd.rank_order, + c.call_id, + p.proposer_id, + i.instrument_id, + fp.fap_time_allocation, + p.questionary_id, + tr.public_comment + FROM fap_proposals fp + JOIN faps f ON f.fap_id = fp.fap_id + JOIN call c ON c.call_id = fp.call_id + JOIN proposals p ON p.proposal_pk = fp.proposal_pk + JOIN technical_review tr ON tr.proposal_pk = p.proposal_pk + AND tr.instrument_id = fp.instrument_id + LEFT JOIN fap_meeting_decisions fmd ON fmd.proposal_pk = p.proposal_pk + JOIN call_has_instruments chi ON chi.instrument_id = fp.instrument_id + AND chi.call_id = c.call_id + JOIN instruments i ON i.instrument_id = chi.instrument_id + LEFT JOIN workflow_has_statuses whs ON whs.workflow_status_id = p.workflow_status_id + WHERE whs.status_id <> 'EXPIRED' + AND whs.status_id <> 'DRAFT' + ) proposal + LEFT JOIN ( SELECT fr.proposal_pk, + avg(CASE + WHEN fr.grade ~ '^\d+(\.\d+)\?$' THEN fr.grade::double precision + ELSE NULL + END) AS avg + FROM fap_proposals fp + JOIN fap_reviews fr ON fr.proposal_pk = fp.proposal_pk + GROUP BY fr.proposal_pk + ) grade ON grade.proposal_pk = proposal.proposal_pk; + + + END; + 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 b25ef98568..e8cf4af651 100644 --- a/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql +++ b/apps/backend/db_patches/db_seeds/0001_ProposalForScheduling.sql @@ -7,14 +7,28 @@ DECLARE technical_review_questionary_id_var int; BEGIN - INSERT INTO instruments (instrument_id, name, short_code, description, manager_user_id) VALUES (1, 'Instrument 1', 'INSTR1', 'Test instrument 1', 0); - INSERT INTO instruments (instrument_id, name, short_code, description, manager_user_id) VALUES (2, 'Instrument 2', 'INSTR2', 'Test instrument 2', 0); - INSERT INTO instruments (instrument_id, name, short_code, description, manager_user_id) VALUES (3, 'Instrument 3', 'INSTR3', 'Test instrument 3', 0); - - INSERT INTO techniques (technique_id, name, short_code, description) VALUES (1, 'Technique 1', 'TECH1', 'Test technique 1'); - INSERT INTO techniques (technique_id, name, short_code, description) VALUES (2, 'Technique 2', 'TECH2', 'Test technique 2'); - INSERT INTO techniques (technique_id, name, short_code, description) VALUES (3, 'Technique 3', 'TECH3', 'Test technique 3'); - + INSERT INTO public.workflow_has_statuses( + workflow_id, pos_x, pos_y, status_id) + VALUES + (1, 10, 70, 'FEASIBILITY_REVIEW'), + (1, 10, 150, 'NOT_FEASIBLE'), + (1, 10, 210, 'FAP_SELECTION'), + (1, 10, 270, 'FAP_REVIEW'), + (1, 10, 330, 'ALLOCATED'), + (1, 10, 390, 'NOT_ALLOCATED'), + (1, 10, 450, 'SCHEDULING'), + (1, 10, 520, 'EXPIRED'), + (1, 10, 580, 'EDITABLE_SUBMITTED_INTERNAL'), + (1, 10, 640, 'EDITABLE_SUBMITTED'); + + INSERT INTO instruments (name, short_code, description, manager_user_id) VALUES ('Instrument 1', 'INSTR1', 'Test instrument 1', 0); + INSERT INTO instruments (name, short_code, description, manager_user_id) VALUES ('Instrument 2', 'INSTR2', 'Test instrument 2', 0); + INSERT INTO instruments (name, short_code, description, manager_user_id) VALUES ('Instrument 3', 'INSTR3', 'Test instrument 3', 0); + + INSERT INTO techniques (name, short_code, description) VALUES ('Technique 1', 'TECH1', 'Test technique 1'); + INSERT INTO techniques (name, short_code, description) VALUES ('Technique 2', 'TECH2', 'Test technique 2'); + INSERT INTO techniques (name, short_code, description) VALUES ('Technique 3', 'TECH3', 'Test technique 3'); + INSERT INTO call_has_instruments (call_id, instrument_id, availability_time) VALUES (1, 1, NULL); INSERT INTO call_has_instruments (call_id, instrument_id, availability_time) VALUES (1, 3, NULL); @@ -55,7 +69,7 @@ BEGIN ( title , abstract - , status_id + , workflow_status_id , proposer_id , created_at , updated_at @@ -72,7 +86,7 @@ BEGIN ( 'Test proposal' , 'Lorem ipsum' - , 8 + , 8 , 1 , NOW() , NOW() @@ -88,14 +102,14 @@ BEGIN INSERT INTO instrument_has_proposals(instrument_id, proposal_pk) VALUES (1, 1); - INSERT INTO technical_review(technical_review_id, proposal_pk, comment, time_allocation, status, public_comment, reviewer_id, technical_review_assignee_id, instrument_id, questionary_id) - VALUES (1, 1, '', 2, 0, '', 0, 0, 1, technical_review_questionary_id_var); + 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); INSERT INTO proposals ( title , abstract - , status_id + , workflow_status_id , proposer_id , created_at , updated_at @@ -112,7 +126,7 @@ BEGIN ( 'Test proposal 2' , 'Lorem ipsum 2' - , 8 + , 8 , 1 , NOW() , NOW() diff --git a/apps/backend/db_patches/db_seeds/0004_Experiments.sql b/apps/backend/db_patches/db_seeds/0004_Experiments.sql index 31df13299a..985275549a 100644 --- a/apps/backend/db_patches/db_seeds/0004_Experiments.sql +++ b/apps/backend/db_patches/db_seeds/0004_Experiments.sql @@ -6,11 +6,21 @@ DECLARE exp_safety_review_template_topic_id_var int; instrument_id_1_var int; instrument_id_2_var int; + experiment_workflow_id_var int; BEGIN -- Get instrument ids SELECT instrument_id INTO instrument_id_1_var FROM instruments WHERE name='Instrument 1' limit 1; SELECT instrument_id INTO instrument_id_2_var FROM instruments WHERE name='Instrument 2' limit 1; + INSERT INTO workflows(name, description, entity_type) + VALUES ('Experiment Safety Review Workflow', 'Workflow for Experiment Safety Review', 'EXPERIMENT') + RETURNING workflow_id INTO experiment_workflow_id_var; + + 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')); + INSERT INTO experiments( experiment_pk, experiment_id, scheduled_event_id, starts_at, ends_at, proposal_pk, status, local_contact_id, instrument_id) VALUES (996,'000001', 996, '2030-01-07 10:00:00', '2030-01-07 11:00:00', 1, 'ACTIVE', 1, instrument_id_1_var); diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index fb4af05278..2c86e766e2 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -17753,6 +17753,16 @@ "node": ">=0.6.0" } }, + "node_modules/xstate": { + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.25.0.tgz", + "integrity": "sha512-yyWzfhVRoTHNLjLoMmdwZGagAYfmnzpm9gPjlX2MhJZsDojXGqRxODDOi4BsgGRKD46NZRAdcLp6CKOyvQS0Bw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT", diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index b1f2df9d82..8d01687fa7 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -13,10 +13,9 @@ import { TagDataSource } from '../datasources/TagDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; import { Roles } from '../models/Role'; import { ProposalStatusDefaultShortCodes } from '../models/Status'; -import { UserWithRole } from '../models/User'; +import { UserWithRole, UserJWT } from '../models/User'; import { Proposal } from '../resolvers/types/Proposal'; import { UserDataSource } from './../datasources/UserDataSource'; -import { UserJWT } from './../models/User'; import { UserAuthorization } from './UserAuthorization'; @injectable() @@ -350,13 +349,15 @@ export class ProposalAuthorization { callId, checkIfInternalEditable ); - const proposalStatus = ( - await this.statusDataSource.getStatus(proposal.statusId) - )?.shortCode; + const proposalStatus = + await this.statusDataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); + const proposalStatusId = proposalStatus!.id; if ( - proposalStatus === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED || + proposalStatusId === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED || (checkIfInternalEditable && - proposalStatus === + proposalStatusId === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL) ) { return true; @@ -365,7 +366,7 @@ export class ProposalAuthorization { if (isCallEnded) { return false; } else { - return proposalStatus === ProposalStatusDefaultShortCodes.DRAFT; + return proposalStatusId === ProposalStatusDefaultShortCodes.DRAFT; } } diff --git a/apps/backend/src/auth/UserAuthorization.ts b/apps/backend/src/auth/UserAuthorization.ts index a62b4ff43e..d4fe0961ae 100644 --- a/apps/backend/src/auth/UserAuthorization.ts +++ b/apps/backend/src/auth/UserAuthorization.ts @@ -59,7 +59,7 @@ export abstract class UserAuthorization { } isApiToken(agent: UserWithRole | null) { - return agent?.isApiAccessToken; + return agent?.isApiAccessToken ?? false; } async hasRole(agent: UserWithRole | null, role: string): Promise { diff --git a/apps/backend/src/datasources/ExperimentDataSource.ts b/apps/backend/src/datasources/ExperimentDataSource.ts index 43e8feb1e8..2467334165 100644 --- a/apps/backend/src/datasources/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/ExperimentDataSource.ts @@ -1,4 +1,3 @@ -import { Event } from '../events/event.enum'; import { Experiment, ExperimentHasSample, @@ -96,10 +95,6 @@ export interface ExperimentDataSource { experiments: Experiment[]; }>; getExperimentsByProposalPk(proposalPk: number): Promise; - markEventAsDoneOnExperimentSafeties( - event: Event, - experimentPks: number[] - ): Promise; getExperimentSafetyEvents( experimentPk: number ): Promise; diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 5c68b2f888..eb36715183 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -1,4 +1,3 @@ -import { Event } from '../events/event.enum'; import { Call } from '../models/Call'; import { InvitedProposal, Proposal, Proposals } from '../models/Proposal'; import { ProposalView } from '../models/ProposalView'; @@ -8,7 +7,6 @@ import { UpdateTechnicalReviewAssigneeInput } from '../resolvers/mutations/Updat import { UserProposalsFilter } from '../resolvers/types/User'; import { PaginationSortDirection } from '../utils/pagination'; import { ProposalsFilter } from './../resolvers/queries/ProposalsQuery'; -import { ProposalEventsRecord } from './postgres/records'; export interface ProposalDataSource { getProposalsFromView( @@ -49,10 +47,6 @@ export interface ProposalDataSource { questionary_id: number ): Promise; update(proposal: Proposal): Promise; - updateProposalStatus( - proposalPk: number, - proposalStatusId: number - ): Promise; updateProposalTechnicalReviewer( args: UpdateTechnicalReviewAssigneeInput ): Promise; @@ -68,20 +62,10 @@ export interface ProposalDataSource { submittedDate: Date ): Promise; deleteProposal(primaryKey: number): Promise; - markEventAsDoneOnProposals( - event: Event, - proposalPk: number[] - ): Promise; getCount(callId: number): Promise; cloneProposal(sourceProposal: Proposal, call: Call): Promise; - resetProposalEvents( - proposalPk: number, - callId: number, - statusId: number - ): Promise; - getProposalEvents(proposalPk: number): Promise; - changeProposalsStatus( - statusId: number, + changeProposalsWorkflowStatus( + workflowStatusId: number, proposalPks: number[] ): Promise; getRelatedUsersOnProposals(id: number): Promise; diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index c73a623bbd..0d15a383c2 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -2,12 +2,11 @@ import { ConnectionHasStatusAction, StatusAction, } from '../models/StatusAction'; -import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; export interface StatusActionsDataSource { getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise; getConnectionStatusAction( workflowConnectionId: number, @@ -22,7 +21,7 @@ export interface StatusActionsDataSource { ): Promise; getStatusAction(actionId: number): Promise; getStatusActions(): Promise; - addConnectionStatusActions( - connectionStatusActionsInput: AddConnectionStatusActionsInput + setStatusActionsOnConnection( + input: SetStatusActionsOnConnectionInput ): Promise; } diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index ef326d4d35..3a30529b40 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -2,12 +2,11 @@ import { Status } from '../models/Status'; import { UpdateStatusInput } from '../resolvers/mutations/settings/UpdateStatusMutation'; export interface StatusDataSource { - createStatus( - newStatusInput: Omit - ): Promise; - getStatus(statusId: number): Promise; + createStatus(newStatusInput: Omit): Promise; + getStatus(statusId: string): Promise; + getStatusByWorkflowStatusId(workflowStatusId: number): Promise; getAllStatuses(entityType: Status['entityType']): Promise; updateStatus(status: UpdateStatusInput): Promise; - deleteStatus(statusId: number): Promise; - getDefaultStatus(entityType: Status['entityType']): Promise; + deleteStatus(statusId: string): Promise; + getInitialStatus(entityType: Status['entityType']): Promise; } diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index e7bd47c9f9..c0de40cde3 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -1,53 +1,54 @@ -import { Status } from '../models/Status'; import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { Workflow } from '../models/Workflow'; -import { - NextAndPreviousStatuses, - WorkflowConnection, - WorkflowConnectionWithStatus, -} from '../models/WorkflowConnections'; +import { WorkflowConnection } from '../models/WorkflowConnections'; +import { WorkflowStatus } from '../models/WorkflowStatus'; +import { WorkflowStructure } from './postgres/records'; +import { AddStatusToWorkflowInput } from '../resolvers/mutations/settings/AddStatusToWorkflowMutation'; +import { CreateWorkflowConnectionInput } from '../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; export interface WorkflowDataSource { createWorkflow(newWorkflowInput: CreateWorkflowInput): Promise; getWorkflow(workflowId: number): Promise; getAllWorkflows(entityType: Workflow['entityType']): Promise; updateWorkflow(workflow: UpdateWorkflowInput): Promise; + updateWorkflowTimestamp(workflowId: number): Promise; deleteWorkflow(workflowId: number): Promise; - deleteWorkflowConnection( - connectionId: number + + createWorkflowConnection( + newWorkflowConnectionInput: CreateWorkflowConnectionInput + ): Promise; + getWorkflowConnection( + connectionId: WorkflowConnection['id'] ): Promise; getWorkflowConnections( workflowId: WorkflowConnection['workflowId'] - ): Promise; - getWorkflowConnection( - connectionId: WorkflowConnection['id'] - ): Promise; - getWorkflowConnectionsById( - workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'] | undefined, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise; - addWorkflowStatus( - newWorkflowStatusInput: Omit< - WorkflowConnection, - 'id' | 'entityType' | 'prevConnectionId' - > - ): Promise; + ): Promise; + deleteWorkflowConnection( + connectionId: number + ): Promise; + + addStatusToWorkflow( + newWorkflowStatusInput: AddStatusToWorkflowInput + ): Promise; + getWorkflowStatus(workflowStatusId: number): Promise; + getWorkflowStatuses(workflowId: number): Promise; updateWorkflowStatus( - workflowStatuses: WorkflowConnection - ): Promise; - deleteWorkflowStatus( - statusId: number, - workflowId: number, - sortOrder: number - ): Promise; - addStatusChangingEventsToConnection( + workflowStatus: UpdateWorkflowStatusInput + ): Promise; + deleteWorkflowStatus(workflowStatusId: number): Promise; + + getInitialWorkflowStatus(workflowId: number): Promise; + + setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise; getStatusChangingEventsByConnectionIds( workflowConnectionIds: number[] ): Promise; + + getWorkflowStructure(workflowId: number): Promise; } diff --git a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts index 70388e15c3..090ddd971e 100644 --- a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts @@ -1,4 +1,3 @@ -import { Event } from '../../events/event.enum'; import { Experiment, ExperimentSafety, @@ -44,7 +43,7 @@ const dummyExperimentSafetyFactory = ( values?.esiQuestionaryId ?? 1, values?.esiQuestionarySubmittedAt ?? null, values?.createdBy ?? 1, - values?.statusId ?? null, + values?.workflowStatusId ?? 1, values?.safetyReviewQuestionaryId ?? 1, values?.reviewedBy ?? null, values?.createdAt ?? new Date(), @@ -132,13 +131,6 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { }; } - async markEventAsDoneOnExperimentSafeties( - event: Event, - experimentPks: number[] - ): Promise { - return [dummyExperimentSafetyEvents]; - } - async getExperimentSafetyEvents( experimentPk: number ): Promise { @@ -367,7 +359,7 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { ? updateFields.esiQuestionarySubmittedAt : safety.esiQuestionarySubmittedAt, safety.createdBy, - safety.statusId, + safety.workflowStatusId, updateFields.safetyReviewQuestionaryId !== undefined ? updateFields.safetyReviewQuestionaryId : safety.safetyReviewQuestionaryId, @@ -413,14 +405,14 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { async updateExperimentSafetyStatus( experimentSafetyPk: number, - statusId: number + workflowStatusId: number ): Promise { const experimentSafety = await this.getExperimentSafety(experimentSafetyPk); if (!experimentSafety) { throw new Error('Experiment does not exist'); } - experimentSafety.statusId = statusId; + experimentSafety.workflowStatusId = workflowStatusId; return experimentSafety; } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 48a7bb7ab8..3b4813af74 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -1,5 +1,4 @@ import 'reflect-metadata'; -import { Event } from '../../events/event.enum'; import { AllocationTimeUnits, Call } from '../../models/Call'; import { FapMeetingDecision } from '../../models/FapMeetingDecision'; import { @@ -16,9 +15,9 @@ import { import { UserWithRole } from '../../models/User'; import { UpdateTechnicalReviewAssigneeInput } from '../../resolvers/mutations/UpdateTechnicalReviewAssigneeMutation'; import { PaginationSortDirection } from '../../utils/pagination'; -import { ProposalEventsRecord } from '../postgres/records'; import { ProposalDataSource } from '../ProposalDataSource'; import { ProposalsFilter } from './../../resolvers/queries/ProposalsQuery'; +import { dummyWorkflowStatuses } from './StatusDataSource'; import { basicDummyUser } from './UserDataSource'; export let dummyProposal: Proposal; @@ -43,7 +42,7 @@ const dummyProposalFactory = (values?: Partial) => { values?.title || 'title', values?.abstract || 'abstract', values?.proposerId || 1, - values?.statusId || 1, + values?.workflowStatusId || 1, values?.created || new Date(), values?.updated || new Date(), values?.proposalId || 'shortCode', @@ -89,45 +88,6 @@ export const dummyProposalTechnicalReview = new TechnicalReview( 1 ); -const dummyProposalEvents = { - proposal_pk: 1, - proposal_created: true, - proposal_submitted: true, - proposal_feasibility_review_feasible: true, - proposal_feasibility_review_unfeasible: false, - call_ended: false, - call_ended_internal: false, - call_review_ended: false, - call_fap_review_ended: false, - proposal_faps_selected: false, - proposal_instruments_selected: false, - proposal_feasibility_review_submitted: false, - proposal_sample_review_submitted: false, - proposal_all_fap_reviews_submitted: false, - proposal_feasibility_review_updated: false, - proposal_management_decision_submitted: false, - proposal_management_decision_updated: false, - proposal_sample_safe: false, - proposal_fap_review_updated: false, - proposal_all_fap_reviewers_selected: false, - proposal_fap_review_submitted: false, - proposal_fap_meeting_submitted: false, - proposal_all_fap_meetings_submitted: false, - proposal_all_reviews_submitted_for_all_faps: false, - proposal_all_fap_meeting_instrument_submitted: false, - proposal_instrument_submitted: false, - proposal_accepted: false, - proposal_reserved: false, - proposal_rejected: false, - proposal_notified: false, - proposal_booking_time_activated: false, - proposal_booking_time_updated: false, - proposal_booking_time_slot_added: false, - proposal_booking_time_slots_removed: false, - proposal_booking_time_completed: false, - proposal_booking_time_reopened: false, -}; - export class ProposalDataSourceMock implements ProposalDataSource { proposalsUpdated: Proposal[]; constructor() { @@ -159,7 +119,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { finalStatus: ProposalEndStatus.ACCEPTED, notified: true, managementDecisionSubmitted: true, - statusId: 2, + workflowStatusId: 2, }); dummyProposalWithNotActiveCall = dummyProposalFactory({ @@ -179,6 +139,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { '', 1, 1, + 'DRAFT', '', '', 'shortCode', @@ -258,20 +219,6 @@ export class ProposalDataSourceMock implements ProposalDataSource { return proposal; } - async updateProposalStatus( - proposalPk: number, - proposalStatusId: number - ): Promise { - const proposal = await this.get(proposalPk); - - if (!proposal) { - throw new Error('Proposal does not exist'); - } - proposal.statusId = proposalStatusId; - - return proposal; - } - async setProposalUsers( proposalPk: number, usersIds: number[] @@ -375,27 +322,6 @@ export class ProposalDataSourceMock implements ProposalDataSource { return { totalCount: 1, proposals: [dummyProposalView] }; } - async markEventAsDoneOnProposals( - event: Event, - proposalPk: number[] - ): Promise { - return [dummyProposalEvents]; - } - - async getProposalEvents( - proposalPk: number - ): Promise { - if (proposalPk === 101) { - return { - ...dummyProposalEvents, - call_fap_review_ended: true, - proposal_all_fap_reviews_submitted: true, - }; - } - - return dummyProposalEvents; - } - async getCount(callId: number): Promise { return 1; } @@ -404,20 +330,18 @@ export class ProposalDataSourceMock implements ProposalDataSource { return dummyProposal; } - async resetProposalEvents( - proposalPk: number, - callId: number, - statusId: number - ): Promise { - return true; - } - - async changeProposalsStatus( - statusId: number, + async changeProposalsWorkflowStatus( + workflowStatusId: number, proposalPks: number[] ): Promise { const proposals = allProposals.map((p) => { - return { ...p, statusId }; + return { + ...p, + workflowStatusId, + statusId: dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + )?.statusId as string, + }; }); return { proposals: proposals }; diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index 7b78d90e92..a35456e89f 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -5,14 +5,13 @@ import { StatusAction, StatusActionType, } from '../../models/StatusAction'; -import { AddConnectionStatusActionsInput } from '../../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; import { StatusActionsDataSource } from '../StatusActionsDataSource'; export const dummyConnectionHasStatusAction = new ConnectionHasStatusAction( 1, 1, 1, - 'Dummy action', StatusActionType.EMAIL, {} ); @@ -41,8 +40,7 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { return dummyConnectionHasStatusAction; } async getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise { return [dummyConnectionHasStatusAction]; } @@ -75,8 +73,8 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { return dummyStatusActions; } - async addConnectionStatusActions( - connectionStatusActionsInput: AddConnectionStatusActionsInput + async setStatusActionsOnConnection( + connectionStatusActionsInput: SetStatusActionsOnConnectionInput ): Promise { return [dummyConnectionHasStatusAction]; } diff --git a/apps/backend/src/datasources/mockups/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index c27dadad05..bf9fe666de 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -1,11 +1,11 @@ import { Status } from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; import { StatusDataSource } from '../StatusDataSource'; export const dummyStatuses = [ - new Status(1, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 2, 'FEASIBILITY_REVIEW', 'Feasibility review', '', @@ -14,29 +14,51 @@ export const dummyStatuses = [ ), ]; +export const dummyWorkflowStatuses = [ + new WorkflowStatus(1, 1, 'DRAFT', 0, 0), + new WorkflowStatus(2, 1, 'FEASIBILITY_REVIEW', 0, 0), + new WorkflowStatus(3, 1, 'APPROVED', 0, 0), + new WorkflowStatus(4, 1, 'UNSUCCESSFUL', 0, 0), + new WorkflowStatus(5, 1, 'FINISHED', 0, 0), + new WorkflowStatus(6, 1, 'NON-TP', 0, 0), + new WorkflowStatus(7, 1, 'EXPIRED', 0, 0), +]; + export class StatusDataSourceMock implements StatusDataSource { // TODO: This needs to be implemented async createStatus( - newStatusInput: Omit + newStatusInput: Omit ): Promise { - return { ...newStatusInput, id: 1, isDefault: false }; + return { ...newStatusInput, isDefault: false }; } - async getStatus(statusId: number): Promise { + async getStatus(statusId: string): Promise { return dummyStatuses.find((s) => s.id === statusId) as Status; } async getAllStatuses(): Promise { return dummyStatuses; } + async getStatusByWorkflowStatusId( + workflowStatusId: number + ): Promise { + const workflowStatus = dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + ); + + return dummyStatuses.find( + (s) => s.id === workflowStatus?.statusId + ) as Status; + } + async updateStatus(status: Omit): Promise { return { ...status, entityType: WorkflowType.PROPOSAL }; } - async deleteStatus(statusId: number): Promise { + async deleteStatus(statusId: string): Promise { return dummyStatuses.splice( dummyStatuses.findIndex((s) => s.id === statusId), 1 )[0]; } - async getDefaultStatus( + async getInitialStatus( entityType: Status['entityType'] ): Promise { return dummyStatuses[0]; diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 317aadf095..f29c0bd46f 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -1,27 +1,17 @@ import { Status } from '../../models/Status'; import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow, WorkflowType } from '../../models/Workflow'; -import { - NextAndPreviousStatuses, - WorkflowConnection, - WorkflowConnectionWithStatus, -} from '../../models/WorkflowConnections'; -import { AddWorkflowStatusInput } from '../../resolvers/mutations/settings/AddWorkflowStatusMutation'; +import { WorkflowConnection } from '../../models/WorkflowConnections'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; +import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; +import { CreateWorkflowConnectionInput } from '../../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; +import { WorkflowStructure } from '../postgres/records'; import { WorkflowDataSource } from '../WorkflowDataSource'; +import { dummyWorkflowStatuses } from './StatusDataSource'; -export const dummyStatuses = [ - new Status(1, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), - new Status( - 2, - 'FEASIBILITY_REVIEW', - 'Feasibility review', - '', - true, - WorkflowType.PROPOSAL - ), -]; export const dummyWorkflow = new Workflow( 1, 'Test workflow', @@ -30,53 +20,63 @@ export const dummyWorkflow = new Workflow( 'default' ); -export const dummyWorkflowConnection = new WorkflowConnectionWithStatus( - 1, - 1, - 1, - 1, +export const dummyStatuses = [ + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 1, - 'TEST_STATUS', - 'Test status', - 'Test status', - false, + 'FEASIBILITY_REVIEW', + 'Feasibility review', + '', + true, WorkflowType.PROPOSAL ), - null, - null, - 100, - 100, - null -); +]; -export const anotherDummyWorkflowConnection = new WorkflowConnectionWithStatus( - 2, - 2, - 1, - 2, - new Status( - 2, - 'TEST_STATUS_2', - 'Test status 2', - 'Test status 2', - false, - WorkflowType.PROPOSAL - ), - null, - 1, - 200, - 150, - null -); +export const dummyWorkflowConnections = [ + new WorkflowConnection(1, 1, 1, 2, 'bottom-source', 'top-target'), + new WorkflowConnection(2, 1, 2, 1, 'bottom-source', 'top-target'), +]; export const dummyStatusChangingEvent = new StatusChangingEvent( - 1, 1, 'PROPOSAL_SUBMITTED' ); export class WorkflowDataSourceMock implements WorkflowDataSource { + async getWorkflowStructure(workflowId: number): Promise { + return { + workflowStatuses: dummyWorkflowStatuses.map((ws) => ({ + workflowStatusId: ws.workflowStatusId, + statusId: ws.statusId, + shortCode: dummyStatuses.find((s) => s.id === ws.statusId)?.id || '', + })), + workflowConnections: dummyWorkflowConnections.map((wc) => ({ + workflowStatusConnectionId: wc.id, + prevWorkflowStatusId: wc.prevWorkflowStatusId, + nextWorkflowStatusId: wc.nextWorkflowStatusId, + statusChangingEvents: ['PROPOSAL_SUBMITTED'], + })), + }; + } + async createWorkflowConnection( + newWorkflowConnectionInput: CreateWorkflowConnectionInput + ): Promise { + return dummyWorkflowConnections[0]; + } + async getWorkflowStatus( + workflowStatusId: number + ): Promise { + return ( + dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + ) || null + ); + } + + async getInitialWorkflowStatus( + workflowId: number + ): Promise { + return dummyWorkflowStatuses[0]; + } async createWorkflow(args: CreateWorkflowInput): Promise { return dummyWorkflow; } @@ -97,71 +97,59 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return dummyWorkflow; } + async updateWorkflowTimestamp(workflowId: number): Promise { + return; + } + async deleteWorkflow(WorkflowId: number): Promise { return dummyWorkflow; } async getWorkflowConnections( workflowId: number - ): Promise { - return [dummyWorkflowConnection, anotherDummyWorkflowConnection]; + ): Promise { + return dummyWorkflowConnections; } - async getWorkflowConnection( - connectionId: number - ): Promise { - if (connectionId === dummyWorkflowConnection.id) { - return dummyWorkflowConnection; - } - if (connectionId === anotherDummyWorkflowConnection.id) { - return anotherDummyWorkflowConnection; - } - - return null; + async getWorkflowStatuses(workflowId: number): Promise { + return dummyWorkflowStatuses; } - async getWorkflowConnectionsById( - workflowId: number, - workflowConnectionId: number, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise { - return [dummyWorkflowConnection]; + async getWorkflowConnection( + connectionId: number + ): Promise { + return dummyWorkflowConnections.find( + (conn) => conn.id === connectionId + ) as WorkflowConnection; } - async addWorkflowStatus( - newWorkflowStatusInput: AddWorkflowStatusInput - ): Promise { - return dummyWorkflowConnection; + async addStatusToWorkflow( + newWorkflowStatusInput: AddStatusToWorkflowInput + ): Promise { + return dummyWorkflowStatuses[0]; } async updateWorkflowStatus( - workflowStatus: WorkflowConnection - ): Promise { - return dummyWorkflowConnection; + workflowStatus: UpdateWorkflowStatusInput + ): Promise { + return dummyWorkflowStatuses[0]; } async deleteWorkflowStatus( - statusId: number, - workflowId: number, - sortOrder: number - ): Promise { - return dummyWorkflowConnection; + workflowStatusId: number + ): Promise { + return dummyWorkflowStatuses[0]; } async deleteWorkflowConnection( connectionId: number ): Promise { - if (connectionId === dummyWorkflowConnection.id) { - return dummyWorkflowConnection; - } - if (connectionId === anotherDummyWorkflowConnection.id) { - return anotherDummyWorkflowConnection; - } - - return null; + return dummyWorkflowConnections.find( + (conn) => conn.id === connectionId + ) as WorkflowConnection; } - async addStatusChangingEventsToConnection( + async setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise { diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index 3f0585904b..9cfd1ae046 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -213,15 +213,14 @@ export default class PostgresCallDataSource implements CallDataSource { query.where('call_ended', false); } - if (filter?.proposalStatusShortCode) { + if (filter?.proposalStatus) { query .join( - 'workflow_connections as w', + 'workflow_has_statuses as w', 'call.proposal_workflow_id', 'w.workflow_id' ) - .leftJoin('statuses as s', 'w.status_id', 's.status_id') - .where('s.short_code', filter.proposalStatusShortCode) + .where('w.status_id', filter.proposalStatus) .distinctOn('call.call_id'); } if (sortField && sortDirection) { diff --git a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts index 68cd99d066..5496aae7af 100644 --- a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts @@ -2,7 +2,6 @@ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; import { injectable } from 'tsyringe'; -import { Event } from '../../events/event.enum'; import { Experiment, ExperimentHasSample, @@ -52,7 +51,7 @@ export function createExperimentSafetyObject(record: ExperimentSafetyRecord) { record.esi_questionary_id, record.esi_questionary_submitted_at, record.created_by, - record.status_id, + record.workflow_status_id, record.safety_review_questionary_id, record.reviewed_by, record.created_at, @@ -379,11 +378,11 @@ export default class PostgresExperimentDataSource async updateExperimentSafetyStatus( experimentSafetyPk: number, - statusId: number + workflowStatusId: number ): Promise { const result = await database('experiment_safety') .update({ - status_id: statusId, + workflow_status_id: workflowStatusId, }) .where('experiment_safety_pk', experimentSafetyPk) .returning('*'); @@ -416,14 +415,14 @@ export default class PostgresExperimentDataSource experimentPk: number, questionaryId: number, creatorId: number, - statusId: number + workflowStatusId: number ): Promise { return database .insert({ experiment_pk: experimentPk, esi_questionary_id: questionaryId, created_by: creatorId, - status_id: statusId, + workflow_status_id: workflowStatusId, reviewed_by: creatorId, //todo: add reviewed_by }) .into('experiment_safety') @@ -622,10 +621,12 @@ export default class PostgresExperimentDataSource 'experiments.experiment_pk', 'experiment_safety.experiment_pk' ) - .where( - 'experiment_safety.status_id', - filter?.experimentSafetyStatusId - ); + .leftJoin( + 'workflow_has_statuses as es_whs', + 'experiment_safety.workflow_status_id', + 'es_whs.workflow_status_id' + ) + .where('es_whs.status_id', filter.experimentSafetyStatusId); } if (filter?.callId) { query.where('proposals.call_id', filter.callId); @@ -703,28 +704,6 @@ export default class PostgresExperimentDataSource ); } - async markEventAsDoneOnExperimentSafeties( - event: Event, - experimentPks: number[] - ): Promise { - const dataToInsert = experimentPks.map((experimentPk) => ({ - experiment_pk: experimentPk, - [event.toLowerCase()]: true, - })); - const result = await database.raw( - `? ON CONFLICT (experiment_pk) - DO UPDATE SET - ${event.toLowerCase()} = true - RETURNING *;`, - [database('experiment_safety_events').insert(dataToInsert)] - ); - - if (result.rows && result.rows.length) { - return result.rows; - } else { - return null; - } - } async getExperimentSafetyEvents( experimentPk: number ): Promise { diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index ffc29dbe64..dbbbdf387d 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -325,11 +325,14 @@ export default class PostgresFapDataSource implements FapDataSource { .join('proposals as p', { 'p.proposal_pk': 'fp.proposal_pk', }) + .join('workflow_has_statuses as whs', { + 'p.workflow_status_id': 'whs.workflow_status_id', + }) .join('statuses as s', { - 'p.status_id': 's.status_id', + 'whs.status_id': 's.status_id', }) .where(function () { - this.where('s.short_code', 'ilike', 'FAP_%'); + this.where('s.status_id', 'ilike', 'FAP_%'); }); if (filter.callId) { @@ -360,8 +363,11 @@ export default class PostgresFapDataSource implements FapDataSource { .join('proposals as p', { 'p.proposal_pk': 'fp.proposal_pk', }) + .join('workflow_has_statuses as whs', { + 'p.workflow_status_id': 'whs.workflow_status_id', + }) .join('statuses as s', { - 'p.status_id': 's.status_id', + 'whs.status_id': 's.status_id', }) .join('call as c', { 'p.call_id': 'c.call_id', @@ -566,8 +572,11 @@ export default class PostgresFapDataSource implements FapDataSource { 'p.proposal_pk': 'fp.proposal_pk', 'p.call_id': callId, }) + .join('workflow_has_statuses as whs', { + 'p.workflow_status_id': 'whs.workflow_status_id', + }) .join('statuses as s', { - 'p.status_id': 's.status_id', + 'whs.status_id': 's.status_id', }) .where('fp.instrument_id', instrumentId) .modify((query) => { diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index f81d377850..7dd88ec37e 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -5,7 +5,6 @@ import { Knex } from 'knex'; import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { Event } from '../../events/event.enum'; import { Call } from '../../models/Call'; import { InvitedProposal, Proposal, Proposals } from '../../models/Proposal'; import { ProposalView } from '../../models/ProposalView'; @@ -16,7 +15,6 @@ import { SettingsId } from '../../models/Settings'; import { TechnicalReview } from '../../models/TechnicalReview'; import { Technique } from '../../models/Technique'; import { UserWithRole } from '../../models/User'; -import { WorkflowConnectionWithStatus } from '../../models/WorkflowConnections'; import { UpdateTechnicalReviewAssigneeInput } from '../../resolvers/mutations/UpdateTechnicalReviewAssigneeMutation'; import { UserProposalsFilter } from '../../resolvers/types/User'; import { PaginationSortDirection } from '../../utils/pagination'; @@ -28,22 +26,20 @@ import { ProposalsFilter, QuestionFilterInput, } from './../../resolvers/queries/ProposalsQuery'; +import CallDataSource from './CallDataSource'; import database from './database'; import { - CallRecord, + createInvitedProposalObject, createProposalObject, createProposalViewObject, + createProposalViewObjectWithTechniques, createTechnicalReviewObject, - ProposalEventsRecord, + InvitedProposalRecord, ProposalRecord, ProposalViewRecord, - WorkflowConnectionRecord, - StatusChangingEventRecord, TechnicalReviewRecord, TechniqueRecord, - createProposalViewObjectWithTechniques, - InvitedProposalRecord, - createInvitedProposalObject, + WorkflowStatusRecord, } from './records'; const fieldMap: { [key: string]: string } = { @@ -104,7 +100,9 @@ export default class PostgresProposalDataSource implements ProposalDataSource { @inject(Tokens.WorkflowDataSource) private workflowDataSource: WorkflowDataSource, @inject(Tokens.AdminDataSource) - private AdminDataSource: AdminDataSource, + private adminDataSource: AdminDataSource, + @inject(Tokens.CallDataSource) + protected callDataSource: CallDataSource, @inject(Tokens.TagDataSource) private tagDataSource: TagDataSource ) {} @@ -266,7 +264,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { title: proposal.title, abstract: proposal.abstract, proposer_id: proposal.proposerId, - status_id: proposal.statusId, + workflow_status_id: proposal.workflowStatusId, created_at: proposal.created, updated_at: proposal.updated, proposal_id: proposal.proposalId, @@ -292,28 +290,6 @@ export default class PostgresProposalDataSource implements ProposalDataSource { }); } - async updateProposalStatus( - proposalPk: number, - proposalStatusId: number - ): Promise { - return database - .update( - { - status_id: proposalStatusId, - }, - ['*'] - ) - .from('proposals') - .where('proposal_pk', proposalPk) - .then((records: ProposalRecord[]) => { - if (records === undefined || !records.length) { - throw new GraphQLError(`Proposal not found ${proposalPk}`); - } - - return createProposalObject(records[0]); - }); - } - async get(id: number): Promise { return database .select() @@ -341,8 +317,33 @@ export default class PostgresProposalDataSource implements ProposalDataSource { call_id: number, questionary_id: number ): Promise { + const call = await this.callDataSource.getCall(call_id); + + if (!call) { + throw new GraphQLError(`Call not found with id: ${call_id}`); + } + + const proposalInitialStatus = + await this.workflowDataSource.getInitialWorkflowStatus( + call.proposalWorkflowId + ); + + if (!proposalInitialStatus) { + throw new GraphQLError( + `Draft workflow status not found for call with id: ${call_id}` + ); + } + return database - .insert({ proposer_id, call_id, questionary_id, status_id: 1 }, ['*']) + .insert( + { + proposer_id, + call_id, + questionary_id, + workflow_status_id: proposalInitialStatus.workflowStatusId, + }, + ['*'] + ) .from('proposals') .then((resultSet: ProposalRecord[]) => { return createProposalObject(resultSet[0]); @@ -649,7 +650,13 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } if (filter?.proposalStatusId) { - query.where('proposals.status_id', filter?.proposalStatusId); + query.whereExists( + database('workflow_has_statuses') + .whereRaw( + 'workflow_has_statuses.workflow_status_id = proposals.workflow_status_id' + ) + .where('workflow_has_statuses.status_id', filter.proposalStatusId) + ); } if (filter?.shortCodes) { @@ -666,7 +673,16 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } if (filter?.excludeProposalStatusIds) { - query.where('status_id', 'not in', filter?.excludeProposalStatusIds); + query.whereNotExists( + database('workflow_has_statuses') + .whereRaw( + 'workflow_has_statuses.workflow_status_id = proposals.workflow_status_id' + ) + .whereIn( + 'workflow_has_statuses.status_id', + filter.excludeProposalStatusIds + ) + ); } if (first) { @@ -757,7 +773,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { { userId: user.id } ).orWhereRaw( // This query finds proposals where the current user is a scientist on an instrument that allows multiple technical reviews - // eslint-disable-next-line prettier/prettier + "jsonb_path_exists(instruments, '$[*] \\? (@.multipleTechReviewsEnabled == true && @.scientists[*].id == :userId:)')", { userId: user.id } ); @@ -891,46 +907,6 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .then((proposal) => createProposalObject(proposal)); } - async markEventAsDoneOnProposals( - event: Event, - proposalPks: number[] - ): Promise { - const dataToInsert = proposalPks.map((proposalPk) => ({ - proposal_pk: proposalPk, - [event.toLowerCase()]: true, - })); - - const result = await database.raw( - `? ON CONFLICT (proposal_pk) - DO UPDATE SET - ${event.toLowerCase()} = true - RETURNING *;`, - [database('proposal_events').insert(dataToInsert)] - ); - - if (result.rows && result.rows.length) { - return result.rows; - } else { - return null; - } - } - - async getProposalEvents( - proposalPk: number - ): Promise { - const result = await database - .select() - .from('proposal_events') - .where('proposal_pk', proposalPk) - .first(); - - if (!result) { - return null; - } - - return result; - } - async getCount(callId: number): Promise { return database('proposals') .count('call_id') @@ -947,7 +923,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { INSERT INTO proposals (title, abstract, - status_id, + workflow_status_id, proposer_id, created_at, updated_at, @@ -961,7 +937,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { management_decision_submitted) SELECT title, abstract, - status_id, + workflow_status_id, proposer_id, created_at, updated_at, @@ -981,105 +957,18 @@ export default class PostgresProposalDataSource implements ProposalDataSource { return createProposalObject(newProposal); } - async resetProposalEvents( - proposalPk: number, - callId: number, - statusId: number - ): Promise { - return database.transaction(async (trx) => { - try { - const proposalCall: CallRecord = await database('call') - .select('*') - .where('call_id', callId) - .first() - .transacting(trx); - - if (!proposalCall) { - logger.logError( - 'Could not reset proposal events because proposal call does not exist', - { callId } - ); - - throw new GraphQLError('Could not reset proposal events'); - } - - const proposalWorkflowId = proposalCall.proposal_workflow_id; - - const proposalEventsToReset: (StatusChangingEventRecord & - WorkflowConnectionRecord)[] = ( - await database - .raw( - ` - SELECT * - FROM workflow_connections AS wc - JOIN status_changing_events sce - ON sce.workflow_connection_id = wc.workflow_connection_id - WHERE wc.workflow_connection_id >= ( - SELECT workflow_connection_id - FROM workflow_connections - WHERE workflow_id = ${proposalWorkflowId} - AND status_id = ${statusId} - ) - AND wc.workflow_id = ${proposalWorkflowId}; - ` - ) - .transacting(trx) - ).rows; - - if (proposalEventsToReset?.length) { - const dataToUpdate: Record = {}; - - proposalEventsToReset.forEach((event) => { - const dataToUpdateHasProperty = dataToUpdate.hasOwnProperty( - event.status_changing_event.toLocaleLowerCase() - ); - // NOTE: Reset the property only if it is not present in the dataToUpdate otherwise we end up with overwriting existing data. - if (!dataToUpdateHasProperty) { - dataToUpdate[event.status_changing_event.toLocaleLowerCase()] = - false; - } - }); - - const [updatedProposalEvents] = await database - .update(dataToUpdate) - .from('proposal_events') - .where('proposal_pk', proposalPk) - .returning('*') - .transacting(trx); - - if (!updatedProposalEvents) { - logger.logError('Could not reset proposal events', { - dataToUpdate, - }); - - throw new GraphQLError('Could not reset proposal events'); - } - } - - return true; - } catch (error) { - logger.logException( - `Failed to reset proposal events proposalPk: ${proposalPk}`, - error - ); - - return false; - } - }); - } - - async changeProposalsStatus( - statusId: number, + async changeProposalsWorkflowStatus( + workflowStatusId: number, proposalPks: number[] ): Promise { - const dataToUpdate: { status_id: number; submitted?: boolean } = { - status_id: statusId, - }; + const workflowStatus = + await this.workflowDataSource.getWorkflowStatus(workflowStatusId); // NOTE: If status is DRAFT re-open the proposal for submission - if (statusId === 1) { - dataToUpdate.submitted = false; - } + const dataToUpdate: Partial = { + workflow_status_id: workflowStatusId, + submitted: workflowStatus?.statusId === 'DRAFT' ? false : undefined, + }; const result: ProposalRecord[] = await database .update(dataToUpdate, ['*']) @@ -1139,7 +1028,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { async doesProposalNeedTechReview(proposalPk: number): Promise { const workflowStatus = ( - await this.AdminDataSource.getSetting( + await this.adminDataSource.getSetting( SettingsId.TECH_REVIEW_OPTIONAL_WORKFLOW_STATUS ) )?.settingsValue; @@ -1156,11 +1045,12 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .first() .then((value) => value.proposal_workflow_id); - const proposalStatus: WorkflowConnectionWithStatus[] = - await this.workflowDataSource.getWorkflowConnections(proposalWorkflowId); + const proposalStatus = await database( + 'workflow_has_statuses' + ).where('workflow_id', proposalWorkflowId); return !!proposalStatus.find((status) => - status.status.shortCode.match(workflowStatus) + status.status_id.match(workflowStatus) ); } @@ -1255,7 +1145,13 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } if (filter?.proposalStatusId) { - query.where('status_id', filter?.proposalStatusId); + query.whereExists( + database('workflow_has_statuses') + .whereRaw( + 'workflow_has_statuses.workflow_status_id = proposals.workflow_status_id' + ) + .where('workflow_has_statuses.status_id', filter.proposalStatusId) + ); } if (filter?.shortCodes) { @@ -1273,7 +1169,16 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } if (filter?.excludeProposalStatusIds) { - query.where('status_id', 'not in', filter?.excludeProposalStatusIds); + query.whereNotExists( + database('workflow_has_statuses') + .whereRaw( + 'workflow_has_statuses.workflow_status_id = proposals.workflow_status_id' + ) + .whereIn( + 'workflow_has_statuses.status_id', + filter.excludeProposalStatusIds + ) + ); } if ( diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index d51c551ae4..e00d464553 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -10,7 +10,7 @@ import { StatusAction, StatusActionType, } from '../../models/StatusAction'; -import { AddConnectionStatusActionsInput } from '../../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; import { EmailActionConfig, RabbitMQActionConfig, @@ -54,17 +54,15 @@ export default class PostgresStatusActionsDataSource private createConnectionStatusActionObject( actionStatusRecord: WorkflowConnectionHasActionsRecord & { - status_action_id: number; - name: string; + workflow_status_action_id: number; type: StatusActionType; config: typeof StatusActionConfig; } ) { return new ConnectionHasStatusAction( - actionStatusRecord.connection_id, - actionStatusRecord.status_action_id, + actionStatusRecord.workflow_status_connection_id, + actionStatusRecord.workflow_status_action_id, actionStatusRecord.workflow_id, - actionStatusRecord.name, actionStatusRecord.type, this.createStatusActionConfig( actionStatusRecord.type, @@ -75,7 +73,7 @@ export default class PostgresStatusActionsDataSource private createStatusActionObject(statusActionRecord: StatusActionRecord) { return new StatusAction( - statusActionRecord.status_action_id, + statusActionRecord.workflow_status_action_id, statusActionRecord.name, statusActionRecord.description, statusActionRecord.type @@ -83,20 +81,18 @@ export default class PostgresStatusActionsDataSource } async getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise { const statusActionRecords: (StatusActionRecord & WorkflowConnectionHasActionsRecord & { config: typeof StatusActionConfig; })[] = await database .select() - .from('status_actions as sa') - .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'sa.status_action_id', + .from('workflow_status_actions as wsa') + .join('workflow_status_connection_has_actions as wca', { + 'wca.workflow_status_action_id': 'wsa.workflow_status_action_id', }) - .where('wca.workflow_id', workflowId) - .andWhere('wca.connection_id', workflowConnectionId); + .andWhere('wca.workflow_status_connection_id', workflowConnectionId); const statusActions = statusActionRecords.map((statusActionRecord) => this.createConnectionStatusActionObject(statusActionRecord) @@ -114,12 +110,12 @@ export default class PostgresStatusActionsDataSource config: typeof StatusActionConfig; } = await database .select() - .from('status_actions as sa') - .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'sa.status_action_id', + .from('workflow_status_actions as wsa') + .join('workflow_status_connection_has_actions as wca', { + 'wca.workflow_status_action_id': 'wsa.workflow_status_action_id', }) - .where('wca.action_id', statusActionId) - .andWhere('wca.connection_id', workflowConnectionId) + .where('wca.workflow_status_action_id', statusActionId) + .andWhere('wca.workflow_status_connection_id', workflowConnectionId) .first(); if (!statusActionRecord) { @@ -137,7 +133,7 @@ export default class PostgresStatusActionsDataSource const fromClause = "config->'recipientsWithEmailTemplate'"; const pattern = `'[{"emailTemplate": {"id": "${emailTemplateId}"}}]'`; const countResult = await database.raw( - `select count(*) from workflow_connection_has_actions where ${fromClause} @> ${pattern}` + `select count(*) from workflow_status_connection_has_actions where ${fromClause} @> ${pattern}` ); return Number(countResult.rows[0].count) > 0; @@ -155,17 +151,15 @@ export default class PostgresStatusActionsDataSource }, ['*'] ) - .from('workflow_connection_has_actions') - .where('connection_id', statusAction.connectionId) - .andWhere('action_id', statusAction.actionId); + .from('workflow_status_connection_has_actions') + .where('workflow_status_connection_id', statusAction.connectionId) + .andWhere('workflow_status_action_id', statusAction.actionId); if (!updatedStatusAction) { throw new GraphQLError(`StatusAction not found ${statusAction.actionId}`); } return this.createConnectionStatusActionObject({ - status_action_id: statusAction.actionId, - name: statusAction.name, type: statusAction.type, ...updatedStatusAction, }); @@ -174,8 +168,8 @@ export default class PostgresStatusActionsDataSource async getStatusAction(actionId: number): Promise { const statusAction = await database .select() - .from('status_actions') - .where('status_action_id', actionId) + .from('workflow_status_actions') + .where('workflow_status_action_id', actionId) .first(); if (!statusAction) { @@ -188,15 +182,15 @@ export default class PostgresStatusActionsDataSource async getStatusActions(): Promise { const statusActions = await database .select('*') - .from('status_actions'); + .from('workflow_status_actions'); return statusActions.map((statusAction) => this.createStatusActionObject(statusAction) ); } - async addConnectionStatusActions( - connectionStatusActionsInput: AddConnectionStatusActionsInput + async setStatusActionsOnConnection( + connectionStatusActionsInput: SetStatusActionsOnConnectionInput ): Promise { const workflow = await this.workflowDataSource.getWorkflow( connectionStatusActionsInput.workflowId @@ -210,8 +204,9 @@ export default class PostgresStatusActionsDataSource const connectionStatusActionsToInsert = connectionStatusActionsInput.actions.map((item) => ({ - connection_id: connectionStatusActionsInput.connectionId, - action_id: item.actionId, + workflow_status_connection_id: + connectionStatusActionsInput.connectionId, + workflow_status_action_id: item.actionId, workflow_id: connectionStatusActionsInput.workflowId, config: item.config ?? null, })); @@ -225,20 +220,25 @@ export default class PostgresStatusActionsDataSource if (!connectionStatusActionsInput.actions.length) { const removedActions = await database .delete() - .from('workflow_connection_has_actions') - .where('connection_id', connectionStatusActionsInput.connectionId) - .andWhere('workflow_id', connectionStatusActionsInput.workflowId) + .from('workflow_status_connection_has_actions') + .where( + 'workflow_status_connection_id', + connectionStatusActionsInput.connectionId + ) .transacting(trx); return await trx.commit(removedActions); } const currentConnectionStatusActionsIds: number[] = await database .select('*') - .from('workflow_connection_has_actions') - .where('connection_id', connectionStatusActionsInput.connectionId) + .from('workflow_status_connection_has_actions') + .where( + 'workflow_status_connection_id', + connectionStatusActionsInput.connectionId + ) .transacting(trx) .then((results: WorkflowConnectionHasActionsRecord[]) => { - return results.map((result) => result.action_id); + return results.map((result) => result.workflow_status_action_id); }); const connectionStatusActionsIdsToRemove = @@ -251,28 +251,39 @@ export default class PostgresStatusActionsDataSource if (connectionStatusActionsIdsToRemove.length) { await database .delete() - .from('workflow_connection_has_actions') - .whereIn('action_id', connectionStatusActionsIdsToRemove) - .where('connection_id', connectionStatusActionsInput.connectionId) - .andWhere('workflow_id', connectionStatusActionsInput.workflowId) + .from('workflow_status_connection_has_actions') + .whereIn( + 'workflow_status_action_id', + connectionStatusActionsIdsToRemove + ) + .where( + 'workflow_status_connection_id', + connectionStatusActionsInput.connectionId + ) .transacting(trx); } - await database('workflow_connection_has_actions') + await database('workflow_status_connection_has_actions') .insert< WorkflowConnectionHasActionsRecord[] >(connectionStatusActionsToInsert) - .onConflict(['connection_id', 'action_id']) + .onConflict([ + 'workflow_status_connection_id', + 'workflow_status_action_id', + ]) .merge() .returning('*') .transacting(trx); const insertedStatusActions = await database .select('*') - .from('workflow_connection_has_actions as wca') - .join('status_actions as sa', { - 'wca.action_id': 'sa.status_action_id ', + .from('workflow_status_connection_has_actions as wsca') + .join('workflow_status_actions as wsa', { + 'wsca.workflow_status_action_id': 'wsa.workflow_status_action_id', }) - .where('wca.connection_id', connectionStatusActionsInput.connectionId) + .where( + 'wsca.workflow_status_connection_id', + connectionStatusActionsInput.connectionId + ) .transacting(trx); return await trx.commit(insertedStatusActions); diff --git a/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts index 64ad162d85..fd7fa73dfa 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts @@ -133,10 +133,10 @@ export default class PostgresStatusActionsLogsDataSource } if (args.filter?.statusActionType) { query - .join('status_actions as sa', { - 'sa.status_action_id': 'sal.action_id', + .join('workflow_status_actions as wsa', { + 'wsa.workflow_status_action_id': 'sal.action_id', }) - .where('sa.type', args.filter.statusActionType); + .where('wsa.type', args.filter.statusActionType); } if (args.filter?.statusActionsMessage) { query.where( diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 496cfe19c3..03bf5264ec 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -1,19 +1,18 @@ import { GraphQLError } from 'graphql'; import { injectable } from 'tsyringe'; +import database from './database'; +import { StatusRecord } from './records'; import { Status } from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; import { UpdateStatusInput } from '../../resolvers/mutations/settings/UpdateStatusMutation'; import { StatusDataSource } from '../StatusDataSource'; -import database from './database'; -import { StatusRecord } from './records'; @injectable() export default class PostgresStatusDataSource implements StatusDataSource { private createStatusObject(status: StatusRecord) { return new Status( status.status_id, - status.short_code, status.name, status.description, status.is_default, @@ -22,11 +21,11 @@ export default class PostgresStatusDataSource implements StatusDataSource { } async createStatus( - newStatusInput: Omit + newStatusInput: Omit ): Promise { const [addedStatus]: StatusRecord[] = await database .insert({ - short_code: newStatusInput.shortCode, + status_id: newStatusInput.id, name: newStatusInput.name, description: newStatusInput.description, entity_type: newStatusInput.entityType, @@ -41,7 +40,7 @@ export default class PostgresStatusDataSource implements StatusDataSource { return this.createStatusObject(addedStatus); } - async getStatus(statusId: number): Promise { + async getStatus(statusId: string): Promise { const status: StatusRecord = await database .select() .from('statuses') @@ -61,6 +60,19 @@ export default class PostgresStatusDataSource implements StatusDataSource { return statuses.map((status) => this.createStatusObject(status)); } + public getStatusByWorkflowStatusId( + workflowStatusId: number + ): Promise { + return database + .select('s.*') + .from('statuses as s') + .join('workflow_has_statuses as whs', 's.status_id', 'whs.status_id') + .where('whs.workflow_status_id', workflowStatusId) + .first() + .then((status: StatusRecord) => + status ? this.createStatusObject(status) : null + ); + } async updateStatus(status: UpdateStatusInput): Promise { const [updatedStatus]: StatusRecord[] = await database .update( @@ -80,7 +92,7 @@ export default class PostgresStatusDataSource implements StatusDataSource { return this.createStatusObject(updatedStatus); } - async deleteStatus(statusId: number): Promise { + async deleteStatus(statusId: string): Promise { const [removedStatus]: StatusRecord[] = await database('statuses') .where('status_id', statusId) .andWhere('is_default', false) @@ -94,30 +106,17 @@ export default class PostgresStatusDataSource implements StatusDataSource { return this.createStatusObject(removedStatus); } - async getDefaultStatus( - entityType: Status['entityType'] - ): Promise { - const status: StatusRecord = await database - .select() - .from('statuses') - .where('entity_type', entityType) - .andWhere('is_default', true) - .first(); - - return status ? this.createStatusObject(status) : null; - } - async getInitialStatus( entityType: Status['entityType'] ): Promise { - const shortCode = + const statusId = entityType === WorkflowType.PROPOSAL ? 'DRAFT' : 'AWAITING_ESF'; const status: StatusRecord = await database .select() .from('statuses') .where('entity_type', entityType) - .andWhere('short_code', shortCode) + .andWhere('status_id', statusId) .first(); return status ? this.createStatusObject(status) : null; diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index e778ecc377..8e7374ed36 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -2,28 +2,36 @@ import { GraphQLError } from 'graphql'; import { inject, injectable } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; -import { Status } from '../../models/Status'; import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow } from '../../models/Workflow'; -import { - WorkflowConnection, - NextAndPreviousStatuses, - WorkflowConnectionWithStatus, -} from '../../models/WorkflowConnections'; +import { WorkflowConnection } from '../../models/WorkflowConnections'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; +import { CreateWorkflowConnectionInput } from '../../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; +import { StatusDataSource } from '../StatusDataSource'; import { WorkflowDataSource } from '../WorkflowDataSource'; import database from './database'; import { StatusChangingEventRecord, - StatusRecord, WorkflowConnectionRecord, WorkflowRecord, + WorkflowStatusRecord, + WorkflowStructure, } from './records'; -import StatusDataSource from './StatusDataSource'; +import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; @injectable() export default class PostgresWorkflowDataSource implements WorkflowDataSource { + private workflowStructureCache = new Map< + number, + { + updatedAt: number; + structure: WorkflowStructure; + } + >(); + constructor( @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource ) {} @@ -42,39 +50,22 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { workflowConnection: WorkflowConnectionRecord ) { return new WorkflowConnection( - workflowConnection.workflow_connection_id, - workflowConnection.sort_order, + workflowConnection.workflow_status_connection_id, workflowConnection.workflow_id, - workflowConnection.status_id, - workflowConnection.next_status_id, - workflowConnection.prev_status_id, - workflowConnection.pos_x, - workflowConnection.pos_y, - workflowConnection.prev_connection_id + workflowConnection.prev_workflow_status_id, + workflowConnection.next_workflow_status_id, + workflowConnection.source_handle, + workflowConnection.target_handle ); } - private createWorkflowConnectionWithStatusObject( - workflowConnection: WorkflowConnectionRecord & StatusRecord - ) { - return new WorkflowConnectionWithStatus( - workflowConnection.workflow_connection_id, - workflowConnection.sort_order, - workflowConnection.workflow_id, - workflowConnection.status_id, - { - id: workflowConnection.status_id, - shortCode: workflowConnection.short_code, - name: workflowConnection.name, - description: workflowConnection.description, - isDefault: workflowConnection.is_default, - entityType: workflowConnection.entity_type, - }, - workflowConnection.next_status_id, - workflowConnection.prev_status_id, - workflowConnection.pos_x, - workflowConnection.pos_y, - workflowConnection.prev_connection_id + private createWorkflowStatusObject(workflowStatus: WorkflowStatusRecord) { + return new WorkflowStatus( + workflowStatus.workflow_status_id, + workflowStatus.workflow_id, + workflowStatus.status_id, + workflowStatus.pos_x, + workflowStatus.pos_y ); } @@ -106,15 +97,11 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ); } - await this.addWorkflowStatus({ - sortOrder: 0, - nextStatusId: null, - prevStatusId: null, + await this.addStatusToWorkflow({ statusId: initialStatus.id, workflowId: workflowRecord.workflow_id, posX: 0, posY: 0, - prevConnectionId: null, }); return this.createWorkflowObject(workflowRecord); @@ -162,6 +149,16 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { return this.createWorkflowObject(records[0]); }); } + + async updateWorkflowTimestamp(workflowId: number): Promise { + await database + .update({ + updated_at: new Date(), + }) + .from('workflows') + .where('workflow_id', workflowId); + } + async deleteWorkflow(workflowId: number): Promise { return database('workflows') .where('workflow_id', workflowId) @@ -174,103 +171,86 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ); } + this.workflowStructureCache.delete(workflowId); + return this.createWorkflowObject(workflows[0]); }); } async getWorkflowConnections( - workflowId: WorkflowConnection['workflowId'] - ): Promise { - const getUniqueOrderedWorkflowConnectionsQuery = ` - SELECT * FROM ( - SELECT * - FROM workflow_connections as wc - LEFT JOIN - statuses as s - ON - s.status_id = wc.status_id - WHERE workflow_id = ${workflowId} - ) t - ORDER BY - sort_order ASC - `; - - const workflowConnections: - | (WorkflowConnectionRecord & StatusRecord)[] - | null = (await database.raw(getUniqueOrderedWorkflowConnectionsQuery)) - .rows; - - return workflowConnections - ? workflowConnections.map((workflowConnection) => - this.createWorkflowConnectionWithStatusObject(workflowConnection) - ) - : []; + workflowId: number + ): Promise { + const workflowConnections: WorkflowConnectionRecord[] = await database + .select('*') + .from('workflow_status_connections') + .where('workflow_id', workflowId); + + return workflowConnections.map((workflowConnection) => + this.createWorkflowConnectionObject(workflowConnection) + ); } - async getWorkflowConnection( - connectionId: WorkflowConnection['id'] - ): Promise { - const query = ` - SELECT wc.*, s.* - FROM workflow_connections as wc - LEFT JOIN statuses as s ON s.status_id = wc.status_id - WHERE wc.workflow_connection_id = ? - `; - - const result = await database.raw(query, [connectionId]); - const workflowConnection: (WorkflowConnectionRecord & StatusRecord) | null = - result.rows[0] || null; + async getWorkflowStatuses(workflowId: number): Promise { + const workflowStatuses: WorkflowStatusRecord[] = await database + .select('*') + .from('workflow_has_statuses') + .where('workflow_id', workflowId); - return workflowConnection - ? this.createWorkflowConnectionWithStatusObject(workflowConnection) - : null; + return workflowStatuses.map((workflowStatus) => + this.createWorkflowStatusObject(workflowStatus) + ); } - async getWorkflowConnectionsById( - workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'] | undefined, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise { - const workflowConnectionRecords: (WorkflowConnectionRecord & - StatusRecord)[] = await database + + async getInitialWorkflowStatus( + workflowId: number + ): Promise { + const workflow = await database .select() - .from('workflow_connections as wc') - .join('statuses as s', { - 's.status_id': 'wc.status_id', - }) + .from('workflows') .where('workflow_id', workflowId) - .modify((query) => { - if (statusId) { - query.andWhere('wc.status_id', statusId); - } + .first(); - if (nextStatusId) { - query.andWhere('wc.next_status_id', nextStatusId); - } + if (!workflow) { + throw new GraphQLError(`Workflow not found with id: ${workflowId}`); + } - if (prevStatusId) { - query.andWhere('wc.prev_status_id', prevStatusId); - } + const defaultStatus = await this.statusDataSource.getInitialStatus( + workflow.entity_type + ); - if (sortOrder) { - query.andWhere('wc.sort_order', sortOrder); - } - }); + if (!defaultStatus) { + return null; + } - if (!workflowConnectionRecords) { - throw new GraphQLError( - `Could not find wkflow connections with statusId: ${statusId}` - ); + const workflowStatus: WorkflowStatusRecord | null = await database + .select() + .from('workflow_has_statuses') + .where('workflow_id', workflowId) + .andWhere('status_id', defaultStatus.id) + .first(); + + if (!workflowStatus) { + return null; } - const workflowConnections = workflowConnectionRecords.map( - (workflowConnectionRecord) => - this.createWorkflowConnectionWithStatusObject(workflowConnectionRecord) - ); + return this.createWorkflowStatusObject(workflowStatus); + } + + async getWorkflowConnection( + connectionId: number + ): Promise { + const workflowConnection: WorkflowConnectionRecord = await database + .select('*') + .from('workflow_status_connections') + .where('workflow_status_connection_id', connectionId) + .first(); - return workflowConnections; + return workflowConnection + ? this.createWorkflowConnectionObject(workflowConnection) + : null; } - async addWorkflowStatus( - newWorkflowStatusInput: Omit - ): Promise { + async addStatusToWorkflow( + newWorkflowStatusInput: AddStatusToWorkflowInput + ): Promise { const workflow = await this.getWorkflow(newWorkflowStatusInput.workflowId); if (!workflow) { @@ -278,136 +258,154 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { `Could not find workflow with id: ${newWorkflowStatusInput.workflowId}` ); } - const [workflowConnectionRecord]: (WorkflowConnectionRecord & - StatusRecord)[] = await database + const [workflowStatusRecord]: WorkflowStatusRecord[] = await database .insert({ workflow_id: newWorkflowStatusInput.workflowId, status_id: newWorkflowStatusInput.statusId, - next_status_id: newWorkflowStatusInput.nextStatusId, - prev_status_id: newWorkflowStatusInput.prevStatusId, - sort_order: newWorkflowStatusInput.sortOrder, pos_x: newWorkflowStatusInput.posX, pos_y: newWorkflowStatusInput.posY, - prev_connection_id: newWorkflowStatusInput.prevConnectionId, }) - .into('workflow_connections as wc') - .returning('*') - .join('statuses as s', { - 's.status_id': newWorkflowStatusInput.statusId, - }); - if (!workflowConnectionRecord) { + .into('workflow_has_statuses') + .returning('*'); + + if (!workflowStatusRecord) { throw new GraphQLError('Could not create workflow status'); } - return this.createWorkflowConnectionWithStatusObject( - workflowConnectionRecord - ); + await this.updateWorkflowTimestamp(newWorkflowStatusInput.workflowId); + + return this.createWorkflowStatusObject(workflowStatusRecord); } - async updateWorkflowStatus(connection: WorkflowConnection) { - const result = await database.raw( - `WITH updated AS ( - INSERT INTO workflow_connections ( - workflow_connection_id, workflow_id, status_id, next_status_id, - prev_status_id, sort_order, pos_x, pos_y, prev_connection_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (workflow_connection_id) - DO UPDATE SET - status_id = EXCLUDED.status_id, - next_status_id = EXCLUDED.next_status_id, - prev_status_id = EXCLUDED.prev_status_id, - sort_order = EXCLUDED.sort_order, - pos_x = EXCLUDED.pos_x, - pos_y = EXCLUDED.pos_y, - prev_connection_id = EXCLUDED.prev_connection_id - RETURNING * + async updateWorkflowStatus( + args: UpdateWorkflowStatusInput + ): Promise { + const [updatedStatus]: WorkflowStatusRecord[] = await database + .update( + { + pos_x: args.posX, + pos_y: args.posY, + }, + ['*'] ) - SELECT wc.*, s.* - FROM updated wc - LEFT JOIN statuses s ON s.status_id = wc.status_id;`, - [ - connection.id, - connection.workflowId, - connection.statusId, - connection.nextStatusId, - connection.prevStatusId, - connection.sortOrder, - connection.posX, - connection.posY, - connection.prevConnectionId, - ] - ); + .from('workflow_has_statuses') + .where('workflow_status_id', args.workflowStatusId); - if (!result.rows[0]) { + if (!updatedStatus) { throw new GraphQLError('Could not update workflow status'); } - return this.createWorkflowConnectionWithStatusObject(result.rows[0]); + await this.updateWorkflowTimestamp(updatedStatus.workflow_id); + + return this.createWorkflowStatusObject(updatedStatus); + } + + async createWorkflowConnection({ + prevWorkflowStatusId, + nextWorkflowStatusId, + sourceHandle, + targetHandle, + }: CreateWorkflowConnectionInput): Promise { + const prevStatus = await this.getWorkflowStatus(prevWorkflowStatusId); + + if (!prevStatus) { + throw new GraphQLError( + `Could not find workflow status with id: ${prevWorkflowStatusId}` + ); + } + + const nextStatus = await this.getWorkflowStatus(nextWorkflowStatusId); + + if (!nextStatus) { + throw new GraphQLError( + `Could not find workflow status with id: ${nextWorkflowStatusId}` + ); + } + + if (prevStatus.workflowId !== nextStatus.workflowId) { + throw new GraphQLError( + `Cannot connect statuses from different workflows previous status belongs to workflow id: ${prevStatus.workflowId} while next status belongs to workflow id: ${nextStatus.workflowId}` + ); + } + + const [createdConnection]: WorkflowConnectionRecord[] = await database( + 'workflow_status_connections' + ) + .insert({ + workflow_id: prevStatus.workflowId, + prev_workflow_status_id: prevWorkflowStatusId, + next_workflow_status_id: nextWorkflowStatusId, + source_handle: sourceHandle, + target_handle: targetHandle, + }) + .returning('*'); + + if (!createdConnection) { + throw new GraphQLError('Could not create workflow connection'); + } + + await this.updateWorkflowTimestamp(createdConnection.workflow_id); + + return this.createWorkflowConnectionObject(createdConnection); } async deleteWorkflowConnection( connectionId: number ): Promise { - const deletedConnection: WorkflowConnectionRecord[] = await database( - 'workflow_connections' + const [deletedConnection]: WorkflowConnectionRecord[] = await database( + 'workflow_status_connections' ) - .where('workflow_connection_id', connectionId) + .where('workflow_status_connection_id', connectionId) .del() .returning('*'); - if (deletedConnection.length === 0) { + if (!deletedConnection) { return null; } - return this.createWorkflowConnectionObject(deletedConnection[0]); + await this.updateWorkflowTimestamp(deletedConnection.workflow_id); + + return this.createWorkflowConnectionObject(deletedConnection); } async deleteWorkflowStatus( - statusId: number, - workflowId: number, - sortOrder: number - ): Promise { - const removeWorkflowConnectionQuery = database('workflow_connections') - .where('workflow_id', workflowId) - .andWhere('status_id', statusId) - .andWhere('sort_order', sortOrder) + workflowStatusId: number + ): Promise { + const [deletedStatus]: WorkflowStatusRecord[] = await database( + 'workflow_has_statuses' + ) + .where('workflow_status_id', workflowStatusId) .del() .returning('*'); - return removeWorkflowConnectionQuery.then( - (workflowStatus: WorkflowConnectionRecord[]) => { - if (workflowStatus === undefined || workflowStatus.length < 1) { - throw new GraphQLError( - `Could not delete workflow status with id: ${workflowId} ` - ); - } + if (!deletedStatus) { + throw new GraphQLError( + `Could not delete from workflow_has_statuses with workflow_status_id: ${workflowStatusId} ` + ); + } - // NOTE: I need this object only to be able to reorder and update other statuses in the logic layer. - return this.createWorkflowConnectionObject({ - ...workflowStatus[0], - }); - } - ); + await this.updateWorkflowTimestamp(deletedStatus.workflow_id); + + return this.createWorkflowStatusObject(deletedStatus); } private createStatusChangingEventObject( statusChangingEvent: StatusChangingEventRecord ) { return new StatusChangingEvent( - statusChangingEvent.status_changing_event_id, - statusChangingEvent.workflow_connection_id, + statusChangingEvent.workflow_status_connection_id, statusChangingEvent.status_changing_event ); } - async addStatusChangingEventsToConnection( + async setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise { const workflowConnection = await database .select() - .from('workflow_connections') - .where('workflow_connection_id', workflowConnectionId) + .from('workflow_status_connections') + .where('workflow_status_connection_id', workflowConnectionId) .first() .then((workflowConnection: WorkflowConnectionRecord | null) => workflowConnection @@ -421,38 +419,150 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ); } - const eventsToInsert = statusChangingEvents.map((statusChangingEvent) => ({ - workflow_connection_id: workflowConnectionId, - status_changing_event: statusChangingEvent, - })); - - await database('status_changing_events') - .where('workflow_connection_id', workflowConnectionId) + await database('workflow_status_connection_has_events') + .where('workflow_status_connection_id', workflowConnectionId) .del(); - const statusChangingEventsResult: StatusChangingEventRecord[] = - await database('status_changing_events') - .insert(eventsToInsert) - .returning(['*']); + const eventsToReturn: StatusChangingEvent[] = []; - return ( - statusChangingEventsResult?.map((statusChangingEventResult) => - this.createStatusChangingEventObject(statusChangingEventResult) - ) || [] - ); + for (const eventName of statusChangingEvents) { + await database('workflow_status_connection_has_events').insert({ + workflow_status_connection_id: workflowConnectionId, + status_changing_event: eventName, + }); + + eventsToReturn.push( + new StatusChangingEvent(workflowConnectionId, eventName) + ); + } + + if (workflowConnection) { + await this.updateWorkflowTimestamp(workflowConnection.workflowId); + } + + return eventsToReturn; } async getStatusChangingEventsByConnectionIds( workflowConnectionIds: number[] ): Promise { return database - .select('*') - .from('status_changing_events') - .whereIn('workflow_connection_id', workflowConnectionIds) + .select('workflow_status_connection_id', 'status_changing_event') + .from('workflow_status_connection_has_events') + .whereIn('workflow_status_connection_id', workflowConnectionIds) .then((statusChangingEvents: StatusChangingEventRecord[]) => { return statusChangingEvents.map((statusChangingEvent) => this.createStatusChangingEventObject(statusChangingEvent) ); }); } + + async getWorkflowStatus( + workflowStatusId: number + ): Promise { + const workflowStatus: WorkflowStatusRecord = await database + .select('*') + .from('workflow_has_statuses') + .where('workflow_status_id', workflowStatusId) + .first(); + + return workflowStatus + ? this.createWorkflowStatusObject(workflowStatus) + : null; + } + + async getWorkflowStructure(workflowId: number): Promise { + const workflow = await database + .select('updated_at') + .from('workflows') + .where('workflow_id', workflowId) + .first(); + + if (!workflow) { + throw new GraphQLError(`Workflow not found with id: ${workflowId}`); + } + + const cacheEntry = this.workflowStructureCache.get(workflowId); + if ( + cacheEntry && + workflow.updated_at && + cacheEntry.updatedAt === new Date(workflow.updated_at).getTime() + ) { + return cacheEntry.structure; + } + + const workflowStatuses = await database + .select( + 'workflow_has_statuses.workflow_status_id as workflowStatusId', + 'workflow_has_statuses.status_id as statusId' + ) + .from('workflow_has_statuses') + .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') + .where('workflow_has_statuses.workflow_id', workflowId); + + const workflowConnections = await database + .select( + 'workflow_status_connections.workflow_status_connection_id as workflowStatusConnectionId', + 'workflow_status_connections.prev_workflow_status_id as prevWorkflowStatusId', + 'workflow_status_connections.next_workflow_status_id as nextWorkflowStatusId', + 'workflow_status_connection_has_events.status_changing_event as statusChangingEvent' + ) + .from('workflow_status_connections') + .leftJoin( + 'workflow_status_connection_has_events', + 'workflow_status_connections.workflow_status_connection_id', + 'workflow_status_connection_has_events.workflow_status_connection_id' + ) + .where('workflow_status_connections.workflow_id', workflowId); + + const normalizedWorkflowConnectionsMap = new Map< + number, + { + workflowStatusConnectionId: number; + prevWorkflowStatusId: number; + nextWorkflowStatusId: number; + statusChangingEvents: string[]; + } + >(); + + workflowConnections.forEach((wc) => { + const existingConnection = normalizedWorkflowConnectionsMap.get( + wc.workflowStatusConnectionId + ); + + if (!existingConnection) { + normalizedWorkflowConnectionsMap.set(wc.workflowStatusConnectionId, { + workflowStatusConnectionId: wc.workflowStatusConnectionId, + prevWorkflowStatusId: wc.prevWorkflowStatusId, + nextWorkflowStatusId: wc.nextWorkflowStatusId, + statusChangingEvents: [], + }); + } + + if (wc.statusChangingEvent) { + normalizedWorkflowConnectionsMap + .get(wc.workflowStatusConnectionId)! + .statusChangingEvents.push(wc.statusChangingEvent); + } + }); + + const normalizedWorkflowConnections = Array.from( + normalizedWorkflowConnectionsMap.values() + ); + + if (workflow.updated_at) { + this.workflowStructureCache.set(workflowId, { + updatedAt: new Date(workflow.updated_at).getTime(), + structure: { + workflowStatuses, + workflowConnections: normalizedWorkflowConnections, + }, + }); + } + + return { + workflowStatuses, + workflowConnections: normalizedWorkflowConnections, + }; + } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 409fffbd33..9ed7db8079 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -101,7 +101,8 @@ export interface ProposalRecord { readonly title: string; readonly abstract: string; readonly proposer_id: number; - readonly status_id: number; + readonly status_id: string; + readonly workflow_status_id: number; readonly created_at: Date; readonly updated_at: Date; readonly full_count: number; @@ -124,7 +125,8 @@ export interface ProposalViewRecord { readonly proposal_pk: number; readonly title: string; readonly principal_investigator: number; - readonly proposal_status_id: number; + readonly proposal_workflow_status_id: number; + readonly proposal_status_id: string; readonly proposal_status_name: string; readonly proposal_status_description: string; readonly proposal_id: string; @@ -571,8 +573,7 @@ export interface ShipmentRecord { } export interface StatusRecord { - readonly status_id: number; - readonly short_code: string; + readonly status_id: string; readonly name: string; readonly description: string; readonly is_default: boolean; @@ -587,23 +588,44 @@ export interface WorkflowRecord { readonly full_count: number; readonly entity_type: WorkflowType; readonly connection_line_type: string; + readonly updated_at: Date; } export interface WorkflowConnectionRecord { - readonly workflow_connection_id: number; - readonly sort_order: number; + readonly workflow_status_connection_id: number; + readonly workflow_id: number; + readonly prev_workflow_status_id: number; + readonly next_workflow_status_id: number; + readonly source_handle: string; + readonly target_handle: string; +} + +// NOTE: This is a data structure returned by getWorkflowStructure function in WorkflowDataSource, +// and contains full information about workflow statuses and connections, +// so we do not need to make multiple calls to database to get workflow structure +export interface WorkflowStructure { + readonly workflowStatuses: { + readonly workflowStatusId: number; + readonly statusId: string; + }[]; + readonly workflowConnections: { + readonly workflowStatusConnectionId: number; + readonly prevWorkflowStatusId: number; + readonly nextWorkflowStatusId: number; + readonly statusChangingEvents: string[]; + }[]; +} + +export interface WorkflowStatusRecord { + readonly workflow_status_id: number; readonly workflow_id: number; - readonly status_id: number; - readonly next_status_id: number | null; - readonly prev_status_id: number | null; + readonly status_id: string; readonly pos_x: number; readonly pos_y: number; - readonly prev_connection_id: number | null; } export interface StatusChangingEventRecord { - readonly status_changing_event_id: number; - readonly workflow_connection_id: number; + readonly workflow_status_connection_id: number; readonly status_changing_event: string; } @@ -625,45 +647,6 @@ export interface FapProposalWithReviewGradesAndRankingRecord { readonly review_grades: number[]; } -export interface ProposalEventsRecord { - readonly proposal_pk: number; - readonly proposal_created: boolean; - readonly proposal_submitted: boolean; - readonly proposal_feasibility_review_feasible: boolean; - readonly proposal_feasibility_review_unfeasible: boolean; - readonly call_ended: boolean; - readonly call_ended_internal: boolean; - readonly call_review_ended: boolean; - readonly call_fap_review_ended: boolean; - readonly proposal_faps_selected: boolean; - readonly proposal_instruments_selected: boolean; - readonly proposal_feasibility_review_submitted: boolean; - readonly proposal_sample_review_submitted: boolean; - readonly proposal_all_fap_reviewers_selected: boolean; - readonly proposal_management_decision_updated: boolean; - readonly proposal_management_decision_submitted: boolean; - readonly proposal_all_fap_reviews_submitted: boolean; - readonly proposal_fap_review_updated: boolean; - readonly proposal_feasibility_review_updated: boolean; - readonly proposal_sample_safe: boolean; - readonly proposal_fap_review_submitted: boolean; - readonly proposal_fap_meeting_submitted: boolean; - readonly proposal_all_fap_meetings_submitted: boolean; - readonly proposal_all_reviews_submitted_for_all_faps: boolean; - readonly proposal_all_fap_meeting_instrument_submitted: boolean; - readonly proposal_instrument_submitted: boolean; - readonly proposal_accepted: boolean; - readonly proposal_reserved: boolean; - readonly proposal_rejected: boolean; - readonly proposal_notified: boolean; - readonly proposal_booking_time_activated: boolean; - readonly proposal_booking_time_updated: boolean; - readonly proposal_booking_time_slot_added: boolean; - readonly proposal_booking_time_slots_removed: boolean; - readonly proposal_booking_time_completed: boolean; - readonly proposal_booking_time_reopened: boolean; -} - export interface FeatureRecord { readonly feature_id: string; readonly is_enabled: boolean; @@ -734,15 +717,15 @@ export interface QuantityRecord { } export interface StatusActionRecord { - readonly status_action_id: number; + readonly workflow_status_action_id: number; readonly name: string; readonly description: string; readonly type: StatusActionType; } export interface WorkflowConnectionHasActionsRecord { - readonly connection_id: number; - readonly action_id: number; + readonly workflow_status_connection_id: number; + readonly workflow_status_action_id: number; readonly workflow_id: number; readonly config: string; } @@ -790,7 +773,7 @@ export const createProposalObject = (proposal: ProposalRecord) => { proposal.title || '', proposal.abstract || '', proposal.proposer_id, - proposal.status_id, + proposal.workflow_status_id, proposal.created_at, proposal.updated_at, proposal.proposal_id, @@ -862,6 +845,7 @@ export const createProposalViewObject = (proposal: ProposalViewRecord) => { proposal.proposal_pk, proposal.title || '', proposal.principal_investigator, + proposal.proposal_workflow_status_id, proposal.proposal_status_id, proposal.proposal_status_name, proposal.proposal_status_description, @@ -1389,6 +1373,7 @@ export const createProposalViewObjectWithTechniques = ( proposal.proposal_pk, proposal.title || '', proposal.principal_investigator, + proposal.proposal_workflow_status_id, proposal.proposal_status_id, proposal.proposal_status_name, proposal.proposal_status_description, @@ -1546,7 +1531,7 @@ export interface ExperimentSafetyRecord { readonly esi_questionary_id: number; readonly esi_questionary_submitted_at: Date; readonly created_by: number; - readonly status_id: number | null; + readonly workflow_status_id: number; readonly safety_review_questionary_id: number; readonly reviewed_by: number; readonly created_at: Date; diff --git a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts index 7f2a534e17..1ad9e102f4 100644 --- a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts @@ -10,8 +10,8 @@ import { UserWithRole } from '../../models/User'; import { ProposalViewTechnicalReview } from '../../resolvers/types/ProposalView'; import { removeDuplicates } from '../../utils/helperFunctions'; import { PaginationSortDirection } from '../../utils/pagination'; -import { CallDataSource } from '../CallDataSource'; import PostgresAdminDataSource from '../postgres/AdminDataSource'; +import PostgresCallDataSource from '../postgres/CallDataSource'; import database from '../postgres/database'; import { CallRecord, @@ -30,29 +30,15 @@ import { StfcUserDataSource } from './StfcUserDataSource'; const postgresProposalDataSource = new PostgresProposalDataSource( new PostgresWorkflowDataSource(new PostgresStatusDataSource()), new PostgresAdminDataSource(), + new PostgresCallDataSource(), new PostgresTagDataSource(new PostgresUserDataSource()) ); -const fieldMap: { [key: string]: string } = { - finalStatus: 'final_status', - callShortCode: 'call_short_code', - //'instruments.name': "instruments->0->'name'", - statusName: 'proposal_status_id', - proposalId: 'proposal_id', - title: 'title', - submitted: 'submitted', - notified: 'notified', - submittedDate: 'submitted_date', -}; - @injectable() export default class StfcProposalDataSource extends PostgresProposalDataSource { protected stfcUserDataSource: StfcUserDataSource = container.resolve( Tokens.UserDataSource ) as StfcUserDataSource; - protected callDataSource: CallDataSource = container.resolve( - Tokens.CallDataSource - ) as CallDataSource; async getInstrumentScientistProposals( user: UserWithRole, @@ -70,7 +56,7 @@ export default class StfcProposalDataSource extends PostgresProposalDataSource { const techniqueProposalCallIds: number[] = ( await this.callDataSource.getCalls({ - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', }) ).map((call) => call.id); diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index ef3d6063f1..5ca1e06e64 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -20,12 +20,12 @@ const postgresUserDataSource = new PostgresUserDataSource(); const UOWSClient = createUOWSClient(); -type StfcRolesToEssRole = { [key: string]: Roles[] }; +type StfcRolesToSystemRole = { [key: string]: Roles[] }; /* * Must not contain user role, this is appended at the very last step. */ -const stfcRolesToSystemRoleDefinitions: StfcRolesToEssRole = { +const stfcRolesToSystemRoleDefinitions: StfcRolesToSystemRole = { 'User Officer': [Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST], 'ISIS Instrument Scientist': [Roles.INSTRUMENT_SCIENTIST], 'CLF Artemis FAP Secretary': [Roles.INSTRUMENT_SCIENTIST], diff --git a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts index 9a1cb4d0e4..b4bd1c14de 100644 --- a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts +++ b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.spec.ts @@ -1,15 +1,18 @@ import { container } from 'tsyringe'; -import createExperimentSafetyWorkflowHandler, { - handleWorkflowEngineChange, -} from './experimentSafetyWorkflow'; 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 * as workflowEngineModule from '../workflowEngine/experiment'; -import { WorkflowEngineExperimentType } from '../workflowEngine/experiment'; +import { + ExperimentWorkflowEngine, + WorkflowEngineExperimentType, +} from '../workflowEngine/experiment'; const mockPublish = jest.fn(); @@ -19,11 +22,8 @@ let mockStatusDataSource: StatusDataSourceMock; beforeAll(() => { spyMarkEvent = jest - .spyOn( - workflowEngineModule, - 'markExperimentSafetyEventAsDoneAndCallWorkflowEngine' - ) - .mockResolvedValue([]); + .spyOn(workflowEngineModule, 'handleWorkflowEngineChange') + .mockResolvedValue(); spyResolveEventBus = jest .spyOn(eventBusModule, 'resolveApplicationEventBus') @@ -38,7 +38,6 @@ afterAll(() => { beforeEach(() => { jest.clearAllMocks(); - spyMarkEvent.mockResolvedValue([]); spyResolveEventBus.mockReturnValue({ publish: mockPublish } as any); mockStatusDataSource = container.resolve(Tokens.StatusDataSource); @@ -108,8 +107,11 @@ describe('experimentSafetyWorkflowHandler', () => { await handler(event); expect(spyMarkEvent).toHaveBeenCalledWith( - Event.EXPERIMENT_ESF_SUBMITTED, - [100] + expect.objectContaining({ + type: Event.EXPERIMENT_ESF_SUBMITTED, + experimentsafety: expect.objectContaining({ experimentPk: 100 }), + }), + 100 ); }); @@ -126,8 +128,11 @@ describe('experimentSafetyWorkflowHandler', () => { await handler(event); expect(spyMarkEvent).toHaveBeenCalledWith( - Event.EXPERIMENT_ESF_SUBMITTED, - [77] + expect.objectContaining({ + type: Event.EXPERIMENT_ESF_SUBMITTED, + experimentsafety: expect.objectContaining({ experimentPk: 77 }), + }), + 77 ); }); }); @@ -156,19 +161,12 @@ describe('handleWorkflowEngineChange', () => { await handleWorkflowEngineChange(event, 42); - expect(spyMarkEvent).toHaveBeenCalledWith(Event.EXPERIMENT_ESF_SUBMITTED, [ - 42, - ]); - }); - - test('should pass an array of pks directly to workflow engine', async () => { - const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); - - await handleWorkflowEngineChange(event, [10, 20, 30]); - expect(spyMarkEvent).toHaveBeenCalledWith( - Event.EXPERIMENT_ESF_SUBMITTED, - [10, 20, 30] + expect.objectContaining({ + type: Event.EXPERIMENT_ESF_SUBMITTED, + experimentsafety: expect.objectContaining({ experimentPk: 42 }), + }), + 42 ); }); @@ -176,28 +174,25 @@ describe('handleWorkflowEngineChange', () => { test('should publish status change events when workflow engine returns updated experiments', async () => { const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); const updatedExperiment = createMockUpdatedExperiment({ - statusId: 10, - prevStatusId: 5, - }); + workflowStatusId: 10, + prevWorkflowStatusId: 5, + } as Partial); - spyMarkEvent.mockResolvedValue([updatedExperiment]); + spyMarkEvent.mockRestore(); - jest - .spyOn(mockStatusDataSource, 'getStatus') - .mockResolvedValueOnce({ name: 'Approved' } as any) // new statusId (called first) - .mockResolvedValueOnce({ name: 'Draft' } as any); // prevStatusId (called second) + const runSpy = jest + .spyOn(ExperimentWorkflowEngine.prototype, 'run') + .mockResolvedValueOnce([updatedExperiment]); await handleWorkflowEngineChange(event, 42); - await new Promise(process.nextTick); - expect(mockPublish).toHaveBeenCalledWith( - expect.objectContaining({ - type: Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, - experimentsafety: updatedExperiment, - isRejection: false, - }) - ); + 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 () => { @@ -228,7 +223,7 @@ describe('handleWorkflowEngineChange', () => { test('should not publish if updated experiment has no statusId', async () => { const event = createMockEvent({ type: Event.EXPERIMENT_ESF_SUBMITTED }); const updatedExperiment = createMockUpdatedExperiment({ - statusId: undefined as unknown as number, + workflowStatusId: 1, }); spyMarkEvent.mockResolvedValue([updatedExperiment]); diff --git a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts index 38e0382518..74d35749dd 100644 --- a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts +++ b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts @@ -1,39 +1,37 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; -import { StatusDataSource } from '../datasources/StatusDataSource'; +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, - markExperimentSafetyEventAsDoneAndCallWorkflowEngine, + ExperimentWorkflowEngine, } from '../workflowEngine/experiment'; enum ExperimentInformationKeys { ExperimentPk = 'experimentPk', } -const publishExperimentSafetyStatusChange = async ( - updatedExperimentSafeties: (void | WorkflowEngineExperimentType)[] +export const publishExperimentSafetyStatusChange = async ( + updatedExperimentSafeties: WorkflowEngineExperimentType[] ) => { - if (!updatedExperimentSafeties) { - return; - } const eventBus = resolveApplicationEventBus(); - const statusDataSource = container.resolve( - Tokens.StatusDataSource + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource ); updatedExperimentSafeties.map(async (updatedExperimentSafety) => { - if (updatedExperimentSafety && updatedExperimentSafety.statusId) { - const experimentSafetyStatus = await statusDataSource.getStatus( - updatedExperimentSafety.statusId - ); - const previousExperimentStatus = await statusDataSource.getStatus( - updatedExperimentSafety.prevStatusId + 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, @@ -41,28 +39,36 @@ const publishExperimentSafetyStatusChange = async ( isRejection: false, key: 'experimentsafety', loggedInUserId: null, - description: `From "${previousExperimentStatus?.name}" to "${experimentSafetyStatus?.name}"`, + 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 updatedExperimentSafeties = - await markExperimentSafetyEventAsDoneAndCallWorkflowEngine( - event.type, - isArray ? experimentPks : [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 ) { - await publishExperimentSafetyStatusChange(updatedExperimentSafeties); + // publish event EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW to the EventBus + await experimentSafetyWorkflowHelpers.publishExperimentSafetyStatusChange( + updatedExperimentSafeties + ); } }; diff --git a/apps/backend/src/eventHandlers/logging.ts b/apps/backend/src/eventHandlers/logging.ts index 481f3056a5..e6ad534b92 100644 --- a/apps/backend/src/eventHandlers/logging.ts +++ b/apps/backend/src/eventHandlers/logging.ts @@ -182,9 +182,10 @@ export default function createLoggingHandler() { case Event.PROPOSAL_STATUS_CHANGED_BY_USER: await Promise.all( event.proposals.proposals.map(async (proposal) => { - const proposalStatus = await statusDataSource.getStatus( - proposal.statusId - ); + const proposalStatus = + await statusDataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); const description = `Status changed to: ${proposalStatus?.name}`; diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index b865a6b802..8ff414c654 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -32,7 +32,7 @@ import { Proposal } from '../models/Proposal'; import { Sample } from '../models/Sample'; import { Visit } from '../models/Visit'; import { VisitRegistrationStatus } from '../models/VisitRegistration'; -import { markProposalsEventAsDoneAndCallWorkflowEngine } from '../workflowEngine/proposal'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; export const QUEUE_NAME = (process.env.RABBITMQ_CORE_QUEUE_NAME as Queue) || @@ -140,7 +140,9 @@ export const getProposalMessageData = async (proposal: Proposal) => { Tokens.CallDataSource ); - const proposalStatus = await statusDataSource.getStatus(proposal.statusId); + const proposalStatus = await statusDataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); const proposalUsersWithInstitution = await userDataSource.getProposalUsersWithInstitution(proposal.primaryKey); @@ -203,7 +205,7 @@ export const getProposalMessageData = async (proposal: Proposal) => { mapUserWithInstitutionToMember ), visitors: visitorsWithInstitution.map(mapUserWithInstitutionToMember), - newStatus: proposalStatus?.shortCode, + newStatus: proposalStatus?.id, submitted: proposal.submitted, samples: ( await sampleDataSource.getSamples({ @@ -551,6 +553,8 @@ export async function createListenToRabbitMQHandler() { Tokens.VisitDataSource ); + const workflowEngine = container.resolve(ProposalWorkflowEngine); + const handleProposalWorkflowEngineChange = async ( eventType: Event, proposalPk: number | null @@ -559,9 +563,10 @@ export async function createListenToRabbitMQHandler() { throw new Error('Proposal id not found in the message'); } - await markProposalsEventAsDoneAndCallWorkflowEngine(eventType, [ - proposalPk, - ]); + await workflowEngine.run({ + event: eventType, + proposalPks: [proposalPk], + }); }; const cancelVisit = async (visit: Visit) => { diff --git a/apps/backend/src/eventHandlers/proposalWorkflow.ts b/apps/backend/src/eventHandlers/proposalWorkflow.ts index d6b6555794..49223dd3af 100644 --- a/apps/backend/src/eventHandlers/proposalWorkflow.ts +++ b/apps/backend/src/eventHandlers/proposalWorkflow.ts @@ -2,15 +2,15 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; -import { StatusDataSource } from '../datasources/StatusDataSource'; +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, - markProposalsEventAsDoneAndCallWorkflowEngine, } from '../workflowEngine/proposal'; enum ProposalInformationKeys { @@ -20,22 +20,19 @@ enum ProposalInformationKeys { } const publishProposalStatusChange = async ( - updatedProposals: (void | WorkflowEngineProposalType)[] + updatedProposals: WorkflowEngineProposalType[] ) => { - if (!updatedProposals) { - return; - } const eventBus = resolveApplicationEventBus(); - const statusDataSource = container.resolve( - Tokens.StatusDataSource + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource ); updatedProposals.map(async (updatedProposal) => { if (updatedProposal) { - const proposalStatus = await statusDataSource.getStatus( - updatedProposal.statusId + const proposalStatus = await workflowDataSource.getWorkflowStatus( + updatedProposal.workflowStatusId ); - const previousProposalStatus = await statusDataSource.getStatus( + const previousProposalStatus = await workflowDataSource.getWorkflowStatus( updatedProposal.prevStatusId ); @@ -45,21 +42,21 @@ const publishProposalStatusChange = async ( isRejection: false, key: 'proposal', loggedInUserId: null, - description: `From "${previousProposalStatus?.name}" to "${proposalStatus?.name}"`, + description: `From "${previousProposalStatus?.statusId}" to "${proposalStatus?.statusId}"`, }); } }); }; const handleSubmittedProposalsAfterCallEnded = async ( - updatedProposals: (void | WorkflowEngineProposalType)[] + updatedProposals: WorkflowEngineProposalType[] ) => { - if (!updatedProposals) { - return; - } const callDataSource = container.resolve( Tokens.CallDataSource ); + + const workflowEngine = container.resolve(ProposalWorkflowEngine); + const notEndedInternalCalls = await callDataSource .getCalls({ isEnded: true, @@ -77,11 +74,10 @@ const handleSubmittedProposalsAfterCallEnded = async ( return; } - const updatedSubmittedProposals = - await markProposalsEventAsDoneAndCallWorkflowEngine( - Event.CALL_ENDED, - proposalPks - ); + const updatedSubmittedProposals = await workflowEngine.run({ + event: Event.CALL_ENDED, + proposalPks, + }); if (updatedSubmittedProposals) { await publishProposalStatusChange(updatedSubmittedProposals); } @@ -93,15 +89,17 @@ export const handleWorkflowEngineChange = async ( ) => { const isArray = Array.isArray(proposalPks); - const updatedProposals = await markProposalsEventAsDoneAndCallWorkflowEngine( - event.type, - isArray ? proposalPks : [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); diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 52303b35bf..62ce77f458 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,3 +1,35 @@ +import { isCallEndedGuard } from '../workflowEngine/guards/isCallEndedGuard'; +import { isCallEndedInternalGuard } from '../workflowEngine/guards/isCallEndedInternalGuard'; +import { isCallFapReviewEndedGuard } from '../workflowEngine/guards/isCallFapReviewEndedGuard'; +import { isCallReviewEndedGuard } from '../workflowEngine/guards/isCallReviewEndedGuard'; +import { isEveryFapInstrumentMeetingSubmittedForProposalGuard } from '../workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard'; +import { isEveryFapInstrumentMeetingSubmittedGuard } from '../workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard'; +import { isEveryFapReviewerRequirementMetForProposalGuard } from '../workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard'; +import { isEveryFapReviewSubmittedForProposalGuard } from '../workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard'; +import { isEveryFapSubmittedReviewRequirementMetForProposalGuard } from '../workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard'; +import { isEveryFeasibilityReviewFeasibleForProposalGuard } from '../workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard'; +import { isEveryFeasibilityReviewSubmittedForProposalGuard } from '../workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard'; +import { isProposalAcceptedGuard } from '../workflowEngine/guards/isProposalAcceptedGuard'; +import { isProposalAssignedToTechniquesGuard } from '../workflowEngine/guards/isProposalAssignedToTechniquesGuard'; +import { isProposalBookingTimeActivatedGuard } from '../workflowEngine/guards/isProposalBookingTimeActivatedGuard'; +import { isProposalBookingTimeCompletedGuard } from '../workflowEngine/guards/isProposalBookingTimeCompletedGuard'; +import { isProposalFapMeetingInstrumentSubmittedGuard } from '../workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard'; +import { isProposalFapMeetingInstrumentUnsubmittedGuard } from '../workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard'; +import { isProposalFapReviewSubmittedGuard } from '../workflowEngine/guards/isProposalFapReviewSubmittedGuard'; +import { isProposalFapsSelectedGuard } from '../workflowEngine/guards/isProposalFapsSelectedGuard'; +import { isProposalFeasibilityReviewFeasibleGuard } from '../workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard'; +import { isProposalFeasibilityReviewSubmittedGuard } from '../workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard'; +import { isProposalFeasibilityReviewUnfeasibleGuard } from '../workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard'; +import { isProposalInstrumentsSelectedGuard } from '../workflowEngine/guards/isProposalInstrumentsSelectedGuard'; +import { isProposalManagementDecisionSubmittedGuard } from '../workflowEngine/guards/isProposalManagementDecisionSubmittedGuard'; +import { isProposalNotifiedGuard } from '../workflowEngine/guards/isProposalNotifiedGuard'; +import { isProposalRejectedGuard } from '../workflowEngine/guards/isProposalRejectedGuard'; +import { isProposalReservedGuard } from '../workflowEngine/guards/isProposalReservedGuard'; +import { isProposalSampleReviewSubmittedGuard } from '../workflowEngine/guards/isProposalSampleReviewSubmittedGuard'; +import { isProposalSampleSafeGuard } from '../workflowEngine/guards/isProposalSampleSafeGuard'; +import { isProposalSubmittedGuard } from '../workflowEngine/guards/isProposalSubmittedGuard'; +import { GuardFn } from '../workflowEngine/stateMachine/stateMachine'; + // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { PROPOSAL_CREATED = 'PROPOSAL_CREATED', @@ -102,325 +134,571 @@ export enum Event { VISIT_CREATED = 'VISIT_CREATED', } -export const EventLabel = new Map([ - [Event.PROPOSAL_CREATED, 'Event occurs when proposal is created'], - [Event.PROPOSAL_UPDATED, 'Event occurs when proposal is updated'], - [Event.PROPOSAL_SUBMITTED, 'Event occurs when proposal is submitted'], - [Event.PROPOSAL_DELETED, 'Event occurs when proposal is removed'], +interface EventMetadata { + label: string; + guard?: GuardFn; +} + +export const EventMetadataByEvent = new Map([ + [Event.PROPOSAL_CREATED, { label: 'Event occurs when proposal is created' }], + [Event.PROPOSAL_UPDATED, { label: 'Event occurs when proposal is updated' }], + [ + Event.PROPOSAL_SUBMITTED, + { + label: 'Event occurs when proposal is submitted', + guard: isProposalSubmittedGuard, + }, + ], + [Event.PROPOSAL_DELETED, { label: 'Event occurs when proposal is removed' }], [ Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE, - 'Event occurs when proposal feasibility review is submitted with value of feasible', + { + label: + 'Event occurs when proposal feasibility review is submitted with value of feasible', + guard: isProposalFeasibilityReviewFeasibleGuard, + }, ], [ Event.PROPOSAL_FEASIBILITY_REVIEW_UNFEASIBLE, - 'Event occurs when proposal feasibility review is submitted with value of unfeasible', + { + label: + 'Event occurs when proposal feasibility review is submitted with value of unfeasible', + guard: isProposalFeasibilityReviewUnfeasibleGuard, + }, ], [ Event.PROPOSAL_FAPS_SELECTED, - 'Event occurs when FAPs are assigned to a proposal', + { + label: 'Event occurs when FAPs are assigned to a proposal', + guard: isProposalFapsSelectedGuard, + }, ], [ Event.PROPOSAL_FAPS_REMOVED, - 'Event occurs when proposal is removed from a FAPs', + { label: 'Event occurs when proposal is removed from a FAPs' }, ], [ Event.PROPOSAL_INSTRUMENTS_SELECTED, - 'Event occurs when instrument/s gets assigned to a proposal', + { + label: 'Event occurs when instrument/s gets assigned to a proposal', + guard: isProposalInstrumentsSelectedGuard, + }, ], [ Event.PROPOSAL_FEASIBILITY_REVIEW_UPDATED, - 'Event occurs when proposal feasibility review is updated', + { label: 'Event occurs when proposal feasibility review is updated' }, ], [ Event.PROPOSAL_FEASIBILITY_REVIEW_SUBMITTED, - 'Event occurs when proposal feasibility review is submitted with any value', + { + label: + 'Event occurs when proposal feasibility review is submitted with any value', + guard: isProposalFeasibilityReviewSubmittedGuard, + }, ], [ - Event.PROPOSAL_ALL_FEASIBILITY_REVIEWS_FEASIBLE, - 'Event occurs when all proposal feasibility reviews are submitted with Feasible value', + Event.PROPOSAL_ALL_FEASIBILITY_REVIEWS_SUBMITTED, + { + label: + 'Event occurs when every feasibility review on a proposal is submitted', + guard: isEveryFeasibilityReviewSubmittedForProposalGuard, + }, ], [ - Event.PROPOSAL_ALL_FEASIBILITY_REVIEWS_SUBMITTED, - 'Event occurs when all proposal feasibility reviews are submitted with any value', + Event.PROPOSAL_ALL_FEASIBILITY_REVIEWS_FEASIBLE, + { + label: + 'Event occurs when every feasibility review on a proposal is feasible', + guard: isEveryFeasibilityReviewFeasibleForProposalGuard, + }, ], [ Event.PROPOSAL_SAMPLE_REVIEW_SUBMITTED, - 'Event occurs when proposal sample review gets submitted with any value', + { + label: + 'Event occurs when proposal sample review gets submitted with any value', + guard: isProposalSampleReviewSubmittedGuard, + }, ], [ Event.PROPOSAL_SAMPLE_SAFE, - 'Event occurs when proposal sample review gets submitted with value of low risk', + { + label: + 'Event occurs when proposal sample review gets submitted with value of low risk', + guard: isProposalSampleSafeGuard, + }, ], [ Event.PROPOSAL_ALL_FAP_REVIEWERS_SELECTED, - 'Event occurs when all FAP reviewers are selected on a proposal', + { + label: + 'Event occurs when every FAP on a proposal meets its reviewer requirement', + guard: isEveryFapReviewerRequirementMetForProposalGuard, + }, ], [ Event.PROPOSAL_FAP_REVIEW_UPDATED, - 'Event occurs when at least one proposal FAP review is updated', + { + label: 'Event occurs when at least one proposal FAP review is updated', + }, ], [ Event.PROPOSAL_FAP_REVIEW_SUBMITTED, - 'Event occurs when at least one proposal FAP review is submitted', + { + label: 'Event occurs when at least one proposal FAP review is submitted', + guard: isProposalFapReviewSubmittedGuard, + }, ], [ Event.PROPOSAL_ALL_FAP_REVIEWS_SUBMITTED, - 'Event occurs when all FAP reviews on a proposal are submitted for the current FAP', + { + label: + 'Event occurs when every FAP review on a proposal is submitted for the current FAP', + guard: isEveryFapReviewSubmittedForProposalGuard, + }, ], [ Event.PROPOSAL_FAP_MEETING_SAVED, - 'Event occurs when FAP meeting is saved on a proposal', + { label: 'Event occurs when FAP meeting is saved on a proposal' }, ], [ Event.PROPOSAL_FAP_MEETING_SUBMITTED, - 'Event occurs when FAP meeting is submitted on a proposal', + { label: 'Event occurs when FAP meeting is submitted on a proposal' }, ], [ Event.PROPOSAL_ALL_FAP_MEETINGS_SUBMITTED, - 'Event occurs when all the FAP meetings are submitted for a specific proposal', + { + label: 'Event occurs when every FAP meeting on a proposal is submitted', + guard: isEveryFapInstrumentMeetingSubmittedGuard, + }, ], [ Event.PROPOSAL_ALL_REVIEWS_SUBMITTED_FOR_ALL_FAPS, - 'Event occurs when all proposal FAP reviews are submitted throughout all the FAPs', + { + label: + 'Event occurs when every review on a proposal is submitted across every FAP', + guard: isEveryFapSubmittedReviewRequirementMetForProposalGuard, + }, ], [ Event.PROPOSAL_FAP_MEETING_RANKING_OVERWRITTEN, - 'Event occurs when FAP meeting ranking is overwritten on a proposal', + { + label: + 'Event occurs when FAP meeting ranking is overwritten on a proposal', + }, ], [ Event.PROPOSAL_FAP_MEETING_REORDER, - 'Event occurs when proposals are reordered in FAP meeting components', + { + label: + 'Event occurs when proposals are reordered in FAP meeting components', + }, ], [ Event.PROPOSAL_FAP_MEETING_INSTRUMENT_SUBMITTED, - 'Event occurs when instrument is submitted after FAP meeting is finalized', + { + label: + 'Event occurs when instrument is submitted after FAP meeting is finalized', + guard: isProposalFapMeetingInstrumentSubmittedGuard, + }, + ], + [ + Event.PROPOSAL_FAP_MEETING_INSTRUMENT_UNSUBMITTED, + { + label: 'Event occurs when instrument is unsubmitted in the FAP meeting', + guard: isProposalFapMeetingInstrumentUnsubmittedGuard, + }, ], [ Event.PROPOSAL_ALL_FAP_MEETING_INSTRUMENT_SUBMITTED, - 'Event occurs when instrument is submitted after FAP meeting is finalized for all the FAPs proposal is part of', + { + label: + 'Event occurs when every FAP linked to a proposal has its instrument meeting submitted', + guard: isEveryFapInstrumentMeetingSubmittedForProposalGuard, + }, ], [ - Event.PROPOSAL_FAP_MEETING_INSTRUMENT_UNSUBMITTED, - 'Event occurs when instrument is unsubmitted in the FAP meeting', + Event.PROPOSAL_MANAGEMENT_DECISION_UPDATED, + { + label: 'Event occurs when proposal management decision is updated', + }, + ], + [ + Event.PROPOSAL_MANAGEMENT_DECISION_SUBMITTED, + { + label: 'Event occurs when proposal management decision is submitted', + guard: isProposalManagementDecisionSubmittedGuard, + }, ], [ Event.PROPOSAL_ACCEPTED, - 'Event occurs when proposal gets final decision as accepted', + { + label: 'Event occurs when proposal gets final decision as accepted', + guard: isProposalAcceptedGuard, + }, ], [ - Event.PROPOSAL_MANAGEMENT_DECISION_UPDATED, - 'Event occurs when proposal management decision is updated', + Event.PROPOSAL_RESERVED, + { + label: 'Event occurs when proposal gets reserved', + guard: isProposalReservedGuard, + }, ], [ - Event.PROPOSAL_MANAGEMENT_DECISION_SUBMITTED, - 'Event occurs when proposal management decision is submitted', + Event.PROPOSAL_REJECTED, + { + label: 'Event occurs when proposal gets rejected', + guard: isProposalRejectedGuard, + }, ], - [Event.PROPOSAL_REJECTED, 'Event occurs when proposal gets rejected'], - [Event.PROPOSAL_RESERVED, 'Event occurs when proposal gets reserved'], + [Event.CALL_CREATED, { label: 'Event occurs on a when a call is created' }], [ Event.CALL_ENDED, - 'Event occurs on a specific call end date set on the call', + { + label: 'Event occurs on a specific call end date set on the call', + guard: isCallEndedGuard, + }, ], [ Event.CALL_ENDED_INTERNAL, - 'Event occurs on a specific call internal end date set on the call', + { + label: + 'Event occurs on a specific call internal end date set on the call', + guard: isCallEndedInternalGuard, + }, ], - [Event.CALL_CREATED, 'Event occurs on a when a call is created'], [ Event.CALL_REVIEW_ENDED, - 'Event occurs on a specific call review end date set on the call', + { + label: 'Event occurs on a specific call review end date set on the call', + guard: isCallReviewEndedGuard, + }, ], [ Event.CALL_FAP_REVIEW_ENDED, - 'Event occurs on a specific call FAP review end date set on the call', + { + label: + 'Event occurs on a specific call FAP review end date set on the call', + guard: isCallFapReviewEndedGuard, + }, ], - [Event.USER_UPDATED, 'Event occurs when user is updated'], - [Event.USER_ROLE_UPDATED, 'Event occurs when user roles are updated'], - [Event.USER_DELETED, 'Event occurs when user is removed'], + [Event.USER_UPDATED, { label: 'Event occurs when user is updated' }], + [ + Event.USER_ROLE_UPDATED, + { label: 'Event occurs when user roles are updated' }, + ], + [Event.USER_DELETED, { label: 'Event occurs when user is removed' }], [ Event.USER_PASSWORD_RESET_EMAIL, - 'Event occurs when user password is reset by email', + { label: 'Event occurs when user password is reset by email' }, + ], + [Event.FAP_CREATED, { label: 'Event occurs when FAP is created' }], + [Event.FAP_UPDATED, { label: 'Event occurs when FAP is updated' }], + [ + Event.FAP_MEMBERS_ASSIGNED, + { label: 'Event occurs when we assign member/s to a FAP' }, ], - [Event.FAP_CREATED, 'Event occurs when FAP is created'], - [Event.FAP_UPDATED, 'Event occurs when FAP is updated'], - [Event.FAP_MEMBERS_ASSIGNED, 'Event occurs when we assign member/s to a FAP'], [ Event.FAP_REVIEWER_NOTIFIED, - 'Event occurs when we notify the FAP reviewer, about its not submitted review, by email 2 days before the review end date', + { + label: + 'Event occurs when we notify the FAP reviewer, about its not submitted review, by email 2 days before the review end date', + }, ], [ Event.FAP_MEMBER_REMOVED, - 'Event occurs when FAP member gets removed from the panel', + { label: 'Event occurs when FAP member gets removed from the panel' }, ], [ Event.FAP_MEMBER_ASSIGNED_TO_PROPOSAL, - 'Event occurs when FAP member gets assigned to a proposal for a review', + { + label: + 'Event occurs when FAP member gets assigned to a proposal for a review', + }, ], [ Event.FAP_MEMBER_REMOVED_FROM_PROPOSAL, - 'Event occurs when FAP member is removed from proposal for review', + { + label: 'Event occurs when FAP member is removed from proposal for review', + }, ], [ Event.FAP_ALL_MEETINGS_SUBMITTED, - 'Event occurs when all the FAP meetings are submitted for all of the proposals', + { + label: + 'Event occurs when all the FAP meetings are submitted for all of the proposals', + }, ], - [Event.PROPOSAL_NOTIFIED, 'Event occurs when proposal is notified'], - [Event.PROPOSAL_CLONED, 'Event occurs when proposal is cloned'], + [ + Event.PROPOSAL_NOTIFIED, + { + label: 'Event occurs when proposal is notified', + guard: isProposalNotifiedGuard, + }, + ], + [Event.PROPOSAL_CLONED, { label: 'Event occurs when proposal is cloned' }], [ Event.PROPOSAL_STATUS_ACTION_EXECUTED, - 'Event occurs when the proposal status action is being executed in the status engine', + { + label: + 'Event occurs when the proposal status action is being executed in the status engine', + }, ], [ Event.PROPOSAL_CO_PROPOSER_INVITES_UPDATED, - 'Event occurs when co-proposer invites are updated for a proposal', + { + label: 'Event occurs when co-proposer invites are updated for a proposal', + }, ], [ Event.PROPOSAL_CO_PROPOSER_INVITE_SENT, - 'Event occurs when co-proposer invite is sent to a user', + { label: 'Event occurs when co-proposer invite is sent to a user' }, ], [ Event.PROPOSAL_CO_PROPOSER_INVITE_ACCEPTED, - 'Event occurs when user accepts the co-proposer claim for a proposal', + { + label: + 'Event occurs when user accepts the co-proposer claim for a proposal', + }, ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITES_UPDATED, - 'Event occurs when visit registration invites are updated for a proposal', + { + label: + 'Event occurs when visit registration invites are updated for a proposal', + }, ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITE_SENT, - 'Event occurs when visit registration invite is sent to a user', + { label: 'Event occurs when visit registration invite is sent to a user' }, ], [ Event.PROPOSAL_VISIT_REGISTRATION_INVITE_ACCEPTED, - 'Event occurs when user accepts the visit registration claim for a proposal', + { + label: + 'Event occurs when user accepts the visit registration claim for a proposal', + }, ], [ Event.PROPOSAL_STATUS_CHANGED_BY_WORKFLOW, - 'Event occurs when the proposal status was changed by the workflow engine', + { + label: + 'Event occurs when the proposal status was changed by the workflow engine', + }, ], [ Event.PROPOSAL_STATUS_CHANGED_BY_USER, - 'Event occurs when the proposal status was changed by the user', + { + label: 'Event occurs when the proposal status was changed by the user', + }, ], [ Event.TOPIC_ANSWERED, - 'Event occurs when the user clicks save on a topic in any questionary', + { + label: + 'Event occurs when the user clicks save on a topic in any questionary', + }, ], [ Event.PROPOSAL_BOOKING_TIME_SLOT_ADDED, - 'Event occurs when the new time slot is booked in the scheduler', + { label: 'Event occurs when the new time slot is booked in the scheduler' }, ], [ Event.PROPOSAL_BOOKING_TIME_SLOTS_REMOVED, - 'Event occurs when the time slots are removed in the scheduler', + { + label: 'Event occurs when the time slots are removed in the scheduler', + }, ], [ Event.PROPOSAL_BOOKING_TIME_ACTIVATED, - 'Event occurs when the time slot booking is activated in the scheduler', + { + label: + 'Event occurs when the time slot booking is activated in the scheduler', + guard: isProposalBookingTimeActivatedGuard, + }, ], [ Event.PROPOSAL_BOOKING_TIME_COMPLETED, - 'Event occurs when the time slot booking is completed in the scheduler', + { + label: + 'Event occurs when the time slot booking is completed in the scheduler', + guard: isProposalBookingTimeCompletedGuard, + }, ], [ Event.PROPOSAL_BOOKING_TIME_UPDATED, - 'Event occurs when the time slot booking is updated in the scheduler', + { + label: + 'Event occurs when the time slot booking is updated in the scheduler', + }, ], [ Event.PROPOSAL_BOOKING_TIME_REOPENED, - 'Event occurs when the time slot booking is re-opened in the scheduler', + { + label: + 'Event occurs when the time slot booking is re-opened in the scheduler', + }, + ], + [ + Event.INSTRUMENT_CREATED, + { label: 'Event occurs when the instrument is created' }, + ], + [ + Event.INSTRUMENT_UPDATED, + { label: 'Event occurs when the instrument is updated' }, + ], + [ + Event.INSTRUMENT_DELETED, + { label: 'Event occurs when the instrument is removed' }, + ], + [ + Event.INSTRUMENT_ASSIGNED_TO_SCIENTIST, + { + label: 'Event occurs when instrument is assigned to a scientist', + }, ], - [Event.INSTRUMENT_DELETED, 'Event occurs when the instrument is removed'], [ Event.PREDEFINED_MESSAGE_CREATED, - 'Event occurs when predefined message is created', + { label: 'Event occurs when predefined message is created' }, ], [ Event.PREDEFINED_MESSAGE_UPDATED, - 'Event occurs when predefined message is updated', + { label: 'Event occurs when predefined message is updated' }, ], [ Event.PREDEFINED_MESSAGE_DELETED, - 'Event occurs when predefined message is removed', + { label: 'Event occurs when predefined message is removed' }, ], [ Event.INTERNAL_REVIEW_CREATED, - 'Event occurs when internal (technical) review is created', + { + label: 'Event occurs when internal (technical) review is created', + }, ], [ Event.INTERNAL_REVIEW_UPDATED, - 'Event occurs when internal (technical) review is updated', + { + label: 'Event occurs when internal (technical) review is updated', + }, ], [ Event.INTERNAL_REVIEW_DELETED, - 'Event occurs when internal (technical) review is removed', + { + label: 'Event occurs when internal (technical) review is removed', + }, + ], + [ + Event.TECHNIQUE_CREATED, + { label: 'Event occurs when the technique is created' }, + ], + [ + Event.TECHNIQUE_UPDATED, + { label: 'Event occurs when the technique is updated' }, + ], + [ + Event.TECHNIQUE_DELETED, + { label: 'Event occurs when the technique is removed' }, ], - [Event.TECHNIQUE_CREATED, 'Event occurs when the technique is created'], - [Event.TECHNIQUE_UPDATED, 'Event occurs when the technique is updated'], - [Event.TECHNIQUE_DELETED, 'Event occurs when the technique is removed'], [ Event.INSTRUMENTS_ASSIGNED_TO_TECHNIQUE, - 'Event occurs when instruments are assigned to a technique', + { + label: 'Event occurs when instruments are assigned to a technique', + }, ], [ Event.INSTRUMENTS_REMOVED_FROM_TECHNIQUE, - 'Event occurs when instruments are removed from a technique', + { + label: 'Event occurs when instruments are removed from a technique', + }, ], [ Event.PROPOSAL_ASSIGNED_TO_TECHNIQUES, - 'Event occurs when a proposal is assigned to techniques', + { + label: 'Event occurs when a proposal is assigned to techniques', + guard: isProposalAssignedToTechniquesGuard, + }, ], - [Event.VISIT_CREATED, 'Event occurs when visit is created'], + [Event.VISIT_CREATED, { label: 'Event occurs when visit is created' }], [ Event.VISIT_REGISTRATION_APPROVED, - 'Event occurs when visit registration is approved', + { label: 'Event occurs when visit registration is approved' }, ], [ Event.VISIT_REGISTRATION_CANCELLED, - 'Event occurs when visit registration is cancelled', + { label: 'Event occurs when visit registration is cancelled' }, ], [ Event.EXPERIMENT_ESF_SUBMITTED, - 'Event occurs when experiment ESF is submitted', + { label: 'Event occurs when experiment ESF is submitted' }, ], [ Event.EXPERIMENT_ESF_APPROVED_BY_IS, - 'Event occurs when experiment ESF is approved by IS', + { label: 'Event occurs when experiment ESF is approved by IS' }, ], [ Event.EXPERIMENT_ESF_REJECTED_BY_IS, - 'Event occurs when experiment ESF is rejected by IS', + { label: 'Event occurs when experiment ESF is rejected by IS' }, ], [ Event.EXPERIMENT_ESF_APPROVED_BY_ESR, - 'Event occurs when experiment ESF is approved by ESR', + { label: 'Event occurs when experiment ESF is approved by ESR' }, ], [ Event.EXPERIMENT_ESF_REJECTED_BY_ESR, - 'Event occurs when experiment ESF is rejected by ESR', + { label: 'Event occurs when experiment ESF is rejected by ESR' }, ], [ Event.DATA_ACCESS_USERS_UPDATED, - 'Event occurs when data access users are updated', + { label: 'Event occurs when data access users are updated' }, ], [ Event.EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_IS, - 'Event occurs when experiment safety management decision is submitted by IS', + { + label: + 'Event occurs when experiment safety management decision is submitted by IS', + }, ], [ Event.EXPERIMENT_SAFETY_MANAGEMENT_DECISION_SUBMITTED_BY_ESR, - 'Event occurs when experiment safety management decision is submitted by ESR', + { + label: + 'Event occurs when experiment safety management decision is submitted by ESR', + }, ], [ Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_USER, - 'Event occurs when experiment safety status is changed by user', + { label: 'Event occurs when experiment safety status is changed by user' }, ], [ Event.EXPERIMENT_SAFETY_STATUS_CHANGED_BY_WORKFLOW, - 'Event occurs when experiment safety status is changed by workflow', + { + label: + 'Event occurs when experiment safety status is changed by workflow', + }, + ], + [ + Event.EMAIL_TEMPLATE_CREATED, + { label: 'Event occurs when email template is created' }, + ], + [ + Event.EMAIL_TEMPLATE_UPDATED, + { label: 'Event occurs when email template is updated' }, + ], + [ + Event.EMAIL_TEMPLATE_DELETED, + { label: 'Event occurs when email template is deleted' }, + ], + [Event.VISIT_CREATED, { label: 'Event occurs when visit is created' }], + [ + Event.EMAIL_TEMPLATE_CREATED, + { label: 'Event occurs when email template is created' }, + ], + [ + Event.EMAIL_TEMPLATE_UPDATED, + { label: 'Event occurs when email template is updated' }, + ], + [ + Event.EMAIL_TEMPLATE_DELETED, + { label: 'Event occurs when email template is deleted' }, ], - [Event.VISIT_CREATED, 'Event occurs when visit is created'], - [Event.EMAIL_TEMPLATE_CREATED, 'Event occurs when email template is created'], - [Event.EMAIL_TEMPLATE_UPDATED, 'Event occurs when email template is updated'], - [Event.EMAIL_TEMPLATE_DELETED, 'Event occurs when email template is deleted'], ]); diff --git a/apps/backend/src/factory/pdf/experimentSafety.ts b/apps/backend/src/factory/pdf/experimentSafety.ts index 1ca2718e48..a1afca780e 100644 --- a/apps/backend/src/factory/pdf/experimentSafety.ts +++ b/apps/backend/src/factory/pdf/experimentSafety.ts @@ -27,6 +27,7 @@ import { } from './experimentSample'; import { collectGenericTemplatePDFData } from './genericTemplates'; import { collectSamplePDFData } from './sample'; +import { StatusDataSource } from '../../datasources/StatusDataSource'; export type ExperimentSafetyPDFData = { proposal: Proposal; @@ -98,6 +99,10 @@ export const collectExperimentPDFData = async ( user: UserWithRole, notify?: CallableFunction ): Promise => { + const statusDataSource = container.resolve( + Tokens.StatusDataSource + ); + const experiment = await baseContext.queries.experiment.getExperiment( user, experimentPk @@ -117,10 +122,10 @@ export const collectExperimentPDFData = async ( } // Get the status of the experiment safety - const experimentSafetyStatus = await baseContext.queries.status.getStatus( - user, - experimentSafety.statusId ?? 0 - ); + const experimentSafetyStatus = + await statusDataSource.getStatusByWorkflowStatusId( + experimentSafety.workflowStatusId + ); const esiQuestionarySteps = await baseContext.queries.questionary.getQuestionarySteps( @@ -329,12 +334,6 @@ export const collectExperimentPDFDataTokenAccess = async ( Tokens.ExperimentSafetyPdfTemplateDataSource ); - // Try to get experiment-specific template - const templateDataSource = - container.resolve( - Tokens.ExperimentSafetyPdfTemplateDataSource - ); - let pdfTemplate: ExperimentSafetyPdfTemplate | null = null; try { diff --git a/apps/backend/src/factory/xlsx/proposal.ts b/apps/backend/src/factory/xlsx/proposal.ts index a3330ac199..352eb7e0bc 100644 --- a/apps/backend/src/factory/xlsx/proposal.ts +++ b/apps/backend/src/factory/xlsx/proposal.ts @@ -144,10 +144,10 @@ export const collectTechniqueProposalXLSXData = async ( ? proposal.submittedDate.toLocaleString() : ''; - const status = await baseContext.queries.status.getStatus( - user, - proposal.statusId - ); + const status = + await baseContext.queries.status.dataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); return [ proposal.proposalId, diff --git a/apps/backend/src/models/Experiment.ts b/apps/backend/src/models/Experiment.ts index 6563330abf..c5a9fef515 100644 --- a/apps/backend/src/models/Experiment.ts +++ b/apps/backend/src/models/Experiment.ts @@ -57,7 +57,7 @@ export class ExperimentSafety { public esiQuestionaryId: number, public esiQuestionarySubmittedAt: Date | null, public createdBy: number, - public statusId: number | null, + public workflowStatusId: number, public safetyReviewQuestionaryId: number | null, public reviewedBy: number | null, public createdAt: Date, diff --git a/apps/backend/src/models/Proposal.ts b/apps/backend/src/models/Proposal.ts index afdde1577d..ff5a4e0ce1 100644 --- a/apps/backend/src/models/Proposal.ts +++ b/apps/backend/src/models/Proposal.ts @@ -33,7 +33,7 @@ export class Proposal { public title: string, public abstract: string, public proposerId: number, - public statusId: number, // proposal status id while it moving though proposal workflow + public workflowStatusId: number, // current workflow status id public created: Date, public updated: Date, public proposalId: string, diff --git a/apps/backend/src/models/ProposalView.ts b/apps/backend/src/models/ProposalView.ts index a0f83c3c12..c556876688 100644 --- a/apps/backend/src/models/ProposalView.ts +++ b/apps/backend/src/models/ProposalView.ts @@ -11,7 +11,8 @@ export class ProposalView { public primaryKey: number, public title: string, public principalInvestigatorId: number, - public statusId: number, + public workflowStatusId: number, + public statusId: string, public statusName: string, public statusDescription: string, public proposalId: string, diff --git a/apps/backend/src/models/Status.ts b/apps/backend/src/models/Status.ts index 541a92134c..419695fe05 100644 --- a/apps/backend/src/models/Status.ts +++ b/apps/backend/src/models/Status.ts @@ -18,8 +18,7 @@ export enum ProposalStatusDefaultShortCodes { export class Status { constructor( - public id: number, - public shortCode: string, + public id: string, public name: string, public description: string, public isDefault: boolean, diff --git a/apps/backend/src/models/StatusAction.ts b/apps/backend/src/models/StatusAction.ts index 14c1f77552..6a39ebb2bf 100644 --- a/apps/backend/src/models/StatusAction.ts +++ b/apps/backend/src/models/StatusAction.ts @@ -19,7 +19,6 @@ export class ConnectionHasStatusAction { public connectionId: number, public actionId: number, public workflowId: number, - public name: string, public type: StatusActionType, public config: typeof StatusActionConfig | null ) {} diff --git a/apps/backend/src/models/StatusChangingEvent.ts b/apps/backend/src/models/StatusChangingEvent.ts index e4169b0e55..f171d6abb3 100644 --- a/apps/backend/src/models/StatusChangingEvent.ts +++ b/apps/backend/src/models/StatusChangingEvent.ts @@ -1,6 +1,5 @@ export class StatusChangingEvent { constructor( - public statusChangingEventId: number, public workflowConnectionId: number, public statusChangingEvent: string ) {} diff --git a/apps/backend/src/models/WorkflowConnections.ts b/apps/backend/src/models/WorkflowConnections.ts index 450a2efebb..af4e6c3b31 100644 --- a/apps/backend/src/models/WorkflowConnections.ts +++ b/apps/backend/src/models/WorkflowConnections.ts @@ -1,36 +1,10 @@ -import { Status } from './Status'; - -export type NextAndPreviousStatuses = { - nextStatusId?: number | null; - prevStatusId?: number | null; - sortOrder?: number | null; -}; - export class WorkflowConnection { constructor( public id: number, - public sortOrder: number, public workflowId: number, - public statusId: number, - public nextStatusId: number | null, - public prevStatusId: number | null, - public posX: number, - public posY: number, - public prevConnectionId: number | null - ) {} -} - -export class WorkflowConnectionWithStatus { - constructor( - public id: number, - public sortOrder: number, - public workflowId: number, - public statusId: number, - public status: Status, - public nextStatusId: number | null, - public prevStatusId: number | null, - public posX: number, - public posY: number, - public prevConnectionId: number | null + public prevWorkflowStatusId: number, + public nextWorkflowStatusId: number, + public sourceHandle: string, + public targetHandle: string ) {} } diff --git a/apps/backend/src/models/WorkflowStatus.ts b/apps/backend/src/models/WorkflowStatus.ts new file mode 100644 index 0000000000..69e7b770bb --- /dev/null +++ b/apps/backend/src/models/WorkflowStatus.ts @@ -0,0 +1,9 @@ +export class WorkflowStatus { + constructor( + public workflowStatusId: number, + public workflowId: number, + public statusId: string, + public posX: number, + public posY: number + ) {} +} diff --git a/apps/backend/src/mutations/ExperimentMutation.ts b/apps/backend/src/mutations/ExperimentMutation.ts index 62c2754b78..72d0e491e7 100644 --- a/apps/backend/src/mutations/ExperimentMutation.ts +++ b/apps/backend/src/mutations/ExperimentMutation.ts @@ -8,8 +8,8 @@ import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { SampleDataSource } from '../datasources/SampleDataSource'; -import { StatusDataSource } from '../datasources/StatusDataSource'; import { TemplateDataSource } from '../datasources/TemplateDataSource'; +import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Authorized, EventBus } from '../decorators'; import { Event } from '../events/event.enum'; import { @@ -22,7 +22,6 @@ import { rejection, Rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; import { TemplateGroupId } from '../models/Template'; import { UserWithRole } from '../models/User'; -import { WorkflowType } from '../models/Workflow'; import { AddSampleToExperimentInput } from '../resolvers/mutations/AddSampleToExperimentMutation'; import { CloneExperimentSampleInput } from '../resolvers/mutations/CloneExperimentSampleMutation'; import { RemoveSampleFromExperimentInput } from '../resolvers/mutations/RemoveSampleFromExperimentMutation'; @@ -53,8 +52,8 @@ export default class ExperimentMutations { private sampleDataSource: SampleDataSource, @inject(Tokens.TemplateDataSource) private templateDataSource: TemplateDataSource, - @inject(Tokens.StatusDataSource) - private statusDataSource: StatusDataSource, + @inject(Tokens.WorkflowDataSource) + private workflowDataSource: WorkflowDataSource, @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization, @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization @@ -119,9 +118,17 @@ export default class ExperimentMutations { ); } - const experimentSafetyWorkflowDefaultStatus = - await this.statusDataSource.getDefaultStatus(WorkflowType.EXPERIMENT); - if (!experimentSafetyWorkflowDefaultStatus) { + if (!call.experimentWorkflowId) { + return rejection( + 'Can not create Experiment Safety, because system has no Experiment Workflow configured' + ); + } + + const experimentSafetyInitialWorkflowStatus = + await this.workflowDataSource.getInitialWorkflowStatus( + call.experimentWorkflowId + ); + if (!experimentSafetyInitialWorkflowStatus) { return rejection( 'Can not create Experiment Safety, because system has no default status for Experiment Safety' ); @@ -141,7 +148,7 @@ export default class ExperimentMutations { experimentPk, newEsiQuestionary.questionaryId, agent!.id, - experimentSafetyWorkflowDefaultStatus.id + experimentSafetyInitialWorkflowStatus.workflowStatusId ); } diff --git a/apps/backend/src/mutations/InstrumentMutations.spec.ts b/apps/backend/src/mutations/InstrumentMutations.spec.ts index 2e054aaaca..c983549a68 100644 --- a/apps/backend/src/mutations/InstrumentMutations.spec.ts +++ b/apps/backend/src/mutations/InstrumentMutations.spec.ts @@ -7,6 +7,7 @@ import { dummyInstrument, dummyInstrumentHasProposals, } from '../datasources/mockups/InstrumentDataSource'; +import { ProposalDataSourceMock } from '../datasources/mockups/ProposalDataSource'; import { StatusDataSourceMock } from '../datasources/mockups/StatusDataSource'; import { TechniqueDataSourceMock } from '../datasources/mockups/TechniqueDataSource'; import { @@ -16,18 +17,22 @@ import { } from '../datasources/mockups/UserDataSource'; import { WorkflowType } from '../models/Workflow'; -let statusDataSource: StatusDataSourceMock; +let proposalDataSource: ProposalDataSourceMock; let techniqueDataSource: TechniqueDataSourceMock; +let statusDataSource: StatusDataSourceMock; const instrumentMutations = container.resolve(InstrumentMutations); beforeEach(() => { - statusDataSource = container.resolve( - Tokens.StatusDataSource - ); techniqueDataSource = container.resolve( Tokens.TechniqueDataSource ); + proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + statusDataSource = container.resolve( + Tokens.StatusDataSource + ); }); describe('Test Instrument Mutations', () => { @@ -200,19 +205,6 @@ describe('Test Instrument Mutations', () => { describe('Test technique proposal instrument assignment', () => { test('A user officer can change the instrument of a technique proposal from any status', () => { - const proposal = { statusId: 1 }; - - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'EXPIRED', - name: 'Expired', - description: '', - isDefault: true, - entityType: WorkflowType.PROPOSAL, - }, - ]); - return expect( instrumentMutations.assignTechniqueProposalsToInstruments( dummyUserOfficerWithRole, @@ -230,19 +222,6 @@ describe('Test Instrument Mutations', () => { }); test('A scientist cannot change the instrument of a technique proposal from any status', () => { - const proposal = { statusId: 1 }; - - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'EXPIRED', - name: 'Expired', - description: '', - isDefault: true, - entityType: WorkflowType.PROPOSAL, - }, - ]); - return expect( instrumentMutations.assignTechniqueProposalsToInstruments( dummyInstrumentScientist, @@ -259,18 +238,21 @@ describe('Test Instrument Mutations', () => { }); test('A scientist can change the instrument of a technique proposal when the status is under review', () => { - const proposal = { statusId: 1 }; + // @ts-ignore skip type error for testing purposes + jest.spyOn(proposalDataSource, 'get').mockResolvedValue({ + primaryKey: 1, + workflowStatusId: 2, + }); - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'UNDER_REVIEW', - name: 'Under review', + jest + .spyOn(statusDataSource, 'getStatusByWorkflowStatusId') + .mockResolvedValue({ + id: 'UNDER_REVIEW', + name: 'UNDER_REVIEW', description: '', isDefault: true, entityType: WorkflowType.PROPOSAL, - }, - ]); + }); return expect( instrumentMutations.assignTechniqueProposalsToInstruments( @@ -331,6 +313,16 @@ describe('Test Instrument Mutations', () => { }, ]); + jest + .spyOn(statusDataSource, 'getStatusByWorkflowStatusId') + .mockResolvedValue({ + id: 'UNDER_REVIEW', + name: 'UNDER_REVIEW', + description: '', + isDefault: true, + entityType: WorkflowType.PROPOSAL, + }); + return expect( instrumentMutations.assignTechniqueProposalsToInstruments( dummyInstrumentScientist, diff --git a/apps/backend/src/mutations/InstrumentMutations.ts b/apps/backend/src/mutations/InstrumentMutations.ts index 803db8eb75..53cef1c344 100644 --- a/apps/backend/src/mutations/InstrumentMutations.ts +++ b/apps/backend/src/mutations/InstrumentMutations.ts @@ -25,7 +25,6 @@ import { Instrument, InstrumentsHasProposals } from '../models/Instrument'; import { rejection, Rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; -import { WorkflowType } from '../models/Workflow'; import { AssignProposalsToInstrumentsArgs, RemoveProposalsFromInstrumentArgs, @@ -547,19 +546,17 @@ export default class InstrumentMutations { ); } - const statuses = await this.statusDataSource.getAllStatuses( - WorkflowType.PROPOSAL - ); - - const currentStatus = statuses.find((s) => s.id === proposal.statusId); - - if (currentStatus?.shortCode !== 'UNDER_REVIEW') { + const proposalStatus = + await this.statusDataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); + if (proposalStatus?.id !== 'UNDER_REVIEW') { return rejection( 'Could not assign instrument: forbidden current status', { agent, args, - currentStatus: currentStatus, + currentStatus: proposalStatus?.id, } ); } diff --git a/apps/backend/src/mutations/ProposalMutations.spec.ts b/apps/backend/src/mutations/ProposalMutations.spec.ts index 74adbf17c2..4294b1b3da 100644 --- a/apps/backend/src/mutations/ProposalMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalMutations.spec.ts @@ -4,10 +4,10 @@ import { container } from 'tsyringe'; import ProposalMutations from './ProposalMutations'; import { Tokens } from '../config/Tokens'; import { - ProposalDataSourceMock, - dummyProposalWithNotActiveCall, - dummyProposalSubmitted, dummyProposal, + dummyProposalSubmitted, + dummyProposalWithNotActiveCall, + ProposalDataSourceMock, } from '../datasources/mockups/ProposalDataSource'; import { StatusDataSourceMock } from '../datasources/mockups/StatusDataSource'; import { @@ -18,15 +18,18 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; +import { WorkflowDataSourceMock } from '../datasources/mockups/WorkflowDataSource'; import { Proposal } from '../models/Proposal'; import { isRejection, Rejection } from '../models/Rejection'; import { Status } from '../models/Status'; import { WorkflowType } from '../models/Workflow'; +import { WorkflowStatus } from '../models/WorkflowStatus'; const proposalMutations = container.resolve(ProposalMutations); let proposalDataSource: ProposalDataSourceMock; let statusDataSource: StatusDataSourceMock; +let workflowDataSource: WorkflowDataSourceMock; beforeEach(() => { proposalDataSource = container.resolve( @@ -37,6 +40,10 @@ beforeEach(() => { statusDataSource = container.resolve( Tokens.StatusDataSource ); + + workflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); }); test('A user on the proposal can update its title if it is in edit mode', () => { @@ -432,71 +439,63 @@ describe('Test technique proposal change status', () => { const expiredId = 7; const dummyProposalStatuses = [ - new Status(draftId, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - submittedId, 'SUBMITTED_LOCKED', 'Submitted (locked)', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - underReviewId, 'UNDER_REVIEW', 'Under review', '', - true, - WorkflowType.PROPOSAL - ), - new Status( - approvedId, - 'APPROVED', - 'Approved', - '', - true, + false, WorkflowType.PROPOSAL ), + new Status('APPROVED', 'Approved', '', false, WorkflowType.PROPOSAL), new Status( - unsuccessfulId, 'UNSUCCESSFUL', 'Unsuccessful', '', - true, + false, WorkflowType.PROPOSAL ), + new Status('FINISHED', 'Finished', '', false, WorkflowType.PROPOSAL), new Status( - finishedId, - 'FINISHED', - 'Finished', - '', - true, - WorkflowType.PROPOSAL - ), - new Status( - nonTechniqueProposalId, 'NON-TP', - 'A non-technique proposal status', - '', - true, - WorkflowType.PROPOSAL - ), - new Status( - expiredId, - 'EXPIRED', - 'Expired', + 'Non-technique proposal', '', - true, + false, WorkflowType.PROPOSAL ), ]; + const dummyWorkflowStatuses = [ + new WorkflowStatus(draftId, 1, 'DRAFT', 0, 0), + new WorkflowStatus(submittedId, 1, 'SUBMITTED_LOCKED', 0, 0), + new WorkflowStatus(underReviewId, 1, 'UNDER_REVIEW', 0, 0), + new WorkflowStatus(approvedId, 1, 'APPROVED', 0, 0), + new WorkflowStatus(unsuccessfulId, 1, 'UNSUCCESSFUL', 0, 0), + new WorkflowStatus(finishedId, 1, 'FINISHED', 0, 0), + new WorkflowStatus(nonTechniqueProposalId, 1, 'NON-TP', 0, 0), + ]; + beforeEach(() => { jest.restoreAllMocks(); jest .spyOn(statusDataSource, 'getAllStatuses') .mockResolvedValue(dummyProposalStatuses); + + jest + .spyOn(workflowDataSource, 'getWorkflowStatus') + .mockImplementation((id: number) => { + return Promise.resolve( + dummyWorkflowStatuses.find((ws) => ws.workflowStatusId === id) || null + ); + }); }); test('A scientist cannot change status when a proposal is a draft', async () => { @@ -504,12 +503,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: draftId, + workflowStatusId: draftId, }, ]); @@ -517,7 +516,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -533,12 +532,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: finishedId, + workflowStatusId: finishedId, }, ]); @@ -546,7 +545,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -562,12 +561,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: unsuccessfulId, + workflowStatusId: unsuccessfulId, }, ]); @@ -575,7 +574,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -591,12 +590,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -604,7 +603,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -620,12 +619,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: submittedId, + workflowStatusId: submittedId, }, ]); @@ -633,7 +632,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: nonTechniqueProposalId, + workflowStatusId: nonTechniqueProposalId, proposalPks: [1, 2], } ) @@ -649,12 +648,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -662,7 +661,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: draftId, + workflowStatusId: draftId, proposalPks: [1, 2], } ) @@ -678,12 +677,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -691,7 +690,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: expiredId, + workflowStatusId: expiredId, proposalPks: [1, 2], } ) @@ -707,12 +706,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -720,7 +719,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: submittedId, + workflowStatusId: submittedId, proposalPks: [1, 2], } ) @@ -736,12 +735,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: approvedId, + workflowStatusId: approvedId, }, ]); @@ -749,7 +748,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: finishedId, + workflowStatusId: finishedId, proposalPks: [1, 2], } ) @@ -765,12 +764,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: submittedId, + workflowStatusId: submittedId, }, ]); @@ -778,7 +777,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -787,11 +786,11 @@ describe('Test technique proposal change status', () => { proposals: expect.arrayContaining([ expect.objectContaining({ primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }), expect.objectContaining({ primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }), ]), }) @@ -803,12 +802,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: finishedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: finishedId, + workflowStatusId: finishedId, }, ]); @@ -816,7 +815,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyUserOfficerWithRole, { - statusId: draftId, + workflowStatusId: draftId, proposalPks: [1, 2], } ) @@ -825,11 +824,11 @@ describe('Test technique proposal change status', () => { proposals: expect.arrayContaining([ expect.objectContaining({ primaryKey: 1, - statusId: draftId, + workflowStatusId: draftId, }), expect.objectContaining({ primaryKey: 2, - statusId: draftId, + workflowStatusId: draftId, }), ]), }) diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index b0dda48cc0..1522e458f5 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -21,9 +21,9 @@ import ProposalInternalCommentsDataSource from '../datasources/postgres/Proposal import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { SampleDataSource } from '../datasources/SampleDataSource'; -import { StatusDataSource } from '../datasources/StatusDataSource'; import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; +import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Authorized, EventBus, ValidateArgs } from '../decorators'; import { Event } from '../events/event.enum'; import { Call } from '../models/Call'; @@ -31,7 +31,6 @@ import { Proposal, ProposalEndStatus, Proposals } from '../models/Proposal'; import { rejection, Rejection } from '../models/Rejection'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; -import { WorkflowType } from '../models/Workflow'; import { AdministrationProposalArgs } from '../resolvers/mutations/AdministrationProposalMutation'; import { ChangeProposalsStatusInput } from '../resolvers/mutations/ChangeProposalsStatusMutation'; import { CloneProposalsInput } from '../resolvers/mutations/CloneProposalMutation'; @@ -55,8 +54,6 @@ export default class ProposalMutations { constructor( @inject(Tokens.ProposalDataSource) public proposalDataSource: ProposalDataSource, - @inject(Tokens.StatusDataSource) - private statusDataSource: StatusDataSource, @inject(Tokens.QuestionaryDataSource) public questionaryDataSource: QuestionaryDataSource, @inject(Tokens.CallDataSource) private callDataSource: CallDataSource, @@ -76,7 +73,9 @@ export default class ProposalMutations { @inject(Tokens.ProposalAuthorization) private proposalAuth: ProposalAuthorization, @inject(Tokens.ProposalInternalCommentsDataSource) - private proposalInternalCommentsDataSource: ProposalInternalCommentsDataSource + private proposalInternalCommentsDataSource: ProposalInternalCommentsDataSource, + @inject(Tokens.WorkflowDataSource) + private workflowDataSource: WorkflowDataSource ) {} @ValidateArgs(createProposalValidationSchema) @@ -542,9 +541,9 @@ export default class ProposalMutations { agent: UserWithRole | null, args: ChangeProposalsStatusInput ): Promise { - const { statusId, proposalPks } = args; + const { workflowStatusId: statusId, proposalPks } = args; - const result = await this.proposalDataSource.changeProposalsStatus( + const result = await this.proposalDataSource.changeProposalsWorkflowStatus( statusId, proposalPks.map((proposalPk) => proposalPk) ); @@ -560,12 +559,6 @@ export default class ProposalMutations { return null; } - await this.proposalDataSource.resetProposalEvents( - proposalPk, - fullProposal.callId, - statusId - ); - const proposalWorkflow = await this.callDataSource.getProposalWorkflowByCall( fullProposal.callId @@ -583,7 +576,6 @@ export default class ProposalMutations { return { ...fullProposal, - workflowId: proposalWorkflow.id, }; }) ); @@ -651,35 +643,37 @@ export default class ProposalMutations { ); } - const allStatuses = await this.statusDataSource.getAllStatuses( - WorkflowType.PROPOSAL + const newWorkflowStatus = await this.workflowDataSource.getWorkflowStatus( + args.workflowStatusId ); for (const proposal of proposals) { - const currentStatus = allStatuses.find( - (ps) => ps.id === proposal.statusId - ); - - const newStatus = allStatuses.find((ps) => ps.id === args.statusId); + const currentWorkflowStatus = + await this.workflowDataSource.getWorkflowStatus( + proposal.workflowStatusId + ); - const context = { - currentStatus: currentStatus, - newStatus: newStatus, + const logContext = { + currentWorkflowStatus: currentWorkflowStatus, + newWorkflowStatus: newWorkflowStatus, proposalId: proposal.proposalId, ...requesterContext, }; - if (!currentStatus || !newStatus) { + if (!currentWorkflowStatus || !newWorkflowStatus) { return rejection( 'Could not change status of technique proposal(s): cannot determine statuses', - context + logContext ); } - if (currentStatus.id === newStatus.id) { + if ( + currentWorkflowStatus.workflowStatusId === + newWorkflowStatus.workflowStatusId + ) { return rejection( 'Could not change status of technique proposal(s): same status', - context + logContext ); } @@ -693,21 +687,22 @@ export default class ProposalMutations { EXPIRED = 'EXPIRED', } - if (!(newStatus.shortCode in TechniqueProposalStatus)) { + if (!(newWorkflowStatus.statusId in TechniqueProposalStatus)) { return rejection( 'Could not change status of technique proposal(s): forbidden new status', - context + logContext ); } if ( - newStatus.shortCode === TechniqueProposalStatus.DRAFT || - newStatus.shortCode === TechniqueProposalStatus.SUBMITTED_LOCKED || - newStatus.shortCode === TechniqueProposalStatus.EXPIRED + newWorkflowStatus.statusId === TechniqueProposalStatus.DRAFT || + newWorkflowStatus.statusId === + TechniqueProposalStatus.SUBMITTED_LOCKED || + newWorkflowStatus.statusId === TechniqueProposalStatus.EXPIRED ) { return rejection( 'Could not change status of technique proposal(s): forbidden new status', - context + logContext ); } @@ -719,20 +714,21 @@ export default class ProposalMutations { const isInstrumentAbsent = (proposalInstruments?.length ?? 0) === 0; const isCurrentlyDraft = - currentStatus.shortCode === TechniqueProposalStatus.DRAFT; + currentWorkflowStatus.statusId === TechniqueProposalStatus.DRAFT; const isCurrentlySubmitted = - currentStatus.shortCode === TechniqueProposalStatus.SUBMITTED_LOCKED; + currentWorkflowStatus.statusId === + TechniqueProposalStatus.SUBMITTED_LOCKED; const isCurrentlyUnsuccessful = - currentStatus.shortCode === TechniqueProposalStatus.UNSUCCESSFUL; + currentWorkflowStatus.statusId === TechniqueProposalStatus.UNSUCCESSFUL; const isCurrentlyApproved = - currentStatus.shortCode === TechniqueProposalStatus.APPROVED; + currentWorkflowStatus.statusId === TechniqueProposalStatus.APPROVED; const isCurrentlyFinished = - currentStatus.shortCode === TechniqueProposalStatus.FINISHED; + currentWorkflowStatus.statusId === TechniqueProposalStatus.FINISHED; if (isCurrentlyDraft || isCurrentlyFinished || isCurrentlyUnsuccessful) { return rejection( 'Could not change status of technique proposal(s): unmodifiable current status', - context + logContext ); } @@ -746,18 +742,18 @@ export default class ProposalMutations { const shouldDisableFinished = !isCurrentlyApproved || isInstrumentAbsent; if ( - (newStatus.shortCode === TechniqueProposalStatus.UNDER_REVIEW && + (newWorkflowStatus.statusId === TechniqueProposalStatus.UNDER_REVIEW && shouldDisableUnderReview) || - (newStatus.shortCode === TechniqueProposalStatus.APPROVED && + (newWorkflowStatus.statusId === TechniqueProposalStatus.APPROVED && shouldDisableApproved) || - (newStatus.shortCode === TechniqueProposalStatus.UNSUCCESSFUL && + (newWorkflowStatus.statusId === TechniqueProposalStatus.UNSUCCESSFUL && shouldDisableUnsuccessful) || - (newStatus.shortCode === TechniqueProposalStatus.FINISHED && + (newWorkflowStatus.statusId === TechniqueProposalStatus.FINISHED && shouldDisableFinished) ) { return rejection( 'Could not change status of technique proposal(s): forbidden status transition', - context + logContext ); } } @@ -839,13 +835,25 @@ export default class ProposalMutations { true ); + const defaultWorkflowStatus = + await this.workflowDataSource.getInitialWorkflowStatus( + call.proposalWorkflowId + ); + + if (!defaultWorkflowStatus) { + return rejection( + 'Could not clone the proposal because the call has misconfigured workflow statuses', + { call, sourceProposal } + ); + } + // TODO: Check if we need to also clone the technical review when cloning the proposal. clonedProposal = await this.proposalDataSource.update({ primaryKey: clonedProposal.primaryKey, title: `Copy of ${clonedProposal.title}`, abstract: clonedProposal.abstract, proposerId: sourceProposal.proposerId, - statusId: 1, + workflowStatusId: defaultWorkflowStatus.workflowStatusId, created: new Date(), updated: new Date(), proposalId: clonedProposal.proposalId, diff --git a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 59d721805d..cd45c1c456 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -7,10 +7,7 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; -import { - dummyWorkflow, - dummyWorkflowConnection, -} from '../datasources/mockups/WorkflowDataSource'; +import { dummyWorkflow } from '../datasources/mockups/WorkflowDataSource'; import { Rejection } from '../models/Rejection'; import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { WorkflowType } from '../models/Workflow'; @@ -20,7 +17,6 @@ const statusMutationsInstance = container.resolve(StatusMutations); const workflowMutationsInstance = container.resolve(WorkflowMutations); const dummyStatusChangingEvent = new StatusChangingEvent( - 1, 1, 'PROPOSAL_SUBMITTED' ); @@ -30,7 +26,7 @@ describe('Test Proposal settings mutations', () => { const result = (await statusMutationsInstance.createStatus( dummyUserWithRole, { - shortCode: 'NEW', + id: 'NEW', name: 'new', description: 'new', entityType: WorkflowType.PROPOSAL, @@ -43,7 +39,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can not create proposal status with bad input arguments', () => { return expect( statusMutationsInstance.createStatus(dummyUserOfficerWithRole, { - shortCode: 'Test', + id: 'Test', name: 'Test', description: 'This is some small description', entityType: WorkflowType.PROPOSAL, @@ -53,7 +49,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can create proposal status', () => { const newStatus = { - shortCode: 'NEW', + id: 'NEW', name: 'NEW', description: 'NEW', entityType: WorkflowType.PROPOSAL as const, @@ -68,11 +64,9 @@ describe('Test Proposal settings mutations', () => { const result = (await statusMutationsInstance.updateStatus( dummyUserWithRole, { - id: 1, - shortCode: 'UPDATE', + id: 'DRAFT', name: 'update', description: 'update', - isDefault: false, } )) as Rejection; @@ -81,11 +75,10 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can update proposal status', () => { const updatedStatus = { - id: 1, + id: 'DRAFT', shortCode: 'UPDATE', name: 'update', description: 'update', - isDefault: false, }; return expect( @@ -100,7 +93,7 @@ describe('Test Proposal settings mutations', () => { }); test('A userofficer can remove proposal status', () => { - const statusId = 2; + const statusId = 'FEASIBILITY_REVIEW'; return expect( statusMutationsInstance.deleteStatus(dummyUserOfficerWithRole, { @@ -164,17 +157,24 @@ describe('Test Proposal settings mutations', () => { }); test('A userofficer can create new proposal workflow connection', () => { + const newConnection = { + nextWorkflowStatusId: 2, + prevWorkflowStatusId: 1, + sourceHandle: 'bottom-source', + targetHandle: 'top-target', + }; + return expect( - workflowMutationsInstance.addWorkflowStatus( + workflowMutationsInstance.createWorkflowConnection( dummyUserOfficerWithRole, - dummyWorkflowConnection + newConnection ) - ).resolves.toStrictEqual(dummyWorkflowConnection); + ).resolves.toMatchObject(newConnection); }); test('A userofficer can add next status event/s to workflow connection', () => { return expect( - workflowMutationsInstance.addStatusChangingEventsToConnection( + workflowMutationsInstance.setStatusChangingEventsOnConnection( dummyUserOfficerWithRole, { statusChangingEvents: ['PROPOSAL_SUBMITTED'], @@ -187,9 +187,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can remove proposal workflow connection', () => { return expect( workflowMutationsInstance.deleteWorkflowStatus(dummyUserOfficerWithRole, { - statusId: 1, - workflowId: 1, - sortOrder: 0, + workflowStatusId: 1, }) ).resolves.toStrictEqual(true); }); @@ -200,6 +198,6 @@ describe('Test Proposal settings mutations', () => { dummyUserOfficerWithRole, 1 ) - ).resolves.toStrictEqual(dummyWorkflowConnection); + ).resolves.toMatchObject({ id: 1 }); }); }); diff --git a/apps/backend/src/mutations/StatusActionsLogsMutations.ts b/apps/backend/src/mutations/StatusActionsLogsMutations.ts index 0973d3ccfe..ff36654237 100644 --- a/apps/backend/src/mutations/StatusActionsLogsMutations.ts +++ b/apps/backend/src/mutations/StatusActionsLogsMutations.ts @@ -54,10 +54,7 @@ export default class StatusActionsLogsMutations { return null; } - return { - ...proposal, - workflowId: proposalWorkflow.id, - }; + return proposal; }) ); diff --git a/apps/backend/src/mutations/StatusMutations.ts b/apps/backend/src/mutations/StatusMutations.ts index 99fa70158a..b498168b52 100644 --- a/apps/backend/src/mutations/StatusMutations.ts +++ b/apps/backend/src/mutations/StatusMutations.ts @@ -32,7 +32,6 @@ export default class StatusMutations { return rejection('Could not create status', { agent, args }, error); }); } - @ValidateArgs(updateStatusValidationSchema) @Authorized([Roles.USER_OFFICER]) async updateStatus( @@ -48,7 +47,7 @@ export default class StatusMutations { @Authorized([Roles.USER_OFFICER]) async deleteStatus( agent: UserWithRole | null, - args: { id: number } + args: { id: string } ): Promise { return this.dataSource.deleteStatus(args.id).catch((error) => { return rejection('Could not delete status', { agent, args }, error); diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 6a6ca8e525..a676df026c 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -1,4 +1,5 @@ import { + addNextStatusEventsValidationSchema, addStatusActionsToConnectionValidationSchema, createWorkflowValidationSchema, deleteWorkflowStatusValidationSchema, @@ -21,11 +22,13 @@ import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; -import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; -import { AddStatusChangingEventsToConnectionInput } from '../resolvers/mutations/settings/AddStatusChangingEventsToConnectionMutation'; -import { AddWorkflowStatusInput } from '../resolvers/mutations/settings/AddWorkflowStatusMutation'; +import { WorkflowStatus } from '../models/WorkflowStatus'; +import { AddStatusToWorkflowInput } from '../resolvers/mutations/settings/AddStatusToWorkflowMutation'; +import { CreateWorkflowConnectionInput } from '../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { DeleteWorkflowStatusInput } from '../resolvers/mutations/settings/DeleteWorkflowStatusMutation'; +import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; +import { SetStatusChangingEventsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation'; import { UpdateWorkflowInput } from '../resolvers/mutations/settings/UpdateWorkflowMutation'; import { UpdateWorkflowStatusInput } from '../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; import { EmailStatusActionRecipients } from '../resolvers/types/StatusActionConfig'; @@ -74,61 +77,40 @@ export default class WorkflowMutations { } @Authorized([Roles.USER_OFFICER]) - async addWorkflowStatus( + async addStatusToWorkflow( agent: UserWithRole | null, - args: AddWorkflowStatusInput - ): Promise { + args: AddStatusToWorkflowInput + ): Promise { try { - if (args.prevStatusId) { - const previousWorkflowConnection = - await this.dataSource.getWorkflowConnectionsById( - args.workflowId, - args.prevStatusId, - {} - ); - if (previousWorkflowConnection.length > 0) { - // If there is a previous connection, we need to update its nextStatusId - const updatedConnection = previousWorkflowConnection[0]; - updatedConnection.nextStatusId = args.statusId; - await this.dataSource.updateWorkflowStatus(updatedConnection); - } - } - - return await this.dataSource.addWorkflowStatus(args); + return await this.dataSource.addStatusToWorkflow(args); } catch (error) { return rejection('Could not add workflow status', { agent, args }, error); } } @Authorized([Roles.USER_OFFICER]) - async updateWorkflowStatus( + async createWorkflowConnection( agent: UserWithRole | null, - args: UpdateWorkflowStatusInput + args: CreateWorkflowConnectionInput ): Promise { try { - const connection = await this.dataSource.getWorkflowConnection(args.id); - - if (!connection) { - return rejection( - 'Workflow connection not found', - { agent, args }, - new Error('Connection not found') - ); - } - - const updatedConnection = new WorkflowConnection( - connection.id, - connection.sortOrder, - connection.workflowId, - connection.statusId, - args.nextStatusId ?? connection.nextStatusId, - args.prevStatusId ?? connection.prevStatusId, - args.posX ?? connection.posX, - args.posY ?? connection.posY, - args.prevConnectionId ?? connection.prevConnectionId + return await this.dataSource.createWorkflowConnection(args); + } catch (error) { + return rejection( + 'Could not add workflow connection', + { agent, args }, + error ); + } + } - return await this.dataSource.updateWorkflowStatus(updatedConnection); + @Authorized([Roles.USER_OFFICER]) + async updateWorkflowStatus( + agent: UserWithRole | null, + args: UpdateWorkflowStatusInput + ): Promise { + try { + return await this.dataSource.updateWorkflowStatus(args); } catch (error) { return rejection( 'Could not update workflow status', @@ -138,14 +120,14 @@ export default class WorkflowMutations { } } - // @ValidateArgs(addNextStatusEventsValidationSchema) + @ValidateArgs(addNextStatusEventsValidationSchema) @Authorized([Roles.USER_OFFICER]) - async addStatusChangingEventsToConnection( + async setStatusChangingEventsOnConnection( agent: UserWithRole | null, - args: AddStatusChangingEventsToConnectionInput + args: SetStatusChangingEventsOnConnectionInput ): Promise { return this.dataSource - .addStatusChangingEventsToConnection( + .setStatusChangingEventsOnConnection( args.workflowConnectionId, args.statusChangingEvents ) @@ -165,11 +147,7 @@ export default class WorkflowMutations { args: DeleteWorkflowStatusInput ): Promise { try { - await this.dataSource.deleteWorkflowStatus( - args.statusId, - args.workflowId, - args.sortOrder - ); + await this.dataSource.deleteWorkflowStatus(args.workflowStatusId); return true; } catch (error) { @@ -221,12 +199,10 @@ export default class WorkflowMutations { ) ) @Authorized([Roles.USER_OFFICER]) - async addConnectionStatusActions( + async setStatusActionsOnConnection( agent: UserWithRole | null, - connectionStatusActionsInput: AddConnectionStatusActionsInput + input: SetStatusActionsOnConnectionInput ): Promise { - return this.statusActionsDataSource.addConnectionStatusActions( - connectionStatusActionsInput - ); + return this.statusActionsDataSource.setStatusActionsOnConnection(input); } } diff --git a/apps/backend/src/queries/FapQueries.spec.ts b/apps/backend/src/queries/FapQueries.spec.ts index 4604218ea4..0438f04f2c 100644 --- a/apps/backend/src/queries/FapQueries.spec.ts +++ b/apps/backend/src/queries/FapQueries.spec.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { container } from 'tsyringe'; import FapQueries from './FapQueries'; +import { dummyCall } from '../datasources/mockups/CallDataSource'; import { anotherDummyFap, dummyFap, @@ -108,7 +109,7 @@ describe('Test FapQueries', () => { test('A userofficer can get proposal assignments no matter the review visibility', async () => { const fapId = 3; - const proposalPk = 100; + const proposalPk = 1; await FapQueriesInstance.getFapProposalAssignments( dummyUserOfficerWithRole, @@ -120,7 +121,7 @@ describe('Test FapQueries', () => { test('A reviewer gets filtered assignments when PROPOSAL_REVIEWS_COMPLETE and not all reviews are in', async () => { const fapId = 3; - const proposalPk = 100; + const proposalPk = 1; await FapQueriesInstance.getFapProposalAssignments( dummyFapReviewerWithRole, @@ -136,7 +137,22 @@ describe('Test FapQueries', () => { test('A reviewer gets filtered assignments when REVIEWS_VISIBLE_FAP_ENDED and fap is still in review', async () => { const fapId = 4; - const proposalPk = 100; + const proposalPk = 1; + + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 1); + + const callWithFutureEndFapReview = { + ...dummyCall, + id: 1, + endFapReview: futureDate, + }; + + const callDataSourceSpy = jest.spyOn( + FapQueriesInstance['callDataSource'], + 'getCall' + ); + callDataSourceSpy.mockResolvedValue(callWithFutureEndFapReview); await FapQueriesInstance.getFapProposalAssignments( dummyFapReviewerWithRole, @@ -148,11 +164,28 @@ describe('Test FapQueries', () => { proposalPk, dummyFapReviewerWithRole.id ); + + callDataSourceSpy.mockRestore(); }); test('A reviewer gets unfiltered assignments when REVIEWS_VISIBLE_FAP_ENDED and fap review is finished', async () => { const fapId = 4; - const proposalPk = 101; + const proposalPk = 1; + + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() - 1); + + const callWithFutureEndFapReview = { + ...dummyCall, + id: 1, + endFapReview: futureDate, + }; + + const callDataSourceSpy = jest.spyOn( + FapQueriesInstance['callDataSource'], + 'getCall' + ); + callDataSourceSpy.mockResolvedValue(callWithFutureEndFapReview); await FapQueriesInstance.getFapProposalAssignments( dummyFapReviewerWithRole, @@ -164,7 +197,7 @@ describe('Test FapQueries', () => { test('A reviewer gets unfiltered assignments when REVIEWS_VISIBLE', async () => { const fapId = 5; - const proposalPk = 100; + const proposalPk = 1; await FapQueriesInstance.getFapProposalAssignments( dummyFapReviewerWithRole, diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index ee57f92167..8ceb5e7c83 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -1,11 +1,16 @@ +import { DateTime } from 'luxon'; import { inject, injectable } from 'tsyringe'; import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; +import { CallDataSource } from '../datasources/CallDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; +import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { Authorized } from '../decorators'; +import { Call } from '../models/Call'; import { FapReviewVisibility } from '../models/Fap'; +import { ReviewStatus } from '../models/Review'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { FapsFilter } from '../resolvers/queries/FapsQuery'; @@ -16,6 +21,9 @@ export default class FapQueries { @inject(Tokens.FapDataSource) public dataSource: FapDataSource, @inject(Tokens.ProposalDataSource) public proposalDataSource: ProposalDataSource, + @inject(Tokens.ReviewDataSource) + private reviewDataSource: ReviewDataSource, + @inject(Tokens.CallDataSource) private callDataSource: CallDataSource, @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization ) {} @@ -32,15 +40,7 @@ export default class FapQueries { return null; } - if ( - this.userAuth.isApiToken(agent) || - this.userAuth.isUserOfficer(agent) || - (await this.userAuth.isMemberOfFap(agent, id)) - ) { - return fap; - } else { - return null; - } + return (await this.userHasFapAccess(agent, id)) ? fap : null; } @Authorized([Roles.USER_OFFICER]) @@ -124,15 +124,9 @@ export default class FapQueries { agent: UserWithRole | null, { fapId, proposalPk }: { fapId: number; proposalPk: number } ) { - if ( - this.userAuth.isApiToken(agent) || - this.userAuth.isUserOfficer(agent) || - (await this.userAuth.isMemberOfFap(agent, fapId)) - ) { - return this.dataSource.getFapProposal(fapId, proposalPk); - } else { - return null; - } + return (await this.userHasFapAccess(agent, fapId)) + ? this.dataSource.getFapProposal(fapId, proposalPk) + : null; } @Authorized([ @@ -149,17 +143,11 @@ export default class FapQueries { callId, }: { fapId: number; instrumentId: number; callId: number } ) { - if ( - this.userAuth.isApiToken(agent) || - this.userAuth.isUserOfficer(agent) || - (await this.userAuth.isMemberOfFap(agent, fapId)) - ) { - return this.dataSource.getFapProposalsByInstrument(instrumentId, callId, { - fapId, - }); - } else { - return null; - } + return (await this.userHasFapAccess(agent, fapId)) + ? this.dataSource.getFapProposalsByInstrument(instrumentId, callId, { + fapId, + }) + : null; } @Authorized([ @@ -178,42 +166,54 @@ export default class FapQueries { proposalPk: number; } ) { - let reviewerId = null; + const proposal = await this.proposalDataSource.get(proposalPk); + if (!proposal) { + return null; + } - const proposalEvents = - await this.proposalDataSource.getProposalEvents(proposalPk); + const call = await this.callDataSource.getCall(proposal.callId); + if (!call) { + return null; + } - const visibility = await this.dataSource.getFapReviewVisibility(fapId); + const isApiToken = this.userAuth.isApiToken(agent); + const isUserOfficer = this.userAuth.isUserOfficer(agent); + const isChairOrSecretary = await this.userAuth.isChairOrSecretaryOfFap( + agent, + fapId + ); + + if (isUserOfficer || isChairOrSecretary) { + // Applying no restrictions for user officers, fap chairs and fap secretaries + return this.dataSource.getFapProposalAssignments(fapId, proposalPk, null); + } - let reviewsVisibleOnFap = false; + const visibility = await this.dataSource.getFapReviewVisibility(fapId); + let fapAccessRestrictions = true; switch (visibility) { case FapReviewVisibility.PROPOSAL_REVIEWS_COMPLETE: - reviewsVisibleOnFap = - proposalEvents?.proposal_all_fap_reviews_submitted || false; + const canSeeAllAssignments = + isApiToken || + isUserOfficer || + isChairOrSecretary || + (await this.allFapReviewsComplete(fapId, proposalPk)); + + if (canSeeAllAssignments) fapAccessRestrictions = false; break; case FapReviewVisibility.REVIEWS_VISIBLE_FAP_ENDED: - reviewsVisibleOnFap = proposalEvents?.call_fap_review_ended || false; + const hasFapEnded = this.hasFapEnded(call); + if (hasFapEnded) fapAccessRestrictions = false; break; case FapReviewVisibility.REVIEWS_VISIBLE: - reviewsVisibleOnFap = true; + fapAccessRestrictions = false; break; } - // NOTE: If not officer, Fap Chair or Fap Secretary should return only the proposals based the review visibility setting of the fap - if ( - agent && - !this.userAuth.isUserOfficer(agent) && - !(await this.userAuth.isChairOrSecretaryOfFap(agent, fapId)) && - !reviewsVisibleOnFap - ) { - reviewerId = agent.id; - } - return this.dataSource.getFapProposalAssignments( fapId, proposalPk, - reviewerId + fapAccessRestrictions ? agent!.id : null ); } @@ -227,21 +227,19 @@ export default class FapQueries { [proposalPk], fapId ); - const fap = await this.dataSource.getFapByProposalPk(proposalPk); - if (!fapMeetingDecisions.length || !fap) { + if (!fapMeetingDecisions.length) { return []; } - if ( - this.userAuth.isApiToken(agent) || - this.userAuth.isUserOfficer(agent) || - (await this.userAuth.isMemberOfFap(agent, fap.id)) - ) { - return fapMeetingDecisions; - } else { + const fap = await this.dataSource.getFapByProposalPk(proposalPk); + if (!fap) { return []; } + + return (await this.userHasFapAccess(agent, fap.id)) + ? fapMeetingDecisions + : []; } async getProposalsFaps(agent: UserWithRole | null, proposalPks: number[]) { @@ -252,4 +250,33 @@ export default class FapQueries { async getFapReviewVisibilityOptions(agent: UserWithRole | null) { return await this.dataSource.getFapReviewVisibilityOptions(); } + + private async userHasFapAccess( + agent: UserWithRole | null, + fapId: number + ): Promise { + return ( + this.userAuth.isApiToken(agent) || + this.userAuth.isUserOfficer(agent) || + (await this.userAuth.isMemberOfFap(agent, fapId)) + ); + } + + private async allFapReviewsComplete(fapId: number, proposalPk: number) { + const reviews = await this.reviewDataSource.getProposalReviews( + proposalPk, + fapId + ); + + return ( + reviews.length > 0 && + reviews.every((review) => review.status === ReviewStatus.SUBMITTED) + ); + } + + private hasFapEnded(call: Call) { + const currentDate = DateTime.now(); + + return call.endFapReview?.getTime() <= currentDate.toMillis(); + } } diff --git a/apps/backend/src/queries/SettingsQueries.ts b/apps/backend/src/queries/SettingsQueries.ts index 91445c1c42..69596d5414 100644 --- a/apps/backend/src/queries/SettingsQueries.ts +++ b/apps/backend/src/queries/SettingsQueries.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { injectable } from 'tsyringe'; import { Authorized } from '../decorators'; -import { Event, EventLabel } from '../events/event.enum'; +import { Event, EventMetadataByEvent } from '../events/event.enum'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { WorkflowType } from '../models/Workflow'; @@ -20,19 +20,27 @@ export default class SettingsQueries { (eventItem) => eventItem.startsWith('PROPOSAL_') || eventItem.startsWith('CALL_') ) - .map((eventItem) => ({ - name: eventItem, - description: EventLabel.get(eventItem), - })); + .map((eventItem) => { + const metadata = EventMetadataByEvent.get(eventItem as Event); + + return { + name: eventItem, + description: metadata?.label, + }; + }); return allProposalEvents; } else if (entityType === WorkflowType.EXPERIMENT) { const allExperimentSafetyEvents = allEventsArray .filter((eventItem) => eventItem.startsWith('EXPERIMENT_')) - .map((eventItem) => ({ - name: eventItem, - description: EventLabel.get(eventItem), - })); + .map((eventItem) => { + const metadata = EventMetadataByEvent.get(eventItem as Event); + + return { + name: eventItem, + description: metadata?.label, + }; + }); return allExperimentSafetyEvents; } else { diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 2d47e85907..8087eb08f2 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -45,9 +45,9 @@ export default class StatusActionQueries { @Authorized([Roles.USER_OFFICER]) async getConnectionStatusActions( agent: UserWithRole | null, - { connectionId, workflowId }: { connectionId: number; workflowId: number } + { workflowConnectionId }: { workflowConnectionId: number } ) { - return this.dataSource.getConnectionStatusActions(connectionId, workflowId); + return this.dataSource.getConnectionStatusActions(workflowConnectionId); } @Authorized([Roles.USER_OFFICER]) diff --git a/apps/backend/src/queries/StatusQueries.ts b/apps/backend/src/queries/StatusQueries.ts index 4dd3182bf9..75f4ccfe8f 100644 --- a/apps/backend/src/queries/StatusQueries.ts +++ b/apps/backend/src/queries/StatusQueries.ts @@ -17,7 +17,7 @@ export default class StatusQueries { ) {} @Authorized() - async getStatus(agent: UserWithRole | null, id: number) { + async getStatus(agent: UserWithRole | null, id: string) { const status = await this.dataSource.getStatus(id); return status; diff --git a/apps/backend/src/queries/WorkflowQueries.ts b/apps/backend/src/queries/WorkflowQueries.ts index f867f28e39..1d1d427604 100644 --- a/apps/backend/src/queries/WorkflowQueries.ts +++ b/apps/backend/src/queries/WorkflowQueries.ts @@ -40,10 +40,23 @@ export default class WorkflowQueries { } @Authorized() - async getWorkflowConnections(agent: UserWithRole | null, workflowId: number) { + async getConnections(agent: UserWithRole | null, workflowId: number) { return this.dataSource.getWorkflowConnections(workflowId); } + @Authorized() + async getWorkflowStatuses(agent: UserWithRole | null, workflowId: number) { + return this.dataSource.getWorkflowStatuses(workflowId); + } + + @Authorized() + async getWorkflowStatus( + agent: UserWithRole | null, + workflowStatusId: number + ) { + return this.dataSource.getWorkflowStatus(workflowStatusId); + } + @Authorized([Roles.USER_OFFICER]) async getStatusChangingEventsByConnectionId( agent: UserWithRole | null, diff --git a/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts b/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts index 2a71a66b37..5be68c951e 100644 --- a/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts @@ -14,7 +14,7 @@ import { isRejection } from '../../models/Rejection'; @InputType() export class ChangeProposalsStatusInput { @Field(() => Int) - public statusId: number; + public workflowStatusId: number; @Field(() => [Int]) public proposalPks: number[]; diff --git a/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts new file mode 100644 index 0000000000..c9eb968386 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts @@ -0,0 +1,43 @@ +import { + Ctx, + Mutation, + Resolver, + Field, + InputType, + Arg, + Int, +} from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { WorkflowConnection } from '../../types/WorkflowConnection'; +import { WorkflowStatus } from '../../types/WorkflowStatus'; + +@InputType() +export class AddStatusToWorkflowInput implements Partial { + @Field(() => Int) + public workflowId: number; + + @Field(() => String) + public statusId: string; + + @Field(() => Int) + public posX: number; + + @Field(() => Int) + public posY: number; +} + +@Resolver() +export class AddStatusToWorkflowMutation { + @Mutation(() => WorkflowStatus) + async addStatusToWorkflow( + @Ctx() context: ResolverContext, + @Arg('newWorkflowStatusInput') + newWorkflowStatusInput: AddStatusToWorkflowInput + ) { + return context.mutations.workflow.addStatusToWorkflow( + context.user, + newWorkflowStatusInput + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts deleted file mode 100644 index 21c24a4972..0000000000 --- a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - Ctx, - Mutation, - Resolver, - Field, - InputType, - Arg, - Int, -} from 'type-graphql'; - -import { ResolverContext } from '../../../context'; -import { WorkflowConnection } from '../../types/WorkflowConnection'; - -@InputType() -export class AddWorkflowStatusInput implements Partial { - @Field(() => Int) - public workflowId: number; - - @Field(() => Int) - public sortOrder: number; - - @Field(() => Int) - public statusId: number; - - @Field(() => Int, { nullable: true }) - public nextStatusId: number | null; - - @Field(() => Int, { nullable: true }) - public prevStatusId: number | null; - - @Field(() => Int) - public posX: number; - - @Field(() => Int) - public posY: number; - - @Field(() => Int, { nullable: true }) - public prevConnectionId: number | null; -} - -@Resolver() -export class AddWorkflowStatusMutation { - @Mutation(() => WorkflowConnection) - async addWorkflowStatus( - @Ctx() context: ResolverContext, - @Arg('newWorkflowStatusInput') - newWorkflowStatusInput: AddWorkflowStatusInput - ) { - return context.mutations.workflow.addWorkflowStatus( - context.user, - newWorkflowStatusInput - ); - } -} diff --git a/apps/backend/src/resolvers/mutations/settings/CreateStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/CreateStatusMutation.ts index 83fae4ce21..e3e8c10190 100644 --- a/apps/backend/src/resolvers/mutations/settings/CreateStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/CreateStatusMutation.ts @@ -7,7 +7,7 @@ import { Status } from '../../types/Status'; @InputType() export class CreateStatusInput implements Partial { @Field(() => String) - public shortCode: string; + public id: string; @Field(() => String) public name: string; diff --git a/apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts b/apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts new file mode 100644 index 0000000000..eabcf7a76f --- /dev/null +++ b/apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts @@ -0,0 +1,42 @@ +import { + Ctx, + Mutation, + Resolver, + Field, + InputType, + Arg, + Int, +} from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { WorkflowConnection } from '../../types/WorkflowConnection'; + +@InputType() +export class CreateWorkflowConnectionInput { + @Field(() => Int) + public prevWorkflowStatusId: number; + + @Field(() => Int) + public nextWorkflowStatusId: number; + + @Field(() => String) + public sourceHandle: string; + + @Field(() => String) + public targetHandle: string; +} + +@Resolver() +export class CreateWorkflowConnectionMutation { + @Mutation(() => WorkflowConnection) + async createWorkflowConnection( + @Ctx() context: ResolverContext, + @Arg('newWorkflowConnectionInput') + newWorkflowConnectionInput: CreateWorkflowConnectionInput + ) { + return context.mutations.workflow.createWorkflowConnection( + context.user, + newWorkflowConnectionInput + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/settings/DeleteStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/DeleteStatusMutation.ts index 0b703a1186..8823704b90 100644 --- a/apps/backend/src/resolvers/mutations/settings/DeleteStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/DeleteStatusMutation.ts @@ -1,4 +1,4 @@ -import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; +import { Arg, Ctx, Mutation, Resolver } from 'type-graphql'; import { ResolverContext } from '../../../context'; import { Status } from '../../types/Status'; @@ -7,7 +7,7 @@ import { Status } from '../../types/Status'; export class DeleteStatusMutation { @Mutation(() => Status) async deleteStatus( - @Arg('id', () => Int) id: number, + @Arg('id', () => String) id: string, @Ctx() context: ResolverContext ) { return context.mutations.status.deleteStatus(context.user, { diff --git a/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowStatusMutation.ts index eca66abbd0..9c9e8cb4e4 100644 --- a/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/DeleteWorkflowStatusMutation.ts @@ -13,13 +13,7 @@ import { ResolverContext } from '../../../context'; @InputType() export class DeleteWorkflowStatusInput { @Field(() => Int) - public statusId: number; - - @Field(() => Int) - public workflowId: number; - - @Field(() => Int) - public sortOrder: number; + public workflowStatusId: number; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/settings/AddConnectionStatusActionsMutation.ts b/apps/backend/src/resolvers/mutations/settings/SetStatusActionsOnConnectionMutation.ts similarity index 70% rename from apps/backend/src/resolvers/mutations/settings/AddConnectionStatusActionsMutation.ts rename to apps/backend/src/resolvers/mutations/settings/SetStatusActionsOnConnectionMutation.ts index 73748f984f..6a0f4bb74d 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddConnectionStatusActionsMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/SetStatusActionsOnConnectionMutation.ts @@ -28,7 +28,7 @@ export class ConnectionHasActionsInput { } @InputType() -export class AddConnectionStatusActionsInput +export class SetStatusActionsOnConnectionInput implements Partial { @Field(() => Int) @@ -42,16 +42,16 @@ export class AddConnectionStatusActionsInput } @Resolver() -export class AddConnectionStatusActionsMutation { +export class SetStatusActionsOnConnectionMutation { @Mutation(() => [ConnectionStatusAction], { nullable: true }) - async addConnectionStatusActions( + async setStatusActionsOnConnection( @Ctx() context: ResolverContext, - @Arg('newConnectionStatusActionsInput') - newConnectionStatusActionsInput: AddConnectionStatusActionsInput + @Arg('input') + input: SetStatusActionsOnConnectionInput ) { - return context.mutations.workflow.addConnectionStatusActions( + return context.mutations.workflow.setStatusActionsOnConnection( context.user, - newConnectionStatusActionsInput + input ); } } diff --git a/apps/backend/src/resolvers/mutations/settings/AddStatusChangingEventsToConnectionMutation.ts b/apps/backend/src/resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation.ts similarity index 53% rename from apps/backend/src/resolvers/mutations/settings/AddStatusChangingEventsToConnectionMutation.ts rename to apps/backend/src/resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation.ts index 199929fc8f..52c346960d 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddStatusChangingEventsToConnectionMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation.ts @@ -1,36 +1,36 @@ -import { - Ctx, - Mutation, - Resolver, - Field, - InputType, - Arg, - Int, -} from 'type-graphql'; - -import { ResolverContext } from '../../../context'; -import { StatusChangingEvent } from '../../types/StatusChangingEvent'; - -@InputType() -export class AddStatusChangingEventsToConnectionInput { - @Field(() => Int) - public workflowConnectionId: number; - - @Field(() => [String]) - public statusChangingEvents: string[]; -} - -@Resolver() -export class AddStatusChangingEventsToConnectionMutation { - @Mutation(() => [StatusChangingEvent]) - async addStatusChangingEventsToConnection( - @Ctx() context: ResolverContext, - @Arg('addStatusChangingEventsToConnectionInput') - addStatusChangingEventsToConnectionInput: AddStatusChangingEventsToConnectionInput - ) { - return context.mutations.workflow.addStatusChangingEventsToConnection( - context.user, - addStatusChangingEventsToConnectionInput - ); - } -} +import { + Ctx, + Mutation, + Resolver, + Field, + InputType, + Arg, + Int, +} from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { StatusChangingEvent } from '../../types/StatusChangingEvent'; + +@InputType() +export class SetStatusChangingEventsOnConnectionInput { + @Field(() => Int) + public workflowConnectionId: number; + + @Field(() => [String]) + public statusChangingEvents: string[]; +} + +@Resolver() +export class SetStatusChangingEventsOnConnectionMutation { + @Mutation(() => [StatusChangingEvent]) + async setStatusChangingEventsOnConnection( + @Ctx() context: ResolverContext, + @Arg('setStatusChangingEventsOnConnectionInput') + setStatusChangingEventsOnConnectionInput: SetStatusChangingEventsOnConnectionInput + ) { + return context.mutations.workflow.setStatusChangingEventsOnConnection( + context.user, + setStatusChangingEventsOnConnectionInput + ); + } +} diff --git a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts index e907bbc495..853ca3af65 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts @@ -1,32 +1,18 @@ -import { - Ctx, - Mutation, - Resolver, - Field, - Int, - InputType, - Arg, -} from 'type-graphql'; +import { Ctx, Mutation, Resolver, Field, InputType, Arg } from 'type-graphql'; import { ResolverContext } from '../../../context'; import { Status } from '../../types/Status'; @InputType() export class UpdateStatusInput { - @Field(() => Int) - public id: number; - - @Field(() => String, { nullable: true }) - public shortCode?: string; + @Field(() => String) + public id: string; @Field(() => String, { nullable: true }) public name?: string; @Field(() => String, { nullable: true }) public description?: string; - - @Field(() => Boolean, { nullable: true }) - public isDefault?: boolean; } @Resolver() diff --git a/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts index 5678048621..309d61d76a 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateWorkflowStatusMutation.ts @@ -1,40 +1,31 @@ import { + Arg, Ctx, - Mutation, - Resolver, Field, InputType, - Arg, Int, + Mutation, + Resolver, } from 'type-graphql'; import { ResolverContext } from '../../../context'; -import { WorkflowConnection } from '../../types/WorkflowConnection'; +import { WorkflowStatus } from '../../types/WorkflowStatus'; @InputType() export class UpdateWorkflowStatusInput { @Field(() => Int) - public id: number; + public workflowStatusId: number; @Field(() => Int, { nullable: true }) public posX?: number; @Field(() => Int, { nullable: true }) public posY?: number; - - @Field(() => Int, { nullable: true }) - public prevConnectionId?: number; - - @Field(() => Int, { nullable: true }) - public prevStatusId?: number; - - @Field(() => Int, { nullable: true }) - public nextStatusId?: number; } @Resolver() export class UpdateWorkflowStatusMutation { - @Mutation(() => WorkflowConnection) + @Mutation(() => WorkflowStatus) async updateWorkflowStatus( @Ctx() context: ResolverContext, @Arg('updateWorkflowStatusInput') diff --git a/apps/backend/src/resolvers/queries/CallsQuery.ts b/apps/backend/src/resolvers/queries/CallsQuery.ts index d0b83e90d8..5bcb60e73b 100644 --- a/apps/backend/src/resolvers/queries/CallsQuery.ts +++ b/apps/backend/src/resolvers/queries/CallsQuery.ts @@ -20,7 +20,7 @@ export class CallsFilter { public shortCode?: string; @Field(() => String, { nullable: true }) - public proposalStatusShortCode?: string; + public proposalStatus?: string; @Field(() => [Int], { nullable: true }) public templateIds?: number[]; diff --git a/apps/backend/src/resolvers/queries/ProposalsQuery.ts b/apps/backend/src/resolvers/queries/ProposalsQuery.ts index 1d115ca795..ac939d844d 100644 --- a/apps/backend/src/resolvers/queries/ProposalsQuery.ts +++ b/apps/backend/src/resolvers/queries/ProposalsQuery.ts @@ -87,11 +87,11 @@ export class ProposalsFilter { @Field(() => InstrumentFilterInput, { nullable: true }) public instrumentFilter?: InstrumentFilterInput; - @Field(() => Int, { nullable: true }) - public proposalStatusId?: number; + @Field(() => String, { nullable: true }) + public proposalStatusId?: string; - @Field(() => [Int], { nullable: true }) - public excludeProposalStatusIds?: number[]; + @Field(() => [String], { nullable: true }) + public excludeProposalStatusIds?: string[]; @Field(() => [String], { nullable: true }) public shortCodes?: string[]; diff --git a/apps/backend/src/resolvers/queries/StatusQuery.ts b/apps/backend/src/resolvers/queries/StatusQuery.ts index c965b5e939..9c11b696be 100644 --- a/apps/backend/src/resolvers/queries/StatusQuery.ts +++ b/apps/backend/src/resolvers/queries/StatusQuery.ts @@ -1,21 +1,34 @@ -import { Query, Ctx, Resolver, Int, ArgsType, Field, Args } from 'type-graphql'; +import { Query, Ctx, Resolver, ArgsType, Field, Args, Int } from 'type-graphql'; import { ResolverContext } from '../../context'; import { WorkflowType } from '../../models/Workflow'; import { Status } from '../types/Status'; +import { WorkflowStatus } from '../types/WorkflowStatus'; @ArgsType() export class StatusArgs { - @Field(() => Int) - statusId: number; + @Field(() => String!) + statusId: string; } @ArgsType() export class StatusesArgs { - @Field(() => WorkflowType) + @Field(() => WorkflowType!) entityType: WorkflowType; } +@ArgsType() +export class WorkflowStatusArgs { + @Field(() => Int!) + id: number; +} + +@ArgsType() +export class WorkflowStatusesArgs { + @Field(() => Int!) + workflowId: number; +} + @Resolver() export class StatusQuery { @Query(() => Status, { nullable: true }) @@ -23,8 +36,27 @@ export class StatusQuery { return context.queries.status.getStatus(context.user, args.statusId); } - @Query(() => [Status], { nullable: true }) + @Query(() => [Status]) statuses(@Args() args: StatusesArgs, @Ctx() context: ResolverContext) { return context.queries.status.getAllStatuses(context.user, args.entityType); } + + @Query(() => WorkflowStatus, { nullable: true }) + workflowStatus( + @Args() args: WorkflowStatusArgs, + @Ctx() context: ResolverContext + ) { + return context.queries.workflow.getWorkflowStatus(context.user, args.id); + } + + @Query(() => [WorkflowStatus]) + workflowStatuses( + @Args() args: WorkflowStatusesArgs, + @Ctx() context: ResolverContext + ) { + return context.queries.workflow.getWorkflowStatuses( + context.user, + args.workflowId + ); + } } diff --git a/apps/backend/src/resolvers/types/ExperimentSafety.ts b/apps/backend/src/resolvers/types/ExperimentSafety.ts index e0e28d05bd..49cf1030e9 100644 --- a/apps/backend/src/resolvers/types/ExperimentSafety.ts +++ b/apps/backend/src/resolvers/types/ExperimentSafety.ts @@ -42,8 +42,8 @@ export class ExperimentSafety implements ExperimentSafetyOrigin { @Field(() => Number) public createdBy: number; - @Field(() => Number, { nullable: true }) - public statusId: number | null; + @Field(() => Number) + public workflowStatusId: number; @Field(() => Number, { nullable: true }) public safetyReviewQuestionaryId: number | null; @@ -153,14 +153,10 @@ export class ExperimentSafetyResolver { @Root() experimentSafety: ExperimentSafety, @Ctx() context: ResolverContext ): Promise { - if (experimentSafety.statusId === null) { - return null; - } - - const status = await context.queries.status.getStatus( - context.user, - experimentSafety.statusId - ); + const status = + await context.queries.status.dataSource.getStatusByWorkflowStatusId( + experimentSafety.workflowStatusId + ); if (status === null) { throw new GraphQLError( @@ -170,4 +166,17 @@ export class ExperimentSafetyResolver { return status; } + + @FieldResolver(() => String) + async statusId( + @Root() experimentSafety: ExperimentSafety, + @Ctx() context: ResolverContext + ): Promise { + const status = + await context.queries.status.dataSource.getStatusByWorkflowStatusId( + experimentSafety.workflowStatusId + ); + + return status!.id; + } } diff --git a/apps/backend/src/resolvers/types/Proposal.ts b/apps/backend/src/resolvers/types/Proposal.ts index e5157d7ec2..b5ad309e80 100644 --- a/apps/backend/src/resolvers/types/Proposal.ts +++ b/apps/backend/src/resolvers/types/Proposal.ts @@ -54,7 +54,7 @@ export class Proposal implements Partial { public abstract: string; @Field(() => Int) - public statusId: number; + public workflowStatusId: number; @Field(() => Date) public created: Date; @@ -161,12 +161,24 @@ export class ProposalResolver { @Root() proposal: Proposal, @Ctx() context: ResolverContext ): Promise { - return await context.queries.status.getStatus( - context.user, - proposal.statusId + return await context.queries.status.dataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId ); } + @FieldResolver(() => String) + async statusId( + @Root() proposal: Proposal, + @Ctx() context: ResolverContext + ): Promise { + const status = + await context.queries.status.dataSource.getStatusByWorkflowStatusId( + proposal.workflowStatusId + ); + + return status!.id; + } + @FieldResolver(() => ProposalPublicStatus) async publicStatus( @Root() proposal: ProposalOrigin diff --git a/apps/backend/src/resolvers/types/ProposalView.ts b/apps/backend/src/resolvers/types/ProposalView.ts index 807d5fc674..a2321abfb5 100644 --- a/apps/backend/src/resolvers/types/ProposalView.ts +++ b/apps/backend/src/resolvers/types/ProposalView.ts @@ -118,7 +118,10 @@ export class ProposalView implements Partial { public principalInvestigatorId: number; @Field(() => Int) - public statusId: number; + public workflowStatusId: number; + + @Field(() => String) + public statusId: string; @Field(() => String) public statusName: string; diff --git a/apps/backend/src/resolvers/types/Status.ts b/apps/backend/src/resolvers/types/Status.ts index df7ff100e7..d1a10a9638 100644 --- a/apps/backend/src/resolvers/types/Status.ts +++ b/apps/backend/src/resolvers/types/Status.ts @@ -1,15 +1,12 @@ -import { ObjectType, Field, Int } from 'type-graphql'; +import { ObjectType, Field } from 'type-graphql'; import { Status as StatusOrigin } from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; @ObjectType() export class Status implements Partial { - @Field(() => Int) - public id: number; - @Field(() => String) - public shortCode: string; + public id: string; @Field(() => String) public name: string; diff --git a/apps/backend/src/resolvers/types/StatusActionConfig.ts b/apps/backend/src/resolvers/types/StatusActionConfig.ts index 9f70884d99..d182f3eafc 100644 --- a/apps/backend/src/resolvers/types/StatusActionConfig.ts +++ b/apps/backend/src/resolvers/types/StatusActionConfig.ts @@ -57,6 +57,7 @@ export class EmailStatusActionRecipient { export class EmailStatusActionEmailTemplate { @Field(() => String) public id: string; + @Field(() => String) public name: string; } diff --git a/apps/backend/src/resolvers/types/StatusChangingEvent.ts b/apps/backend/src/resolvers/types/StatusChangingEvent.ts index b635482e52..61a4849873 100644 --- a/apps/backend/src/resolvers/types/StatusChangingEvent.ts +++ b/apps/backend/src/resolvers/types/StatusChangingEvent.ts @@ -4,9 +4,6 @@ import { StatusChangingEvent as StatusChangingEventOrigin } from '../../models/S @ObjectType() export class StatusChangingEvent implements StatusChangingEventOrigin { - @Field(() => Int) - public statusChangingEventId: number; - @Field(() => Int) public workflowConnectionId: number; diff --git a/apps/backend/src/resolvers/types/Workflow.ts b/apps/backend/src/resolvers/types/Workflow.ts index 62c4efad5e..0dba9e9eb2 100644 --- a/apps/backend/src/resolvers/types/Workflow.ts +++ b/apps/backend/src/resolvers/types/Workflow.ts @@ -9,6 +9,7 @@ import { } from 'type-graphql'; import { WorkflowConnection } from './WorkflowConnection'; +import { WorkflowStatus } from './WorkflowStatus'; import { ResolverContext } from '../../context'; import { Event } from '../../events/event.enum'; import { isRejection } from '../../models/Rejection'; @@ -38,18 +39,32 @@ export class Workflow implements Partial { @Resolver(() => Workflow) export class WorkflowResolver { @FieldResolver(() => [WorkflowConnection]) - async workflowConnections( + async connections( @Root() workflow: Workflow, @Ctx() context: ResolverContext ): Promise { - const connections = await context.queries.workflow.getWorkflowConnections( + const connections = await context.queries.workflow.getConnections( context.user, workflow.id ); return isRejection(connections) ? [] : connections; } + + @FieldResolver(() => [WorkflowStatus]) + async statuses( + @Root() workflow: Workflow, + @Ctx() context: ResolverContext + ): Promise { + const statuses = await context.queries.workflow.getWorkflowStatuses( + context.user, + workflow.id + ); + + return isRejection(statuses) ? [] : statuses; + } } + @ObjectType() export class WorkflowEvent { @Field(() => Event) diff --git a/apps/backend/src/resolvers/types/WorkflowConnection.ts b/apps/backend/src/resolvers/types/WorkflowConnection.ts index cd5ed3831e..1874d20628 100644 --- a/apps/backend/src/resolvers/types/WorkflowConnection.ts +++ b/apps/backend/src/resolvers/types/WorkflowConnection.ts @@ -1,53 +1,39 @@ import { - ObjectType, + Ctx, Field, + FieldResolver, Int, + ObjectType, Resolver, - FieldResolver, Root, - Ctx, } from 'type-graphql'; import { ConnectionStatusAction } from './ConnectionStatusAction'; -import { Status } from './Status'; import { StatusChangingEvent } from './StatusChangingEvent'; +import { WorkflowStatus } from './WorkflowStatus'; import { ResolverContext } from '../../context'; import { isRejection } from '../../models/Rejection'; -import { WorkflowConnectionWithStatus as WorkflowConnectionWithStatusOrigin } from '../../models/WorkflowConnections'; +import { WorkflowConnection as WorkflowConnectionOrigin } from '../../models/WorkflowConnections'; @ObjectType() -export class WorkflowConnection - implements Partial -{ +export class WorkflowConnection implements Partial { @Field(() => Int) public id: number; - @Field(() => Int) - public sortOrder: number; - @Field(() => Int) public workflowId: number; @Field(() => Int) - public statusId: number; - - @Field(() => Status) - public status: Status; - - @Field(() => Int, { nullable: true }) - public nextStatusId: number | null; - - @Field(() => Int, { nullable: true }) - public prevStatusId: number | null; + public prevWorkflowStatusId: number; @Field(() => Int) - public posX: number; + public nextWorkflowStatusId: number; - @Field(() => Int) - public posY: number; + @Field(() => String) + public sourceHandle: string; - @Field(() => Int, { nullable: true }) - public prevConnectionId: number | null; + @Field(() => String) + public targetHandle: string; } @Resolver(() => WorkflowConnection) @@ -75,11 +61,48 @@ export class WorkflowConnectionResolver { await context.queries.statusAction.getConnectionStatusActions( context.user, { - connectionId: workflowConnection.id, - workflowId: workflowConnection.workflowId, + workflowConnectionId: workflowConnection.id, } ); return isRejection(statusActions) ? [] : statusActions; } + + @FieldResolver(() => WorkflowStatus) + async prevStatus( + @Root() workflowConnection: WorkflowConnection, + @Ctx() context: ResolverContext + ): Promise { + const status = await context.queries.workflow.getWorkflowStatus( + context.user, + workflowConnection.prevWorkflowStatusId + ); + + if (status === null) { + throw new Error( + `Workflow status with id ${workflowConnection.prevWorkflowStatusId} not found` + ); + } + + return status; + } + + @FieldResolver(() => WorkflowStatus) + async nextStatus( + @Root() workflowConnection: WorkflowConnection, + @Ctx() context: ResolverContext + ): Promise { + const status = await context.queries.workflow.getWorkflowStatus( + context.user, + workflowConnection.nextWorkflowStatusId + ); + + if (status === null) { + throw new Error( + `Workflow status with id ${workflowConnection.nextWorkflowStatusId} not found` + ); + } + + return status; + } } diff --git a/apps/backend/src/resolvers/types/WorkflowStatus.ts b/apps/backend/src/resolvers/types/WorkflowStatus.ts new file mode 100644 index 0000000000..97a76e981e --- /dev/null +++ b/apps/backend/src/resolvers/types/WorkflowStatus.ts @@ -0,0 +1,45 @@ +import { + ObjectType, + Field, + Int, + Resolver, + FieldResolver, + Root, + Ctx, +} from 'type-graphql'; + +import { Status } from './Status'; +import { ResolverContext } from '../../context'; +import { WorkflowStatus as WorkflowStatusOrigin } from '../../models/WorkflowStatus'; + +@ObjectType() +export class WorkflowStatus implements Partial { + @Field(() => Int) + public workflowStatusId: number; + + @Field(() => Int) + public workflowId: number; + + @Field(() => String) + public statusId: string; + + @Field(() => Int) + public posX: number; + + @Field(() => Int) + public posY: number; +} + +@Resolver(() => WorkflowStatus) +export class WorkflowStatusResolver { + @FieldResolver(() => Status) + async status( + @Root() workflowStatus: WorkflowStatus, + @Ctx() context: ResolverContext + ): Promise { + return context.queries.status.getStatus( + context.user, + workflowStatus.statusId + ); + } +} diff --git a/apps/backend/src/statusActionEngine/proposal.ts b/apps/backend/src/statusActionEngine/proposal.ts index 2f8ee19f00..616d8b18a6 100644 --- a/apps/backend/src/statusActionEngine/proposal.ts +++ b/apps/backend/src/statusActionEngine/proposal.ts @@ -6,8 +6,8 @@ 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 { getWorkflowConnectionByStatusId } from '../workflowEngine/experiment'; import { WorkflowEngineProposalType } from '../workflowEngine/proposal'; export const proposalStatusActionEngine = async ( @@ -17,6 +17,10 @@ export const proposalStatusActionEngine = async ( Tokens.StatusActionsDataSource ); + const workflowDataSource: WorkflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + // NOTE: We need to group the proposals by 'workflow' and 'status' because proposals coming in here can be from different workflows/calls. const groupByProperties = ['workflowId', 'statusId']; // NOTE: Here the result is something like: [[proposalsWithWorkflowStatusIdCombination1], [proposalsWithWorkflowStatusIdCombination2]...] @@ -25,12 +29,10 @@ export const proposalStatusActionEngine = async ( Promise.all( groupResult.map(async (groupedProposals) => { // NOTE: We get the needed ids from the first proposal in the group. - const [{ workflowId, statusId, prevStatusId }] = groupedProposals; + const [{ workflowStatusConnectionId }] = groupedProposals; - const [currentConnection] = await getWorkflowConnectionByStatusId( - workflowId, - statusId, - prevStatusId + const currentConnection = await workflowDataSource.getWorkflowConnection( + workflowStatusConnectionId ); if (!currentConnection) { @@ -39,8 +41,7 @@ export const proposalStatusActionEngine = async ( const statusActions = await statusActionsDataSource.getConnectionStatusActions( - currentConnection.id, - currentConnection.workflowId + currentConnection.id ); if (!statusActions?.length) { diff --git a/apps/backend/src/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index 708731baec..c6b8f146e2 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -12,7 +12,6 @@ import { QuestionaryDataSource } from '../datasources/QuestionaryDataSource'; import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; import { TemplateDataSource } from '../datasources/TemplateDataSource'; import { UserDataSource } from '../datasources/UserDataSource'; -import { resolveApplicationEventBus } from '../events'; import { ApplicationEvent } from '../events/applicationEvents'; import { Event } from '../events/event.enum'; import { InstrumentWithManagementTime } from '../models/Instrument'; @@ -197,7 +196,7 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( const quickReviewCalls = await callDataSource .getCalls({ - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', }) .then((calls) => calls.map((call) => call.id)); if (quickReviewCalls.includes(proposal.callId)) { @@ -519,42 +518,6 @@ export const constructProposalStatusChangeEvent = ( return event; }; -export const publishProposalMessageToTheEventBus = async ( - proposal: WorkflowEngineProposalType, - messageDescription: string, - exchange?: string, - loggedInUserId?: number -) => { - const eventBus = resolveApplicationEventBus(); - const event = constructProposalStatusChangeEvent( - proposal, - loggedInUserId || null, - messageDescription, - exchange - ); - - return eventBus - .publish(event) - .catch((e) => logger.logError(`EventBus publish failed ${event.type}`, e)); -}; -export const publishMessageToTheEventBus = async ( - proposals: WorkflowEngineProposalType[], - messageDescription: string, - exchange?: string, - loggedInUserId?: number -) => { - await Promise.all( - proposals.map(async (proposal) => - publishProposalMessageToTheEventBus( - proposal, - messageDescription, - exchange, - loggedInUserId - ) - ) - ); -}; - export const statusActionLogger = (args: { connectionId: number; actionId: number; diff --git a/apps/backend/src/workflowEngine/experiment.ts b/apps/backend/src/workflowEngine/experiment.ts index 4fb3465a3d..f7b54a8da3 100644 --- a/apps/backend/src/workflowEngine/experiment.ts +++ b/apps/backend/src/workflowEngine/experiment.ts @@ -1,318 +1,179 @@ -import { container } from 'tsyringe'; +import { logger } from '@user-office-software/duo-logger'; +import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; -import { ExperimentSafetyEventsRecord } from '../datasources/postgres/records'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Event } from '../events/event.enum'; import { ExperimentSafety } from '../models/Experiment'; -import { StatusChangingEvent } from '../models/StatusChangingEvent'; -import { Workflow } from '../models/Workflow'; -import { WorkflowConnectionWithStatus } from '../models/WorkflowConnections'; +import { createWorkflowMachine } from './stateMachine/createWorkflowMachine'; +import { createActor } from './stateMachine/stateMachine'; -const getExperimentWorkflowByCallId = (callId: number) => { - const callDataSource = container.resolve( - Tokens.CallDataSource - ); +type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; - return callDataSource.getExperimentWorkflowByCall(callId); +export type WorkflowEngineExperimentType = ExperimentSafety & { + prevWorkflowStatusId: number; + workflowStatusConnectionId: number; }; -export const getWorkflowConnectionByStatusId = ( - workflowId: number, - statusId?: number, - prevStatusId?: number -) => { - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource - ); - - return workflowDataSource.getWorkflowConnectionsById(workflowId, statusId, { - prevStatusId, - }); +type WorkflowRunSingleInput = { + experimentPk: number; + currentEvent: Event; }; -const shouldMoveToNextStatus = ( - statusChangingEvents: StatusChangingEvent[], - experimentSafetyEvents: ExperimentSafetyEventsRecord -): boolean => { - const experimentSafetyEventsKeys = Object.keys(experimentSafetyEvents); - const allExperimentIncompleteEvents = experimentSafetyEventsKeys.filter( - (experimentSafetyEventsKey) => - !experimentSafetyEvents[ - experimentSafetyEventsKey as keyof ExperimentSafetyEventsRecord - ] - ); - const allNextStatusRulesFulfilled = !statusChangingEvents.some( - (statusChangingEvent) => - allExperimentIncompleteEvents.indexOf( - statusChangingEvent.statusChangingEvent.toLowerCase() - ) >= 0 - ); - - return allNextStatusRulesFulfilled; +type WorkflowRunBatchInput = { + experimentPks: number[]; + event: Event; }; -const checkIfConditionsForNextStatusAreMet = async ({ - nextWorkflowConnections, - experimentWorkflow, - workflowDataSource, - experimentSafetyWithEvents, -}: { - nextWorkflowConnections: WorkflowConnectionWithStatus[]; - experimentWorkflow: Workflow; - workflowDataSource: WorkflowDataSource; - experimentSafetyWithEvents: { - experimentPk: number; - experimentSafetyEvents?: ExperimentSafetyEventsRecord; - currentEvent: Event; - }; -}) => { - for (const nextWorkflowConnection of nextWorkflowConnections) { - if (!nextWorkflowConnection.nextStatusId) { - continue; +export type WorkflowRunInput = + | WorkflowRunSingleInput + | WorkflowRunSingleInput[] + | WorkflowRunBatchInput; + +const isBatchWorkflowInput = ( + input: WorkflowRunInput +): input is WorkflowRunBatchInput => { + return Array.isArray((input as WorkflowRunBatchInput).experimentPks); +}; + +@injectable() +export class ExperimentWorkflowEngine { + constructor( + @inject(Tokens.ExperimentDataSource) + private readonly experimentDataSource: ExperimentDataSource, + @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.experimentPks.map((experimentPk) => ({ + experimentPk, + currentEvent: input.event, + })); + } else { + normalizedInput = [input]; } - const nextNextWorkflowConnections = await getWorkflowConnectionByStatusId( - experimentWorkflow.id, - nextWorkflowConnection.nextStatusId + const experimentsWithChangedStatuses = await Promise.all( + normalizedInput.map(({ experimentPk, currentEvent }) => + this.runOne(experimentPk, currentEvent) + ) ); - const newStatusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - nextNextWorkflowConnections.map((connection) => connection.id) - ); - if (!experimentSafetyWithEvents.experimentSafetyEvents) { + const validExperiments = experimentsWithChangedStatuses.filter( + (exp): exp is WorkflowEngineExperimentType => !!exp + ); + + return validExperiments; + } + + /** + * Internal method to run the workflow engine for a single experiment and event. + */ + private async runOne( + experimentPk: number, + event: Event + ): Promise { + const experiment = + await this.experimentDataSource.getExperiment(experimentPk); + + if (!experiment) { + logger.logError('Experiment not found', { experimentPk }); + return; } - for (const sce of newStatusChangingEvents) { - const experimentSafetyEventsKeys = Object.keys( - experimentSafetyWithEvents.experimentSafetyEvents! - ); - const allExperimnentSafetiesCompleteEvents = - experimentSafetyEventsKeys.filter( - (experimentSafetyEventsKey) => - experimentSafetyWithEvents.experimentSafetyEvents![ - experimentSafetyEventsKey as keyof ExperimentSafetyEventsRecord - ] - ); + const proposal = await this.proposalDataSource.get(experiment.proposalPk); - const nextStatusRulesFulfilled = - allExperimnentSafetiesCompleteEvents.includes( - sce.statusChangingEvent.toLowerCase() - ); + if (!proposal) { + logger.logError('Proposal not found', { + proposalPk: experiment.proposalPk, + }); - if (sce.statusChangingEvent && nextStatusRulesFulfilled) - await workflowEngine([ - { - currentEvent: sce.statusChangingEvent as Event, - experimentSafetyEvents: - experimentSafetyWithEvents.experimentSafetyEvents, - experimentPk: experimentSafetyWithEvents.experimentPk, - }, - ]); + return; } - } -}; -export type WorkflowEngineExperimentType = ExperimentSafety & { - workflowId: number; - prevStatusId: number; - callShortCode: string; -}; + const experimentWorkflowId = ( + await this.callDataSource.getExperimentWorkflowByCall(proposal.callId) + )?.id; -export const workflowEngine = async ( - args: { - experimentPk: number; - experimentSafetyEvents?: ExperimentSafetyEventsRecord; - currentEvent: Event; - }[] -): Promise | void> => { - const experimentDataSource = container.resolve( - Tokens.ExperimentDataSource - ); - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - const experimentWithChangedStatuses = ( - await Promise.all( - args.map(async (experimentSafetyWithEvents) => { - const experiment = await experimentDataSource.getExperiment( - experimentSafetyWithEvents.experimentPk - ); - if (!experiment) { - throw new Error( - `Experiment with Primary Key ${experimentSafetyWithEvents.experimentPk} not found` - ); - } + if (!experimentWorkflowId) { + logger.logError('Workflow not found for experiment', { experimentPk }); - const proposal = await proposalDataSource.get(experiment.proposalPk); - if (!proposal) { - throw new Error( - `Proposal with id ${experiment.proposalPk} not found` - ); - } + return; + } - const experimentSafety = - await experimentDataSource.getExperimentSafetyByExperimentPk( - experimentSafetyWithEvents.experimentPk - ); + const experimentSafety = + await this.experimentDataSource.getExperimentSafetyByExperimentPk( + experimentPk + ); - if (!experimentSafety) { - return; - } + if (!experimentSafety) { + return; + } - const experimentWorkflow = await getExperimentWorkflowByCallId( - proposal.callId - ); + const currentWorkflowStatusId = experimentSafety.workflowStatusId; - if (!experimentWorkflow) { - return; - } - if (!experimentSafety.statusId) return; + if (!currentWorkflowStatusId) { + logger.logError('Experiment safety does not have a workflow status id', { + experimentSafetyPk: experimentSafety.experimentSafetyPk, + }); - const currentWorkflowConnections = - await getWorkflowConnectionByStatusId( - experimentWorkflow.id, - experimentSafety.statusId - ); + return; + } - if (!currentWorkflowConnections.length) { - return; - } + const machine = await createWorkflowMachine(experimentWorkflowId); - const callDataSource = container.resolve( - Tokens.CallDataSource + const currentExperimentState = Object.entries(machine.schema.states).find( + ([, state]) => { + return ( + (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === + currentWorkflowStatusId ); + } + )?.[0]; - const call = await callDataSource.getCall(proposal.callId); - - if (!call) { - return; - } - - /** - * NOTE: We can have more than one current connection because of the multi-column workflows. - * This is the way how we store the connection that has multiple next connections. - * We have multiple separate connection records pointing to each next connection. - * For example if we have status: FEASIBILITY_REVIEW which has multiple next statuses like: FAP_SELECTION and NOT_FEASIBLE. - * We store one record of FEASIBILITY_REVIEW with nextStatusId = FAP_SELECTION and another one with nextStatusId = NOT_FEASIBLE. - * We go through each record and based on the currentEvent we move the proposal into the right direction - */ - const response = await Promise.all( - currentWorkflowConnections.map(async (currentWorkflowConnection) => { - const nextWorkflowConnections = - await getWorkflowConnectionByStatusId( - experimentWorkflow.id, - undefined, - currentWorkflowConnection.statusId - ); - - return Promise.all( - nextWorkflowConnections.map(async (nextWorkflowConnection) => { - if (!experimentSafetyWithEvents.experimentSafetyEvents) { - return; - } - const workflowDataSource = - container.resolve( - Tokens.WorkflowDataSource - ); - - const statusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - [nextWorkflowConnection.id] - ); - if (!statusChangingEvents) { - return; - } - - const eventThatTriggeredStatusChangeIsStatusChangingEvent = - statusChangingEvents.find( - (statusChangingEvent) => - experimentSafetyWithEvents.currentEvent === - statusChangingEvent.statusChangingEvent - ); - - if (!eventThatTriggeredStatusChangeIsStatusChangingEvent) { - return; - } - - if ( - shouldMoveToNextStatus( - statusChangingEvents, - experimentSafetyWithEvents.experimentSafetyEvents - ) - ) { - const updatedExperimentSafety = - await experimentDataSource.updateExperimentSafetyStatus( - experimentSafety.experimentSafetyPk, - nextWorkflowConnection.statusId - ); - - if (updatedExperimentSafety) { - await checkIfConditionsForNextStatusAreMet({ - nextWorkflowConnections, - experimentWorkflow, - workflowDataSource, - experimentSafetyWithEvents, - }); - - return { - ...updatedExperimentSafety, - workflowId: experimentWorkflow.id, - prevStatusId: currentWorkflowConnection.statusId, - callShortCode: call.shortCode, - }; - } - } - }) - ); - }) - ).then((results) => results.flat()); - - return response; - }) - ) - ).flat(); - - // NOTE: Filter the undefined or null items in the array. - const filteredExperimentsWithChangedStatuses = - experimentWithChangedStatuses.filter( - (p): p is WorkflowEngineExperimentType => !!p + const actor = createActor( + machine, + { id: experimentSafety.experimentSafetyPk }, + currentExperimentState ); + const currentWorkflowStatus = actor.getState(); - return filteredExperimentsWithChangedStatuses; -}; - -export const markExperimentSafetyEventAsDoneAndCallWorkflowEngine = async ( - eventType: Event, - experimentPks: number[] -) => { - const ExperimentDataSource = container.resolve( - Tokens.ExperimentDataSource - ); - - const allExperimentSafetyEvents = - await ExperimentDataSource.markEventAsDoneOnExperimentSafeties( - eventType, - experimentPks + const { nextStateValue, connectionId } = await actor.event( + event.toUpperCase() ); - const experimentPksWithEvents = experimentPks.map((experimentPk) => { - return { - experimentPk, - experimentSafetyEvents: allExperimentSafetyEvents?.find( - (experimentSafetyEvents) => - experimentSafetyEvents.experiment_pk === experimentPk - ), - currentEvent: eventType, - }; - }); - - const updatedExperiments = await workflowEngine(experimentPksWithEvents); + if (nextStateValue !== currentWorkflowStatus) { + const meta = machine.schema.states[nextStateValue]?.meta as + | WorkflowStateMeta + | undefined; + const nextWorkflowStatusId = meta?.workflowStatusId; + + if (nextWorkflowStatusId) { + const updatedExperimentSafety = + await this.experimentDataSource.updateExperimentSafetyStatus( + experimentSafety.experimentSafetyPk, + nextWorkflowStatusId + ); - return updatedExperiments; -}; + return { + ...updatedExperimentSafety, + prevWorkflowStatusId: currentWorkflowStatusId, + workflowStatusConnectionId: connectionId, + }; + } + } + } +} diff --git a/apps/backend/src/workflowEngine/guards/isCallEndedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isCallEndedGuard.spec.ts new file mode 100644 index 0000000000..bb5c90d3d1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isCallEndedGuard } from './isCallEndedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isCallEndedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + const mockCallDataSource = { + getCall: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + if (token === Tokens.CallDataSource) return mockCallDataSource; + + return null; + }) as any; + }); + + it('returns false if proposal is not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + const result = await isCallEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if call is not found', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue(null); + + const result = await isCallEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if call is ended', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callEnded: true }); + + const result = await isCallEndedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if call is not ended', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callEnded: false }); + + const result = await isCallEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isCallEndedGuard.ts b/apps/backend/src/workflowEngine/guards/isCallEndedGuard.ts new file mode 100644 index 0000000000..b17e5e0532 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedGuard.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CallDataSource } from '../../datasources/CallDataSource'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isCallEndedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + return false; + } + + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + const call = await callDataSource.getCall(proposal.callId); + + if (!call) { + return false; + } + + return call.callEnded; +}; diff --git a/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.spec.ts new file mode 100644 index 0000000000..e1be6953e0 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isCallEndedInternalGuard } from './isCallEndedInternalGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isCallEndedInternalGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + const mockCallDataSource = { + getCall: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + if (token === Tokens.CallDataSource) return mockCallDataSource; + + return null; + }) as any; + }); + + it('returns false if proposal is not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + const result = await isCallEndedInternalGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if call is not found', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue(null); + + const result = await isCallEndedInternalGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if call ended internal is true', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callEndedInternal: true }); + + const result = await isCallEndedInternalGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if call ended internal is false', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callEndedInternal: false }); + + const result = await isCallEndedInternalGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.ts b/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.ts new file mode 100644 index 0000000000..c72cafd4a8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CallDataSource } from '../../datasources/CallDataSource'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isCallEndedInternalGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + return false; + } + + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + const call = await callDataSource.getCall(proposal.callId); + + if (!call) { + return false; + } + + return call.callEndedInternal; +}; diff --git a/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.spec.ts new file mode 100644 index 0000000000..b43337d644 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isCallFapReviewEndedGuard } from './isCallFapReviewEndedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isCallFapReviewEndedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + const mockCallDataSource = { + getCall: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + if (token === Tokens.CallDataSource) return mockCallDataSource; + + return null; + }) as any; + }); + + it('returns false if proposal is not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + const result = await isCallFapReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if call is not found', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue(null); + + const result = await isCallFapReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if call fap review ended is true', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callFapReviewEnded: true }); + + const result = await isCallFapReviewEndedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if call fap review ended is false', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callFapReviewEnded: false }); + + const result = await isCallFapReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.ts b/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.ts new file mode 100644 index 0000000000..a77579259c --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CallDataSource } from '../../datasources/CallDataSource'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isCallFapReviewEndedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + return false; + } + + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + const call = await callDataSource.getCall(proposal.callId); + + if (!call) { + return false; + } + + return call.callFapReviewEnded; +}; diff --git a/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.spec.ts new file mode 100644 index 0000000000..132e75a833 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isCallReviewEndedGuard } from './isCallReviewEndedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isCallReviewEndedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + const mockCallDataSource = { + getCall: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + if (token === Tokens.CallDataSource) return mockCallDataSource; + + return null; + }) as any; + }); + + it('returns false if proposal is not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + const result = await isCallReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if call is not found', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue(null); + + const result = await isCallReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if call review ended is true', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callReviewEnded: true }); + + const result = await isCallReviewEndedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if call review ended is false', async () => { + mockProposalDataSource.get.mockResolvedValue({ callId: 100 }); + mockCallDataSource.getCall.mockResolvedValue({ callReviewEnded: false }); + + const result = await isCallReviewEndedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.ts b/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.ts new file mode 100644 index 0000000000..51c6e65792 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { CallDataSource } from '../../datasources/CallDataSource'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isCallReviewEndedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + return false; + } + + const callDataSource = container.resolve( + Tokens.CallDataSource + ); + const call = await callDataSource.getCall(proposal.callId); + + if (!call) { + return false; + } + + return call.callReviewEnded; +}; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.spec.ts new file mode 100644 index 0000000000..c39c89abb1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFapInstrumentMeetingSubmittedForProposalGuard } from './isEveryFapInstrumentMeetingSubmittedForProposalGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isEveryFapInstrumentMeetingSubmittedForProposalGuard', () => { + const mockFapDataSource = { + getFapsByProposalPks: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no fap proposals found', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isEveryFapInstrumentMeetingSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if all fap proposals are submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: true }, + { fapInstrumentMeetingSubmitted: true }, + ]); + + const result = await isEveryFapInstrumentMeetingSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(true); + }); + + it('returns false if any fap proposal is not submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: true }, + { fapInstrumentMeetingSubmitted: false }, + ]); + + const result = await isEveryFapInstrumentMeetingSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.ts new file mode 100644 index 0000000000..9caf261e55 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedForProposalGuard.ts @@ -0,0 +1,23 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every FAP linked to the proposal has its instrument meeting submitted. + */ +export const isEveryFapInstrumentMeetingSubmittedForProposalGuard: GuardFn = + async (entity: Entity) => { + const fapDataSource = container.resolve( + Tokens.FapDataSource + ); + + const fapProposals = await fapDataSource.getFapsByProposalPks([entity.id]); + + if (fapProposals.length === 0) { + return false; + } + + return fapProposals.every((fp) => fp.fapInstrumentMeetingSubmitted); + }; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.spec.ts new file mode 100644 index 0000000000..f228c7ca7c --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFapInstrumentMeetingSubmittedGuard } from './isEveryFapInstrumentMeetingSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isEveryFapInstrumentMeetingSubmittedGuard', () => { + const mockFapDataSource = { + getFapsByProposalPks: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no fap proposals found', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isEveryFapInstrumentMeetingSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if all fap proposals are submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: true }, + { fapInstrumentMeetingSubmitted: true }, + ]); + + const result = await isEveryFapInstrumentMeetingSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if any fap proposal is not submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: true }, + { fapInstrumentMeetingSubmitted: false }, + ]); + + const result = await isEveryFapInstrumentMeetingSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.ts new file mode 100644 index 0000000000..3041285175 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapInstrumentMeetingSubmittedGuard.ts @@ -0,0 +1,24 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every FAP linked to the proposal has its instrument meeting submitted. + */ +export const isEveryFapInstrumentMeetingSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const fapDataSource = container.resolve(Tokens.FapDataSource); + + const fapProposals = await fapDataSource.getFapsByProposalPks([entity.id]); + + if (fapProposals.length === 0) { + return false; + } + + return fapProposals.every( + (fapProposal) => fapProposal.fapInstrumentMeetingSubmitted + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.spec.ts new file mode 100644 index 0000000000..0d25309a61 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFapReviewSubmittedForProposalGuard } from './isEveryFapReviewSubmittedForProposalGuard'; +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; + +describe('isEveryFapReviewSubmittedForProposalGuard', () => { + const mockReviewDataSource = { + getProposalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no reviews are found', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([]); + const result = await isEveryFapReviewSubmittedForProposalGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if all reviews are submitted', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.SUBMITTED }, + { status: ReviewStatus.SUBMITTED }, + ]); + + const result = await isEveryFapReviewSubmittedForProposalGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if any review is not submitted', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.SUBMITTED }, + { status: ReviewStatus.DRAFT }, + ]); + + const result = await isEveryFapReviewSubmittedForProposalGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.ts new file mode 100644 index 0000000000..88ae40f26d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapReviewSubmittedForProposalGuard.ts @@ -0,0 +1,25 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { ReviewStatus } from '../../models/Review'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every FAP review on the proposal has been submitted. + */ +export const isEveryFapReviewSubmittedForProposalGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const reviews = await reviewDataSource.getProposalReviews(entity.id); + + if (reviews.length === 0) { + return false; + } + + return reviews.every((review) => review.status === ReviewStatus.SUBMITTED); +}; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.spec.ts new file mode 100644 index 0000000000..c999cf2ca7 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.spec.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFapReviewerRequirementMetForProposalGuard } from './isEveryFapReviewerRequirementMetForProposalGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isEveryFapReviewerRequirementMetForProposalGuard', () => { + const mockFapDataSource = { + getFapsByProposalPk: jest.fn(), + getAllFapProposalAssignments: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no faps found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([]); + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([]); + + const result = await isEveryFapReviewerRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if reviews selected meet requirements', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 2 }, + ]); + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([ + { fapId: 1 }, + { fapId: 1 }, + ]); + + const result = await isEveryFapReviewerRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(true); + }); + + it('returns false if reviews selected do not meet requirements', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 2 }, + ]); + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([ + { fapId: 1 }, + ]); + + const result = await isEveryFapReviewerRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('handles multiple faps correctly', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 1 }, + { id: 2, numberRatingsRequired: 1 }, + ]); + // Only fap 1 has assignments + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([ + { fapId: 1 }, + ]); + + const result = await isEveryFapReviewerRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + + // Both have assignments + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([ + { fapId: 1 }, + { fapId: 2 }, + ]); + const result2 = await isEveryFapReviewerRequirementMetForProposalGuard({ + id: 1, + }); + expect(result2).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.ts new file mode 100644 index 0000000000..344c1082a9 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapReviewerRequirementMetForProposalGuard.ts @@ -0,0 +1,31 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every FAP on the proposal has enough assigned reviewers to meet its requirement. + */ +export const isEveryFapReviewerRequirementMetForProposalGuard: GuardFn = async ( + entity: Entity +) => { + const fapDataSource = container.resolve(Tokens.FapDataSource); + + const faps = await fapDataSource.getFapsByProposalPk(entity.id); + const assignments = await fapDataSource.getAllFapProposalAssignments( + entity.id + ); + + if (faps.length === 0) { + return false; + } + + return faps.every((fap) => { + const fapAssignments = assignments.filter( + (assignment) => assignment.fapId === fap.id + ); + + return fapAssignments.length >= fap.numberRatingsRequired; + }); +}; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.spec.ts new file mode 100644 index 0000000000..047d2adb52 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.spec.ts @@ -0,0 +1,96 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFapSubmittedReviewRequirementMetForProposalGuard } from './isEveryFapSubmittedReviewRequirementMetForProposalGuard'; +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; + +describe('isEveryFapSubmittedReviewRequirementMetForProposalGuard', () => { + const mockFapDataSource = { + getFapsByProposalPk: jest.fn(), + }; + const mockReviewDataSource = { + getProposalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no faps found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([]); + mockReviewDataSource.getProposalReviews.mockResolvedValue([]); + + const result = + await isEveryFapSubmittedReviewRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if reviews submitted meet requirements for all faps', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 2 }, + ]); + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { fapID: 1, status: ReviewStatus.SUBMITTED }, + { fapID: 1, status: ReviewStatus.SUBMITTED }, + ]); + + const result = + await isEveryFapSubmittedReviewRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(true); + }); + + it('returns false if reviews submitted do not meet requirements for any fap', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 2 }, + ]); + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { fapID: 1, status: ReviewStatus.SUBMITTED }, + { fapID: 1, status: ReviewStatus.DRAFT }, + ]); + + const result = + await isEveryFapSubmittedReviewRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('handles multiple faps correctly', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([ + { id: 1, numberRatingsRequired: 1 }, + { id: 2, numberRatingsRequired: 1 }, + ]); + + // FAP 1 has submitted review, FAP 2 doesn't + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { fapID: 1, status: ReviewStatus.SUBMITTED }, + ]); + const result = + await isEveryFapSubmittedReviewRequirementMetForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + + // Both have submitted reviews + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { fapID: 1, status: ReviewStatus.SUBMITTED }, + { fapID: 2, status: ReviewStatus.SUBMITTED }, + ]); + const result2 = + await isEveryFapSubmittedReviewRequirementMetForProposalGuard({ + id: 1, + }); + expect(result2).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.ts new file mode 100644 index 0000000000..5eb1ce7954 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFapSubmittedReviewRequirementMetForProposalGuard.ts @@ -0,0 +1,37 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { ReviewStatus } from '../../models/Review'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every FAP on the proposal has enough submitted reviews to satisfy its requirement. + */ +export const isEveryFapSubmittedReviewRequirementMetForProposalGuard: GuardFn = + async (entity: Entity) => { + const fapDataSource = container.resolve( + Tokens.FapDataSource + ); + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const faps = await fapDataSource.getFapsByProposalPk(entity.id); + const reviews = await reviewDataSource.getProposalReviews(entity.id); + + if (faps.length === 0) { + return false; + } + + return faps.every((fap) => { + const fapReviews = reviews.filter((review) => review.fapID === fap.id); + + const submittedReviews = fapReviews.filter( + (review) => review.status === ReviewStatus.SUBMITTED + ); + + return submittedReviews.length >= fap.numberRatingsRequired; + }); + }; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.spec.ts new file mode 100644 index 0000000000..ace081316a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.spec.ts @@ -0,0 +1,59 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFeasibilityReviewFeasibleForProposalGuard } from './isEveryFeasibilityReviewFeasibleForProposalGuard'; +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; + +describe('isEveryFeasibilityReviewFeasibleForProposalGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no technical reviews found', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isEveryFeasibilityReviewFeasibleForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + + mockReviewDataSource.getTechnicalReviews.mockResolvedValue(null); + const resultNull = await isEveryFeasibilityReviewFeasibleForProposalGuard({ + id: 1, + }); + expect(resultNull).toBe(false); + }); + + it('returns true if all technical reviews are feasible', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.FEASIBLE }, + { status: TechnicalReviewStatus.FEASIBLE }, + ]); + + const result = await isEveryFeasibilityReviewFeasibleForProposalGuard({ + id: 1, + }); + expect(result).toBe(true); + }); + + it('returns false if any technical review is not feasible', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.FEASIBLE }, + { status: TechnicalReviewStatus.UNFEASIBLE }, + ]); + + const result = await isEveryFeasibilityReviewFeasibleForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.ts new file mode 100644 index 0000000000..4e61df627a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewFeasibleForProposalGuard.ts @@ -0,0 +1,30 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every feasibility review for the proposal is marked feasible. + */ +export const isEveryFeasibilityReviewFeasibleForProposalGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.every( + (technicalReview) => + technicalReview.status === TechnicalReviewStatus.FEASIBLE + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.spec.ts new file mode 100644 index 0000000000..2acdefabb4 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isEveryFeasibilityReviewSubmittedForProposalGuard } from './isEveryFeasibilityReviewSubmittedForProposalGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isEveryFeasibilityReviewSubmittedForProposalGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as typeof container.resolve; + }); + + it('returns false if no technical reviews found', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isEveryFeasibilityReviewSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if all technical reviews are submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: true }, + { submitted: true }, + ]); + + const result = await isEveryFeasibilityReviewSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(true); + }); + + it('returns false if any technical review is not submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: true }, + { submitted: false }, + ]); + + const result = await isEveryFeasibilityReviewSubmittedForProposalGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.ts b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.ts new file mode 100644 index 0000000000..fc1f420bbc --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isEveryFeasibilityReviewSubmittedForProposalGuard.ts @@ -0,0 +1,27 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +/** + * Returns true when every feasibility review for the proposal has been submitted. + */ +export const isEveryFeasibilityReviewSubmittedForProposalGuard: GuardFn = + async (entity: Entity) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.every( + (technicalReview) => technicalReview.submitted + ); + }; diff --git a/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts new file mode 100644 index 0000000000..f5dcde41af --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalAcceptedGuard } from './isProposalAcceptedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; + +describe('isProposalAcceptedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('returns false if proposal is not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + const result = await isProposalAcceptedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if proposal status is ACCEPTED and management decision submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.ACCEPTED, + managementDecisionSubmitted: true, + }); + + const result = await isProposalAcceptedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if proposal status is not ACCEPTED', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.REJECTED, + managementDecisionSubmitted: true, + }); + + const result = await isProposalAcceptedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if management decision is not submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.ACCEPTED, + managementDecisionSubmitted: false, + }); + + const result = await isProposalAcceptedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.ts new file mode 100644 index 0000000000..21c4efdbb8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.ts @@ -0,0 +1,22 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalAcceptedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + return false; + } + + return ( + proposal.finalStatus === ProposalEndStatus.ACCEPTED && + proposal.managementDecisionSubmitted + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..6ec48f8296 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalAllReviewsSubmittedGuard } from './isProposalAllReviewsSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; + +describe('isProposalAllReviewsSubmittedGuard', () => { + const mockReviewDataSource = { + getProposalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no reviews are found', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([]); + const result = await isProposalAllReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if all reviews are submitted', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.SUBMITTED }, + { status: ReviewStatus.SUBMITTED }, + ]); + + const result = await isProposalAllReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); + + it('returns false if any review is not submitted', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.SUBMITTED }, + { status: ReviewStatus.DRAFT }, + ]); + + const result = await isProposalAllReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.ts new file mode 100644 index 0000000000..290c86cfcd --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.ts @@ -0,0 +1,22 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { ReviewStatus } from '../../models/Review'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalAllReviewsSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const reviews = await reviewDataSource.getProposalReviews(entity.id); + + if (reviews.length === 0) { + return false; + } + + return reviews.every((review) => review.status === ReviewStatus.SUBMITTED); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.spec.ts new file mode 100644 index 0000000000..fe0aecc970 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.spec.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalAssignedToTechniquesGuard } from './isProposalAssignedToTechniquesGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalAssignedToTechniquesGuard', () => { + const mockTechniqueDataSource = { + getTechniquesByProposalPk: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.TechniqueDataSource) return mockTechniqueDataSource; + + return null; + }) as any; + }); + + it('returns false if no techniques found for proposal', async () => { + mockTechniqueDataSource.getTechniquesByProposalPk.mockResolvedValue([]); + const result = await isProposalAssignedToTechniquesGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if techniques found for proposal', async () => { + mockTechniqueDataSource.getTechniquesByProposalPk.mockResolvedValue([ + { id: 1 }, + ]); + const result = await isProposalAssignedToTechniquesGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.ts new file mode 100644 index 0000000000..cb8cc55d61 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.ts @@ -0,0 +1,19 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { TechniqueDataSource } from '../../datasources/TechniqueDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalAssignedToTechniquesGuard: GuardFn = async ( + entity: Entity +) => { + const techniqueDataSource = container.resolve( + Tokens.TechniqueDataSource + ); + + const techniques = await techniqueDataSource.getTechniquesByProposalPk( + entity.id + ); + + return techniques.length > 0; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.spec.ts new file mode 100644 index 0000000000..60fc6105cd --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalBookingTimeActivatedGuard } from './isProposalBookingTimeActivatedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ExperimentStatus } from '../../models/Experiment'; + +describe('isProposalBookingTimeActivatedGuard', () => { + const mockExperimentDataSource = { + getExperimentsByProposalPk: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if no experiments found for proposal', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([]); + const result = await isProposalBookingTimeActivatedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no experiment is ACTIVE', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([ + { status: ExperimentStatus.COMPLETED }, + { status: ExperimentStatus.DRAFT }, + ]); + const result = await isProposalBookingTimeActivatedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one experiment is ACTIVE', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([ + { status: ExperimentStatus.COMPLETED }, + { status: ExperimentStatus.ACTIVE }, + ]); + const result = await isProposalBookingTimeActivatedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.ts new file mode 100644 index 0000000000..9d3dd7e58f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.ts @@ -0,0 +1,26 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { ExperimentStatus } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalBookingTimeActivatedGuard: GuardFn = async ( + entity: Entity +) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experiments = await experimentDataSource.getExperimentsByProposalPk( + entity.id + ); + + if (experiments.length === 0) { + return false; + } + + return experiments.some( + (experiment) => experiment.status === ExperimentStatus.ACTIVE + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.spec.ts new file mode 100644 index 0000000000..42d6eb69b9 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalBookingTimeCompletedGuard } from './isProposalBookingTimeCompletedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ExperimentStatus } from '../../models/Experiment'; + +describe('isProposalBookingTimeCompletedGuard', () => { + const mockExperimentDataSource = { + getExperimentsByProposalPk: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ExperimentDataSource) + return mockExperimentDataSource; + + return null; + }) as any; + }); + + it('returns false if no experiments found for proposal', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([]); + const result = await isProposalBookingTimeCompletedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if any experiment is NOT COMPLETED', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([ + { status: ExperimentStatus.COMPLETED }, + { status: ExperimentStatus.ACTIVE }, + ]); + const result = await isProposalBookingTimeCompletedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if all experiments are COMPLETED', async () => { + mockExperimentDataSource.getExperimentsByProposalPk.mockResolvedValue([ + { status: ExperimentStatus.COMPLETED }, + { status: ExperimentStatus.COMPLETED }, + ]); + const result = await isProposalBookingTimeCompletedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.ts new file mode 100644 index 0000000000..9513c06ae7 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.ts @@ -0,0 +1,26 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentDataSource } from '../../datasources/ExperimentDataSource'; +import { ExperimentStatus } from '../../models/Experiment'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalBookingTimeCompletedGuard: GuardFn = async ( + entity: Entity +) => { + const experimentDataSource = container.resolve( + Tokens.ExperimentDataSource + ); + + const experiments = await experimentDataSource.getExperimentsByProposalPk( + entity.id + ); + + if (experiments.length === 0) { + return false; + } + + return experiments.every( + (experiment) => experiment.status === ExperimentStatus.COMPLETED + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.spec.ts new file mode 100644 index 0000000000..4b07f35fda --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFapMeetingInstrumentSubmittedGuard } from './isProposalFapMeetingInstrumentSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalFapMeetingInstrumentSubmittedGuard', () => { + const mockFapDataSource = { + getFapsByProposalPks: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as any; + }); + + it('returns false if no FAPS found for proposal', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isProposalFapMeetingInstrumentSubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns false if no FAP meeting instrument is submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: false }, + { fapInstrumentMeetingSubmitted: false }, + ]); + const result = await isProposalFapMeetingInstrumentSubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if at least one FAP meeting instrument is submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: false }, + { fapInstrumentMeetingSubmitted: true }, + ]); + const result = await isProposalFapMeetingInstrumentSubmittedGuard({ + id: 1, + }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.ts new file mode 100644 index 0000000000..9b8d258bac --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.ts @@ -0,0 +1,19 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFapMeetingInstrumentSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const fapDataSource = container.resolve(Tokens.FapDataSource); + + const fapProposals = await fapDataSource.getFapsByProposalPks([entity.id]); + + if (fapProposals.length === 0) { + return false; + } + + return fapProposals.some((fp) => fp.fapInstrumentMeetingSubmitted); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.spec.ts new file mode 100644 index 0000000000..c593d3856a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFapMeetingInstrumentUnsubmittedGuard } from './isProposalFapMeetingInstrumentUnsubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalFapMeetingInstrumentUnsubmittedGuard', () => { + const mockFapDataSource = { + getFapsByProposalPks: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as any; + }); + + it('returns false if no FAPS found for proposal', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isProposalFapMeetingInstrumentUnsubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns false if any FAP meeting instrument is submitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: false }, + { fapInstrumentMeetingSubmitted: true }, + ]); + const result = await isProposalFapMeetingInstrumentUnsubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); + + it('returns true if all FAP meeting instruments are unsubmitted', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([ + { fapInstrumentMeetingSubmitted: false }, + { fapInstrumentMeetingSubmitted: false }, + ]); + const result = await isProposalFapMeetingInstrumentUnsubmittedGuard({ + id: 1, + }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.ts new file mode 100644 index 0000000000..2941115015 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.ts @@ -0,0 +1,19 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFapMeetingInstrumentUnsubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const fapDataSource = container.resolve(Tokens.FapDataSource); + + const fapProposals = await fapDataSource.getFapsByProposalPks([entity.id]); + + if (fapProposals.length === 0) { + return false; + } + + return fapProposals.every((fp) => !fp.fapInstrumentMeetingSubmitted); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.spec.ts new file mode 100644 index 0000000000..8d7f4b2809 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFapReviewSubmittedGuard } from './isProposalFapReviewSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; + +describe('isProposalFapReviewSubmittedGuard', () => { + const mockReviewDataSource = { + getProposalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no reviews found for proposal', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([]); + const result = await isProposalFapReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no review is SUBMITTED', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.DRAFT }, + { status: ReviewStatus.DRAFT }, + ]); + const result = await isProposalFapReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one review is SUBMITTED', async () => { + mockReviewDataSource.getProposalReviews.mockResolvedValue([ + { status: ReviewStatus.DRAFT }, + { status: ReviewStatus.SUBMITTED }, + ]); + const result = await isProposalFapReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.ts new file mode 100644 index 0000000000..4b3cead49f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.ts @@ -0,0 +1,22 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { ReviewStatus } from '../../models/Review'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFapReviewSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const reviews = await reviewDataSource.getProposalReviews(entity.id); + + if (reviews.length === 0) { + return false; + } + + return reviews.some((review) => review.status === ReviewStatus.SUBMITTED); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.spec.ts new file mode 100644 index 0000000000..c3ca66c4d1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.spec.ts @@ -0,0 +1,32 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFapsSelectedGuard } from './isProposalFapsSelectedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalFapsSelectedGuard', () => { + const mockFapDataSource = { + getFapsByProposalPk: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.FapDataSource) return mockFapDataSource; + + return null; + }) as any; + }); + + it('returns false if no FAPS found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([]); + const result = await isProposalFapsSelectedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if FAPS are found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([{ id: 1 }]); + const result = await isProposalFapsSelectedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.ts new file mode 100644 index 0000000000..9148d0a45f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.ts @@ -0,0 +1,13 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFapsSelectedGuard: GuardFn = async (entity: Entity) => { + const fapDataSource = container.resolve(Tokens.FapDataSource); + + const faps = await fapDataSource.getFapsByProposalPk(entity.id); + + return faps.length > 0; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.spec.ts new file mode 100644 index 0000000000..2ef6131b9c --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFeasibilityReviewFeasibleGuard } from './isProposalFeasibilityReviewFeasibleGuard'; +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; + +describe('isProposalFeasibilityReviewFeasibleGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no technical reviews found for proposal', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isProposalFeasibilityReviewFeasibleGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no technical review is FEASIBLE', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.UNFEASIBLE }, + ]); + const result = await isProposalFeasibilityReviewFeasibleGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one technical review is FEASIBLE', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.UNFEASIBLE }, + { status: TechnicalReviewStatus.FEASIBLE }, + ]); + const result = await isProposalFeasibilityReviewFeasibleGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.ts new file mode 100644 index 0000000000..43a208e25d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.ts @@ -0,0 +1,27 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFeasibilityReviewFeasibleGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.some( + (technicalReview) => + technicalReview.status === TechnicalReviewStatus.FEASIBLE + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.spec.ts new file mode 100644 index 0000000000..019fde93d6 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFeasibilityReviewSubmittedGuard } from './isProposalFeasibilityReviewSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalFeasibilityReviewSubmittedGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no technical reviews found', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isProposalFeasibilityReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no technical review is submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: false }, + ]); + const result = await isProposalFeasibilityReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one technical review is submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: false }, + { submitted: true }, + ]); + const result = await isProposalFeasibilityReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.ts new file mode 100644 index 0000000000..b68c5c3e98 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.ts @@ -0,0 +1,23 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFeasibilityReviewSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.some((technicalReview) => technicalReview.submitted); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.spec.ts new file mode 100644 index 0000000000..b538adf3d2 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalFeasibilityReviewUnfeasibleGuard } from './isProposalFeasibilityReviewUnfeasibleGuard'; +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; + +describe('isProposalFeasibilityReviewUnfeasibleGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no technical reviews found', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isProposalFeasibilityReviewUnfeasibleGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no technical review is UNFEASIBLE', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.FEASIBLE }, + ]); + const result = await isProposalFeasibilityReviewUnfeasibleGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one technical review is UNFEASIBLE', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { status: TechnicalReviewStatus.FEASIBLE }, + { status: TechnicalReviewStatus.UNFEASIBLE }, + ]); + const result = await isProposalFeasibilityReviewUnfeasibleGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.ts new file mode 100644 index 0000000000..a3d574f874 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.ts @@ -0,0 +1,27 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalFeasibilityReviewUnfeasibleGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.some( + (technicalReview) => + technicalReview.status === TechnicalReviewStatus.UNFEASIBLE + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.spec.ts new file mode 100644 index 0000000000..4a463d5857 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.spec.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalInstrumentsSelectedGuard } from './isProposalInstrumentsSelectedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalInstrumentsSelectedGuard', () => { + const mockInstrumentDataSource = { + getInstrumentsByProposalPk: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.InstrumentDataSource) + return mockInstrumentDataSource; + + return null; + }) as any; + }); + + it('returns false if no instruments selected', async () => { + mockInstrumentDataSource.getInstrumentsByProposalPk.mockResolvedValue([]); + const result = await isProposalInstrumentsSelectedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if instruments selected', async () => { + mockInstrumentDataSource.getInstrumentsByProposalPk.mockResolvedValue([ + { id: 1 }, + ]); + const result = await isProposalInstrumentsSelectedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts new file mode 100644 index 0000000000..bb96968f66 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts @@ -0,0 +1,17 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { InstrumentDataSource } from '../../datasources/InstrumentDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalInstrumentsSelectedGuard: GuardFn = async ( + entity: Entity +) => { + const instrumentDs = container.resolve( + Tokens.InstrumentDataSource + ); + + const instruments = await instrumentDs.getInstrumentsByProposalPk(entity.id); + + return instruments.length > 0; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts new file mode 100644 index 0000000000..f79cc76936 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalManagementDecisionSubmittedGuard } from './isProposalManagementDecisionSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalManagementDecisionSubmittedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('throws error if proposal not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + await expect( + isProposalManagementDecisionSubmittedGuard({ id: 1 }) + ).rejects.toThrow('Proposal with pk 1 not found'); + }); + + it('returns false if management decision not submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ + managementDecisionSubmitted: false, + }); + const result = await isProposalManagementDecisionSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if management decision submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ + managementDecisionSubmitted: true, + }); + const result = await isProposalManagementDecisionSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.ts new file mode 100644 index 0000000000..d68c12e34d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalManagementDecisionSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + throw new Error(`Proposal with pk ${entity.id} not found`); + } + + return proposal.managementDecisionSubmitted; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.spec.ts new file mode 100644 index 0000000000..47cca2f58a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalNotifiedGuard } from './isProposalNotifiedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalNotifiedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('throws error if proposal not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + await expect(isProposalNotifiedGuard({ id: 1 })).rejects.toThrow( + 'Proposal with pk 1 not found' + ); + }); + + it('returns false if proposal not notified', async () => { + mockProposalDataSource.get.mockResolvedValue({ notified: false }); + const result = await isProposalNotifiedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if proposal notified', async () => { + mockProposalDataSource.get.mockResolvedValue({ notified: true }); + const result = await isProposalNotifiedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.ts new file mode 100644 index 0000000000..1be4602e12 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.ts @@ -0,0 +1,19 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalNotifiedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + throw new Error(`Proposal with pk ${entity.id} not found`); + } + + return proposal.notified; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.spec.ts new file mode 100644 index 0000000000..b506503953 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalRejectedGuard } from './isProposalRejectedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; + +describe('isProposalRejectedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('throws error if proposal not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + await expect(isProposalRejectedGuard({ id: 1 })).rejects.toThrow( + 'Proposal with pk 1 not found' + ); + }); + + it('returns false if proposal not REJECTED', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.ACCEPTED, + }); + const result = await isProposalRejectedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if proposal REJECTED', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.REJECTED, + }); + const result = await isProposalRejectedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.ts new file mode 100644 index 0000000000..560d03cfb6 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.ts @@ -0,0 +1,20 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalRejectedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + throw new Error(`Proposal with pk ${entity.id} not found`); + } + + return proposal.finalStatus === ProposalEndStatus.REJECTED; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.spec.ts new file mode 100644 index 0000000000..6f5bbdaa11 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalReservedGuard } from './isProposalReservedGuard'; +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; + +describe('isProposalReservedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('throws error if proposal not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + await expect(isProposalReservedGuard({ id: 1 })).rejects.toThrow( + 'Proposal with pk 1 not found' + ); + }); + + it('returns false if proposal not RESERVED', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.ACCEPTED, + }); + const result = await isProposalReservedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if proposal RESERVED', async () => { + mockProposalDataSource.get.mockResolvedValue({ + finalStatus: ProposalEndStatus.RESERVED, + }); + const result = await isProposalReservedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.ts new file mode 100644 index 0000000000..e3f0cad8cc --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.ts @@ -0,0 +1,20 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalReservedGuard: GuardFn = async (entity: Entity) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(entity.id); + + if (!proposal) { + throw new Error(`Proposal with pk ${entity.id} not found`); + } + + return proposal.finalStatus === ProposalEndStatus.RESERVED; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.spec.ts new file mode 100644 index 0000000000..d36f2cbfd3 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalSampleReviewSubmittedGuard } from './isProposalSampleReviewSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; +import { SampleStatus } from '../../models/Sample'; + +describe('isProposalSampleReviewSubmittedGuard', () => { + const mockSampleDataSource = { + getSamples: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.SampleDataSource) return mockSampleDataSource; + + return null; + }) as any; + }); + + it('returns false if no samples found', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([]); + const result = await isProposalSampleReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no sample has submitted safety reviews', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([ + { safetyStatus: SampleStatus.PENDING_EVALUATION }, + ]); + const result = await isProposalSampleReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one sample has submitted safety review', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([ + { safetyStatus: SampleStatus.PENDING_EVALUATION }, + { safetyStatus: SampleStatus.LOW_RISK }, + ]); + const result = await isProposalSampleReviewSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.ts new file mode 100644 index 0000000000..45768838b7 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.ts @@ -0,0 +1,26 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { SampleDataSource } from '../../datasources/SampleDataSource'; +import { SampleStatus } from '../../models/Sample'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalSampleReviewSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const sampleDataSource = container.resolve( + Tokens.SampleDataSource + ); + + const samples = await sampleDataSource.getSamples({ + filter: { proposalPk: entity.id }, + }); + + if (samples.length === 0) { + return false; + } + + return samples.some( + (sample) => sample.safetyStatus !== SampleStatus.PENDING_EVALUATION + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.spec.ts new file mode 100644 index 0000000000..fce94b2c19 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalSampleSafeGuard } from './isProposalSampleSafeGuard'; +import { Tokens } from '../../config/Tokens'; +import { SampleStatus } from '../../models/Sample'; + +describe('isProposalSampleSafeGuard', () => { + const mockSampleDataSource = { + getSamples: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.SampleDataSource) return mockSampleDataSource; + + return null; + }) as any; + }); + + it('returns false if no samples found', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([]); + const result = await isProposalSampleSafeGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if no sample is LOW_RISK', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([ + { safetyStatus: SampleStatus.HIGH_RISK }, + ]); + const result = await isProposalSampleSafeGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if at least one sample is LOW_RISK', async () => { + mockSampleDataSource.getSamples.mockResolvedValue([ + { safetyStatus: SampleStatus.HIGH_RISK }, + { safetyStatus: SampleStatus.LOW_RISK }, + ]); + const result = await isProposalSampleSafeGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.ts new file mode 100644 index 0000000000..0dce028208 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.ts @@ -0,0 +1,24 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { SampleDataSource } from '../../datasources/SampleDataSource'; +import { SampleStatus } from '../../models/Sample'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalSampleSafeGuard: GuardFn = async (entity: Entity) => { + const sampleDataSource = container.resolve( + Tokens.SampleDataSource + ); + + const samples = await sampleDataSource.getSamples({ + filter: { proposalPk: entity.id }, + }); + + if (samples.length === 0) { + return false; + } + + return samples.some( + (sample) => sample.safetyStatus === SampleStatus.LOW_RISK + ); +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.spec.ts new file mode 100644 index 0000000000..04a791cbea --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalSubmittedGuard } from './isProposalSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalSubmittedGuard', () => { + const mockProposalDataSource = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ProposalDataSource) return mockProposalDataSource; + + return null; + }) as any; + }); + + it('throws error if proposal not found', async () => { + mockProposalDataSource.get.mockResolvedValue(null); + await expect(isProposalSubmittedGuard({ id: 1 })).rejects.toThrow( + 'Proposal with pk 1 not found' + ); + }); + + it('returns false if proposal not submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ submitted: false }); + const result = await isProposalSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if proposal submitted', async () => { + mockProposalDataSource.get.mockResolvedValue({ submitted: true }); + const result = await isProposalSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts new file mode 100644 index 0000000000..7182ccbaa6 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalSubmittedGuard: GuardFn = async ( + entity: Entity +): Promise => { + const proposalDs = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDs.get(entity.id); + + if (!proposal) { + throw new Error(`Proposal with pk ${entity.id} not found`); + } + + return proposal.submitted; +}; diff --git a/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..1a801e40c0 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { isProposalTechnicalReviewsSubmittedGuard } from './isProposalTechnicalReviewsSubmittedGuard'; +import { Tokens } from '../../config/Tokens'; + +describe('isProposalTechnicalReviewsSubmittedGuard', () => { + const mockReviewDataSource = { + getTechnicalReviews: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + container.resolve = jest.fn((token) => { + if (token === Tokens.ReviewDataSource) return mockReviewDataSource; + + return null; + }) as any; + }); + + it('returns false if no technical reviews found', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([]); + const result = await isProposalTechnicalReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns false if any technical review is not submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: true }, + { submitted: false }, + ]); + const result = await isProposalTechnicalReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); + + it('returns true if all technical reviews are submitted', async () => { + mockReviewDataSource.getTechnicalReviews.mockResolvedValue([ + { submitted: true }, + { submitted: true }, + ]); + const result = await isProposalTechnicalReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.ts new file mode 100644 index 0000000000..e5e7a7a853 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.ts @@ -0,0 +1,23 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { Entity, GuardFn } from '../stateMachine/stateMachine'; + +export const isProposalTechnicalReviewsSubmittedGuard: GuardFn = async ( + entity: Entity +) => { + const reviewDataSource = container.resolve( + Tokens.ReviewDataSource + ); + + const technicalReviews = await reviewDataSource.getTechnicalReviews( + entity.id + ); + + if (!technicalReviews || technicalReviews.length === 0) { + return false; + } + + return technicalReviews.every((tr) => tr.submitted); +}; diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 105607f331..38f09fd4d4 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,311 +1,155 @@ import { logger } from '@user-office-software/duo-logger'; -import { container } from 'tsyringe'; +import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; -import { ProposalEventsRecord } from '../datasources/postgres/records'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Event } from '../events/event.enum'; import { Proposal } from '../models/Proposal'; -import { StatusChangingEvent } from '../models/StatusChangingEvent'; -import { Workflow } from '../models/Workflow'; -import { WorkflowConnectionWithStatus } from '../models/WorkflowConnections'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; +import { createWorkflowMachine } from './stateMachine/createWorkflowMachine'; +import { createActor } from './stateMachine/stateMachine'; -const getProposalWorkflowByCallId = (callId: number) => { - const callDataSource = container.resolve( - Tokens.CallDataSource - ); +type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; - return callDataSource.getProposalWorkflowByCall(callId); +export type WorkflowEngineProposalType = Proposal & { + prevStatusId: number; + workflowStatusConnectionId: number; }; -export const getProposalWorkflowConnectionByStatusId = ( - workflowId: number, - statusId?: number, - prevStatusId?: number -) => { - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource - ); - - return workflowDataSource.getWorkflowConnectionsById(workflowId, statusId, { - prevStatusId, - }); +type WorkflowRunSingleInput = { + proposalPk: number; + currentEvent: Event; }; -const shouldMoveToNextStatus = ( - statusChangingEvents: StatusChangingEvent[], - proposalEvents: ProposalEventsRecord -): boolean => { - const proposalEventsKeys = Object.keys(proposalEvents); - const allProposalIncompleteEvents = proposalEventsKeys.filter( - (proposalEventsKey) => - !proposalEvents[proposalEventsKey as keyof ProposalEventsRecord] - ); +type WorkflowRunBatchInput = { + proposalPks: number[]; + event: Event; +}; - const allNextStatusRulesFulfilled = !statusChangingEvents.some( - (statusChangingEvent) => - allProposalIncompleteEvents.indexOf( - statusChangingEvent.statusChangingEvent.toLowerCase() - ) >= 0 - ); +export type WorkflowRunInput = + | WorkflowRunSingleInput + | WorkflowRunSingleInput[] + | WorkflowRunBatchInput; - return allNextStatusRulesFulfilled; +const isBatchWorkflowInput = ( + input: WorkflowRunInput +): input is WorkflowRunBatchInput => { + return Array.isArray((input as WorkflowRunBatchInput).proposalPks); }; -const checkIfConditionsForNextStatusAreMet = async ({ - nextWorkflowConnections, - proposalWorkflow, - workflowDataSource, - proposalWithEvents, -}: { - nextWorkflowConnections: WorkflowConnectionWithStatus[]; - proposalWorkflow: Workflow; - workflowDataSource: WorkflowDataSource; - proposalWithEvents: { - proposalPk: number; - proposalEvents?: ProposalEventsRecord; - currentEvent: Event; - }; -}) => { - for (const nextWorkflowConnection of nextWorkflowConnections) { - if (!nextWorkflowConnection.nextStatusId) { - continue; +@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 nextNextWorkflowConnections = - await getProposalWorkflowConnectionByStatusId( - proposalWorkflow.id, - nextWorkflowConnection.nextStatusId - ); - const newStatusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - nextNextWorkflowConnections.map((connection) => connection.id) - ); + const proposalsWithChangedStatuses = await Promise.all( + normalizedInput.map(({ proposalPk, currentEvent }) => + this.runOne(proposalPk, currentEvent) + ) + ); - if (!proposalWithEvents.proposalEvents) { - return; - } + const validProposals = proposalsWithChangedStatuses.filter( + (p): p is WorkflowEngineProposalType => !!p + ); - for (const sce of newStatusChangingEvents) { - const proposalEventsKeys = Object.keys( - proposalWithEvents.proposalEvents! - ); - const allProposalCompleteEvents = proposalEventsKeys.filter( - (proposalEventsKey) => - proposalWithEvents.proposalEvents![ - proposalEventsKey as keyof ProposalEventsRecord - ] - ); + if (validProposals.length > 0) { + await proposalStatusActionEngine(validProposals); + } - const nextStatusRulesFulfilled = allProposalCompleteEvents.includes( - sce.statusChangingEvent.toLowerCase() - ); + return validProposals; + } - if (sce.statusChangingEvent && nextStatusRulesFulfilled) - await workflowEngine([ - { - currentEvent: sce.statusChangingEvent as Event, - proposalEvents: proposalWithEvents.proposalEvents, - proposalPk: proposalWithEvents.proposalPk, - }, - ]); + /** + * 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; } - } -}; -export type WorkflowEngineProposalType = Proposal & { - workflowId: number; - prevStatusId: number; - callShortCode: string; -}; + const proposal = await this.proposalDataSource.get(proposalPk); -export const workflowEngine = async ( - args: { - proposalPk: number; - proposalEvents?: ProposalEventsRecord; - currentEvent: Event; - }[] -): Promise | void> => { - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - const proposalsWithChangedStatuses = ( - await Promise.all( - args.map(async (proposalWithEvents) => { - const proposal = await proposalDataSource.get( - proposalWithEvents.proposalPk - ); + if (!proposal) { + logger.logError('Proposal not found', { proposalPk }); - if (!proposal) { - throw new Error( - `Proposal with id ${proposalWithEvents.proposalPk} not found` - ); - } + return; + } - const proposalWorkflow = await getProposalWorkflowByCallId( - proposal.callId - ); + const proposalWorkflowId = ( + await this.callDataSource.getProposalWorkflowByCall(proposal.callId) + )?.id; - if (!proposalWorkflow) { - return; - } + if (!proposalWorkflowId) { + logger.logError('Workflow not found for proposal', { proposalPk }); - const currentWorkflowConnections = - await getProposalWorkflowConnectionByStatusId( - proposalWorkflow.id, - proposal.statusId - ); + return; + } - if (!currentWorkflowConnections.length) { - return; - } + const machine = await createWorkflowMachine(proposalWorkflowId); - const callDataSource = container.resolve( - Tokens.CallDataSource + 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 call = await callDataSource.getCall(proposal.callId); - - if (!call) { - return; - } - - /** - * NOTE: We can have more than one current connection because of the multi-column workflows. - * This is the way how we store the connection that has multiple next connections. - * We have multiple separate connection records pointing to each next connection. - * For example if we have status: FEASIBILITY_REVIEW which has multiple next statuses like: FAP_SELECTION and NOT_FEASIBLE. - * We store one record of FEASIBILITY_REVIEW with nextStatusId = FAP_SELECTION and another one with nextStatusId = NOT_FEASIBLE. - * We go through each record and based on the currentEvent we move the proposal into the right direction - */ - - const response = await Promise.all( - currentWorkflowConnections.map(async (currentWorkflowConnection) => { - const nextWorkflowConnections = - await getProposalWorkflowConnectionByStatusId( - proposalWorkflow.id, - undefined, - currentWorkflowConnection.statusId - ); - - return Promise.all( - nextWorkflowConnections.map(async (nextWorkflowConnection) => { - if (!proposalWithEvents.proposalEvents) { - return; - } - const workflowDataSource = - container.resolve( - Tokens.WorkflowDataSource - ); - - const statusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - [nextWorkflowConnection.id] - ); - - if (!statusChangingEvents) { - return; - } - - const eventThatTriggeredStatusChangeIsStatusChangingEvent = - statusChangingEvents.find( - (statusChangingEvent) => - proposalWithEvents.currentEvent === - statusChangingEvent.statusChangingEvent - ); - - if (!eventThatTriggeredStatusChangeIsStatusChangingEvent) { - return; - } - - if ( - shouldMoveToNextStatus( - statusChangingEvents, - proposalWithEvents.proposalEvents - ) - ) { - const updatedProposal = - await proposalDataSource.updateProposalStatus( - proposalWithEvents.proposalPk, - nextWorkflowConnection.statusId - ); - - if (updatedProposal) { - await checkIfConditionsForNextStatusAreMet({ - nextWorkflowConnections, - proposalWorkflow, - workflowDataSource, - proposalWithEvents, - }); - - return { - ...updatedProposal, - workflowId: proposalWorkflow.id, - prevStatusId: currentWorkflowConnection.statusId, - callShortCode: call.shortCode, - }; - } - } - }) - ); - }) - ).then((results) => results.flat()); - - return response; - }) - ) - ).flat(); - - // NOTE: Filter the undefined or null items in the array. - const filteredProposalsWithChangedStatuses = - proposalsWithChangedStatuses.filter( - (p): p is WorkflowEngineProposalType => !!p + const actor = createActor( + machine, + { id: proposal.primaryKey }, + currentProposalState ); - // NOTE: Call the actions engine here - if (filteredProposalsWithChangedStatuses.length) { - proposalStatusActionEngine(filteredProposalsWithChangedStatuses); - } - - return filteredProposalsWithChangedStatuses; -}; - -export const markProposalsEventAsDoneAndCallWorkflowEngine = async ( - eventType: Event, - proposalPks: number[] -) => { - if (eventType === Event.PROPOSAL_DELETED) { - logger.logInfo( - `${eventType} event triggered and workflow engine cannot continue because the referenced proposal/s are removed`, - { proposalPks } - ); - - return; - } - - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); + const { nextStateValue, connectionId, transitionPerformed } = + await actor.event(event.toUpperCase()); - const allProposalEvents = await proposalDataSource.markEventAsDoneOnProposals( - eventType, - proposalPks - ); + if (transitionPerformed) { + const meta = machine.schema.states[nextStateValue]?.meta as + | WorkflowStateMeta + | undefined; + const nextWorkflowStatusId = meta?.workflowStatusId; - const proposalPksWithEvents = proposalPks.map((proposalPk) => { - return { - proposalPk, - proposalEvents: allProposalEvents?.find( - (proposalEvents) => proposalEvents.proposal_pk === proposalPk - ), - currentEvent: eventType, - }; - }); + if (nextWorkflowStatusId) { + const { proposals } = + await this.proposalDataSource.changeProposalsWorkflowStatus( + nextWorkflowStatusId, + [proposalPk] + ); - const updatedProposals = await workflowEngine(proposalPksWithEvents); + const updatedProposal = proposals[0]; - return updatedProposals; -}; + return { + ...updatedProposal, + prevStatusId: proposal.workflowStatusId, + workflowStatusConnectionId: connectionId, + }; + } + } + } +} diff --git a/apps/backend/src/workflowEngine/stateMachine/createWorkflowMachine.ts b/apps/backend/src/workflowEngine/stateMachine/createWorkflowMachine.ts new file mode 100644 index 0000000000..eb8148f336 --- /dev/null +++ b/apps/backend/src/workflowEngine/stateMachine/createWorkflowMachine.ts @@ -0,0 +1,96 @@ +import { container } from 'tsyringe'; + +import { createMachine, GuardFn, StateConfig } from './stateMachine'; +import { Tokens } from '../../config/Tokens'; +import { WorkflowDataSource } from '../../datasources/WorkflowDataSource'; +import { Event, EventMetadataByEvent } from '../../events/event.enum'; + +const createWorkFlowStatusName = (statusId: string, workflowStatusId: number) => + `${statusId}-${workflowStatusId}`; + +const getEventsGuards = (events: string[]): GuardFn[] => { + const guards: GuardFn[] = []; + + events.forEach((eventName) => { + const event = Event[eventName as keyof typeof Event]; + if (!event) { + return; + } + + const eventMetadata = EventMetadataByEvent.get(event); + if (eventMetadata?.guard) { + guards.push(eventMetadata.guard); + } + }); + + return guards; +}; + +export const createWorkflowMachine = async (workflowId: number) => { + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + + const { workflowStatuses, workflowConnections } = + await workflowDataSource.getWorkflowStructure(workflowId); + + const workFlowStates: Record = {}; + const workFlowStatusIdToNameMap = new Map(); // Map workflowStatusId to statusId for easy lookup + + workflowStatuses.forEach((ws) => { + const workFlowStatusName = createWorkFlowStatusName( + ws.statusId, + ws.workflowStatusId + ); + workFlowStatusIdToNameMap.set(ws.workflowStatusId, workFlowStatusName); + workFlowStates[workFlowStatusName] = { + on: {}, + meta: { + workflowStatusId: ws.workflowStatusId, + statusId: ws.statusId, + }, + }; + }); + + workflowConnections.forEach((conn) => { + const sourceStatus = workFlowStatusIdToNameMap.get( + conn.prevWorkflowStatusId + ); + const targetStatus = workFlowStatusIdToNameMap.get( + conn.nextWorkflowStatusId + ); + + if (!sourceStatus || !targetStatus) { + return; + } + + conn.statusChangingEvents.forEach((eventName) => { + const event = eventName.toUpperCase(); + + if (!event) { + return; + } + + const guards = getEventsGuards(conn.statusChangingEvents); + workFlowStates[sourceStatus].on = workFlowStates[sourceStatus].on || {}; + workFlowStates[sourceStatus].on![event] = { + connectionId: conn.workflowStatusConnectionId, + target: targetStatus, + guards, + }; + }); + }); + + const defaultWorkFlowStatus = + (await workflowDataSource.getInitialWorkflowStatus(workflowId))!; + + const machine = createMachine({ + id: `workflow-${workflowId}`, + initial: workFlowStatusIdToNameMap.get( + defaultWorkFlowStatus.workflowStatusId + )!, + states: workFlowStates, + }); + + return machine; +}; diff --git a/apps/backend/src/workflowEngine/stateMachine/stateMachine.spec.ts b/apps/backend/src/workflowEngine/stateMachine/stateMachine.spec.ts new file mode 100644 index 0000000000..e65b586628 --- /dev/null +++ b/apps/backend/src/workflowEngine/stateMachine/stateMachine.spec.ts @@ -0,0 +1,121 @@ +import { createActor, createMachine } from './stateMachine'; + +describe('stateMachine', () => { + it('throws when the initial state is missing from the schema', () => { + expect(() => + createMachine({ + initial: 'missing', + states: {}, + }) + ).toThrow('Unknown initial state "missing"'); + }); + + it('transitions to the target state on event', async () => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + on: { + APPROVE: { + target: 'approved', + guards: [], + connectionId: 0, + }, + }, + }, + approved: {}, + }, + }); + + const actor = createActor(machine, { id: 1 }); + expect(actor.getState()).toBe('pending'); + + const nextState = await actor.event('APPROVE'); + expect(nextState.nextStateValue).toBe('approved'); + expect(nextState.transitionPerformed).toBe(true); + expect(actor.getState()).toBe('approved'); + }); + + it('prevents transitions when the guard resolves to false', async () => { + const guard = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const machine = createMachine({ + initial: 'draft', + states: { + draft: { + on: { + SUBMIT: { + target: 'submitted', + guards: [guard], + connectionId: 0, + }, + }, + }, + submitted: {}, + }, + }); + + const actor = createActor(machine, { id: 2 }); + + const firstAttempt = await actor.event('SUBMIT'); + expect(firstAttempt.nextStateValue).toBe('draft'); + expect(actor.getState()).toBe('draft'); + + const secondAttempt = await actor.event('SUBMIT'); + expect(secondAttempt.nextStateValue).toBe('submitted'); + expect(actor.getState()).toBe('submitted'); + expect(guard).toHaveBeenCalledTimes(2); + expect(guard).toHaveBeenLastCalledWith({ id: 2 }); + }); + + it('returns transitionPerformed false when guards do not allow the transition', async () => { + const guard = jest.fn().mockResolvedValue(false); + const machine = createMachine({ + initial: 'draft', + states: { + draft: { + on: { + SUBMIT: { + target: 'submitted', + guards: [guard], + connectionId: 0, + }, + }, + }, + submitted: {}, + }, + }); + + const actor = createActor(machine, { id: 3 }); + + const result = await actor.event('SUBMIT'); + expect(result.transitionPerformed).toBe(false); + expect(result.nextStateValue).toBe('draft'); + expect(actor.getState()).toBe('draft'); + }); + + it('throws when a transition targets an unknown state', async () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + NEXT: { + target: 'missing', + guards: [], + connectionId: 0, + }, + }, + }, + }, + }); + + const actor = createActor(machine, { id: 1 }); + + await expect(actor.event('NEXT')).rejects.toThrow( + 'Unknown target state "missing"' + ); + }); +}); diff --git a/apps/backend/src/workflowEngine/stateMachine/stateMachine.ts b/apps/backend/src/workflowEngine/stateMachine/stateMachine.ts new file mode 100644 index 0000000000..3e6264ec1c --- /dev/null +++ b/apps/backend/src/workflowEngine/stateMachine/stateMachine.ts @@ -0,0 +1,114 @@ +export type Entity = { id: number }; + +export type GuardFn = (entity: Entity) => boolean | Promise; +export type ActionFn = (entity: Entity) => void | Promise; + +export type TransitionConfig = { + connectionId: number; + target: string; + guards: GuardFn[]; +}; + +export type StateConfig = { + on?: Record; + action?: ActionFn; + meta?: Record; +}; + +export type MachineSchema = { + id?: string; + initial: string; + states: Record; +}; + +export type Machine = { + schema: MachineSchema; +}; + +export const createMachine = (schema: MachineSchema): Machine => { + if (!schema.initial) { + throw new Error('initial state is required'); + } + + if (!schema.states[schema.initial]) { + throw new Error(`Unknown initial state "${schema.initial}"`); + } + + return { schema }; +}; + +export type Actor = { + getState: () => string; + event: (eventName: string) => Promise<{ + nextStateValue: string; + connectionId: number; + transitionPerformed: boolean; + }>; +}; + +export const createActor = ( + machine: Machine, + entity: Entity, + startingState?: string +): Actor => { + if (entity === undefined || entity === null) { + throw new Error('entity is required'); + } + + const { schema } = machine; + let currentState = startingState ?? schema.initial; + + if (!schema.states[currentState]) { + throw new Error(`Unknown state "${currentState}"`); + } + + // Status actions (e.g. emails, RabbitMQ, downloads) are handled by the + // statusActionEngine, not here. See statusActionEngine/proposal.ts. + + const getState = () => currentState; + + const event = async ( + eventName: string + ): Promise<{ + nextStateValue: string; + connectionId: number; + transitionPerformed: boolean; + }> => { + const stateConfig = schema.states[currentState]; + const transition = stateConfig?.on?.[eventName]; + + if (!stateConfig || !transition) { + return { + nextStateValue: currentState, + connectionId: -1, + transitionPerformed: false, + }; + } + + // all Guards from current state to target state must pass + for await (const guardTransition of transition.guards) { + const result = await guardTransition(entity); + if (!result) { + return { + nextStateValue: currentState, + connectionId: transition.connectionId, + transitionPerformed: false, + }; + } + } + + if (!schema.states[transition.target]) { + throw new Error(`Unknown target state "${transition.target}"`); + } + + currentState = transition.target; + + return { + nextStateValue: transition.target, + connectionId: transition.connectionId, + transitionPerformed: true, + }; + }; + + return { getState, event }; +}; diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index a4b8b25f5c..005868adba 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -193,6 +193,9 @@ let thirdCreatedProposalPk: number; let createdWorkflowId: number; let createdEsiTemplateId: number; let newlyCreatedInstrumentId: number; +let fapReviewWorkflowStatusId: number; +let expiredWorkflowStatusId: number; +let finishedWorkflowStatusId: number; function createWorkflowAndEsiTemplate() { const workflowName = faker.lorem.words(2); @@ -212,14 +215,32 @@ function createWorkflowAndEsiTemplate() { .get(SettingsId.TECH_REVIEW_OPTIONAL_WORKFLOW_STATUS) !== 'FEASIBILITY' ) { - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.fapReview.id, workflowId: createdWorkflowId, - sortOrder: 1, - prevStatusId: 1, posX: 0, posY: 200, - prevConnectionId: 1, + }).then((wfConnection) => { + fapReviewWorkflowStatusId = + wfConnection.addStatusToWorkflow.workflowStatusId; + }); + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.expired.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 400, + }).then((wfConnection) => { + expiredWorkflowStatusId = + wfConnection.addStatusToWorkflow.workflowStatusId; + }); + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.finished.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 400, + }).then((wfConnection) => { + finishedWorkflowStatusId = + wfConnection.addStatusToWorkflow.workflowStatusId; }); } @@ -263,7 +284,7 @@ function initializationBeforeTests() { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [firstCreatedProposalPk], }); @@ -315,10 +336,17 @@ function initializationBeforeTests() { proposerId: initialDBData.users.user1.id, }); - // Manually changing the proposal status to be shown in the Faps. --------> - cy.changeProposalsStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.fapReview.id, - proposalPks: [secondCreatedProposalPk], + workflowId: initialDBData.workflows.defaultWorkflow.id, + posX: 0, + posY: 200, + }).then((wfConnection) => { + cy.changeProposalsStatus({ + workflowStatusId: + wfConnection.addStatusToWorkflow.workflowStatusId, + proposalPks: [createdProposal.primaryKey], + }); }); cy.assignProposalsToInstruments({ @@ -343,7 +371,7 @@ function initializationBeforeTests() { context('Fap reviews tests', () => { beforeEach(function () { - cy.resetDB(); + cy.resetDB(true); cy.getAndStoreFeaturesEnabled().then(() => { if (!featureFlags.getEnabledFeatures().get(FeatureId.FAP_REVIEW)) { this.skip(); @@ -468,9 +496,15 @@ context('Fap reviews tests', () => { cy.submitProposal({ proposalPk: createdProposal.primaryKey }); + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.fapReview.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + posX: 0, + posY: 200, + }); // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -1947,7 +1981,7 @@ context('Fap reviews tests', () => { cy.submitProposal({ proposalPk: createdProposal.primaryKey }); cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.finished.id, + workflowStatusId: finishedWorkflowStatusId, proposalPks: [secondCreatedProposalPk], }); @@ -2044,17 +2078,21 @@ context('Fap reviews tests', () => { numberRatingsRequired: 2, gradeGuide: fap1.gradeGuide, active: true, - reviewVisibility: 1, + reviewVisibility: 3, }); - cy.updateFap({ - id: createdFapId, - code: fap1.code, - description: fap1.description, - numberRatingsRequired: 2, - gradeGuide: fap1.gradeGuide, - active: true, - reviewVisibility: 3, + cy.updateCall({ + id: initialDBData.call.id, + endFapReview: new Date( + new Date().getTime() + 1000 * 60 * 60 * 24 + ).toISOString(), + }); + + cy.updateCall({ + id: createdCallId, + endFapReview: new Date( + new Date().getTime() + 1000 * 60 * 60 * 24 + ).toISOString(), }); // Reviewer should not see any reviews when review visibility is set to 3 (reviews_visible_fap_ended) @@ -2306,7 +2344,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); } @@ -2368,7 +2406,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); } @@ -2487,7 +2525,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -3068,7 +3106,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -3654,7 +3692,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -3888,7 +3926,7 @@ context('Fap meeting components tests', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -4615,7 +4653,7 @@ context('Fap meeting exports test', () => { // Manually changing the proposal status to be shown in the Faps. --------> cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: fapReviewWorkflowStatusId, proposalPks: [createdProposal.primaryKey], }); @@ -4828,7 +4866,7 @@ context('Fap meeting exports test', () => { cy.changeProposalsStatus({ proposalPks: [proposalPK], - statusId: 9, + workflowStatusId: expiredWorkflowStatusId, }); cy.login('officer'); diff --git a/apps/e2e/cypress/e2e/appSettings.cy.ts b/apps/e2e/cypress/e2e/appSettings.cy.ts index 47b80993be..d9a11837ec 100644 --- a/apps/e2e/cypress/e2e/appSettings.cy.ts +++ b/apps/e2e/cypress/e2e/appSettings.cy.ts @@ -140,9 +140,12 @@ context('App settings tests', () => { ); } if (initialDBData.getFormats().statusFilter === 'ALL') { - cy.get('[data-cy="status-filter"] input').should('have.value', 0); + cy.get('[data-cy="status-filter"] input').should('have.value', 'ALL'); } else { - cy.get('[data-cy="status-filter"] input').should('have.value', 2); + cy.get('[data-cy="status-filter"] input').should( + 'have.value', + 'FEASIBILITY_REVIEW' + ); } }); }); diff --git a/apps/e2e/cypress/e2e/calls.cy.ts b/apps/e2e/cypress/e2e/calls.cy.ts index ccd91bf473..6b68492084 100644 --- a/apps/e2e/cypress/e2e/calls.cy.ts +++ b/apps/e2e/cypress/e2e/calls.cy.ts @@ -126,17 +126,16 @@ context('Calls tests', () => { cy.createWorkflow(proposalInternalWorkflow).then((result) => { const workflow = result.createWorkflow; if (workflow) { - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, workflowId: workflow.id, - sortOrder: 1, - prevStatusId: workflow.workflowConnections[0].id, + prevId: workflow.statuses[0].workflowStatusId, posX: 0, posY: 200, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + if (result.addStatusToWorkflow) { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, statusChangingEvents: ['CALL_ENDED'], }); } diff --git a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts index 839205e3d6..bf129360fc 100644 --- a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts +++ b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts @@ -10,15 +10,6 @@ import initialDBData from '../support/initialDBData'; // Test Constants const TEST_CONSTANTS = { - // Workflow Status IDs - WORKFLOW_STATUS: { - INITIAL: 17, - IS_REVIEW: 18, - ESR_REVIEW: 19, - APPROVED: 21, - REJECTED: 20, - }, - // Sort Orders SORT_ORDER: { FIRST: 1, @@ -163,61 +154,55 @@ function createWorkflowForInstrumentScientist() { const workflowData = createWorkflowResult.createWorkflow; return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.IS_REVIEW, + .addStatusToWorkflow({ + statusId: 'ESF_IS_REVIEW', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.FIRST, - prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.INITIAL, + prevId: workflowData.statuses[0].workflowStatusId, posX: -9, posY: 75, - prevConnectionId: 2, // Connect to initial status }) .then((result) => { - if (result.addWorkflowStatus) { + if (result.createWorkflowConnection) { return cy - .addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + .setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, statusChangingEvents: [TEST_CONSTANTS.EVENTS.ESF_SUBMITTED], }) .then(() => { return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.APPROVED, + .addStatusToWorkflow({ + statusId: 'ESF_APPROVED', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.SECOND, - prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.IS_REVIEW, + prevId: result.addStatusToWorkflow.workflowStatusId, posX: 228, posY: 213, - prevConnectionId: result.addWorkflowStatus.id, }) .then((secondResult) => { - if (secondResult.addWorkflowStatus) { + if (secondResult.createWorkflowConnection) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: - secondResult.addWorkflowStatus.id, + secondResult.createWorkflowConnection.id, statusChangingEvents: [ TEST_CONSTANTS.EVENTS.ESF_APPROVED_BY_IS, ], }) .then(() => { return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.REJECTED, + .addStatusToWorkflow({ + statusId: 'ESF_REJECTED', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.THIRD, - prevStatusId: - TEST_CONSTANTS.WORKFLOW_STATUS.IS_REVIEW, + prevId: + result.addStatusToWorkflow.workflowStatusId, posX: -221, posY: 218, - prevConnectionId: result.addWorkflowStatus.id, }) .then((thirdResult) => { - if (thirdResult.addWorkflowStatus) { + if (thirdResult.createWorkflowConnection) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: - thirdResult.addWorkflowStatus.id, + thirdResult.createWorkflowConnection.id, statusChangingEvents: [ TEST_CONSTANTS.EVENTS.ESF_REJECTED_BY_IS, ], @@ -257,61 +242,58 @@ function createWorkflowForESR() { const workflowData = createWorkflowResult.createWorkflow; return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.ESR_REVIEW, + .addStatusToWorkflow({ + statusId: 'ESR_REVIEW', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.FIRST, - prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.INITIAL, + prevId: workflowData.statuses.find( + (status) => status.statusId === 'INITIAL' + )!.workflowStatusId, posX: 1, posY: 111, - prevConnectionId: 2, }) .then((result) => { - if (result.addWorkflowStatus) { + if (result.addStatusToWorkflow) { return cy - .addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + .setStatusChangingEventsOnConnection({ + workflowConnectionId: + result.addStatusToWorkflow.workflowStatusId, statusChangingEvents: [TEST_CONSTANTS.EVENTS.ESF_SUBMITTED], }) .then(() => { return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.APPROVED, + .addStatusToWorkflow({ + statusId: 'APPROVED', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.SECOND, - prevStatusId: TEST_CONSTANTS.WORKFLOW_STATUS.ESR_REVIEW, + prevId: result.addStatusToWorkflow.workflowStatusId, posX: -211, posY: 282, - prevConnectionId: result.addWorkflowStatus.id, }) .then((secondResult) => { - if (secondResult.addWorkflowStatus) { + if (secondResult.addStatusToWorkflow) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: - secondResult.addWorkflowStatus.id, + secondResult.createWorkflowConnection.id, statusChangingEvents: [ TEST_CONSTANTS.EVENTS.ESF_APPROVED_BY_ESR, ], }) .then(() => { return cy - .addWorkflowStatus({ - statusId: TEST_CONSTANTS.WORKFLOW_STATUS.REJECTED, + .addStatusToWorkflow({ + statusId: 'REJECTED', workflowId: workflowData.id, - sortOrder: TEST_CONSTANTS.SORT_ORDER.THIRD, - prevStatusId: - TEST_CONSTANTS.WORKFLOW_STATUS.ESR_REVIEW, + prevId: + result.addStatusToWorkflow.workflowStatusId, posX: 195, posY: 287, - prevConnectionId: result.addWorkflowStatus.id, }) .then((thirdResult) => { - if (thirdResult.addWorkflowStatus) { + if (thirdResult.addStatusToWorkflow) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: - thirdResult.addWorkflowStatus.id, + thirdResult.createWorkflowConnection.id, statusChangingEvents: [ TEST_CONSTANTS.EVENTS.ESF_REJECTED_BY_ESR, ], @@ -579,7 +561,7 @@ context('Experiment Safety Review tests', () => { cy.testActionButton('finish-experiment-safety-form-icon', 'completed'); }); - it('Should validate experiment status change after ESF submission', () => { + it.only('Should validate experiment status change after ESF submission', () => { submitESFByUser(); // Instrument scientist should see status change to "ESF IS REVIEW" diff --git a/apps/e2e/cypress/e2e/instruments.cy.ts b/apps/e2e/cypress/e2e/instruments.cy.ts index 5916562255..401692a160 100644 --- a/apps/e2e/cypress/e2e/instruments.cy.ts +++ b/apps/e2e/cypress/e2e/instruments.cy.ts @@ -4,6 +4,7 @@ import { TechnicalReviewStatus, FeatureId, SettingsId, + WorkflowType, } from '@user-office-software-libs/shared-types'; import featureFlags from '../support/featureFlags'; @@ -12,7 +13,7 @@ import settings from '../support/settings'; const selectAllProposalsFilterStatus = () => { cy.get('[data-cy="status-filter"]').click(); - cy.get('[role="listbox"] [data-value="0"]').click(); + cy.get('[role="listbox"] [data-value="ALL"]').click(); }; context('Instrument tests', () => { @@ -55,6 +56,19 @@ context('Instrument tests', () => { beforeEach(() => { 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() @@ -486,7 +500,6 @@ context('Instrument tests', () => { cy.get('[role="dialog"]').contains('Technical review').click(); cy.get('[data-cy="save-and-continue-button"]').should('not.be.disabled'); - //cy.get('[data-cy="submit-technical-review"]').should('not.be.disabled'); cy.get('[data-cy="timeAllocation"] input').should('not.be.disabled'); cy.get('[data-cy="timeAllocation"] label').should( 'include.text', diff --git a/apps/e2e/cypress/e2e/personalInformation.cy.ts b/apps/e2e/cypress/e2e/personalInformation.cy.ts index c243f09c08..f9ec4cdef5 100644 --- a/apps/e2e/cypress/e2e/personalInformation.cy.ts +++ b/apps/e2e/cypress/e2e/personalInformation.cy.ts @@ -171,7 +171,10 @@ context('Personal information tests', () => { cy.notification({ variant: 'success', text: 'created successfully' }); - cy.get('[data-cy="connection_DRAFT"]').should('contain.text', 'DRAFT'); + cy.get('[data-cy="workflow_status_DRAFT"]').should( + 'contain.text', + 'DRAFT' + ); cy.get("[data-cy='profile-page-btn']").click(); diff --git a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts index eeca280043..972822b63a 100644 --- a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts +++ b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts @@ -106,17 +106,16 @@ context('Pregenerated PDF tests', () => { } }); - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, }).then((result) => { - const connection = result.addWorkflowStatus; + const connection = result.createWorkflowConnection; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [PROPOSAL_EVENTS.PROPOSAL_SUBMITTED], }); diff --git a/apps/e2e/cypress/e2e/proposalAdministration.cy.ts b/apps/e2e/cypress/e2e/proposalAdministration.cy.ts index 102a3788e3..45431efdb3 100644 --- a/apps/e2e/cypress/e2e/proposalAdministration.cy.ts +++ b/apps/e2e/cypress/e2e/proposalAdministration.cy.ts @@ -62,6 +62,13 @@ context('Proposal administration tests', () => { }); } + cy.addStatusToWorkflow({ + workflowId: initialDBData.workflows.defaultWorkflow.id, + statusId: 'SCHEDULING', + posX: 400, + posY: 200, + }); + cy.createProposal({ callId: initialDBData.call.id }).then( ({ createProposal }) => { if (createProposal) { @@ -243,10 +250,10 @@ context('Proposal administration tests', () => { cy.get('@dialog').contains('Change proposal(s) status'); cy.get('@dialog') - .find('#selectedStatusId-input') + .find('#selectedWorkflowStatusId-input') .should('not.have.class', 'Mui-disabled'); - cy.get('@dialog').find('#selectedStatusId-input').click(); + cy.get('@dialog').find('#selectedWorkflowStatusId-input').click(); cy.get('[role="listbox"]').contains('SCHEDULING').click(); @@ -273,10 +280,10 @@ context('Proposal administration tests', () => { cy.get('@dialog').contains('Change proposal(s) status'); cy.get('@dialog') - .find('#selectedStatusId-input') + .find('#selectedWorkflowStatusId-input') .should('not.have.class', 'Mui-disabled'); - cy.get('@dialog').find('#selectedStatusId-input').click(); + cy.get('@dialog').find('#selectedWorkflowStatusId-input').click(); cy.get('[role="listbox"]').contains('DRAFT').click(); diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 5f8f4b7cd5..33e0a79b83 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -49,6 +49,7 @@ context('Proposal tests', () => { let createdProposalId: string; let createdCallId: number; let createdTemplateId: number; + let createdFapMeetingWorkflowStatusId: number; const textQuestion = faker.lorem.words(2); const currentDayStart = DateTime.now().startOf('day'); @@ -122,19 +123,24 @@ context('Proposal tests', () => { name: 'default esi template', groupId: TemplateGroupId.PROPOSAL_ESI, }); + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.fapMeeting.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + }).then((workflowStatusResult) => { + if (workflowStatusResult.addStatusToWorkflow) { + createdFapMeetingWorkflowStatusId = + workflowStatusResult.addStatusToWorkflow.workflowStatusId; + } + }); cy.createWorkflow({ name: proposalWorkflow.name, description: proposalWorkflow.description, entityType: WorkflowType.PROPOSAL, }).then((result) => { if (result.createWorkflow) { - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: result.createWorkflow.id, - sortOrder: 1, - prevStatusId: 1, - posX: 0, - posY: 200, }); createdWorkflowId = result.createWorkflow.id; } @@ -750,10 +756,10 @@ context('Proposal tests', () => { cy.get('@dialog').contains('Change proposal(s) status'); cy.get('@dialog') - .find('#selectedStatusId-input') + .find('#selectedWorkflowStatusId-input') .should('not.have.class', 'Mui-disabled'); - cy.get('@dialog').find('#selectedStatusId-input').click(); + cy.get('@dialog').find('#selectedWorkflowStatusId-input').click(); cy.get('[role="listbox"]').contains('DRAFT').click(); @@ -774,10 +780,10 @@ context('Proposal tests', () => { cy.get('@dialog').contains('Change proposal(s) status'); cy.get('@dialog') - .find('#selectedStatusId-input') + .find('#selectedWorkflowStatusId-input') .should('not.have.class', 'Mui-disabled'); - cy.get('@dialog').find('#selectedStatusId-input').click(); + cy.get('@dialog').find('#selectedWorkflowStatusId-input').click(); cy.get('[role="listbox"]') .contains(initialDBData.proposalStatuses.fapMeeting.name) @@ -804,7 +810,7 @@ context('Proposal tests', () => { proposalsToClonePk: [createdProposalPk], }); cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapMeeting.id, + workflowStatusId: createdFapMeetingWorkflowStatusId, proposalPks: [createdProposalPk], }); cy.login('officer'); @@ -833,7 +839,7 @@ context('Proposal tests', () => { .uncheck(); cy.contains(initialDBData.proposalStatuses.fapMeeting.name) - .parent() + .closest('tr') .find('[type="checkbox"]') .check(); cy.get('[data-cy="change-proposal-status"]').click(); @@ -849,7 +855,7 @@ context('Proposal tests', () => { cy.get('body').trigger('keydown', { keyCode: 27 }); cy.changeProposalsStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, + workflowStatusId: createdFapMeetingWorkflowStatusId, proposalPks: [createdProposalPk], }); @@ -1092,8 +1098,10 @@ context('Proposal tests', () => { cy.get('[index="0"] input').check(); cy.get('[data-cy="change-proposal-status"]').click(); - cy.get('#selectedStatusId-input').click(); - cy.get('[role="listbox"]').contains('EDITABLE_SUBMITTED').click(); + cy.get('#selectedWorkflowStatusId-input').click(); + cy.get('[role="listbox"]') + .contains(/^EDITABLE_SUBMITTED$/) + .click(); cy.get('[data-cy="submit-proposal-status-change"] ').click(); cy.login('user1', initialDBData.roles.user); diff --git a/apps/e2e/cypress/e2e/settings.cy.ts b/apps/e2e/cypress/e2e/settings.cy.ts index 0d96162945..fba7e47293 100644 --- a/apps/e2e/cypress/e2e/settings.cy.ts +++ b/apps/e2e/cypress/e2e/settings.cy.ts @@ -15,16 +15,7 @@ import initialDBData from '../support/initialDBData'; import settings from '../support/settings'; import { updatedCall } from '../support/utils'; -const { - feasibilityReview, - fapSelection, - notFeasible, - draft, - esfIsReview, - awaitingEsf, - esfEsrReview, - esfRejected, -} = initialDBData.proposalStatuses; +const statuses = initialDBData.proposalStatuses; context('Settings tests', () => { beforeEach(() => { @@ -35,7 +26,7 @@ context('Settings tests', () => { describe('Proposal statuses tests', () => { const name = faker.lorem.words(2); const description = faker.lorem.words(5); - const shortCode = name.toUpperCase().replace(/\s/g, '_'); + const id = name.toUpperCase().replace(/\s/g, '_'); it('User should not be able to see Settings page', () => { cy.login('user1', initialDBData.roles.user); @@ -56,7 +47,7 @@ context('Settings tests', () => { cy.contains('Proposal statuses').click(); cy.finishedLoading(); cy.contains('Create').click(); - cy.get('#shortCode').type(shortCode); + cy.get('#id').type(id); cy.get('#name').type(name); cy.get('#description').type(description); cy.get('[data-cy="submit"]').click(); @@ -65,15 +56,14 @@ context('Settings tests', () => { cy.get('[data-cy="proposal-statuses-table"]').as('proposalStatusesTable'); - cy.get('@proposalStatusesTable') - .find('span[aria-label="Last Page"] > button') - .as('lastPageButtonElement'); - - cy.get('@lastPageButtonElement').click({ force: true }); - cy.get('[data-cy="proposal-statuses-table"]').as( 'proposalStatusesTableNew' ); + + cy.get('@proposalStatusesTableNew') + .find('[placeholder="Search"]') + .type(id); + cy.get('@proposalStatusesTableNew') .find('tr[level="0"]') .last() @@ -81,7 +71,7 @@ context('Settings tests', () => { cy.get('@proposalStatusesTableLastRow').invoke('text').as('lastRowText'); - cy.get('@lastRowText').should('contain', shortCode); + cy.get('@lastRowText').should('contain', id); cy.get('@lastRowText').should('contain', name); cy.get('@lastRowText').should('contain', description); }); @@ -98,7 +88,7 @@ context('Settings tests', () => { cy.contains('DRAFT').parent().find('[aria-label="Edit"]').click(); - cy.get('#shortCode').should('be.disabled'); + cy.get('#id').should('be.disabled'); cy.get('#name').clear(); cy.get('#name').type(newName); @@ -115,9 +105,9 @@ context('Settings tests', () => { it('User Officer should be able to delete Proposal status', () => { cy.createStatus({ + id, name, description, - shortCode, entityType: WorkflowType.PROPOSAL, }); cy.login('officer'); @@ -129,12 +119,9 @@ context('Settings tests', () => { cy.finishedLoading(); cy.get('[data-cy="proposal-statuses-table"]').as('proposalStatusesTable'); - cy.get('@proposalStatusesTable') - .find('span[aria-label="Last Page"] > button') - .as('lastPageButtonElement'); - - cy.get('@lastPageButtonElement').click({ force: true }); + .find('[placeholder="Search"]') + .type(name); cy.contains(name).parent().find('[aria-label="Delete"]').click(); @@ -156,14 +143,13 @@ context('Settings tests', () => { const updatedWorkflowName = faker.lorem.words(2); const updatedWorkflowDescription = faker.lorem.words(5); const instrument1 = { - name: faker.random.words(2), - shortCode: faker.random.alphaNumeric(15), - description: faker.random.words(5), + name: faker.lorem.words(2), + shortCode: faker.string.alphanumeric(15), + description: faker.lorem.words(5), managerUserId: initialDBData.users.user1.id, }; let createdWorkflowId: number; - let prevStatusId: number; - let prevConnectionId: number; + let createdDraftWorkflowStatusId: number; let createdEsiTemplateId: number; let createdInstrumentId: number; @@ -181,120 +167,131 @@ context('Settings tests', () => { }; const addWorkflowWithChangingEvents = () => { - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, + // Add FEASIBILITY_REVIEW + cy.addStatusToWorkflow({ + statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, - posX: 0, - posY: 100, - sortOrder: 1, - prevStatusId: prevStatusId, - }).then((result) => { - const connection = result.addWorkflowStatus; - if (connection) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: connection.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + prevId: createdDraftWorkflowStatusId, + }) + .then((result) => { + const connection = result.createWorkflowConnection; + + return cy + .setStatusChangingEventsOnConnection({ + workflowConnectionId: connection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }) + .then(() => result.addStatusToWorkflow.workflowStatusId); + }) + .then((feasibilityReviewWorkflowStatusId) => { + // Add FAP_SELECTION + return cy.addStatusToWorkflow({ + statusId: statuses.fapSelection.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 200, + prevId: feasibilityReviewWorkflowStatusId, }); - } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.fapSelection.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 200, - sortOrder: 2, - prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [ - Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE, - Event.PROPOSAL_INSTRUMENTS_SELECTED, - ], + }) + .then((result) => { + const connection = result.createWorkflowConnection; + + return cy + .setStatusChangingEventsOnConnection({ + workflowConnectionId: connection.id, + statusChangingEvents: [ + Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE, + Event.PROPOSAL_INSTRUMENTS_SELECTED, + ], + }) + .then(() => result.addStatusToWorkflow.workflowStatusId); + }) + .then((fapSelectionWorkflowStatusId) => { + // Add FAP_REVIEW + return cy.addStatusToWorkflow({ + statusId: statuses.fapReview.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 300, + prevId: fapSelectionWorkflowStatusId, }); - } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.fapReview.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 300, - sortOrder: 3, - prevStatusId: initialDBData.proposalStatuses.fapSelection.id, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.PROPOSAL_FAPS_SELECTED], + }) + .then((result) => { + const connection = result.createWorkflowConnection; + + return cy + .setStatusChangingEventsOnConnection({ + workflowConnectionId: connection.id, + statusChangingEvents: [Event.PROPOSAL_FAPS_SELECTED], + }) + .then(() => result.addStatusToWorkflow.workflowStatusId); + }) + .then((fapReviewWorkflowStatusId) => { + // Add FAP_MEETING + return cy.addStatusToWorkflow({ + statusId: statuses.fapMeeting.id, + workflowId: createdWorkflowId, + posX: 0, + posY: 400, + prevId: fapReviewWorkflowStatusId, }); - } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.fapMeeting.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 400, - sortOrder: 4, - prevStatusId: initialDBData.proposalStatuses.fapReview.id, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + }) + .then((result) => { + const connection = result.createWorkflowConnection; + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: connection.id, statusChangingEvents: [Event.PROPOSAL_ALL_FAP_REVIEWS_SUBMITTED], }); - } - }); + }); }; const addWorkflowWithBranchesAndChangingEvents = () => { - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, + // Add FEASIBILITY_REVIEW from DRAFT + cy.addStatusToWorkflow({ + statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, posX: 0, - posY: 500, - sortOrder: 1, - prevStatusId: prevStatusId, - }).then((result) => { - const connection = result.addWorkflowStatus; - if (connection) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: connection.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.fapSelection.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 600, - sortOrder: 0, - prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, + posY: 100, + prevId: createdDraftWorkflowStatusId, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + const createdFeasibilityReviewWorkflowStatus = + result.addStatusToWorkflow; + const connection = result.createWorkflowConnection; + + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: connection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); + + // Add FAP_SELECTION from FEASIBILITY_REVIEW + cy.addStatusToWorkflow({ + statusId: statuses.fapSelection.id, + workflowId: createdWorkflowId, + posX: -100, + posY: 200, + prevId: createdFeasibilityReviewWorkflowStatus.workflowStatusId, + }).then((result) => { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, statusChangingEvents: [Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE], }); - } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.notFeasible.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 700, - sortOrder: 0, - prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + }); + + // Add NOT_FEASIBLE from FEASIBILITY_REVIEW + cy.addStatusToWorkflow({ + statusId: statuses.notFeasible.id, + workflowId: createdWorkflowId, + posX: 100, + posY: 200, + prevId: createdFeasibilityReviewWorkflowStatus.workflowStatusId, + }).then((result) => { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, statusChangingEvents: [ Event.PROPOSAL_FEASIBILITY_REVIEW_UNFEASIBLE, ], }); - } + }); }); }; @@ -309,8 +306,7 @@ context('Settings tests', () => { const workflow = result.createWorkflow; if (workflow) { createdWorkflowId = workflow.id; - prevStatusId = workflow.workflowConnections[0].statusId!; - prevConnectionId = workflow.workflowConnections[0].id; + createdDraftWorkflowStatusId = workflow.statuses[0].workflowStatusId; cy.createTemplate({ name: 'default esi template', @@ -336,20 +332,17 @@ context('Settings tests', () => { it('User should be able to edit a submitted proposal in EDITABLE_SUBMITTED status', () => { const proposalTitle = faker.random.words(3); const editedProposalTitle = faker.random.words(3); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.editableSubmitted.id, + cy.addStatusToWorkflow({ + statusId: statuses.editableSubmitted.id, workflowId: createdWorkflowId, posX: 0, posY: 200, - sortOrder: 1, - prevStatusId: prevStatusId, + prevId: createdDraftWorkflowStatusId, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); }); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { if (result.createProposal) { @@ -422,20 +415,17 @@ context('Settings tests', () => { const proposalTitle = faker.random.words(3); const currentDayStart = DateTime.now().startOf('day'); const editedProposalTitle = faker.random.words(3); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, + cy.addStatusToWorkflow({ + statusId: statuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, posX: 0, posY: 200, - sortOrder: 1, - prevStatusId: prevStatusId, + prevId: createdDraftWorkflowStatusId, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); }); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { if (result.createProposal) { @@ -516,20 +506,17 @@ context('Settings tests', () => { } const proposalTitle = faker.random.words(3); const currentDayStart = DateTime.now().startOf('day'); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, + cy.addStatusToWorkflow({ + statusId: statuses.editableSubmittedInternal.id, workflowId: createdWorkflowId, posX: 0, posY: 200, - sortOrder: 1, - prevStatusId: prevStatusId, + prevId: createdDraftWorkflowStatusId, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); }); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { if (result.createProposal) { @@ -602,36 +589,34 @@ context('Settings tests', () => { } const internalProposalTitle = faker.lorem.words(3); const currentDayStart = DateTime.now().startOf('day'); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.editableSubmitted.id, + cy.addStatusToWorkflow({ + statusId: statuses.editableSubmitted.id, workflowId: createdWorkflowId, - posX: 0, - posY: 200, - sortOrder: 1, - prevStatusId: prevStatusId, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, + prevId: createdDraftWorkflowStatusId, + }).then( + ({ + createWorkflowConnection: connectionFromDraft, + addStatusToWorkflow: editableSubmittedWorkflowStatus, + }) => { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: connectionFromDraft.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); + + cy.addStatusToWorkflow({ + statusId: statuses.editableSubmittedInternal.id, + workflowId: createdWorkflowId, + prevId: editableSubmittedWorkflowStatus.workflowStatusId, + }).then( + ({ createWorkflowConnection: connectionFromSubmittedInternal }) => { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: connectionFromSubmittedInternal.id, + statusChangingEvents: [Event.CALL_ENDED], + }); + } + ); } - }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.editableSubmittedInternal.id, - workflowId: createdWorkflowId, - posX: 0, - posY: 200, - sortOrder: 2, - prevStatusId: initialDBData.proposalStatuses.editableSubmitted.id, - }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.CALL_ENDED], - }); - } - }); + ); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { if (result.createProposal) { @@ -655,6 +640,7 @@ context('Settings tests', () => { endCall: currentDayStart.plus({ days: -3 }), endCallInternal: currentDayStart.plus({ days: 365 }), proposalWorkflowId: createdWorkflowId, + callEnded: true, }); cy.contains(internalProposalTitle) @@ -690,10 +676,7 @@ context('Settings tests', () => { cy.contains(internalProposalTitle) .parent() - .should( - 'contain.text', - initialDBData.proposalStatuses.editableSubmittedInternal.name - ); + .should('contain.text', statuses.editableSubmittedInternal.name); }); it('User Officer should be able to create proposal workflow and it should contain default DRAFT status', () => { @@ -710,7 +693,10 @@ context('Settings tests', () => { cy.finishedLoading(); - cy.get('[data-cy^="connection_DRAFT"]').should('contain.text', 'DRAFT'); + cy.get('[data-cy^="workflow_status_DRAFT"]').should( + 'contain.text', + 'DRAFT' + ); cy.get('[data-cy="remove-workflow-status-button"]').should('not.exist'); }); @@ -747,12 +733,12 @@ context('Settings tests', () => { cy.finishedLoading(); - cy.dragStatusIntoWorkflow(feasibilityReview, { + cy.dragStatusIntoWorkflow(statuses.feasibilityReview, { clientX: 0, clientY: 100, }); - cy.get('[data-cy="connection_FEASIBILITY_REVIEW"]').should( + cy.get('[data-cy="workflow_status_FEASIBILITY_REVIEW"]').should( 'contain.text', 'FEASIBILITY_REVIEW' ); @@ -766,14 +752,12 @@ context('Settings tests', () => { }); it('User Officer should be able to select events that are triggering change to proposal workflow status', () => { - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, + cy.addStatusToWorkflow({ + statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, posX: 0, posY: 150, - sortOrder: 1, - prevStatusId: prevStatusId, - prevConnectionId: prevConnectionId, + prevId: createdDraftWorkflowStatusId, }); cy.login('officer'); cy.visit('/'); @@ -795,7 +779,7 @@ context('Settings tests', () => { cy.notification({ variant: 'success', - text: 'Status changing events added successfully!', + text: 'Status changing events set successfully!', }); cy.closeModal(); @@ -812,7 +796,7 @@ context('Settings tests', () => { cy.notification({ variant: 'success', - text: 'Status changing events added successfully!', + text: 'Status changing events set successfully!', }); cy.get( @@ -885,9 +869,7 @@ context('Settings tests', () => { cy.get('.MuiTable-root tbody tr') .first() .then((element) => - expect(element.text()).to.contain( - initialDBData.proposalStatuses.feasibilityReview.name - ) + expect(element.text()).to.contain(statuses.feasibilityReview.name) ); cy.contains(proposalTitle) @@ -1008,85 +990,6 @@ context('Settings tests', () => { cy.contains('FAP_REVIEW'); }); - it('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(); @@ -1243,21 +1146,17 @@ context('Settings tests', () => { cy.get('.MuiTable-root tbody') .first() .then((element) => - expect(element.text()).to.contain( - initialDBData.proposalStatuses.draft.name - ) + expect(element.text()).to.contain(statuses.draft.name) ); cy.get('.MuiTable-root tbody') .first() .then((element) => - expect(element.text()).to.contain( - initialDBData.proposalStatuses.fapReview.name - ) + expect(element.text()).to.contain(statuses.fapReview.name) ); cy.get('[data-cy="status-filter"]').click(); - cy.get('[role="listbox"] [data-value="5"]').click(); + cy.get('[role="listbox"] [data-value="FAP_REVIEW"]').click(); cy.finishedLoading(); @@ -1266,13 +1165,11 @@ context('Settings tests', () => { cy.get('.MuiTable-root tbody tr') .first() .then((element) => - expect(element.text()).to.contain( - initialDBData.proposalStatuses.fapReview.name - ) + expect(element.text()).to.contain(statuses.fapReview.name) ); cy.get('[data-cy="status-filter"]').click(); - cy.get('[role="listbox"] [data-value="1"]').click(); + cy.get('[role="listbox"] [data-value="DRAFT"]').click(); cy.finishedLoading(); @@ -1281,9 +1178,7 @@ context('Settings tests', () => { cy.get('.MuiTable-root tbody tr') .first() .then((element) => - expect(element.text()).to.contain( - initialDBData.proposalStatuses.draft.name - ) + expect(element.text()).to.contain(statuses.draft.name) ); }); @@ -1299,12 +1194,16 @@ context('Settings tests', () => { cy.get('[data-cy="remove-workflow-status-button"]').first().click(); + cy.get('[role="dialog"]').get('[data-cy="confirm-ok"]').click(); + cy.notification({ variant: 'success', text: 'Workflow status removed successfully', }); - cy.get('[data-cy^="connection_FEASIBILITY_REVIEW"]').should('not.exist'); + cy.get('[data-cy^="workflow_status_FEASIBILITY_REVIEW"]').should( + 'not.exist' + ); }); it.skip('User Officer should be able to create proposal workflow with branches', () => { @@ -1312,7 +1211,7 @@ context('Settings tests', () => { cy.login('officer'); cy.visit(`/ProposalWorkflowEditor/${createdWorkflowId}`); - cy.dragStatusIntoWorkflow(feasibilityReview, { + cy.dragStatusIntoWorkflow(statuses.feasibilityReview, { clientX: 600, clientY: 400, }); @@ -1321,12 +1220,12 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_FEASIBILITY_REVIEW"]').should( + cy.get('[data-cy="workflow_status_FEASIBILITY_REVIEW"]').should( 'contain.text', 'FEASIBILITY_REVIEW' ); - cy.dragStatusIntoWorkflow(fapSelection, { + cy.dragStatusIntoWorkflow(statuses.fapSelection, { clientX: 300, clientY: 800, }); @@ -1335,12 +1234,12 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_FAP_SELECTION"]').should( + cy.get('[data-cy="workflow_status_FAP_SELECTION"]').should( 'contain.text', 'FAP_SELECTION' ); - cy.dragStatusIntoWorkflow(notFeasible, { + cy.dragStatusIntoWorkflow(statuses.notFeasible, { clientX: 900, clientY: 800, }); @@ -1349,7 +1248,7 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_NOT_FEASIBLE"]').should( + cy.get('[data-cy="workflow_status_NOT_FEASIBLE"]').should( 'contain.text', 'NOT_FEASIBLE' ); @@ -1358,23 +1257,31 @@ context('Settings tests', () => { cy.get('[title="fit view"]').click(); - cy.connectReactFlowNodes(feasibilityReview, fapSelection, { - force: true, - }); + 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(feasibilityReview, notFeasible, { - force: true, - }); + 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(draft, feasibilityReview, { + cy.connectReactFlowNodes(statuses.draft, statuses.feasibilityReview, { force: true, }); cy.finishedLoading(); @@ -1393,7 +1300,9 @@ context('Settings tests', () => { const secondProposalAbstract = faker.random.words(5); const internalComment = faker.random.words(2); const publicComment = faker.random.words(2); + addWorkflowWithBranchesAndChangingEvents(); + createInstrumentAndAssignItToCall(); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; @@ -1532,20 +1441,17 @@ context('Settings tests', () => { } }); - cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, + cy.addStatusToWorkflow({ + statusId: statuses.feasibilityReview.id, workflowId: createdWorkflowId, + prevId: createdDraftWorkflowStatusId, posX: 0, posY: 200, - sortOrder: 1, - prevStatusId: prevStatusId, }).then((result) => { - if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ - workflowConnectionId: result.addWorkflowStatus.id, - statusChangingEvents: [Event.PROPOSAL_SUBMITTED], - }); - } + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, + statusChangingEvents: [Event.PROPOSAL_SUBMITTED], + }); }); cy.createProposal({ callId: initialDBData.call.id }).then((result) => { @@ -1637,7 +1543,7 @@ context('Settings tests', () => { cy.notification({ variant: 'success', text: 'created successfully' }); cy.finishedLoading(); - cy.get('[data-cy^="connection_AWAITING_ESF"]').should('exist'); + cy.get('[data-cy^="workflow_status_AWAITING_ESF"]').should('exist'); cy.get('[data-cy="remove-workflow-status-button"]').should('not.exist'); }); @@ -1674,7 +1580,7 @@ context('Settings tests', () => { cy.finishedLoading(); - cy.dragStatusIntoWorkflow(esfIsReview, { + cy.dragStatusIntoWorkflow(statuses.esfIsReview, { clientX: 300, clientY: 300, }); @@ -1682,7 +1588,7 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should( + cy.get('[data-cy^="workflow_status_ESF_IS_REVIEW"]').should( 'contain.text', 'ESF IS REVIEW' ); @@ -1701,19 +1607,19 @@ context('Settings tests', () => { cy.finishedLoading(); - cy.dragStatusIntoWorkflow(esfIsReview, { + cy.dragStatusIntoWorkflow(statuses.esfIsReview, { clientX: 300, - clientY: 300, + clientY: 400, }); - cy.get('[data-cy^="connection_ESF_IS_REVIEW"]').should('exist'); + cy.get('[data-cy^="workflow_status_ESF_IS_REVIEW"]').should('exist'); cy.notification({ variant: 'success', text: 'Workflow status added successfully', }); - cy.connectReactFlowNodes(awaitingEsf, esfIsReview, { + cy.connectReactFlowNodes(statuses.awaitingEsf, statuses.esfIsReview, { force: true, }); @@ -1727,7 +1633,7 @@ context('Settings tests', () => { cy.get('[data-cy="submit"]').click(); cy.notification({ variant: 'success', - text: 'Status changing events added successfully!', + text: 'Status changing events set successfully!', }); cy.closeModal(); @@ -1744,7 +1650,7 @@ context('Settings tests', () => { cy.get('[data-cy="submit"]').click(); cy.notification({ variant: 'success', - text: 'Status changing events added successfully!', + text: 'Status changing events set successfully!', }); cy.get( @@ -1760,7 +1666,7 @@ context('Settings tests', () => { cy.login('officer'); cy.visit(`/ExperimentWorkflowEditor/${createdWorkflowId}`); - cy.dragStatusIntoWorkflow(esfIsReview, { + cy.dragStatusIntoWorkflow(statuses.esfIsReview, { clientX: 600, clientY: 400, }); @@ -1769,12 +1675,12 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_ESF_IS_REVIEW"]').should( + cy.get('[data-cy="workflow_status_ESF_IS_REVIEW"]').should( 'contain.text', - esfIsReview.name + statuses.esfIsReview.name ); - cy.dragStatusIntoWorkflow(esfEsrReview, { + cy.dragStatusIntoWorkflow(statuses.esfEsrReview, { clientX: 300, clientY: 800, }); @@ -1783,12 +1689,12 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_ESF_ESR_REVIEW"]').should( + cy.get('[data-cy="workflow_status_ESF_ESR_REVIEW"]').should( 'contain.text', - esfEsrReview.name + statuses.esfEsrReview.name ); - cy.dragStatusIntoWorkflow(esfRejected, { + cy.dragStatusIntoWorkflow(statuses.esfRejected, { clientX: 900, clientY: 800, }); @@ -1797,16 +1703,16 @@ context('Settings tests', () => { variant: 'success', text: 'Workflow status added successfully', }); - cy.get('[data-cy="connection_ESF_REJECTED"]').should( + cy.get('[data-cy="workflow_status_ESF_REJECTED"]').should( 'contain.text', - esfRejected.name + statuses.esfRejected.name ); cy.finishedLoading(); cy.get('[title="fit view"]').click(); - cy.connectReactFlowNodes(esfIsReview, esfEsrReview, { + cy.connectReactFlowNodes(statuses.esfIsReview, statuses.esfEsrReview, { force: true, }); cy.finishedLoading(); @@ -1814,7 +1720,7 @@ context('Settings tests', () => { 'exist' ); - cy.connectReactFlowNodes(esfIsReview, esfRejected, { + cy.connectReactFlowNodes(statuses.esfIsReview, statuses.esfRejected, { force: true, }); cy.finishedLoading(); @@ -1822,7 +1728,7 @@ context('Settings tests', () => { 'exist' ); - cy.connectReactFlowNodes(awaitingEsf, esfIsReview, { + cy.connectReactFlowNodes(statuses.awaitingEsf, statuses.esfIsReview, { force: true, }); cy.finishedLoading(); diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 6a5c8e470b..8b2465bce6 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -33,6 +33,13 @@ const newCall = { cycleComment: faker.lorem.word(10), }; +const instrument = { + name: `0000. ${faker.lorem.words(2)}`, + shortCode: faker.string.alphanumeric(15), + description: faker.lorem.words(5), + managerUserId: 1, +}; + let proposal1Id: string; let proposal2Id: string; let testEmailTemplate1Id: string; @@ -43,6 +50,16 @@ context('Status actions tests', () => { cy.resetDB(); cy.getAndStoreFeaturesEnabled(); + cy.createInstrument(instrument).then((result) => { + if (result.createInstrument) { + const createdInstrumentId = result.createInstrument.id; + cy.assignInstrumentToCall({ + callId: initialDBData.call.id, + instrumentFapIds: [{ instrumentId: createdInstrumentId }], + }); + } + }); + cy.createEmailTemplate({ name: initialDBData.emailTemplates.template1.name, description: initialDBData.emailTemplates.template1.description, @@ -71,14 +88,13 @@ context('Status actions tests', () => { describe('Status actions workflow tests', () => { it('User Officer should be able to add a status action to workflow connection', () => { - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, - prevConnectionId: 1, }); cy.login('officer'); cy.visit('/ProposalWorkflowEditor/1'); @@ -161,14 +177,13 @@ context('Status actions tests', () => { ], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, - prevConnectionId: 1, }).then((result) => { cy.reload(); cy.addConnectionStatusActions({ @@ -179,7 +194,7 @@ context('Status actions tests', () => { config: JSON.stringify(statusActionConfig), }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); @@ -254,14 +269,13 @@ context('Status actions tests', () => { ], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, - prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -272,7 +286,7 @@ context('Status actions tests', () => { }, { actionId: 3, actionType: StatusActionType.PROPOSALDOWNLOAD }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); @@ -331,14 +345,13 @@ context('Status actions tests', () => { const invalidEmail = 'test@test'; const validEmail = faker.internet.email(); - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, - prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -348,7 +361,7 @@ context('Status actions tests', () => { config: JSON.stringify(statusActionConfig), }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); @@ -491,14 +504,13 @@ context('Status actions tests', () => { ], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 150, - prevConnectionId: 1, }).then((result) => { cy.addConnectionStatusActions({ actions: [ @@ -509,7 +521,7 @@ context('Status actions tests', () => { }, { actionId: 3, actionType: StatusActionType.PROPOSALDOWNLOAD }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); @@ -616,15 +628,16 @@ context('Status actions tests', () => { exchanges: ['user_office_backend.fanout'], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, - posX: 0, - posY: 200, - prevConnectionId: 1, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, }).then((result) => { + cy.setStatusChangingEventsOnConnection({ + workflowConnectionId: result.createWorkflowConnection.id, + statusChangingEvents: [PROPOSAL_EVENTS.PROPOSAL_INSTRUMENTS_SELECTED], + }); cy.addConnectionStatusActions({ actions: [ { @@ -639,7 +652,7 @@ context('Status actions tests', () => { }, { actionId: 3, actionType: StatusActionType.PROPOSALDOWNLOAD }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); @@ -649,6 +662,27 @@ context('Status actions tests', () => { cy.finishedLoading(); + cy.contains(proposalTitle) + .parent() + .find('input[type="checkbox"]') + .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(instrument.name) + .click(); + + cy.get('[data-cy="submit-assign-remove-instrument"]').click(); + + cy.get('[data-cy="proposals-instrument-assignment"]').should('not.exist'); + cy.contains(proposalTitle) .parent() .find('input[type="checkbox"]') @@ -741,17 +775,15 @@ context('Status actions tests', () => { exchanges: ['user_office_backend.fanout'], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.editableSubmitted.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, - posX: 0, - posY: 200, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, }).then((result) => { - const connection = result.addWorkflowStatus; + const connection = result.createWorkflowConnection; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [PROPOSAL_EVENTS.PROPOSAL_SUBMITTED], }); diff --git a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts index 4df33784b6..8ffeff06ca 100644 --- a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts +++ b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts @@ -45,97 +45,104 @@ context('Technique Proposal tests', () => { }; const draftStatus: { - id?: number; + workflowStatusId?: number; name: string; shortCode: string; description: string; } = { - id: initialDBData.proposalStatuses.draft.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, name: 'DRAFT', - shortCode: 'SUBMITTED_LOCKED', + shortCode: 'DRAFT', description: '-', }; const submittedStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { name: 'Submitted (locked)', - shortCode: 'SUBMITTED_LOCKED', + statusId: 'SUBMITTED_LOCKED', description: '-', }; const underReviewStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.underReview.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.underReview.id, name: 'Under review', - shortCode: 'UNDER_REVIEW', + statusId: 'UNDER_REVIEW', description: '-', }; const approvedStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.approved.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.approved.id, name: 'Approved', - shortCode: 'APPROVED', + statusId: 'APPROVED', description: '-', }; const unsuccessfulStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.unsuccessful.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.unsuccessful.id, name: 'Unsuccessful', - shortCode: 'UNSUCCESSFUL', + statusId: 'UNSUCCESSFUL', description: '-', }; const finishedStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.finished.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.finished.id, name: 'Finished', - shortCode: 'FINISHED', + statusId: 'FINISHED', description: '-', }; const expiredStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.expired.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.expired.id, name: 'EXPIRED', - shortCode: 'EXPIRED', + statusId: 'EXPIRED', description: '-', }; const quickReviewStatus: { - id?: number; + workflowStatusId?: number; name: string; - shortCode: string; + statusId: string; description: string; } = { - id: initialDBData.proposalStatuses.quickReview.id, + workflowStatusId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.quickReview.id, name: 'Quick review', - shortCode: 'QUICK_REVIEW', + statusId: 'QUICK_REVIEW', description: '-', }; @@ -238,13 +245,13 @@ context('Technique Proposal tests', () => { cy.resetDB(); cy.createStatus({ + id: submittedStatus.statusId, name: submittedStatus.name, - shortCode: submittedStatus.shortCode, description: submittedStatus.description, entityType: WorkflowType.PROPOSAL, }).then((result) => { if (result.createStatus) { - submittedStatus.id = result.createStatus.id; + submittedStatus.statusId = result.createStatus.id; } }); @@ -256,12 +263,74 @@ context('Technique Proposal tests', () => { callWorkflowId = workflow.id; if (result.createWorkflow) { - cy.addWorkflowStatus({ - statusId: quickReviewStatus.id as number, + cy.addStatusToWorkflow({ + statusId: quickReviewStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + quickReviewStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: expiredStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + expiredStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: submittedStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + submittedStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: underReviewStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + underReviewStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: approvedStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + approvedStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: unsuccessfulStatus.statusId, + workflowId: callWorkflowId, + posX: 0, + posY: 200, + }).then((result) => { + unsuccessfulStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; + }); + + cy.addStatusToWorkflow({ + statusId: finishedStatus.statusId, workflowId: callWorkflowId, - sortOrder: 1, posX: 0, posY: 200, + }).then((result) => { + finishedStatus.workflowStatusId = + result.addStatusToWorkflow.workflowStatusId; }); } @@ -449,7 +518,7 @@ context('Technique Proposal tests', () => { }) .then(() => { cy.changeProposalsStatus({ - statusId: submittedStatus.id as number, + workflowStatusId: submittedStatus.workflowStatusId as number, proposalPks: [createdProposalPk3], }).then(() => { cy.assignProposalToTechniques({ @@ -477,7 +546,7 @@ context('Technique Proposal tests', () => { }) .then(() => { cy.changeProposalsStatus({ - statusId: submittedStatus.id as number, + workflowStatusId: submittedStatus.workflowStatusId as number, proposalPks: [createdProposalPk4], }); }); @@ -496,7 +565,7 @@ context('Technique Proposal tests', () => { abstract: proposal5.abstract, }).then(() => { cy.changeProposalsStatus({ - statusId: expiredStatus.id as number, + workflowStatusId: expiredStatus.workflowStatusId as number, proposalPks: [createdProposalPk5], }).then(() => { cy.assignProposalToTechniques({ @@ -1073,7 +1142,7 @@ context('Technique Proposal tests', () => { it('Scientist should not see expired proposals', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: expiredStatus.id as number, + workflowStatusId: expiredStatus.workflowStatusId as number, }).then(() => { cy.login(scientist2); cy.visit('/'); @@ -1251,7 +1320,7 @@ context('Technique Proposal tests', () => { it('Instrument scientist able to select and assign an instrument for a proposal', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: underReviewStatus.id as number, + workflowStatusId: underReviewStatus.workflowStatusId as number, }); /* @@ -1459,7 +1528,7 @@ context('Technique Proposal tests', () => { */ cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: draftStatus.id as number, + workflowStatusId: draftStatus.workflowStatusId as number, }).then(() => { cy.assignProposalsToInstruments({ proposalPks: createdProposalPk1, @@ -1511,7 +1580,7 @@ context('Technique Proposal tests', () => { */ cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: finishedStatus.id as number, + workflowStatusId: finishedStatus.workflowStatusId as number, }).then(() => { cy.assignProposalsToInstruments({ proposalPks: createdProposalPk1, @@ -1557,7 +1626,7 @@ context('Technique Proposal tests', () => { */ cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: unsuccessfulStatus.id as number, + workflowStatusId: unsuccessfulStatus.workflowStatusId as number, }).then(() => { cy.assignProposalsToInstruments({ proposalPks: createdProposalPk1, @@ -1606,7 +1675,7 @@ context('Technique Proposal tests', () => { it('Scientist can only change to specific statuses and cannot assign an instrument when the current status is submitted', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: submittedStatus.id as number, + workflowStatusId: submittedStatus.workflowStatusId as number, }); cy.login(scientist1); @@ -1656,7 +1725,7 @@ context('Technique Proposal tests', () => { it('Scientist can only change to specific statuses when the current status is under review', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: underReviewStatus.id as number, + workflowStatusId: underReviewStatus.workflowStatusId as number, }); cy.login(scientist1); @@ -1742,7 +1811,7 @@ context('Technique Proposal tests', () => { it('Scientist can only change to specific statuses when the current status is approved', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: approvedStatus.id as number, + workflowStatusId: approvedStatus.workflowStatusId as number, }).then(() => { cy.assignProposalsToInstruments({ proposalPks: createdProposalPk1, @@ -1794,7 +1863,7 @@ context('Technique Proposal tests', () => { it('Instrument scientist is not able to select a retired instrument for a proposal', function () { cy.changeProposalsStatus({ proposalPks: createdProposalPk1, - statusId: underReviewStatus.id as number, + workflowStatusId: underReviewStatus.workflowStatusId as number, }); // Proposal 1 is assigned to instrument 1 diff --git a/apps/e2e/cypress/e2e/templatesBasic.cy.ts b/apps/e2e/cypress/e2e/templatesBasic.cy.ts index a6aba08eae..a274cc7b8c 100644 --- a/apps/e2e/cypress/e2e/templatesBasic.cy.ts +++ b/apps/e2e/cypress/e2e/templatesBasic.cy.ts @@ -1235,7 +1235,7 @@ context('Template Basic tests', () => { instrumentId: 1, }); - cy.changeProposalsStatus({ proposalPks: [1], statusId: 2 }); + cy.changeProposalsStatus({ proposalPks: [1], workflowStatusId: 2 }); cy.login('officer'); cy.visit('/'); @@ -2112,14 +2112,13 @@ context('Template Basic tests', () => { ], }; - cy.addWorkflowStatus({ + cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: initialDBData.workflows.defaultWorkflow.id, - sortOrder: 1, - prevStatusId: initialDBData.proposalStatuses.draft.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, posX: 0, posY: 200, - prevConnectionId: 1, }).then((result) => { cy.reload(); cy.addConnectionStatusActions({ @@ -2130,7 +2129,7 @@ context('Template Basic tests', () => { config: JSON.stringify(statusActionConfig), }, ], - connectionId: result.addWorkflowStatus.id, + connectionId: result.createWorkflowConnection.id, workflowId: initialDBData.workflows.defaultWorkflow.id, }); }); diff --git a/apps/e2e/cypress/support/initialDBData.ts b/apps/e2e/cypress/support/initialDBData.ts index 4f53e21a97..65638e713c 100644 --- a/apps/e2e/cypress/support/initialDBData.ts +++ b/apps/e2e/cypress/support/initialDBData.ts @@ -316,94 +316,111 @@ export default { }, proposalStatuses: { draft: { - id: 1, + id: 'DRAFT', name: 'DRAFT', - shortCode: 'DRAFT', }, feasibilityReview: { - id: 2, + id: 'FEASIBILITY_REVIEW', name: 'FEASIBILITY_REVIEW', - shortCode: 'FEASIBILITY_REVIEW', }, notFeasible: { - id: 3, + id: 'NOT_FEASIBLE', name: 'NOT_FEASIBLE', - shortCode: 'NOT_FEASIBLE', }, fapSelection: { - id: 4, + id: 'FAP_SELECTION', name: 'FAP_SELECTION', - shortCode: 'FAP_SELECTION', }, fapReview: { - id: 5, + id: 'FAP_REVIEW', name: 'FAP_REVIEW', - shortCode: 'FAP_REVIEW', }, expired: { - id: 9, + id: 'EXPIRED', name: 'EXPIRED', - shortCode: 'EXPIRED', }, fapMeeting: { - id: 12, + id: 'FAP_MEETING', name: 'FAP Meeting', - shortCode: 'FAP_MEETING', }, editableSubmitted: { - id: 14, + id: 'EDITABLE_SUBMITTED', name: 'EDITABLE_SUBMITTED', - shortCode: 'EDITABLE_SUBMITTED', }, editableSubmittedInternal: { - id: 15, + id: 'EDITABLE_SUBMITTED_INTERNAL', name: 'EDITABLE_SUBMITTED_INTERNAL', - shortCode: 'EDITABLE_SUBMITTED_INTERNAL', }, awaitingEsf: { - id: 17, + id: 'AWAITING_ESF', name: 'AWAITING ESF', - shortCode: 'AWAITING_ESF', }, esfIsReview: { - id: 18, + id: 'ESF_IS_REVIEW', name: 'ESF_IS_REVIEW', - shortCode: 'ESF_IS_REVIEW', }, esfEsrReview: { - id: 19, + id: 'ESF_ESR_REVIEW', name: 'ESF ESR REVIEW', - shortCode: 'ESF_ESR_REVIEW', }, esfRejected: { - id: 20, + id: 'ESF_REJECTED', name: 'ESF REJECTED', - shortCode: 'ESF_REJECTED', }, quickReview: { - id: 22, + id: 'QUICK_REVIEW', name: 'QUICK_REVIEW', - shortCode: 'QUICK_REVIEW', }, underReview: { - id: 23, + id: 'UNDER_REVIEW', name: 'UNDER_REVIEW', - shortCode: 'UNDER_REVIEW', }, approved: { - id: 24, + id: 'APPROVED', name: 'APPROVED', - shortCode: 'APPROVED', }, unsuccessful: { - id: 25, + id: 'UNSUCCESSFUL', name: 'UNSUCCESSFUL', - shortCode: 'UNSUCCESSFUL', }, finished: { - id: 26, + id: 'FINISHED', name: 'FINISHED', - shortCode: 'FINISHED', + }, + }, + workflows: { + defaultWorkflow: { + id: 1, + workflowStatuses: { + draft: { id: 1, statusId: 'DRAFT' }, + feasibilityReview: { id: 2, statusId: 'FEASIBILITY_REVIEW' }, + notFeasible: { id: 3, statusId: 'NOT_FEASIBLE' }, + fapSelection: { id: 4, statusId: 'FAP_SELECTION' }, + fapReview: { id: 5, statusId: 'FAP_REVIEW' }, + expired: { id: 6, statusId: 'EXPIRED' }, + fapMeeting: { id: 7, statusId: 'FAP_MEETING' }, + editableSubmitted: { id: 8, statusId: 'EDITABLE_SUBMITTED' }, + editableSubmittedInternal: { + id: 9, + statusId: 'EDITABLE_SUBMITTED_INTERNAL', + }, + quickReview: { id: 15, statusId: 'QUICK_REVIEW' }, + underReview: { id: 16, statusId: 'UNDER_REVIEW' }, + approved: { id: 17, statusId: 'APPROVED' }, + unsuccessful: { id: 18, statusId: 'UNSUCCESSFUL' }, + finished: { id: 19, statusId: 'FINISHED' }, + }, + }, + + defaultSafetyWorkflow: { + id: 2, + workflowStatuses: { + awaitingEsf: { id: 10, statusId: 'AWAITING_ESF' }, + esfIsReview: { id: 11, statusId: 'ESF_IS_REVIEW' }, + esfEsrReview: { id: 12, statusId: 'ESF_ESR_REVIEW' }, + esfRejected: { id: 13, statusId: 'ESF_REJECTED' }, + esfApproved: { id: 14, statusId: 'ESF_APPROVED' }, + }, }, }, experiments: { @@ -439,12 +456,6 @@ export default { settingsValue: 'dd-MM-yyyy HH:mm', }, }, - workflows: { - defaultWorkflow: { - id: 1, - }, - defaultDroppableGroup: 'proposalWorkflowConnections_0', - }, sample1: { sampleId: 1, title: 'My sample title', diff --git a/apps/e2e/cypress/support/workflow.ts b/apps/e2e/cypress/support/workflow.ts index 2e4dcbdc32..3ecb9fb5b7 100644 --- a/apps/e2e/cypress/support/workflow.ts +++ b/apps/e2e/cypress/support/workflow.ts @@ -1,14 +1,15 @@ import { - AddConnectionStatusActionsMutation, - AddConnectionStatusActionsMutationVariables, - AddWorkflowStatusMutation, - AddWorkflowStatusMutationVariables, - AddStatusChangingEventsToConnectionMutation, - AddStatusChangingEventsToConnectionMutationVariables, + AddStatusToWorkflowMutation, + AddStatusToWorkflowMutationVariables, CreateStatusMutation, CreateStatusMutationVariables, + CreateWorkflowConnectionMutation, CreateWorkflowMutation, CreateWorkflowMutationVariables, + SetStatusActionsOnConnectionInput, + SetStatusActionsOnConnectionMutation, + SetStatusChangingEventsOnConnectionMutation, + SetStatusChangingEventsOnConnectionMutationVariables, Status, } from '@user-office-software-libs/shared-types'; @@ -33,48 +34,85 @@ const createStatus = ( return cy.wrap(request); }; -const addWorkflowStatus = ( - addWorkflowStatusInput: AddWorkflowStatusMutationVariables -): Cypress.Chainable => { +const addStatusToWorkflow = ( + addStatusToWorkflowInput: Omit< + AddStatusToWorkflowMutationVariables, + 'posX' | 'posY' + > & { + prevId?: number; + posX?: number; + posY?: number; + } +): Cypress.Chainable< + AddStatusToWorkflowMutation & CreateWorkflowConnectionMutation +> => { const api = getE2EApi(); - const request = api.addWorkflowStatus(addWorkflowStatusInput); - return cy.wrap(request); + return cy + .wrap(null) + .then(() => + api.addStatusToWorkflow({ + ...addStatusToWorkflowInput, + posX: addStatusToWorkflowInput.posX ?? 0, + posY: addStatusToWorkflowInput.posY ?? 0, + }) + ) + .then( + (request) => { + if (!addStatusToWorkflowInput.prevId) { + const emptyConnection = { + createWorkflowConnection: null, + } as unknown as CreateWorkflowConnectionMutation; + + return Promise.resolve({ ...request, ...emptyConnection }); + } + + return api + .createWorkflowConnection({ + newWorkflowConnectionInput: { + nextWorkflowStatusId: + request.addStatusToWorkflow.workflowStatusId, + prevWorkflowStatusId: addStatusToWorkflowInput.prevId, + sourceHandle: 'bottom-source', + targetHandle: 'top-target', + }, + }) + .then((request2) => ({ ...request, ...request2 })); + } + ); }; -const addStatusChangingEventsToConnection = ( - addStatusChangingEventsToConnectionInput: AddStatusChangingEventsToConnectionMutationVariables -): Cypress.Chainable => { +const setStatusChangingEventsOnConnection = ( + setStatusChangingEventsOnConnectionInput: SetStatusChangingEventsOnConnectionMutationVariables +): Cypress.Chainable => { const api = getE2EApi(); - const request = api.addStatusChangingEventsToConnection( - addStatusChangingEventsToConnectionInput + const request = api.setStatusChangingEventsOnConnection( + setStatusChangingEventsOnConnectionInput ); return cy.wrap(request); }; const addConnectionStatusActions = ( - addStatusActionToConnectionInput: AddConnectionStatusActionsMutationVariables -): Cypress.Chainable => { + setStatusActionsOnConnectionInput: SetStatusActionsOnConnectionInput +): Cypress.Chainable => { const api = getE2EApi(); - const request = api.addConnectionStatusActions( - addStatusActionToConnectionInput + const request = api.setStatusActionsOnConnection( + setStatusActionsOnConnectionInput ); return cy.wrap(request); }; -const addFeasibilityReviewToDefaultWorkflow = - (): Cypress.Chainable => { - return cy.addWorkflowStatus({ - statusId: initialDBData.proposalStatuses.feasibilityReview.id, - workflowId: 1, - sortOrder: 1, - prevStatusId: 1, - posX: 0, - posY: 200, - }); - }; +const addFeasibilityReviewToDefaultWorkflow = (): Cypress.Chainable< + AddStatusToWorkflowMutation & CreateWorkflowConnectionMutation +> => { + return cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.feasibilityReview.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + prevId: 1, + }); +}; /** * Creates a proper DataTransfer mock that stores and returns data like the real browser API */ @@ -100,7 +138,7 @@ function createDataTransferMock() { */ function dragStatusIntoWorkflow( - status: Pick, + status: Pick, options: { clientX?: number; clientY?: number; @@ -108,7 +146,7 @@ function dragStatusIntoWorkflow( ) { const { clientX = 500, clientY = 300 } = options; - const sourceSelector = `[data-cy="status_${status.shortCode}"]`; + const sourceSelector = `[data-cy="status_${status.id}"]`; const targetSelector = '[data-testid="rf__background"]'; cy.get(sourceSelector).then(($element) => { const sourceElement = $element[0]; @@ -147,24 +185,24 @@ function dragStatusIntoWorkflow( * @param options - Additional options for the connection operation */ function connectReactFlowNodes( - sourceStatus: Pick, - targetStatus: Pick, + sourceStatus: Pick, + targetStatus: Pick, options?: { force?: boolean; } ) { - const sourceNodeSelector = `[data-cy="connection_${sourceStatus.shortCode}"] [data-handlepos="bottom"]`; - const targetNodeSelector = `[data-cy="connection_${targetStatus.shortCode}"] [data-handlepos="top"]`; + const sourceNodeSelector = `[data-cy="workflow_status_${sourceStatus.id}"] [data-handlepos="bottom"]`; + const targetNodeSelector = `[data-cy="workflow_status_${targetStatus.id}"] [data-handlepos="top"]`; cy.get(sourceNodeSelector).click(options); cy.get(targetNodeSelector).click(options); } Cypress.Commands.add('createWorkflow', createWorkflow); Cypress.Commands.add('createStatus', createStatus); -Cypress.Commands.add('addWorkflowStatus', addWorkflowStatus); +Cypress.Commands.add('addStatusToWorkflow', addStatusToWorkflow); Cypress.Commands.add( - 'addStatusChangingEventsToConnection', - addStatusChangingEventsToConnection + 'setStatusChangingEventsOnConnection', + setStatusChangingEventsOnConnection ); Cypress.Commands.add('addConnectionStatusActions', addConnectionStatusActions); Cypress.Commands.add( diff --git a/apps/e2e/cypress/types/workflow.d.ts b/apps/e2e/cypress/types/workflow.d.ts index da5b461d0a..ba8c6dfaef 100644 --- a/apps/e2e/cypress/types/workflow.d.ts +++ b/apps/e2e/cypress/types/workflow.d.ts @@ -1,15 +1,17 @@ import { - AddStatusChangingEventsToConnectionMutationVariables, - AddStatusChangingEventsToConnectionMutation, + SetStatusChangingEventsOnConnectionMutationVariables, + SetStatusChangingEventsOnConnectionMutation, CreateWorkflowMutationVariables, CreateWorkflowMutation, CreateStatusMutationVariables, CreateStatusMutation, - AddWorkflowStatusMutationVariables, AddWorkflowStatusMutation, AddConnectionStatusActionsMutation, + AddStatusToWorkflowMutation, AddConnectionStatusActionsMutationVariables, + AddStatusToWorkflowMutationVariables, Status, + CreateWorkflowConnectionMutation, } from '@user-office-software-libs/shared-types'; declare global { @@ -42,26 +44,35 @@ declare global { /** * Adds status changing event/s to status. When those event/s are fired the the status will be changed to statusCode you pass. * - * @returns {typeof addStatusChangingEventsToConnection} + * @returns {typeof setStatusChangingEventsOnConnection} * @memberof Chainable * @example - * cy.addStatusChangingEventsToConnection('FEASIBILITY_REVIEW', ['PROPOSAL_SUBMITTED']) + * cy.setStatusChangingEventsOnConnection('FEASIBILITY_REVIEW', ['PROPOSAL_SUBMITTED']) */ - addStatusChangingEventsToConnection: ( - addStatusChangingEventsToConnectionInput: AddStatusChangingEventsToConnectionMutationVariables - ) => Cypress.Chainable; + setStatusChangingEventsOnConnection: ( + setStatusChangingEventsOnConnectionInput: SetStatusChangingEventsOnConnectionMutationVariables + ) => Cypress.Chainable; /** - * Add proposal status to workflow. + * Add proposal status to workflow and optionally create connection from previous status. * - * @returns {typeof addWorkflowStatus} + * @returns {typeof addStatusToWorkflow} * @memberof Chainable * @example - * cy.addWorkflowStatus(addWorkflowStatusInput: AddWorkflowStatusMutationVariables) + * cy.addStatusToWorkflow(addStatusToWorkflowInput: AddStatusToWorkflowMutationVariables) */ - addWorkflowStatus: ( - addWorkflowStatusInput: AddWorkflowStatusMutationVariables - ) => Cypress.Chainable; + addStatusToWorkflow: ( + addStatusToWorkflowInput: Omit< + AddStatusToWorkflowMutationVariables, + 'posX' | 'posY' + > & { + prevId?: number; + posX?: number; + posY?: number; + } + ) => Cypress.Chainable< + AddStatusToWorkflowMutation & CreateWorkflowConnectionMutation + >; /** * Add proposal status action to workflow connection. * @@ -92,7 +103,7 @@ declare global { * cy.dragStatusIntoWorkflow(initialDBData.proposalStatuses.draft, { clientX: 100, clientY: 200 }); */ dragStatusIntoWorkflow( - sourceSelector: Pick, + sourceSelector: Pick, options?: { clientX?: number; clientY?: number; @@ -112,8 +123,8 @@ declare global { * ); */ connectReactFlowNodes( - sourceStatus: Pick, - targetStatus: Pick, + sourceStatus: Pick, + targetStatus: Pick, options?: { force?: boolean; } diff --git a/apps/frontend/src/components/call/CallGeneralInfo.tsx b/apps/frontend/src/components/call/CallGeneralInfo.tsx index 312e345be0..90af6aaf0a 100644 --- a/apps/frontend/src/components/call/CallGeneralInfo.tsx +++ b/apps/frontend/src/components/call/CallGeneralInfo.tsx @@ -182,10 +182,10 @@ const CallGeneralInfo = ({ (value) => value.id === proposalWorkflowId ); if (selectedProposalWorkFlow) { - const result = selectedProposalWorkFlow.workflowConnections.some( - (connectionStatus) => { + const result = selectedProposalWorkFlow.statuses.some( + (workflowStatus) => { return ( - connectionStatus.status.shortCode === + workflowStatus.statusId === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL ); } diff --git a/apps/frontend/src/components/common/experimentFilters/ExperimentSafetyStatusFilter.tsx b/apps/frontend/src/components/common/experimentFilters/ExperimentSafetyStatusFilter.tsx index 92ecbc4d7d..39de2f456b 100644 --- a/apps/frontend/src/components/common/experimentFilters/ExperimentSafetyStatusFilter.tsx +++ b/apps/frontend/src/components/common/experimentFilters/ExperimentSafetyStatusFilter.tsx @@ -14,10 +14,10 @@ type ExperimentSafetyStatusFilterProps = { onChange?: Dispatch; shouldShowAll?: boolean; statusId?: number; - hiddenStatuses: number[]; + hiddenStatuses: string[]; }; -function isStatusVisible(hiddenStatuses: number[], status: Status) { +function isStatusVisible(hiddenStatuses: string[], status: Status) { if (hiddenStatuses != null) { for (let i = 0; i < hiddenStatuses.length; i++) { if (hiddenStatuses[i] === status.id) return false; diff --git a/apps/frontend/src/components/common/proposalFilters/StatusFilter.tsx b/apps/frontend/src/components/common/proposalFilters/StatusFilter.tsx index 44f6db5058..c52f3abae7 100644 --- a/apps/frontend/src/components/common/proposalFilters/StatusFilter.tsx +++ b/apps/frontend/src/components/common/proposalFilters/StatusFilter.tsx @@ -12,13 +12,13 @@ import { Status } from 'generated/sdk'; type StatusFilterProps = { statuses?: Status[]; isLoading?: boolean; - onChange?: Dispatch; + onChange?: Dispatch; shouldShowAll?: boolean; - statusId?: number; - hiddenStatuses: number[]; + statusId?: string; + hiddenStatuses: string[]; }; -function isStatusVisible(hiddenStatuses: number[], status: Status) { +function isStatusVisible(hiddenStatuses: string[], status: Status) { if (hiddenStatuses != null) { for (let i = 0; i < hiddenStatuses.length; i++) { if (hiddenStatuses[i] === status.id) return false; @@ -66,13 +66,13 @@ const StatusFilter = ({ return searchParams; }); - onChange?.(status.target.value as number); + onChange?.(status.target.value); }} - value={statusId || 0} - defaultValue={0} + value={statusId || 'ALL'} + defaultValue={'ALL'} data-cy="status-filter" > - {shouldShowAll && All} + {shouldShowAll && All} {statuses.map( (status) => isStatusVisible(hiddenStatuses, status) && ( @@ -93,7 +93,7 @@ StatusFilter.propTypes = { isLoading: PropTypes.bool, onChange: PropTypes.func, shouldShowAll: PropTypes.bool, - statusId: PropTypes.number, + statusId: PropTypes.string, }; export default StatusFilter; diff --git a/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx b/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx index 52c6c08f29..42a77d93d5 100644 --- a/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx +++ b/apps/frontend/src/components/experimentSafety/ExperimentSafetyContainer.tsx @@ -28,7 +28,7 @@ export function createExperimentSafetyStub( esiQuestionaryId: 0, esiQuestionarySubmittedAt: 0, createdBy: 0, - statusId: null, + statusId: '', safetyReviewQuestionaryId: 0, reviewedBy: 0, createdAt: 0, diff --git a/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx b/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx index 961f879f87..015858a726 100644 --- a/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx +++ b/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx @@ -81,7 +81,7 @@ function ExperimentSafetyReviewSummary({ getInitialDecision !== '' ); const [isDownloadEnabled, setIsDownloadEnabled] = useState( - state?.experimentSafety.status?.shortCode === 'ESF_APPROVED' + state?.experimentSafety.status?.id === 'ESF_APPROVED' ); const downloadExperimentSafety = useDownloadPDFExperimentSafety(); @@ -190,7 +190,7 @@ function ExperimentSafetyReviewSummary({ // Update download enabled state based on the actual status setIsDownloadEnabled( submitInstrumentScientistExperimentSafetyReview?.status - ?.shortCode === 'ESF_APPROVED' + ?.id === 'ESF_APPROVED' ); } else { // For USER_OFFICER, EXPERIMENT_SAFETY_REVIEWER, and others @@ -221,7 +221,7 @@ function ExperimentSafetyReviewSummary({ // Update download enabled state based on the actual status setIsDownloadEnabled( submitExperimentSafetyReviewerExperimentSafetyReview - ?.status?.shortCode === 'ESF_APPROVED' + ?.status?.id === 'ESF_APPROVED' ); } // Lock the form after successful submission diff --git a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx index 1bda95f8bc..3164dd87a5 100644 --- a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx +++ b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx @@ -4,60 +4,124 @@ 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 from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import i18n from 'i18n'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; -import { Status, WorkflowType } from 'generated/sdk'; -import { useStatusesData } from 'hooks/settings/useStatusesData'; +import WorkflowView from 'components/settings/workflow/WorkflowView'; +import { WorkflowStatus, WorkflowType } from 'generated/sdk'; +import { ProposalViewData } from 'hooks/proposal/useProposalsCoreData'; +import { useWorkflowStatusesData } from 'hooks/settings/useWorkflowStatusesData'; const changeProposalStatusValidationSchema = yup.object().shape({ - selectedStatusId: yup.string().required('You must select proposal status'), + selectedWorkflowStatusId: yup + .string() + .required('You must select proposal status'), }); type ChangeProposalStatusProps = { close: () => void; - changeStatusOnProposals: (status: Status) => Promise; - allSelectedProposalsHaveInstrument: boolean; - selectedProposalStatuses: number[]; + changeStatusOnProposals: (workflowStatus: WorkflowStatus) => Promise; + selectedProposals: ProposalViewData[]; }; const ChangeProposalStatus = ({ close, changeStatusOnProposals, - allSelectedProposalsHaveInstrument, - selectedProposalStatuses, + selectedProposals, }: ChangeProposalStatusProps) => { + const selectedProposalStatuses = selectedProposals.map( + (p) => p.workflowStatusId + ); + const allSelectedProposalsHaveInstrument = selectedProposals.every( + (p) => p.instruments?.length + ); + const selectedProposalsWorkflowIds = selectedProposals.map( + (p) => p.workflowId + ); const { t } = useTranslation(); const { statuses: proposalStatuses, loadingStatuses: loadingProposalStatuses, - } = useStatusesData(WorkflowType.PROPOSAL); + } = useWorkflowStatusesData(selectedProposalsWorkflowIds[0]); + + const allSelectedProposalsHaveSameWorkflowStatus = + selectedProposalStatuses.every( + (item) => item === selectedProposalStatuses[0] + ); - const allSelectedProposalsHaveSameStatus = selectedProposalStatuses.every( - (item) => item === selectedProposalStatuses[0] + const allProposalsHaveSameWorkflow = selectedProposalsWorkflowIds.every( + (id) => id === selectedProposalsWorkflowIds[0] ); - const selectedProposalsStatus = allSelectedProposalsHaveSameStatus - ? selectedProposalStatuses[0] - : null; + const highlightedNodes = useMemo(() => { + const counts = selectedProposals.reduce( + (acc, proposal) => { + const workflowStatus = proposalStatuses.find( + (ws) => ws.workflowStatusId === proposal.workflowStatusId + ); + if (workflowStatus) { + const statusId = workflowStatus.status.id; + if (!acc[statusId]) { + acc[statusId] = []; + } + acc[statusId].push(proposal.proposalId); + } + + return acc; + }, + {} as Record + ); + + return Object.entries(counts).map(([statusId, entities]) => ({ + statusId, + entities, + })); + }, [selectedProposals, proposalStatuses]); + + if (!allProposalsHaveSameWorkflow) { + return ( + + + All selected proposals must belong to the same workflow in order to + change their status. + + + + ); + } + + const selectedProposalsWorkflowStatus = + allSelectedProposalsHaveSameWorkflowStatus + ? selectedProposalStatuses[0] + : null; return ( - + => { const selectedStatus = proposalStatuses.find( - (call) => call.id === values.selectedStatusId + (status) => + status.workflowStatusId === values.selectedWorkflowStatusId ); if (!selectedStatus) { - actions.setFieldError('selectedStatusId', 'Required'); + actions.setFieldError('selectedWorkflowStatusId', 'Required'); return; } @@ -67,70 +131,109 @@ const ChangeProposalStatus = ({ }} validationSchema={changeProposalStatusValidationSchema} > - {({ isSubmitting, values }): JSX.Element => ( + {({ isSubmitting, values, setFieldValue }): JSX.Element => (
- - Change proposal(s) status - - - ({ - value: status.id, - text: status.name, - }))} - required - disabled={isSubmitting} - data-cy="status-selection" - /> + + Change proposal(s) status + + + + +
+ + s.workflowStatusId === values.selectedWorkflowStatusId + )?.status.id + } + onNodeClicked={(statusId, workflowStatusId) => { + setFieldValue( + 'selectedWorkflowStatusId', + workflowStatusId + ); + }} + /> +
+
+ + + + + ({ + value: status.workflowStatusId, + text: status.status.name, + }))} + required + disabled={isSubmitting} + data-cy="status-selection" + /> + + + + {proposalStatuses.find( + (status) => + status.workflowStatusId === + values.selectedWorkflowStatusId + )?.statusId === 'DRAFT' && ( + + Be aware that changing status to "DRAFT" will + reopen proposal for changes and submission. + + )} + {proposalStatuses.find( + (status) => + status.workflowStatusId === + values.selectedWorkflowStatusId + )?.statusId === 'SCHEDULING' && + !allSelectedProposalsHaveInstrument && ( + + {`Be aware that proposal/s not assigned to an ${i18n.format( + t('instrument'), + 'lowercase' + )} will not be shown in the scheduler after changing status to "SCHEDULING"`} + + )} + {!values.selectedWorkflowStatusId && ( + + Be aware that selected proposals have different statuses + and changing status will affect all of them. + + )} + + +
- {values.selectedStatusId === 1 && ( - - Be aware that changing status to "DRAFT" will reopen - proposal for changes and submission. - - )} - {values.selectedStatusId === 8 && - !allSelectedProposalsHaveInstrument && ( - - {`Be aware that proposal/s not assigned to an ${i18n.format( - t('instrument'), - 'lowercase' - )} will not be shown in the scheduler after changing status to "SCHEDULING"`} - - )} - {!values.selectedStatusId && ( - - Be aware that selected proposals have different statuses and - changing status will affect all of them. - - )} -
)}
diff --git a/apps/frontend/src/components/proposal/ProposalCreate.tsx b/apps/frontend/src/components/proposal/ProposalCreate.tsx index fddc16f21a..bda6d90656 100644 --- a/apps/frontend/src/components/proposal/ProposalCreate.tsx +++ b/apps/frontend/src/components/proposal/ProposalCreate.tsx @@ -40,8 +40,7 @@ export function createProposalStub( questionaryId: 0, proposalId: '', status: { - id: 0, - shortCode: 'DRAFT', + id: 'DRAFT', description: '', name: '', isDefault: true, diff --git a/apps/frontend/src/components/proposal/ProposalFilterBar.tsx b/apps/frontend/src/components/proposal/ProposalFilterBar.tsx index a440e150a4..8a57a9e811 100644 --- a/apps/frontend/src/components/proposal/ProposalFilterBar.tsx +++ b/apps/frontend/src/components/proposal/ProposalFilterBar.tsx @@ -49,7 +49,7 @@ type ProposalFilterBarProps = { proposalStatuses?: { data: Status[]; isLoading: boolean }; setProposalFilter: (filter: ProposalsFilter) => void; filter: ProposalsFilter; - hiddenStatuses: number[]; + hiddenStatuses: string[]; }; const ProposalFilterBar = ({ @@ -101,11 +101,11 @@ const ProposalFilterBar = ({ { setProposalFilter({ ...filter, diff --git a/apps/frontend/src/components/proposal/ProposalPage.tsx b/apps/frontend/src/components/proposal/ProposalPage.tsx index ccf35e7a73..87985984ce 100644 --- a/apps/frontend/src/components/proposal/ProposalPage.tsx +++ b/apps/frontend/src/components/proposal/ProposalPage.tsx @@ -44,7 +44,7 @@ export default function ProposalPage() { showAllProposals: !instrumentId, showMultiInstrumentProposals: false, }, - proposalStatusId: proposalStatusId ? +proposalStatusId : undefined, + proposalStatusId: proposalStatusId ?? undefined, referenceNumbers: proposalId ? [proposalId] : undefined, questionFilter: questionaryFilterFromUrlQuery({ compareOperator, diff --git a/apps/frontend/src/components/proposal/ProposalSummary.tsx b/apps/frontend/src/components/proposal/ProposalSummary.tsx index a3bf88c7c1..625174ac91 100644 --- a/apps/frontend/src/components/proposal/ProposalSummary.tsx +++ b/apps/frontend/src/components/proposal/ProposalSummary.tsx @@ -94,23 +94,23 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { const { call } = await api().getCallSubmissionDetails({ callId: proposal.callId, }); - const connections = call?.proposalWorkflow?.workflowConnections; + const statuses = call?.proposalWorkflow?.statuses; const currentStatusId = proposal.status?.id; - if (connections) { + if (statuses) { const editableStatusesShortCodes = [ ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED.valueOf(), ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL.valueOf(), ]; const hasUpcomingEditableStatus = - connections && - connections.some( - (conn) => - conn.prevStatusId && + statuses && + statuses.some( + (status) => + status.statusId && currentStatusId && - conn.prevStatusId === currentStatusId && - editableStatusesShortCodes?.includes(conn.status.shortCode) + status.statusId === currentStatusId && + editableStatusesShortCodes?.includes(status.statusId) ); if (proposal.status != null && hasUpcomingEditableStatus) { @@ -141,7 +141,7 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { <> ) => [ { title: t('FAP'), field: 'fapCode', emptyValue: '-', hidden: true }, ]; -const proposalStatusFilter: Record = { - ALL: 0, - FEASIBILITY_REVIEW: 2, -}; - const PREFETCH_SIZE = 200; const SELECT_ALL_ACTION_TOOLTIP = 'select-all-prefetched-proposals'; /** @@ -275,14 +270,10 @@ const ProposalTableInstrumentScientist = ({ const { t } = useTranslation(); const isInstrumentScientist = useCheckAccess([UserRole.INSTRUMENT_SCIENTIST]); const isInternalReviewer = useCheckAccess([UserRole.INTERNAL_REVIEWER]); - const statusFilterValue = isInstrumentScientist + const statusFilter = isInstrumentScientist ? settingsMap.get(SettingsId.DEFAULT_INST_SCI_STATUS_FILTER) - ?.settingsValue || 2 - : 0; - let statusFilter = proposalStatusFilter[statusFilterValue]; - if (statusFilter === undefined || statusFilter === null) { - statusFilter = isInstrumentScientist ? 2 : 0; - } + ?.settingsValue || 'FEASIBILITY_REVIEW' + : 'ALL'; const reviewFilterValue = isInstrumentScientist ? settingsMap.get(SettingsId.DEFAULT_INST_SCI_REVIEWER_FILTER) ?.settingsValue || 'ME' @@ -321,9 +312,9 @@ const ProposalTableInstrumentScientist = ({ showAllProposals: !instrumentId, showMultiInstrumentProposals: false, }, - proposalStatusId: proposalStatusId ? +proposalStatusId : undefined, + proposalStatusId: proposalStatusId, referenceNumbers: proposalId ? [proposalId] : undefined, - excludeProposalStatusIds: [9], + excludeProposalStatusIds: ['SCHEDULING'], questionFilter: questionaryFilterFromUrlQuery({ compareOperator, dataType, @@ -350,7 +341,10 @@ const ProposalTableInstrumentScientist = ({ const { loading, proposalsData, totalCount, setProposalsData } = useProposalsCoreData( { - proposalStatusId: proposalFilter.proposalStatusId, + proposalStatusId: + proposalFilter.proposalStatusId === 'ALL' + ? null + : proposalFilter.proposalStatusId, excludeProposalStatusIds: proposalFilter.excludeProposalStatusIds, instrumentFilter: proposalFilter.instrumentFilter, callId: proposalFilter.callId, @@ -991,7 +985,7 @@ const ProposalTableInstrumentScientist = ({ setProposalFilter={setProposalFilter} filter={proposalFilter} hiddenStatuses={ - proposalFilter.excludeProposalStatusIds as number[] + proposalFilter.excludeProposalStatusIds as string[] } /> diff --git a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx index 15c53e755f..4dc0bf408e 100644 --- a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx @@ -15,7 +15,7 @@ import GridOnIcon from '@mui/icons-material/GridOn'; import GroupWork from '@mui/icons-material/GroupWork'; import ReduceCapacityIcon from '@mui/icons-material/ReduceCapacity'; import Warning from '@mui/icons-material/Warning'; -import { IconButton, Tooltip } from '@mui/material'; +import { IconButton, Tooltip, Link } from '@mui/material'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; @@ -57,7 +57,7 @@ import { PaginationSortDirection, ProposalViewInstrument, ProposalsFilter, - Status, + WorkflowStatus, UserRole, } from 'generated/sdk'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; @@ -100,7 +100,7 @@ export type ProposalSelectionType = { callId: number; instruments: ProposalViewInstrument[] | null; fapInstruments: FapInstrument[] | null; - statusId: number; + statusId: string; }; let columns: Column[] = [ @@ -444,6 +444,30 @@ const ProposalTableOfficer = ({ columns = columns.map((v: Column) => { v.customSort = () => 0; // Disables client side sorting + if (v.field === 'statusName') { + return { + ...v, + render: (rowData: ProposalViewData) => ( + { + e.preventDefault(); + setSearchParams((searchParams) => { + searchParams.delete('selection'); + searchParams.append('selection', rowData.primaryKey.toString()); + + return searchParams; + }); + setOpenChangeProposalStatus(true); + }} + > + {rowData.statusName} + + ), + }; + } + return v; }); @@ -612,15 +636,15 @@ const ProposalTableOfficer = ({ refreshTableData(); }; - const changeStatusOnProposals = async (status: Status) => { + const changeStatusOnProposals = async (workflowStatus: WorkflowStatus) => { const proposalPks = getSelectedProposalPks(); - if (status?.id && proposalPks.length) { + if (workflowStatus?.workflowStatusId && proposalPks.length) { const shouldAddPluralLetter = proposalPks.length > 1 ? 's' : ''; await api({ toastSuccessMessage: `Proposal${shouldAddPluralLetter} status changed successfully!`, }).changeProposalsStatus({ proposalPks: proposalPks, - statusId: status.id, + workflowStatusId: workflowStatus.workflowStatusId, }); refreshTableData(); } @@ -1038,7 +1062,7 @@ const ProposalTableOfficer = ({ aria-labelledby="simple-modal-title" aria-describedby="simple-modal-description" open={openChangeProposalStatus} - maxWidth="xs" + maxWidth="lg" onClose={(): void => setOpenChangeProposalStatus(false)} fullWidth > @@ -1046,12 +1070,7 @@ const ProposalTableOfficer = ({ setOpenChangeProposalStatus(false)} - selectedProposalStatuses={selectedProposalsData.map( - (selectedProposal) => selectedProposal.statusId - )} - allSelectedProposalsHaveInstrument={selectedProposalsData.every( - (selectedProposal) => selectedProposal.instruments?.length - )} + selectedProposals={selectedProposalsData} /> diff --git a/apps/frontend/src/components/questionary/questionaries/proposal/ProposalQuestionaryWizardStep.ts b/apps/frontend/src/components/questionary/questionaries/proposal/ProposalQuestionaryWizardStep.ts index 44171e5c95..e0b688f8f0 100644 --- a/apps/frontend/src/components/questionary/questionaries/proposal/ProposalQuestionaryWizardStep.ts +++ b/apps/frontend/src/components/questionary/questionaries/proposal/ProposalQuestionaryWizardStep.ts @@ -33,7 +33,7 @@ export class ProposalQuestionaryWizardStep extends QuestionaryWizardStep { private getProposalStatus(proposal: ProposalWithQuestionary) { if (proposal.status != null) { - return proposal.status.shortCode.toString(); + return proposal.status.id; } else { return 'Proposal Status is null'; } diff --git a/apps/frontend/src/components/review/ReviewQuestionary.tsx b/apps/frontend/src/components/review/ReviewQuestionary.tsx index d22a1c7691..4ec7380060 100644 --- a/apps/frontend/src/components/review/ReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/ReviewQuestionary.tsx @@ -66,8 +66,7 @@ export function createFapReviewStub( questionaryId: 0, proposalId: '', status: { - id: 0, - shortCode: 'DRAFT', + id: 'DRAFT', description: '', name: '', isDefault: true, @@ -92,7 +91,8 @@ export function createFapReviewStub( reviews: [], proposerId: 0, technicalReviews: [], - statusId: 0, + statusId: 'DRAFT', + workflowStatusId: 0, visits: [], updated: new Date(), submittedDate: new Date(), diff --git a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx index e0f729fb19..1072c5184b 100644 --- a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx @@ -79,8 +79,7 @@ export function createTechnicalReviewStub( questionaryId: 0, proposalId: '', status: { - id: 0, - shortCode: 'DRAFT', + id: 'DRAFT', description: '', name: '', isDefault: true, @@ -105,7 +104,8 @@ export function createTechnicalReviewStub( reviews: [], proposerId: 0, technicalReviews: [], - statusId: 0, + statusId: 'DRAFT', + workflowStatusId: 0, visits: [], updated: new Date(), submittedDate: new Date(), diff --git a/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx b/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx index a8fb251b24..07c8fe2c9b 100644 --- a/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx +++ b/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx @@ -27,7 +27,7 @@ const CreateUpdateProposalStatus = ({ const initialValues = proposalStatus ? proposalStatus : { - shortCode: '', + id: '', name: '', description: '', entityType: WorkflowType.PROPOSAL, @@ -42,7 +42,6 @@ const CreateUpdateProposalStatus = ({ const { updateStatus } = await api({ toastSuccessMessage: 'Proposal status updated successfully', }).updateStatus({ - id: proposalStatus.id, ...values, }); @@ -74,12 +73,12 @@ const CreateUpdateProposalStatus = ({ {proposalStatus ? 'Update' : 'Create new'} proposal status { /> ); - const deleteProposalStatus = async (id: number) => { + const deleteProposalStatus = async (id: string) => { return await api({ toastSuccessMessage: 'Proposal status deleted successfully', }) diff --git a/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx b/apps/frontend/src/components/settings/workflow/SetStatusChangingEventsOnConnection.tsx similarity index 91% rename from apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx rename to apps/frontend/src/components/settings/workflow/SetStatusChangingEventsOnConnection.tsx index 54706d8ee0..7020a7a6b1 100644 --- a/apps/frontend/src/components/settings/workflow/AddStatusChangingEventsToConnection.tsx +++ b/apps/frontend/src/components/settings/workflow/SetStatusChangingEventsOnConnection.tsx @@ -15,7 +15,7 @@ import { Event, WorkflowType } from 'generated/sdk'; import { useEventsData } from 'hooks/settings/useEventsData'; import { BOLD_TEXT_STYLE } from 'utils/helperFunctions'; -const addStatusChangingEventsToConnectionValidationSchema = yup.object().shape({ +const setStatusChangingEventsOnConnectionValidationSchema = yup.object().shape({ selectedStatusChangingEvents: yup .array() .of(yup.string()) @@ -23,8 +23,8 @@ const addStatusChangingEventsToConnectionValidationSchema = yup.object().shape({ .required('You must select at least one event'), }); -type AddStatusChangingEventsToConnectionProps = { - addStatusChangingEventsToConnection: (statusChangingEvents: string[]) => void; +type SetStatusChangingEventsOnConnectionProps = { + setStatusChangingEventsOnConnection: (statusChangingEvents: string[]) => void; deleteWorkflowConnection: () => void; onClose: () => void; statusChangingEvents?: Event[]; @@ -33,15 +33,15 @@ type AddStatusChangingEventsToConnectionProps = { entityType: WorkflowType; }; -const AddStatusChangingEventsToConnection = ({ +const SetStatusChangingEventsOnConnection = ({ statusChangingEvents, - addStatusChangingEventsToConnection, + setStatusChangingEventsOnConnection, deleteWorkflowConnection, onClose, statusName, isLoading, entityType, -}: AddStatusChangingEventsToConnectionProps) => { +}: SetStatusChangingEventsOnConnectionProps) => { const theme = useTheme(); const { events, loadingEvents } = useEventsData(entityType); @@ -60,11 +60,11 @@ const AddStatusChangingEventsToConnection = ({ => { - addStatusChangingEventsToConnection( + setStatusChangingEventsOnConnection( values.selectedStatusChangingEvents ); }} - validationSchema={addStatusChangingEventsToConnectionValidationSchema} + validationSchema={setStatusChangingEventsOnConnectionValidationSchema} > {({ isSubmitting, values }): JSX.Element => (
@@ -170,7 +170,7 @@ const AddStatusChangingEventsToConnection = ({ data-cy="submit" > {isLoading && } - Add status changing events + Set status changing events @@ -180,4 +180,4 @@ const AddStatusChangingEventsToConnection = ({ ); }; -export default AddStatusChangingEventsToConnection; +export default SetStatusChangingEventsOnConnection; diff --git a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx index 16e0dab394..48d30225f6 100644 --- a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx @@ -14,7 +14,7 @@ import { } from 'generated/sdk'; import AddStatusActionsToConnection from './AddStatusActionsToConnection'; -import AddStatusChangingEventsToConnection from './AddStatusChangingEventsToConnection'; +import SetStatusChangingEventsOnConnection from './SetStatusChangingEventsOnConnection'; import { EventType, Event } from './WorkflowEditorModel'; type StatusEventsAndActionsDialogProps = { @@ -76,29 +76,31 @@ const StatusEventsAndActionsDialog = ({ } tabPanelPadding={theme.spacing(0, 3)} > - statusChangingEvent.statusChangingEvent ) as WorkflowEvent[] } - statusName={workflowConnection?.status.name} - addStatusChangingEventsToConnection={( + statusName={workflowConnection?.nextStatus.status.name} + setStatusChangingEventsOnConnection={( statusChangingEvents: string[] ) => + workflowConnection && dispatch({ - type: EventType.ADD_NEXT_STATUS_EVENTS_REQUESTED, + type: EventType.SET_STATUS_CHANGING_EVENTS_ON_CONNECTION_REQUESTED, payload: { - statusChangingEvents, - workflowConnection, + workflowConnection: workflowConnection, + statusChangingEvents: statusChangingEvents, }, }) } deleteWorkflowConnection={() => + workflowConnection && dispatch({ type: EventType.DELETE_WORKFLOW_CONNECTION_REQUESTED, payload: { - connectionId: workflowConnection?.id, + connectionId: workflowConnection.id, }, }) } @@ -113,12 +115,12 @@ const StatusEventsAndActionsDialog = ({ type: EventType.ADD_STATUS_ACTION_REQUESTED, payload: { statusActions, - workflowConnection, + workflowConnection: workflowConnection!, }, }); }} connectionStatusActions={workflowConnection?.statusActions} - statusName={workflowConnection?.status.name} + statusName={workflowConnection?.nextStatus.status.name} isLoading={isLoading} /> )} diff --git a/apps/frontend/src/components/settings/workflow/StatusNode.tsx b/apps/frontend/src/components/settings/workflow/StatusNode.tsx index 023c80dc02..a8b946e435 100644 --- a/apps/frontend/src/components/settings/workflow/StatusNode.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusNode.tsx @@ -1,25 +1,39 @@ import DeleteIcon from '@mui/icons-material/Delete'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { IconButton, Paper, Typography } from '@mui/material'; -import { styled } from '@mui/system'; +import { Box, IconButton, Paper, Tooltip, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; import React, { useState } from 'react'; -import { Handle, Position } from 'reactflow'; +import { Handle, Position, ReactFlowState, useStore } from 'reactflow'; -import { Status } from 'generated/sdk'; +import { WorkflowStatus } from 'generated/sdk'; +import withConfirm, { WithConfirmProps } from 'utils/withConfirm'; import { WORKFLOW_INITIAL_STATUSES } from 'utils/workflowInitialStatuses'; -const Container = styled(Paper)({ - borderRadius: '15px', +const Container = styled(Paper, { + shouldForwardProp: (prop) => prop !== 'selected', +})<{ selected?: boolean }>(({ theme, selected }) => ({ + borderRadius: '12px', overflow: 'hidden', width: '300px', -}); + border: selected + ? `2px solid ${theme.palette.primary.main}` + : `1px solid ${theme.palette.divider}`, + boxShadow: selected + ? (theme.shadows[8] as string) + : (theme.shadows[2] as string), + transition: 'all 0.2s ease-in-out', + '&:hover': { + boxShadow: theme.shadows[4] as string, + }, +})); const Title = styled('div')(({ theme }) => ({ - background: theme.palette.grey[200], + background: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', - padding: '4px 10px 2px 10px', + padding: '8px 12px', lineHeight: '1', })); @@ -51,73 +65,200 @@ const Description = styled('div', { opacity: expanded ? 1 : 0, })); -const StyledHandle = styled(Handle)({ +const StyledHandle = styled(Handle, { + shouldForwardProp: (prop) => prop !== 'visible', +})<{ visible?: boolean }>(({ theme, visible = true }) => ({ + width: '8px', + height: '8px', borderRadius: '50%', - background: '#333', + background: theme.palette.primary.light, + border: `2px solid ${theme.palette.primary.main}`, + opacity: visible ? 1 : 0, + transition: 'opacity 0.2s', + pointerEvents: visible ? 'auto' : 'none', + zIndex: visible ? 10 : 0, +})); + +const connectionNodeIdSelector = (state: ReactFlowState) => + state.connectionNodeId; + +const FlexSpan = styled('span')({ + flex: 1, + display: 'flex', + justifyContent: 'space-between', }); -interface StatusNodeProps { +const HighlightCount = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + borderRadius: '12px', + padding: '0 6px', + minWidth: '20px', + height: '20px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginLeft: '8px', + fontSize: '12px', + fontWeight: 'bold', + lineHeight: 1, +})); + +const DeleteButton = styled(IconButton)({ + position: 'absolute', + right: '10px', + background: '#EEE', +}); + +const WorkflowStatusId = styled(Typography)({ + padding: '0 10px 10px 10px', +}); + +const TooltipList = styled('ul')({ + listStyle: 'none', + padding: 0, + margin: 0, +}); + +interface StatusNodeProps extends WithConfirmProps { id: string; // Node ID from React Flow data: { label: string; - status: Status; + workflowStatus: WorkflowStatus; onDelete: (connectionId: string) => void; + isReadOnly?: boolean; + highlightCount?: number; + entityIds?: string[]; }; selected: boolean; } -const StatusNode: React.FC = ({ id, data }) => { +const StatusNode: React.FC = ({ + id, + data, + confirm, + selected, +}) => { const [expanded, setExpanded] = useState(false); + const connectionNodeId = useStore(connectionNodeIdSelector); + const isConnecting = !!connectionNodeId; const handleToggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - setExpanded(!expanded); + if (!data.isReadOnly) { + e.stopPropagation(); + setExpanded(!expanded); + } }; return ( -
+
); }; -export default StatusNode; +export default withConfirm(StatusNode); diff --git a/apps/frontend/src/components/settings/workflow/StatusPicker.tsx b/apps/frontend/src/components/settings/workflow/StatusPicker.tsx index 905cb10956..26be2fd960 100644 --- a/apps/frontend/src/components/settings/workflow/StatusPicker.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusPicker.tsx @@ -31,7 +31,7 @@ const StatusPicker: React.FC = ({ status.description.toLowerCase().includes(searchTerm.toLowerCase())) ) .filter( - (status) => WORKFLOW_INITIAL_STATUSES.includes(status.shortCode) === false + (status) => WORKFLOW_INITIAL_STATUSES.includes(status.id) === false ); return ( @@ -66,7 +66,7 @@ const StatusPicker: React.FC = ({ event.dataTransfer.effectAllowed = 'move'; onDragStart(status); }} - data-cy={`status_${status.shortCode}`} + data-cy={`status_${status.id}`} style={{ marginBottom: '5px', border: '1px solid #e0e0e0', diff --git a/apps/frontend/src/components/settings/workflow/WorkflowCanvas.tsx b/apps/frontend/src/components/settings/workflow/WorkflowCanvas.tsx index d60f96350c..82a260f225 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowCanvas.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowCanvas.tsx @@ -26,24 +26,25 @@ const edgeTypes = { interface EdgeData { events: string[]; - sourceStatusShortCode: string; - targetStatusShortCode: string; + sourceStatusId: string; + targetStatusId: string; } -interface WorkflowCanvasProps { +type WorkflowCanvasProps = React.ComponentProps & { nodes: Node[]; edges: Edge[]; onNodesChange: (changes: NodeChange[]) => void; onEdgesChange: (changes: EdgeChange[]) => void; - onConnect: (connection: Connection) => void; - onInit: (instance: ReactFlowInstance) => void; - onDrop: (event: React.DragEvent) => void; - onDragOver: (event: React.DragEvent) => void; - onEdgeClick: (event: React.MouseEvent, edge: Edge) => void; + onConnect?: (connection: Connection) => void; + onInit?: (instance: ReactFlowInstance) => void; + onDrop?: (event: React.DragEvent) => void; + onDragOver?: (event: React.DragEvent) => void; + onEdgeClick?: (event: React.MouseEvent, edge: Edge) => void; + onNodeClick?: (event: React.MouseEvent, node: Node) => void; onNodeDragStop?: NodeDragHandler; reactFlowWrapper: React.RefObject; connectionLineType: ConnectionLineType; -} +}; const WorkflowCanvas: React.FC = ({ nodes, @@ -55,8 +56,10 @@ const WorkflowCanvas: React.FC = ({ onDrop, onDragOver, onEdgeClick, + onNodeClick, onNodeDragStop, reactFlowWrapper, + ...rest }) => { return (
= ({ onDrop={onDrop} onDragOver={onDragOver} onEdgeClick={onEdgeClick} + onNodeClick={onNodeClick} onNodeDragStop={onNodeDragStop} nodeTypes={nodeTypes} edgeTypes={edgeTypes} @@ -81,8 +85,9 @@ const WorkflowCanvas: React.FC = ({ type: 'workflow', }} fitView + {...rest} > - +
diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx index 705982488d..8dfc54ae23 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx @@ -1,4 +1,4 @@ -import { styled } from '@mui/system'; +import { styled } from '@mui/material/styles'; import React from 'react'; import { BaseEdge, @@ -15,23 +15,37 @@ import { ConnectionStatusAction } from 'generated/sdk'; interface WorkflowEdgeData { events: string[]; - sourceStatusShortCode: string; - targetStatusShortCode: string; + sourceStatusId: string; + targetStatusId: string; workflowConnectionId?: number; statusActions: ConnectionStatusAction[]; connectionLineType?: ConnectionLineType; + isReadOnly?: boolean; } -const List = styled('ul')({ - padding: '0px 5px', - margin: '0', +const List = styled('ul')(({ theme }) => ({ + padding: '4px 8px', + margin: '2px', listStyleType: 'none', - fontSize: '11px', - color: '#333', - lineHeight: '1.6', - backgroundColor: '#FFF', - textAlign: 'center', -}); + fontSize: '10px', + fontWeight: 600, + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + borderRadius: '12px', + boxShadow: theme.shadows[1] as string, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + minWidth: '60px', + border: `1px solid ${theme.palette.primary.dark}`, +})); + +const ActionList = styled(List)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + border: `1px solid ${theme.palette.secondary.dark}`, +})); const WorkflowEdge: React.FC> = ({ id, @@ -77,10 +91,22 @@ const WorkflowEdge: React.FC> = ({ const events = data?.events || []; const statusActions = data?.statusActions || []; + const edgeStyle = + events.length > 0 + ? style + : { + ...style, + strokeDasharray: '2 4', + }; return ( <> - +
> = ({ }} className="nodrag nopan" > - {events.length > 0 && ( + {!data?.isReadOnly && events.length > 0 && ( {events.map((e) => (
  • {e}
  • ))}
    )} - {statusActions.length > 0 && ( - 0 && ( + {statusActions.map((e) => (
  • {`⚡ ${e.action.name}`}
  • ))} -
    + )}
    diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index 11c2a96abc..a0d9d9298b 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -6,7 +6,6 @@ import { Connection, ConnectionLineType, Edge, - MarkerType, Node, ReactFlowInstance, useEdgesState, @@ -14,62 +13,22 @@ import { } from 'reactflow'; import 'reactflow/dist/style.css'; -import { - ConnectionStatusAction, - WorkflowConnection, - WorkflowType, -} from 'generated/sdk'; +import { WorkflowConnection, WorkflowType } from 'generated/sdk'; import { usePersistWorkflowEditorModel } from 'hooks/settings/usePersistWorkflowEditorModel'; import { useStatusesData } from 'hooks/settings/useStatusesData'; import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; -import { FunctionType } from 'utils/utilTypes'; import LoadingOverlay from './LoadingOverlay'; import StatusEventsAndActionsDialog from './StatusEventsAndActionsDialog'; import StatusPicker from './StatusPicker'; import WorkflowCanvas from './WorkflowCanvas'; -import WorkflowEditorModel, { Event, EventType } from './WorkflowEditorModel'; +import WorkflowEditorModel, { EventType } from './WorkflowEditorModel'; import WorkflowMetadataEditor from './WorkflowMetadataEditor'; - -interface EdgeData { - events: string[]; - sourceStatusShortCode: string; - targetStatusShortCode: string; - workflowConnectionId?: number; - statusActions: ConnectionStatusAction[]; - connectionLineType?: ConnectionLineType; - prevConnectionId: number | null; -} - -const edgeFactory = ( - edgeData: Edge & { data: EdgeData } -): Edge => { - const base = { - animated: false, - markerEnd: { - type: MarkerType.ArrowClosed, - width: 20, - height: 20, - }, - style: { cursor: 'pointer' }, - }; - - // Ensure we have valid source and target - if (!edgeData.source || !edgeData.target) { - throw new Error('Edge must have source and target'); - } - - return { - ...base, - id: edgeData.id, - source: edgeData.source, - target: edgeData.target, - sourceHandle: edgeData.sourceHandle || null, - targetHandle: edgeData.targetHandle || null, - data: 'data' in edgeData ? edgeData.data : undefined, - ariaLabel: `Edge from ${edgeData.data.sourceStatusShortCode} to ${edgeData.data.targetStatusShortCode}`, - } as Edge; -}; +import { + EdgeData, + edgeFactory, + mapWorkflowToNodesAndEdges, +} from './workflowUtils'; const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { const { enqueueSnackbar } = useSnackbar(); @@ -82,34 +41,26 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { // State for managing transition events dialog const [selectedEdge, setSelectedEdge] = useState | null>(null); - const [workflowConnection, setWorkflowConnection] = + const [selectedWorkflowConnection, setSelectedWorkflowConnection] = useState(null); - const reducerMiddleware = () => { - return (next: FunctionType) => (action: Event) => { - next(action); - }; - }; - const { persistModel, isLoading } = usePersistWorkflowEditorModel(); - const { state, dispatch } = WorkflowEditorModel(entityType, [ - persistModel, - reducerMiddleware, - ]); + const { state, dispatch } = WorkflowEditorModel(entityType, [persistModel]); // Effect to update edge labels when workflowConnection changes React.useEffect(() => { - if (selectedEdge && workflowConnection) { + if (selectedEdge && selectedWorkflowConnection) { setEdges((eds) => eds.map((e) => { if (e.id === selectedEdge.id) { // Map the status changing events to their display names const eventIds = - workflowConnection.statusChangingEvents?.map( + selectedWorkflowConnection.statusChangingEvents?.map( (event) => event.statusChangingEvent ) || []; - const statusActions = workflowConnection.statusActions || []; + const statusActions = + selectedWorkflowConnection.statusActions || []; return { ...e, @@ -127,98 +78,37 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { }) ); } - }, [workflowConnection, selectedEdge, setEdges, state.connectionLineType]); + }, [ + selectedWorkflowConnection, + selectedEdge, + setEdges, + state.connectionLineType, + ]); // Convert workflow connections to React Flow nodes and edges when state changes React.useEffect(() => { - if (!state.workflowConnections || state.workflowConnections.length === 0) { - setNodes([]); - setEdges([]); - - return; - } - - const newNodes: Node[] = []; - const newEdges: Edge[] = []; - - // Sort connections by sortOrder to maintain proper sequence - const sortedConnections = [...state.workflowConnections].sort( - (a, b) => a.sortOrder - b.sortOrder - ); - - // Create nodes for each connection - sortedConnections.forEach((connection) => { - const statusId = connection.status.id.toString(); - const nodeId = connection.id.toString(); - // Use database coordinates if available, otherwise fall back to grid layout - const nodePositionX = connection.posX; - const nodePositionY = connection.posY; - - // Create node for the status - const newNode = { - id: nodeId, - type: 'statusNode', - data: { - label: connection.status.name, - status: connection.status, - statusId: statusId, - onDelete: (connectionId: string) => { - // Since the node ID is the connection ID, use it directly - dispatch({ - type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, - payload: { - connectionId: Number(connectionId), - }, - }); + const { nodes: newNodes, edges: newEdges } = mapWorkflowToNodesAndEdges( + state, + (workflowStatusId: string) => { + dispatch({ + type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, + payload: { + workflowStatusId: parseInt(workflowStatusId), }, - }, - position: { x: nodePositionX, y: nodePositionY }, - }; - - newNodes.push(newNode); - - // Create edge from previous connection id if it exists because node can have only one parent - if (connection.prevConnectionId) { - const prevStatusId = connection.prevStatusId!.toString(); - const prevConnection = sortedConnections.find( - (c) => c.id === connection.prevConnectionId - ); - - if (prevConnection) { - const edgeId = `edge-${prevStatusId}-${statusId}-${connection.id}`; - const edgeAlreadyExists = newEdges.some((edge) => edge.id === edgeId); - - if (!edgeAlreadyExists) { - const newEdge = edgeFactory({ - id: edgeId, // Use connection ID to ensure unique edge identification - source: connection.prevConnectionId.toString(), - target: connection.id.toString(), - type: 'workflow', // Use custom workflow edge type - data: { - events: - connection.statusChangingEvents?.map( - (e) => e.statusChangingEvent - ) || [], - sourceStatusShortCode: prevConnection.status.shortCode, - targetStatusShortCode: connection.status.shortCode, - workflowConnectionId: connection.id, // Use target connection ID (destination) - statusActions: connection.statusActions || [], - connectionLineType: - state.connectionLineType as ConnectionLineType, - prevConnectionId: connection.prevConnectionId || null, - }, - }); - - newEdges.push(newEdge); - } - } + }); } - }); + ); setNodes(newNodes); setEdges(newEdges); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.workflowConnections, state.connectionLineType, setNodes, setEdges]); + }, [ + state.connections, + state.statuses, + state.connectionLineType, + setNodes, + setEdges, + ]); // Handle connecting nodes (adding transitions) const onConnect = useCallback( @@ -244,96 +134,58 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { return; } - // Check if target node already has a parent (restrict to only one parent) - const targetHasParentInEdges = edges.some( - (edge) => edge.target === connection.target - ); - - if (targetHasParentInEdges) { - enqueueSnackbar( - 'Node can only have one parent connection. Try adding another status of the same type instead.', - { - variant: 'warning', - className: 'snackbar-warning', - } - ); - - return; - } - - const sourceConnection = state.workflowConnections.find( - (s) => s.id.toString() === connection.source - ); - const targetConnection = state.workflowConnections.find( - (s) => s.id.toString() === connection.target - ); - - if (!sourceConnection || !targetConnection) { - return; - } - // Find source and target status names for the edge data - const sourceStatus = statuses.find( - (s) => s.id === sourceConnection.statusId + const sourceWorkflowStatus = state.statuses.find( + (s) => s.workflowStatusId.toString() === connection.source ); - const targetStatus = statuses.find( - (s) => s.id === targetConnection.statusId + const targetWorkflowStatus = state.statuses.find( + (s) => s.workflowStatusId.toString() === connection.target ); - if (!sourceStatus || !targetStatus) { + if (!sourceWorkflowStatus || !targetWorkflowStatus) { return; } + const sourceHandle = connection.sourceHandle ?? 'bottom-source'; + const targetHandle = connection.targetHandle ?? 'top-target'; + // Add the connection to the graph const newEdge = edgeFactory({ id: `temp-${connection.source}-${connection.target}`, // Temporary ID until persisted source: connection.source, target: connection.target, + sourceHandle, + targetHandle, type: 'workflow', // Use custom workflow edge type data: { - events: [], // No events initially - sourceStatusShortCode: sourceStatus.shortCode, - targetStatusShortCode: targetStatus.shortCode, + events: [], + sourceStatusId: sourceWorkflowStatus.status.id, + targetStatusId: targetWorkflowStatus.status.id, statusActions: [], connectionLineType: state.connectionLineType as ConnectionLineType, - prevConnectionId: sourceConnection.id, }, }); setEdges((eds) => addEdge(newEdge, eds)); - // Dispatch update events to persist the connection in the database - // We need to update BOTH nodes: source gets nextStatusId, target gets prevStatusId and prevConnectionId - - // Update source node (A) - set its nextStatusId to target (B) - dispatch({ - type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, - payload: { - connectionId: sourceConnection.id, // Use connection ID for persistence - nextStatusId: targetConnection.statusId, - }, - }); - - // Update target node (B) - set its prevStatusId to source (A) and prevConnectionId to source connection ID + // Dispatch update event to persist the connection in the database dispatch({ - type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, + type: EventType.ADD_WORKFLOW_CONNECTION_REQUESTED, payload: { - connectionId: targetConnection.id, // Use connection ID for persistence - prevStatusId: sourceConnection.statusId, - prevConnectionId: sourceConnection.id, // Store the previous connection ID + sourceWorkflowStatusId: sourceWorkflowStatus.workflowStatusId, + targetWorkflowStatusId: targetWorkflowStatus.workflowStatusId, + sourceHandle, + targetHandle, }, }); - - // Note: Connection is persisted by updating both source and target statuses }, [ - dispatch, edges, - enqueueSnackbar, - setEdges, - statuses, + state.statuses, state.connectionLineType, - state.workflowConnections, + setEdges, + dispatch, + enqueueSnackbar, ] ); @@ -342,17 +194,17 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { (event: React.MouseEvent, edge: Edge) => { setSelectedEdge(edge); - const targetWorkflowConnection = state.workflowConnections.find( - (connection) => connection.id.toString() === edge.target + const clickedWorkflowConnection = state.connections.find( + (connection) => connection.id.toString() === edge.id ); - if (!targetWorkflowConnection) { + if (!clickedWorkflowConnection) { return; } - setWorkflowConnection(targetWorkflowConnection); + setSelectedWorkflowConnection(clickedWorkflowConnection); }, - [state.workflowConnections] + [state.connections] ); // Handle status drag from picker to flow area @@ -386,15 +238,18 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { const newNode: Node = { id: statusId, type: 'statusNode', - ariaLabel: `connection_${status.shortCode}`, + ariaLabel: `workflow_status_${status.id}`, data: { label: status.name, - status, - onDelete: (connectionId: string) => { + workflowStatus: { + workflowStatusId: 0, // Temporary ID until persisted + status: status, + }, + onDelete: (nodeId: string) => { dispatch({ type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, payload: { - connectionId: Number(connectionId), + workflowStatusId: parseInt(nodeId), }, }); }, @@ -409,14 +264,10 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { dispatch({ type: EventType.ADD_WORKFLOW_STATUS_REQUESTED, payload: { - statusId: status.id, - status, - sortOrder: 0, workflowId: state.id, - nextStatusId: null, - prevStatusId: null, posX: position.x, posY: position.y, + status: status, }, }); }, @@ -445,25 +296,25 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { onEdgeClick={onEdgeClick} onNodeDragStop={(event, node) => { // Extract statusId from node data - if (node.data && node.data.status && node.position) { + if (node.data && node.data.workflowStatus && node.position) { const newPosX = Math.round(node.position.x); const newPosY = Math.round(node.position.y); // Find the workflow connection for this status to check current position - const workflowConnection = state.workflowConnections.find( - (connection) => connection.id === parseInt(node.id) + const workflowStatus = state.statuses.find( + (status) => status.workflowStatusId === parseInt(node.id) ); // Only dispatch update if position has actually changed if ( - workflowConnection && - (workflowConnection.posX !== newPosX || - workflowConnection.posY !== newPosY) + workflowStatus && + (workflowStatus.posX !== newPosX || + workflowStatus.posY !== newPosY) ) { dispatch({ type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, payload: { - connectionId: workflowConnection.id, + workflowStatusId: workflowStatus.workflowStatusId, posX: newPosX, posY: newPosY, }, @@ -495,8 +346,8 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { {/* Status Events and Actions Dialog */} {selectedEdge && ( & { workflowStatusId: number }; + } + | { + type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED; + payload: { workflowStatusId: number }; + } + | { + type: EventType.WORKFLOW_STATUS_DELETED; + payload: { workflowStatusId: number }; + } + | { + type: EventType.UPDATE_WORKFLOW_METADATA_REQUESTED; + payload: { + id: number; + name: string; + description: string; + connectionLineType: string; + }; + } + | { + type: EventType.WORKFLOW_METADATA_UPDATED; + payload: { + id: number; + name: string; + description: string; + connectionLineType: string; + }; + } + | { + type: EventType.STATUS_CHANGING_EVENTS_ON_CONNECTION_SET; + payload: { + workflowConnection: WorkflowConnection; + statusChangingEvents: StatusChangingEvent[]; + }; + } + | { + type: EventType.SET_STATUS_CHANGING_EVENTS_ON_CONNECTION_REQUESTED; + payload: { + workflowConnection: WorkflowConnection; + statusChangingEvents: string[]; + }; + } + | { + type: EventType.ADD_STATUS_ACTION_REQUESTED; + payload: { + workflowConnection: WorkflowConnection; + statusActions: ConnectionHasActionsInput[]; + }; + } + | { + type: EventType.STATUS_ACTIONS_UPDATED; + payload: { + workflowConnection: WorkflowConnection; + statusActions: ConnectionStatusAction[]; + }; + } + | { + type: EventType.ADD_WORKFLOW_CONNECTION_REQUESTED; + payload: { + sourceWorkflowStatusId: number; + targetWorkflowStatusId: number; + sourceHandle: string; + targetHandle: string; + }; + } + | { + type: EventType.WORKFLOW_CONNECTION_ADDED; + payload: Partial; + } + | { + type: EventType.DELETE_WORKFLOW_CONNECTION_REQUESTED; + payload: { connectionId: number }; + } + | { + type: EventType.WORKFLOW_CONNECTION_DELETED; + payload: { connectionId: number }; + }; const WorkflowEditorModel = ( entityType: WorkflowType, - middlewares?: Array> + middlewares?: Array>, + externalWorkflowId?: number ) => { - const { workflowId } = useParams<{ workflowId: string }>(); + const params = useParams<{ workflowId: string }>(); + // Use external ID if provided, otherwise fallback to URL params + const workflowId = + externalWorkflowId || + (params.workflowId ? parseInt(params.workflowId) : undefined); const blankInitTemplate: Workflow = { id: 0, name: '', description: '', - workflowConnections: [], + connections: [], + statuses: [], connectionLineType: ConnectionLineType.Bezier, entityType: entityType, }; @@ -54,44 +170,33 @@ const WorkflowEditorModel = ( case EventType.READY: return action.payload; case EventType.WORKFLOW_STATUS_ADDED: { - // Add the new workflow connection to the state - if ( - action.payload && - action.payload.statusId && - action.payload.status - ) { - const newConnection = { - id: action.payload.id || 0, // Will be updated when API response comes back + // Add the new workflow status to the state + if (action.payload && action.payload.status) { + const newWorkflowStatus: WorkflowStatus = { + workflowStatusId: action.payload.workflowStatusId, workflowId: action.payload.workflowId, - statusId: action.payload.statusId, + statusId: action.payload.status.id, status: action.payload.status, - sortOrder: action.payload.sortOrder, - prevStatusId: action.payload.prevStatusId, - nextStatusId: action.payload.nextStatusId, posX: action.payload.posX, posY: action.payload.posY, - prevConnectionId: action.payload.prevConnectionId || null, - statusChangingEvents: [], - statusActions: [], }; - draft.workflowConnections.push(newConnection); + draft.statuses.push(newWorkflowStatus); } return draft; } case EventType.WORKFLOW_STATUS_UPDATED: { - // If payload contains an updated connection (from middleware response), update state - if (action.payload && action.payload.id) { - const updatedConnection = action.payload; - const connectionIndex = draft.workflowConnections.findIndex( - (conn) => - conn.id === updatedConnection.id || - (conn.id === 0 && conn.statusId === updatedConnection.statusId) + // If payload contains an updated status (from middleware response), update state + if (action.payload && action.payload.workflowStatusId) { + const updatedStatus = action.payload; + const statusIndex = draft.statuses.findIndex( + (status) => + status.workflowStatusId === updatedStatus.workflowStatusId ); - if (connectionIndex !== -1) { - draft.workflowConnections[connectionIndex] = { - ...draft.workflowConnections[connectionIndex], - ...updatedConnection, + if (statusIndex !== -1) { + draft.statuses[statusIndex] = { + ...draft.statuses[statusIndex], + ...updatedStatus, }; } } @@ -100,10 +205,11 @@ const WorkflowEditorModel = ( return draft; } case EventType.WORKFLOW_STATUS_DELETED: { - // Remove the workflow connection by connectionId - if (action.payload.connectionId) { - draft.workflowConnections = draft.workflowConnections.filter( - (conn) => conn.id !== action.payload.connectionId + // Remove the workflow status by workflow statusId + if (action.payload.workflowStatusId) { + draft.statuses = draft.statuses.filter( + (status) => + status.workflowStatusId !== action.payload.workflowStatusId ); } @@ -112,26 +218,71 @@ const WorkflowEditorModel = ( case EventType.WORKFLOW_METADATA_UPDATED: { return { ...draft, ...action.payload }; } - case EventType.NEXT_STATUS_EVENTS_ADDED: { + case EventType.STATUS_CHANGING_EVENTS_ON_CONNECTION_SET: { const { workflowConnection, statusChangingEvents } = action.payload; - const connectionIndex = draft.workflowConnections.findIndex( + const connectionIndex = draft.connections.findIndex( (conn) => conn.id === workflowConnection.id ); if (connectionIndex !== -1) { - draft.workflowConnections[connectionIndex].statusChangingEvents = + draft.connections[connectionIndex].statusChangingEvents = statusChangingEvents; } return draft; } - case EventType.STATUS_ACTION_ADDED: { + case EventType.STATUS_ACTIONS_UPDATED: { const { workflowConnection, statusActions } = action.payload; - const connectionIndex = draft.workflowConnections.findIndex( + const connectionIndex = draft.connections.findIndex( (conn) => conn.id === workflowConnection.id ); if (connectionIndex !== -1) { - draft.workflowConnections[connectionIndex].statusActions = - statusActions; + draft.connections[connectionIndex].statusActions = statusActions; + } + + return draft; + } + case EventType.ADD_WORKFLOW_CONNECTION_REQUESTED: { + const { sourceWorkflowStatusId, targetWorkflowStatusId } = + action.payload; + + const prevStatus = draft.statuses.find( + (status) => status.workflowStatusId === sourceWorkflowStatusId + )!; + const nextStatus = draft.statuses.find( + (status) => status.workflowStatusId === targetWorkflowStatusId + )!; + + draft.connections.push({ + id: 0, // Temporary ID, will be updated when API response comes back + workflowId: draft.id, + prevWorkflowStatusId: sourceWorkflowStatusId, + nextWorkflowStatusId: targetWorkflowStatusId, + prevStatus, + nextStatus, + statusChangingEvents: [], + statusActions: [], + sourceHandle: action.payload.sourceHandle, + targetHandle: action.payload.targetHandle, + }); + + return draft; + } + case EventType.WORKFLOW_CONNECTION_ADDED: { + // Update the connection with the real ID from the API + const connectionIndex = draft.connections.findIndex( + (conn) => + conn.prevWorkflowStatusId === + action.payload.prevWorkflowStatusId && + conn.nextWorkflowStatusId === action.payload.nextWorkflowStatusId + ); + if ( + connectionIndex !== -1 && + draft.connections[connectionIndex].id === 0 + ) { + draft.connections[connectionIndex] = { + ...draft.connections[connectionIndex], + ...action.payload, + }; } return draft; @@ -139,7 +290,7 @@ const WorkflowEditorModel = ( case EventType.WORKFLOW_CONNECTION_DELETED: { // Remove the workflow connection by connectionId if (action.payload && action.payload.connectionId) { - draft.workflowConnections = draft.workflowConnections.filter( + draft.connections = draft.connections.filter( (conn) => conn.id !== action.payload.connectionId ); } @@ -168,14 +319,16 @@ const WorkflowEditorModel = ( api() .getWorkflow({ - workflowId: parseInt(workflowId), + workflowId: workflowId, entityType: entityType, }) .then((data) => { - memoizedDispatch({ - type: EventType.READY, - payload: data.workflow, - }); + if (data.workflow) { + memoizedDispatch({ + type: EventType.READY, + payload: { ...data.workflow, entityType }, + }); + } }); }, [api, memoizedDispatch, workflowId, entityType]); diff --git a/apps/frontend/src/components/settings/workflow/WorkflowView.tsx b/apps/frontend/src/components/settings/workflow/WorkflowView.tsx new file mode 100644 index 0000000000..0f95d86dc5 --- /dev/null +++ b/apps/frontend/src/components/settings/workflow/WorkflowView.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef } from 'react'; +import { ConnectionLineType, useEdgesState, useNodesState } from 'reactflow'; +import 'reactflow/dist/style.css'; + +import { WorkflowType } from 'generated/sdk'; + +import WorkflowCanvas from './WorkflowCanvas'; +import WorkflowEditorModel from './WorkflowEditorModel'; +import { mapWorkflowToNodesAndEdges } from './workflowUtils'; + +interface WorkflowViewProps { + workflowId: number; + entityType: WorkflowType; + highlightedNodes?: { statusId: string; entities: string[] }[]; + onNodeClicked?: (statusId: string, workflowStatusId: number) => void; + selectedStatusId?: string; // To highlight selected status +} + +const WorkflowView: React.FC = ({ + workflowId, + entityType, + highlightedNodes, + onNodeClicked, + selectedStatusId, +}) => { + const reactFlowWrapper = useRef(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // Pass externalWorkflowId to the model + const { state } = WorkflowEditorModel(entityType, [], workflowId); + + useEffect(() => { + if (state.id === 0) return; // Not loaded yet + + const { nodes: newNodes, edges: newEdges } = mapWorkflowToNodesAndEdges( + state, + undefined, // onDelete + highlightedNodes, + true // isReadOnly + ); + + const nodesWithSelection = newNodes.map((node) => ({ + ...node, + selected: selectedStatusId + ? node.data.statusId === selectedStatusId + : node.selected, + })); + + setNodes(nodesWithSelection); + setEdges(newEdges); + }, [state, highlightedNodes, setNodes, setEdges, selectedStatusId]); + + return ( + { + if (onNodeClicked) { + onNodeClicked( + node.data.statusId, + node.data.workflowStatus.workflowStatusId + ); + } + }} + // Disable other interactions + zoomOnDoubleClick={false} + /> + ); +}; + +export default WorkflowView; diff --git a/apps/frontend/src/components/settings/workflow/workflowUtils.ts b/apps/frontend/src/components/settings/workflow/workflowUtils.ts new file mode 100644 index 0000000000..63394f4c15 --- /dev/null +++ b/apps/frontend/src/components/settings/workflow/workflowUtils.ts @@ -0,0 +1,116 @@ +import { ConnectionLineType, Edge, MarkerType, Node } from 'reactflow'; + +import { + ConnectionStatusAction, + Workflow, + WorkflowConnection, +} from 'generated/sdk'; + +export interface EdgeData { + events: string[]; + sourceStatusId: string; + targetStatusId: string; + workflowConnectionId?: number; + statusActions: ConnectionStatusAction[]; + connectionLineType?: ConnectionLineType; + isReadOnly?: boolean; +} + +export const edgeFactory = ( + edgeData: Edge & { data: EdgeData } +): Edge => { + const base = { + animated: false, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + }, + style: { cursor: 'pointer' }, + }; + + // Ensure we have valid source and target + if (!edgeData.source || !edgeData.target) { + throw new Error('Edge must have source and target'); + } + + return { + ...base, + id: edgeData.id, + source: edgeData.source, + target: edgeData.target, + sourceHandle: edgeData.sourceHandle || null, + targetHandle: edgeData.targetHandle || null, + data: 'data' in edgeData ? edgeData.data : undefined, + ariaLabel: `Edge from ${edgeData.data.sourceStatusId} to ${edgeData.data.targetStatusId}`, + } as Edge; +}; + +export const mapWorkflowToNodesAndEdges = ( + state: Workflow, + onDelete?: (workflowStatusId: string) => void, + highlightedNodes?: { statusId: string; entities: string[] }[], + isReadOnly?: boolean +): { nodes: Node[]; edges: Edge[] } => { + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + + state.statuses.forEach((workflowStatus) => { + const statusId = workflowStatus.status.id.toString(); + const nodeId = workflowStatus.workflowStatusId.toString(); + const nodePositionX = workflowStatus.posX; + const nodePositionY = workflowStatus.posY; + + const highlightedNode = highlightedNodes?.find( + (n) => n.statusId === statusId + ); + + // Create node for the status + const newNode = { + id: nodeId, + type: 'statusNode', + data: { + label: workflowStatus.status.name, + workflowStatus: workflowStatus, + statusId: statusId, + onDelete: onDelete + ? (workflowStatusId: string) => onDelete(workflowStatusId) + : undefined, + isReadOnly, + highlightCount: highlightedNode?.entities.length || 0, + entityIds: highlightedNode?.entities || [], + }, + position: { x: nodePositionX, y: nodePositionY }, + }; + + newNodes.push(newNode); + }); + + state.connections.forEach((connection: WorkflowConnection) => { + const edgeId = `${connection.id}`; + + const newEdge = edgeFactory({ + id: edgeId, // Use connection ID to ensure unique edge identification + source: connection.prevStatus.workflowStatusId.toString(), + target: connection.nextStatus.workflowStatusId.toString(), + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle, + type: 'workflow', // Use custom workflow edge type + data: { + events: + connection.statusChangingEvents?.map((e) => e.statusChangingEvent) || + [], + sourceStatusId: connection.prevStatus.status.id, + targetStatusId: connection.nextStatus.status.id, + workflowConnectionId: connection.id, // Use target connection ID (destination) + statusActions: connection.statusActions || [], + connectionLineType: state.connectionLineType as ConnectionLineType, + isReadOnly, + }, + }); + + newEdges.push(newEdge); + }); + + return { nodes: newNodes, edges: newEdges }; +}; diff --git a/apps/frontend/src/components/techniqueProposal/TechniqueProposalFilterBar.tsx b/apps/frontend/src/components/techniqueProposal/TechniqueProposalFilterBar.tsx index 6f579a3aa0..4801fd2f9e 100644 --- a/apps/frontend/src/components/techniqueProposal/TechniqueProposalFilterBar.tsx +++ b/apps/frontend/src/components/techniqueProposal/TechniqueProposalFilterBar.tsx @@ -86,11 +86,11 @@ const TechniqueProposalFilterBar = ({ { handleFilterChange({ ...filter, diff --git a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx index 04cd8fe591..8b0d4d91db 100644 --- a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx +++ b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx @@ -91,7 +91,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const { calls, loadingCalls, setCallsFilter } = useCallsData( { - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', instrumentIds, }, { @@ -118,7 +118,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { } if (instrumentIds.length > 0) { setCallsFilter({ - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', instrumentIds, }); } @@ -137,7 +137,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const proposalId = searchParams.get('proposalId'); const to = searchParams.get('to'); const from = searchParams.get('from'); - const proposalStatusId = searchParams.get('proposalStatusId'); + const proposalStatusId = searchParams.get('status'); const search = searchParams.get('search'); const selection = searchParams.getAll('selection'); const sortField = searchParams.get('sortField'); @@ -174,23 +174,21 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { ]; const techPropStatuses = proposalStatuses.filter((ps) => - techPropStatusCodes.includes(ps.shortCode as StatusCode) + techPropStatusCodes.includes(ps.id as StatusCode) ); // Use a consistent order representing the technique proposal flow techPropStatuses.sort((a, b) => { return ( - techPropStatusCodes.indexOf(a.shortCode as StatusCode) - - techPropStatusCodes.indexOf(b.shortCode as StatusCode) + techPropStatusCodes.indexOf(a.id as StatusCode) - + techPropStatusCodes.indexOf(b.id as StatusCode) ); }); const excludedStatusIds = proposalStatuses - .filter( - (status) => !techPropStatusCodes.includes(status.shortCode as StatusCode) - ) + .filter((status) => !techPropStatusCodes.includes(status.id as StatusCode)) .map((status) => status.id); - + console.log({ proposalStatusId }); const [proposalFilter, setProposalFilter] = useState({ callId, instrumentFilter: { @@ -208,7 +206,8 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { from: from ? from : null, }, referenceNumbers: proposalId ? [proposalId] : undefined, - proposalStatusId: proposalStatusId ? +proposalStatusId : undefined, + proposalStatusId: + !proposalStatusId || proposalStatusId == 'ALL' ? null : proposalStatusId, text: search, excludeProposalStatusIds: excludedStatusIds, }); @@ -336,12 +335,12 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const updateProposalStatus = async ( proposalPk: number, - statusId: number + workflowStatusId: number ): Promise => { await api({ toastSuccessMessage: 'Proposal status updated successfully!', }).changeTechniqueProposalsStatus({ - statusId: statusId, + workflowStatusId: workflowStatusId, proposalPks: [proposalPk], }); @@ -402,7 +401,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const selectedStatus = proposalStatuses.find( (ps) => ps.name === rowData.statusName - )?.shortCode; + )?.id; const shouldBeUneditable = !isUserOfficer && selectedStatus !== StatusCode.UNDER_REVIEW; @@ -545,16 +544,16 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { } else { availableStatuses = techPropStatuses.filter( (status) => - status.shortCode !== StatusCode.SUBMITTED_LOCKED && - status.shortCode !== StatusCode.DRAFT && - status.shortCode !== StatusCode.EXPIRED + status.id !== StatusCode.SUBMITTED_LOCKED && + status.id !== StatusCode.DRAFT && + status.id !== StatusCode.EXPIRED ); } techPropStatuses.sort((a, b) => { return ( - techPropStatusCodes.indexOf(a.shortCode as StatusCode) - - techPropStatusCodes.indexOf(b.shortCode as StatusCode) + techPropStatusCodes.indexOf(a.id as StatusCode) - + techPropStatusCodes.indexOf(b.id as StatusCode) ); }); @@ -572,12 +571,12 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const isInstrumentAbsent = (rowData.instruments?.length ?? 0) === 0; const status = { - isDraft: fieldValue?.shortCode === StatusCode.DRAFT, - isSubmitted: fieldValue?.shortCode === StatusCode.SUBMITTED_LOCKED, - isUnsuccessful: fieldValue?.shortCode === StatusCode.UNSUCCESSFUL, - isApproved: fieldValue?.shortCode === StatusCode.APPROVED, - isFinished: fieldValue?.shortCode === StatusCode.FINISHED, - isExpired: fieldValue?.shortCode === StatusCode.EXPIRED, + isDraft: fieldValue?.id === StatusCode.DRAFT, + isSubmitted: fieldValue?.id === StatusCode.SUBMITTED_LOCKED, + isUnsuccessful: fieldValue?.id === StatusCode.UNSUCCESSFUL, + isApproved: fieldValue?.id === StatusCode.APPROVED, + isFinished: fieldValue?.id === StatusCode.FINISHED, + isExpired: fieldValue?.id === StatusCode.EXPIRED, }; const shouldDisableUnderReview = @@ -608,10 +607,25 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { if (e.target.value) { confirm( () => { - updateProposalStatus( - rowData.primaryKey, - +e.target.value - ); + api() + .getWorkflowStatuses({ + workflowId: rowData.workflowId, + }) + .then(({ workflowStatuses }) => { + const selectedWorkflowStatus = + workflowStatuses?.find( + (s) => s.statusId === e.target.value + ); + if (!selectedWorkflowStatus) { + throw new Error( + 'Selected workflow status not found' + ); + } + updateProposalStatus( + rowData.primaryKey, + selectedWorkflowStatus.workflowStatusId + ); + }); }, { title: 'Change status', @@ -633,13 +647,13 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { value={status.id} disabled={ !isUserOfficer && - ((status.shortCode === StatusCode.APPROVED && + ((status.id === StatusCode.APPROVED && shouldDisableApproved) || - (status.shortCode === StatusCode.FINISHED && + (status.id === StatusCode.FINISHED && shouldDisableFinished) || - (status.shortCode === StatusCode.UNDER_REVIEW && + (status.id === StatusCode.UNDER_REVIEW && shouldDisableUnderReview) || - (status.shortCode === StatusCode.UNSUCCESSFUL && + (status.id === StatusCode.UNSUCCESSFUL && shouldDisableUnsuccessful)) } > @@ -725,7 +739,9 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { referenceNumbers, dateFilter, excludeProposalStatusIds: - currentRole === UserRole.INSTRUMENT_SCIENTIST ? [9] : [], // Hide expired from scientists + currentRole === UserRole.INSTRUMENT_SCIENTIST + ? [StatusCode.EXPIRED] + : [], }, sortField: orderBy?.orderByField, sortDirection: @@ -832,6 +848,10 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { } else { updatedFilter.callIds = [filter.callId as number]; } + + if (filter.proposalStatusId === 'ALL') { + updatedFilter.proposalStatusId = null; + } } setProposalFilter(updatedFilter); diff --git a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql index a5b0bc7a96..2c76cd086d 100644 --- a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql +++ b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql @@ -3,11 +3,11 @@ query getCallSubmissionDetails($callId: Int!) { proposalWorkflowId submissionMessage proposalWorkflow { - workflowConnections { - prevStatusId - status { - shortCode - } + connections { + ...workflowConnection + } + statuses { + ...workflowStatus } } } diff --git a/apps/frontend/src/graphql/call/updateCall.graphql b/apps/frontend/src/graphql/call/updateCall.graphql index 72dd2baa31..792f23ab3a 100644 --- a/apps/frontend/src/graphql/call/updateCall.graphql +++ b/apps/frontend/src/graphql/call/updateCall.graphql @@ -30,6 +30,7 @@ mutation updateCall( $faps: [Int!] $isActive: Boolean $callFapReviewEnded: Boolean + $callEnded: Boolean ) { updateCall( updateCallInput: { @@ -64,6 +65,7 @@ mutation updateCall( faps: $faps isActive: $isActive callFapReviewEnded: $callFapReviewEnded + callEnded: $callEnded } ) { ...call diff --git a/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql b/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql index 599c1f5e33..61319f93c6 100644 --- a/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql +++ b/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql @@ -1,8 +1,8 @@ -mutation changeProposalsStatus( - $proposalPks: [Int!]! - $statusId: Int! -) { +mutation changeProposalsStatus($proposalPks: [Int!]!, $workflowStatusId: Int!) { changeProposalsStatus( - changeProposalsStatusInput: { proposalPks: $proposalPks, statusId: $statusId } + changeProposalsStatusInput: { + proposalPks: $proposalPks + workflowStatusId: $workflowStatusId + } ) } diff --git a/apps/frontend/src/graphql/proposal/changeTechniqueProposalsStatus.graphql b/apps/frontend/src/graphql/proposal/changeTechniqueProposalsStatus.graphql index 39984b303a..76f4e7425e 100644 --- a/apps/frontend/src/graphql/proposal/changeTechniqueProposalsStatus.graphql +++ b/apps/frontend/src/graphql/proposal/changeTechniqueProposalsStatus.graphql @@ -1,8 +1,11 @@ mutation changeTechniqueProposalsStatus( $proposalPks: [Int!]! - $statusId: Int! + $workflowStatusId: Int! ) { changeTechniqueProposalsStatus( - changeProposalsStatusInput: { proposalPks: $proposalPks, statusId: $statusId } + changeProposalsStatusInput: { + proposalPks: $proposalPks + workflowStatusId: $workflowStatusId + } ) } diff --git a/apps/frontend/src/graphql/proposal/fragment.proposal.graphql b/apps/frontend/src/graphql/proposal/fragment.proposal.graphql index 3d46f378e9..cd284b12f7 100644 --- a/apps/frontend/src/graphql/proposal/fragment.proposal.graphql +++ b/apps/frontend/src/graphql/proposal/fragment.proposal.graphql @@ -3,6 +3,7 @@ fragment proposal on Proposal { title abstract statusId + workflowStatusId status { ...status } diff --git a/apps/frontend/src/graphql/proposal/getProposalsCore.graphql b/apps/frontend/src/graphql/proposal/getProposalsCore.graphql index 45bcfba83b..1ee80cbb65 100644 --- a/apps/frontend/src/graphql/proposal/getProposalsCore.graphql +++ b/apps/frontend/src/graphql/proposal/getProposalsCore.graphql @@ -17,6 +17,7 @@ query getProposalsCore( proposalViews { primaryKey title + workflowStatusId statusId statusName statusDescription diff --git a/apps/frontend/src/graphql/proposal/getTechniqueScientistProposals.graphql b/apps/frontend/src/graphql/proposal/getTechniqueScientistProposals.graphql index f4f63fa17b..bba30bf8a1 100644 --- a/apps/frontend/src/graphql/proposal/getTechniqueScientistProposals.graphql +++ b/apps/frontend/src/graphql/proposal/getTechniqueScientistProposals.graphql @@ -39,6 +39,7 @@ query getTechniqueScientistProposals( id name } + workflowId } totalCount } diff --git a/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql new file mode 100644 index 0000000000..677c4becdb --- /dev/null +++ b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql @@ -0,0 +1,17 @@ +mutation addStatusToWorkflow( + $workflowId: Int! + $statusId: String! + $posX: Int! + $posY: Int! +) { + addStatusToWorkflow( + newWorkflowStatusInput: { + workflowId: $workflowId + statusId: $statusId + posX: $posX + posY: $posY + } + ) { + workflowStatusId + } +} diff --git a/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql deleted file mode 100644 index e2840e2994..0000000000 --- a/apps/frontend/src/graphql/settings/addWorkflowStatus.graphql +++ /dev/null @@ -1,25 +0,0 @@ -mutation addWorkflowStatus( - $workflowId: Int! - $sortOrder: Int! - $statusId: Int! - $posX: Int! - $posY: Int! - $nextStatusId: Int - $prevStatusId: Int - $prevConnectionId: Int -) { - addWorkflowStatus( - newWorkflowStatusInput: { - workflowId: $workflowId - sortOrder: $sortOrder - statusId: $statusId - nextStatusId: $nextStatusId - prevStatusId: $prevStatusId - posX: $posX - posY: $posY - prevConnectionId: $prevConnectionId - } - ) { - id - } -} diff --git a/apps/frontend/src/graphql/settings/createStatus.graphql b/apps/frontend/src/graphql/settings/createStatus.graphql index d7c3dece25..f5d252fae4 100644 --- a/apps/frontend/src/graphql/settings/createStatus.graphql +++ b/apps/frontend/src/graphql/settings/createStatus.graphql @@ -1,12 +1,12 @@ mutation createStatus( - $shortCode: String! + $id: String! $name: String! $description: String! $entityType: WorkflowType! ) { createStatus( newStatusInput: { - shortCode: $shortCode + id: $id name: $name description: $description entityType: $entityType diff --git a/apps/frontend/src/graphql/settings/createWorkflow.graphql b/apps/frontend/src/graphql/settings/createWorkflow.graphql index 7b0eaddf9b..7b36725a52 100644 --- a/apps/frontend/src/graphql/settings/createWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/createWorkflow.graphql @@ -14,21 +14,9 @@ mutation createWorkflow( name description connectionLineType - workflowConnections { - id - sortOrder - workflowId - statusId - status { - ...status - } - nextStatusId - prevStatusId - prevConnectionId - posX - posY + connections { + ...workflowConnection statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } @@ -36,5 +24,8 @@ mutation createWorkflow( ...connectionStatusAction } } + statuses { + ...workflowStatus + } } } diff --git a/apps/frontend/src/graphql/settings/createWorkflowConnection.graphql b/apps/frontend/src/graphql/settings/createWorkflowConnection.graphql new file mode 100644 index 0000000000..ed90202bfd --- /dev/null +++ b/apps/frontend/src/graphql/settings/createWorkflowConnection.graphql @@ -0,0 +1,9 @@ +mutation createWorkflowConnection( + $newWorkflowConnectionInput: CreateWorkflowConnectionInput! +) { + createWorkflowConnection( + newWorkflowConnectionInput: $newWorkflowConnectionInput + ) { + ...workflowConnection + } +} diff --git a/apps/frontend/src/graphql/settings/deleteStatus.graphql b/apps/frontend/src/graphql/settings/deleteStatus.graphql index 4bc33ac068..63a34bc3ac 100644 --- a/apps/frontend/src/graphql/settings/deleteStatus.graphql +++ b/apps/frontend/src/graphql/settings/deleteStatus.graphql @@ -1,4 +1,4 @@ -mutation deleteStatus($id: Int!) { +mutation deleteStatus($id: String!) { deleteStatus(id: $id) { ...status } diff --git a/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql b/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql index d702ff8669..817a2189b0 100644 --- a/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql +++ b/apps/frontend/src/graphql/settings/deleteWorkflowConnection.graphql @@ -1,13 +1,5 @@ mutation deleteWorkflowConnection($connectionId: Int!) { deleteWorkflowConnection(connectionId: $connectionId) { - id - sortOrder - workflowId - statusId - nextStatusId - prevStatusId - prevConnectionId - posX - posY + ...workflowConnection } } diff --git a/apps/frontend/src/graphql/settings/deleteWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/deleteWorkflowStatus.graphql index b583e45483..f635b20c6d 100644 --- a/apps/frontend/src/graphql/settings/deleteWorkflowStatus.graphql +++ b/apps/frontend/src/graphql/settings/deleteWorkflowStatus.graphql @@ -1,13 +1,5 @@ -mutation deleteWorkflowStatus( - $statusId: Int! - $workflowId: Int! - $sortOrder: Int! -) { +mutation deleteWorkflowStatus($workflowStatusId: Int!) { deleteWorkflowStatus( - deleteWorkflowStatusInput: { - statusId: $statusId - workflowId: $workflowId - sortOrder: $sortOrder - } + deleteWorkflowStatusInput: { workflowStatusId: $workflowStatusId } ) } diff --git a/apps/frontend/src/graphql/settings/fragment.status.graphql b/apps/frontend/src/graphql/settings/fragment.status.graphql index 1ce9097b91..e86cf42a8c 100644 --- a/apps/frontend/src/graphql/settings/fragment.status.graphql +++ b/apps/frontend/src/graphql/settings/fragment.status.graphql @@ -1,6 +1,5 @@ fragment status on Status { id - shortCode name description isDefault diff --git a/apps/frontend/src/graphql/settings/fragment.workflow.graphql b/apps/frontend/src/graphql/settings/fragment.workflow.graphql new file mode 100644 index 0000000000..193aea0e46 --- /dev/null +++ b/apps/frontend/src/graphql/settings/fragment.workflow.graphql @@ -0,0 +1,7 @@ +fragment workflow on Workflow { + id + name + description + connectionLineType + entityType +} diff --git a/apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql b/apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql new file mode 100644 index 0000000000..50d1de5b16 --- /dev/null +++ b/apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql @@ -0,0 +1,8 @@ +fragment workflowConnection on WorkflowConnection { + id + workflowId + prevWorkflowStatusId + nextWorkflowStatusId + sourceHandle + targetHandle +} diff --git a/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql b/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql new file mode 100644 index 0000000000..bf7f350ea2 --- /dev/null +++ b/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql @@ -0,0 +1,7 @@ +fragment workflowStatus on WorkflowStatus { + workflowStatusId + workflowId + statusId + posX + posY +} diff --git a/apps/frontend/src/graphql/settings/getWorkflow.graphql b/apps/frontend/src/graphql/settings/getWorkflow.graphql index ece4de8b54..fedef9e7dc 100644 --- a/apps/frontend/src/graphql/settings/getWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflow.graphql @@ -4,26 +4,40 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { name description connectionLineType - workflowConnections { - id - sortOrder - workflowId - statusId - status { - ...status + + connections { + ...workflowConnection + prevStatus { + ...workflowStatus + status { + ...status + } + } + nextStatus { + ...workflowStatus + status { + ...status + } } - nextStatusId - prevStatusId - prevConnectionId - posX - posY statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } statusActions { ...connectionStatusAction + action { + ...statusAction + defaultConfig { + ...statusActionDefaultConfig + } + } + } + } + + statuses { + ...workflowStatus + status { + ...status } } } diff --git a/apps/frontend/src/graphql/settings/getWorkflowStatuses.graphql b/apps/frontend/src/graphql/settings/getWorkflowStatuses.graphql new file mode 100644 index 0000000000..b26605538d --- /dev/null +++ b/apps/frontend/src/graphql/settings/getWorkflowStatuses.graphql @@ -0,0 +1,8 @@ +query getWorkflowStatuses($workflowId: Int!) { + workflowStatuses(workflowId: $workflowId) { + ...workflowStatus + status { + ...status + } + } +} diff --git a/apps/frontend/src/graphql/settings/getWorkflows.graphql b/apps/frontend/src/graphql/settings/getWorkflows.graphql index bd74930763..05689498ff 100644 --- a/apps/frontend/src/graphql/settings/getWorkflows.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflows.graphql @@ -1,12 +1,12 @@ query getWorkflows($entityType: WorkflowType!) { workflows(entityType: $entityType) { - id - name - description - workflowConnections { - status { - shortCode - } + ...workflow + connections { + ...workflowConnection + } + + statuses { + ...workflowStatus } } } diff --git a/apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql similarity index 57% rename from apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql rename to apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql index e3e2bbea05..f2ca1f47cf 100644 --- a/apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql +++ b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql @@ -1,15 +1,14 @@ -mutation addStatusChangingEventsToConnection( - $workflowConnectionId: Int! - $statusChangingEvents: [String!]! -) { - addStatusChangingEventsToConnection( - addStatusChangingEventsToConnectionInput: { - workflowConnectionId: $workflowConnectionId - statusChangingEvents: $statusChangingEvents - } - ) { - statusChangingEventId - workflowConnectionId - statusChangingEvent - } -} +mutation setStatusChangingEventsOnConnection( + $workflowConnectionId: Int! + $statusChangingEvents: [String!]! +) { + setStatusChangingEventsOnConnection( + setStatusChangingEventsOnConnectionInput: { + workflowConnectionId: $workflowConnectionId + statusChangingEvents: $statusChangingEvents + } + ) { + workflowConnectionId + statusChangingEvent + } +} diff --git a/apps/frontend/src/graphql/settings/statusActions/addConnectionStatusActions.graphql b/apps/frontend/src/graphql/settings/statusActions/addConnectionStatusActions.graphql index 2ffd94549c..5a27c898a8 100644 --- a/apps/frontend/src/graphql/settings/statusActions/addConnectionStatusActions.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/addConnectionStatusActions.graphql @@ -1,10 +1,10 @@ -mutation addConnectionStatusActions( +mutation setStatusActionsOnConnection( $connectionId: Int! $workflowId: Int! $actions: [ConnectionHasActionsInput!]! ) { - addConnectionStatusActions( - newConnectionStatusActionsInput: { + setStatusActionsOnConnection( + input: { connectionId: $connectionId workflowId: $workflowId actions: $actions diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql index 108af42263..e2cb0e9669 100644 --- a/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.connectionStatusActionConfig.graphql @@ -7,6 +7,7 @@ fragment connectionStatusActionConfig on StatusActionConfig { } emailTemplate { id + name } otherRecipientEmails combineEmails diff --git a/apps/frontend/src/graphql/settings/statusActions/fragment.statusAction.graphql b/apps/frontend/src/graphql/settings/statusActions/fragment.statusAction.graphql new file mode 100644 index 0000000000..74813d1929 --- /dev/null +++ b/apps/frontend/src/graphql/settings/statusActions/fragment.statusAction.graphql @@ -0,0 +1,6 @@ +fragment statusAction on StatusAction { + description + id + name + type +} diff --git a/apps/frontend/src/graphql/settings/updateStatus.graphql b/apps/frontend/src/graphql/settings/updateStatus.graphql index 86403be4c3..c65bf0dce8 100644 --- a/apps/frontend/src/graphql/settings/updateStatus.graphql +++ b/apps/frontend/src/graphql/settings/updateStatus.graphql @@ -1,16 +1,6 @@ -mutation updateStatus( - $id: Int! - $shortCode: String! - $name: String! - $description: String! -) { +mutation updateStatus($id: String!, $name: String!, $description: String!) { updateStatus( - updatedStatusInput: { - id: $id - shortCode: $shortCode - name: $name - description: $description - } + updatedStatusInput: { id: $id, name: $name, description: $description } ) { ...status } diff --git a/apps/frontend/src/graphql/settings/updateWorkflow.graphql b/apps/frontend/src/graphql/settings/updateWorkflow.graphql index 19b850f23c..92f058e0b8 100644 --- a/apps/frontend/src/graphql/settings/updateWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/updateWorkflow.graphql @@ -1,28 +1,30 @@ -mutation updateWorkflow($id: Int!, $name: String!, $description: String!, $connectionLineType: String) { +mutation updateWorkflow( + $id: Int! + $name: String! + $description: String! + $connectionLineType: String +) { updateWorkflow( - updatedWorkflowInput: { id: $id, name: $name, description: $description, connectionLineType: $connectionLineType } + updatedWorkflowInput: { + id: $id + name: $name + description: $description + connectionLineType: $connectionLineType + } ) { id name description connectionLineType - workflowConnections { - id - sortOrder - workflowId - statusId - status { - id - name - description + connections { + ...workflowConnection + prevStatus { + ...workflowStatus + } + nextStatus { + ...workflowStatus } - nextStatusId - prevStatusId - prevConnectionId - posX - posY statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } diff --git a/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql b/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql index fc2a5a185e..95988ae078 100644 --- a/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql +++ b/apps/frontend/src/graphql/settings/updateWorkflowStatus.graphql @@ -1,32 +1,11 @@ -mutation updateWorkflowStatus( - $id: Int! - $posX: Int - $posY: Int - $prevStatusId: Int - $nextStatusId: Int - $prevConnectionId: Int -) { +mutation updateWorkflowStatus($workflowStatusId: Int!, $posX: Int, $posY: Int) { updateWorkflowStatus( updateWorkflowStatusInput: { - id: $id + workflowStatusId: $workflowStatusId posX: $posX posY: $posY - prevStatusId: $prevStatusId - nextStatusId: $nextStatusId - prevConnectionId: $prevConnectionId } ) { - id - posX - posY - workflowId - statusId - sortOrder - nextStatusId - prevStatusId - prevConnectionId - status { - ...status - } + ...workflowStatus } } diff --git a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index 05c723d4b6..c90449faea 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -6,6 +6,7 @@ import { } from 'components/settings/workflow/WorkflowEditorModel'; import { ConnectionHasActionsInput, + ConnectionStatusAction, WorkflowConnection, Workflow, } from 'generated/sdk'; @@ -39,7 +40,6 @@ export function usePersistWorkflowEditorModel() { type MonitorableServiceCall = () => Promise; const persistModel = ({ - getState, dispatch, }: MiddlewareInputParams) => { const executeAndMonitorCall = (call: MonitorableServiceCall) => { @@ -53,40 +53,54 @@ export function usePersistWorkflowEditorModel() { }); }; + const createWorkflowConneciton = async ( + sourceWorkflowStatusId: number, + targetWorkflowStatusId: number, + sourceHandle: string, + targetHandle: string + ) => { + return api({ + toastSuccessMessage: 'Workflow connection added successfully!', + }) + .createWorkflowConnection({ + newWorkflowConnectionInput: { + prevWorkflowStatusId: sourceWorkflowStatusId, + nextWorkflowStatusId: targetWorkflowStatusId, + sourceHandle: sourceHandle, + targetHandle: targetHandle, + }, + }) + .then((data) => data.createWorkflowConnection); + }; + const insertNewStatusInWorkflow = async ( workflowId: number, - sortOrder: number, - statusId: number, - nextStatusId: number, - prevStatusId: number, + statusId: string, posX: number, posY: number ) => { return api({ toastSuccessMessage: 'Workflow status added successfully' }) - .addWorkflowStatus({ + .addStatusToWorkflow({ workflowId, - sortOrder, statusId, - nextStatusId, - prevStatusId, posY, posX, }) - .then((data) => data.addWorkflowStatus); + .then((data) => data.addStatusToWorkflow); }; - const addStatusChangingEventsToConnection = async ( + const setStatusChangingEventsOnConnection = async ( workflowConnectionId: number, statusChangingEvents: string[] ) => { return api({ - toastSuccessMessage: 'Status changing events added successfully!', + toastSuccessMessage: 'Status changing events set successfully!', }) - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId, statusChangingEvents, }) - .then((data) => data.addStatusChangingEventsToConnection); + .then((data) => data.setStatusChangingEventsOnConnection); }; const deleteWorkflowConnection = async (connectionId: number) => { @@ -99,6 +113,16 @@ export function usePersistWorkflowEditorModel() { .then((data) => data.deleteWorkflowConnection); }; + const deleteWorkflowStatus = async (workflowStatusId: number) => { + return api({ + toastSuccessMessage: 'Workflow status removed successfully', + }) + .deleteWorkflowStatus({ + workflowStatusId, + }) + .then((data) => data.deleteWorkflowStatus); + }; + const addStatusActionToConnection = async ( statusActions: ConnectionHasActionsInput[], workflowConnection: WorkflowConnection @@ -108,17 +132,16 @@ export function usePersistWorkflowEditorModel() { statusActions.length ? 'added' : 'removed' } successfully!`, }) - .addConnectionStatusActions({ + .setStatusActionsOnConnection({ actions: statusActions, workflowId: workflowConnection.workflowId, connectionId: workflowConnection.id, }) - .then((data) => data.addConnectionStatusActions); + .then((data) => data.setStatusActionsOnConnection); }; return (next: FunctionType) => (action: Event) => { next(action); - const state = getState(); switch (action.type) { case EventType.UPDATE_WORKFLOW_METADATA_REQUESTED: { @@ -143,36 +166,24 @@ export function usePersistWorkflowEditorModel() { }); } case EventType.DELETE_WORKFLOW_STATUS_REQUESTED: - // Find the workflow connection to remove based on connectionId - const workflowConnectionToRemove = state.workflowConnections.find( - (connection) => connection.id === action.payload.connectionId - ); - - if (workflowConnectionToRemove) { - return executeAndMonitorCall(async () => { - const result = await deleteWorkflowConnection( - workflowConnectionToRemove.id - ); + // Find the workflow status to remove based on workflowStatusId - dispatch({ - type: EventType.WORKFLOW_STATUS_DELETED, - payload: action.payload, - }); + return executeAndMonitorCall(async () => { + const result = await deleteWorkflowStatus( + action.payload.workflowStatusId + ); - return result; + dispatch({ + type: EventType.WORKFLOW_STATUS_DELETED, + payload: action.payload, }); - } + + return result; + }); break; case EventType.WORKFLOW_STATUS_UPDATE_REQUESTED: { - const { - connectionId, - posX, - posY, - prevStatusId, - nextStatusId, - prevConnectionId, - } = action.payload; + const { workflowStatusId, posX, posY } = action.payload; return executeAndMonitorCall(async () => { try { @@ -180,12 +191,9 @@ export function usePersistWorkflowEditorModel() { toastErrorMessage: 'Failed to update workflow status', }) .updateWorkflowStatus({ - id: connectionId, + workflowStatusId, posX, posY, - prevStatusId, - nextStatusId, - prevConnectionId, }) .then((data) => data.updateWorkflowStatus); @@ -206,65 +214,37 @@ export function usePersistWorkflowEditorModel() { break; } case EventType.ADD_WORKFLOW_STATUS_REQUESTED: { - const { - workflowId, - sortOrder, - statusId, - nextStatusId, - prevStatusId, - posX, - posY, - } = action.payload; - - // Immediately add to state so it shows up in the UI - dispatch({ - type: EventType.WORKFLOW_STATUS_ADDED, - payload: { - ...action.payload, - }, - }); + const { workflowId, status, posX, posY } = action.payload; return executeAndMonitorCall(async () => { - try { - const result = await insertNewStatusInWorkflow( - workflowId, - sortOrder, - statusId, - nextStatusId, - prevStatusId, - posX, - posY - ); - - // Update the connection with the real ID from the API - dispatch({ - type: EventType.WORKFLOW_STATUS_UPDATED, - payload: { ...action.payload, ...result }, - }); + const result = await insertNewStatusInWorkflow( + workflowId, + status.id, + posX, + posY + ); - return result; - } catch (error) { - // Remove from state if API call failed - dispatch({ - type: EventType.WORKFLOW_STATUS_DELETED, - payload: { ...action.payload }, - }); - throw error; - } + // Update the connection with the real ID from the API + dispatch({ + type: EventType.WORKFLOW_STATUS_ADDED, + payload: { ...action.payload, ...result }, + }); + + return result; }); } - case EventType.ADD_NEXT_STATUS_EVENTS_REQUESTED: { + case EventType.SET_STATUS_CHANGING_EVENTS_ON_CONNECTION_REQUESTED: { const { workflowConnection, statusChangingEvents } = action.payload; return executeAndMonitorCall(async () => { - const result = await addStatusChangingEventsToConnection( + const result = await setStatusChangingEventsOnConnection( workflowConnection.id, statusChangingEvents ); dispatch({ - type: EventType.NEXT_STATUS_EVENTS_ADDED, + type: EventType.STATUS_CHANGING_EVENTS_ON_CONNECTION_SET, payload: { workflowConnection, statusChangingEvents: result, @@ -284,16 +264,41 @@ export function usePersistWorkflowEditorModel() { ); dispatch({ - type: EventType.STATUS_ACTION_ADDED, + type: EventType.STATUS_ACTIONS_UPDATED, payload: { workflowConnection: workflowConnection, - statusActions: result, + statusActions: (result || + []) as unknown as ConnectionStatusAction[], }, }); return result; }); } + case EventType.ADD_WORKFLOW_CONNECTION_REQUESTED: { + const { + sourceWorkflowStatusId, + targetWorkflowStatusId, + sourceHandle, + targetHandle, + } = action.payload; + + return executeAndMonitorCall(async () => { + const result = await createWorkflowConneciton( + sourceWorkflowStatusId, + targetWorkflowStatusId, + sourceHandle, + targetHandle + ); + + dispatch({ + type: EventType.WORKFLOW_CONNECTION_ADDED, + payload: result, + }); + + return result; + }); + } case EventType.DELETE_WORKFLOW_CONNECTION_REQUESTED: { const { connectionId } = action.payload; diff --git a/apps/frontend/src/hooks/settings/useWorkflowStatusesData.ts b/apps/frontend/src/hooks/settings/useWorkflowStatusesData.ts new file mode 100644 index 0000000000..bc6cbe3af1 --- /dev/null +++ b/apps/frontend/src/hooks/settings/useWorkflowStatusesData.ts @@ -0,0 +1,49 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +import { WorkflowStatus } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useWorkflowStatusesData(workflowId: number): { + loadingStatuses: boolean; + statuses: WorkflowStatus[]; + setStatusesWithLoading: Dispatch>; +} { + const [statuses, setStatuses] = useState([]); + const [loadingStatuses, setLoadingStatuses] = useState(true); + + const api = useDataApi(); + + const setStatusesWithLoading = (data: SetStateAction) => { + setLoadingStatuses(true); + setStatuses(data); + setLoadingStatuses(false); + }; + + useEffect(() => { + let unmounted = false; + + setLoadingStatuses(true); + api() + .getWorkflowStatuses({ workflowId }) + .then((data) => { + if (unmounted) { + return; + } + + if (data.workflowStatuses) { + setStatuses(data.workflowStatuses); + } + setLoadingStatuses(false); + }); + + return () => { + unmounted = true; + }; + }, [api, workflowId]); + + return { + loadingStatuses, + statuses, + setStatusesWithLoading, + }; +} diff --git a/apps/frontend/src/models/questionary/proposal/ProposalSubmissionState.ts b/apps/frontend/src/models/questionary/proposal/ProposalSubmissionState.ts index ce25d4ee11..6d3c3423f3 100644 --- a/apps/frontend/src/models/questionary/proposal/ProposalSubmissionState.ts +++ b/apps/frontend/src/models/questionary/proposal/ProposalSubmissionState.ts @@ -30,9 +30,9 @@ export class ProposalSubmissionState extends QuestionarySubmissionState { getInitialStepIndex(): number { if ( - this.proposal?.status?.shortCode.toString() == + this.proposal?.status?.id.toString() == ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED || - this.proposal?.status?.shortCode.toString() == + this.proposal?.status?.id.toString() == ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL ) { return 0; diff --git a/apps/frontend/src/utils/helperFunctions.tsx b/apps/frontend/src/utils/helperFunctions.tsx index c891baea75..9711620e44 100644 --- a/apps/frontend/src/utils/helperFunctions.tsx +++ b/apps/frontend/src/utils/helperFunctions.tsx @@ -73,8 +73,9 @@ export const fromProposalToProposalView = (proposal: Proposal) => principalInvestigator: proposal.proposer || null, principalInvestigatorId: proposal.proposer?.id, title: proposal.title, + workflowStatusId: proposal.workflowStatusId, status: proposal.status?.name || '', - statusId: proposal.status?.id || 1, + statusId: proposal.status?.id || 'DRAFT', statusName: proposal.status?.name || '', statusDescription: proposal.status?.description || '', submitted: proposal.submitted, diff --git a/validation/src/Statuses/index.ts b/validation/src/Statuses/index.ts index adf83ebadb..9fe6b4e322 100644 --- a/validation/src/Statuses/index.ts +++ b/validation/src/Statuses/index.ts @@ -2,7 +2,7 @@ import * as Yup from 'yup'; export const createStatusValidationSchema = Yup.object() .shape({ - shortCode: Yup.string() + id: Yup.string() .max(50) .trim() .test( @@ -20,8 +20,7 @@ export const createStatusValidationSchema = Yup.object() export const updateStatusValidationSchema = Yup.object() .shape({ - id: Yup.number().required(), - shortCode: Yup.string() + id: Yup.string() .max(50) .trim() .test( @@ -33,9 +32,10 @@ export const updateStatusValidationSchema = Yup.object() .required(), name: Yup.string().max(100).required(), description: Yup.string().max(200).required(), + isDefault: Yup.bool(), }) .strict(true); export const deleteStatusValidationSchema = Yup.object().shape({ - id: Yup.number().required(), + id: Yup.string().required(), }); diff --git a/validation/src/Workflow/index.ts b/validation/src/Workflow/index.ts index a82c080a0e..fa55a0eee4 100644 --- a/validation/src/Workflow/index.ts +++ b/validation/src/Workflow/index.ts @@ -33,13 +33,12 @@ export const moveWorkflowStatusValidationSchema = Yup.object().shape({ }); export const deleteWorkflowStatusValidationSchema = Yup.object().shape({ - statusId: Yup.number().required(), - workflowId: Yup.number().required(), + workflowStatusId: Yup.number().required(), }); export const addNextStatusEventsValidationSchema = Yup.object().shape({ workflowConnectionId: Yup.number().required(), - nextStatusEvents: Yup.array().of(Yup.string()).required(), + statusChangingEvents: Yup.array().of(Yup.string()).required(), }); export const addStatusActionsToConnectionValidationSchema = (