From f2b8a4c5f5a3fed24f99b094a1ef8d9aefbbf8e8 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 22 Dec 2025 14:13:49 +0100 Subject: [PATCH 001/147] feat: add new workflow data structures and update related data sources --- .../0201_Add_new_workflow_data_structures.sql | 204 ++++++++++++++++++ .../postgres/StatusActionsDataSource.ts | 26 +-- .../postgres/StatusActionsLogsDataSource.ts | 6 +- .../src/datasources/postgres/records.ts | 2 +- 4 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql diff --git a/apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql new file mode 100644 index 0000000000..95ebe00a99 --- /dev/null +++ b/apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql @@ -0,0 +1,204 @@ +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 + + + + -- =============================== + -- 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, + + 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), + + CONSTRAINT fk_wsc_next_state + FOREIGN KEY (workflow_id, next_workflow_status_id) + REFERENCES workflow_has_statuses (workflow_id, workflow_status_id) + ); + + -- (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); + + + + -- ============================================ + -- 3) workflow_status_changing_events (catalog) + -- ============================================ + CREATE TABLE workflow_status_changing_events ( + status_changing_event_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL, + description TEXT + ); + + + + -- ===================================================================== + -- 4) workflow_status_connection_has_workflow_status_changing_events (edge→events) + -- ===================================================================== + CREATE TABLE workflow_status_connection_has_workflow_status_changing_events ( + workflow_status_connection_id BIGINT NOT NULL, + status_changing_event_id BIGINT NOT NULL, + + CONSTRAINT pk_wsc_has_events + PRIMARY KEY (workflow_status_connection_id, status_changing_event_id), + + CONSTRAINT fk_wsche_connection + FOREIGN KEY (workflow_status_connection_id) + REFERENCES workflow_status_connections (workflow_status_connection_id), + + CONSTRAINT fk_wsche_event + FOREIGN KEY (status_changing_event_id) + REFERENCES workflow_status_changing_events (status_changing_event_id) + ); + + -- 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_workflow_status_actions (edge→actions) + -- ========================================================== + CREATE TABLE workflow_status_connection_has_workflow_status_actions ( + workflow_status_connection_id INT NOT NULL, + workflow_status_action_id INT NOT NULL, + workflow_id INT NOT NULL, + config JSONB NOT NULL DEFAULT '{}'::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), + + CONSTRAINT fk_wsca_action + FOREIGN KEY (workflow_status_action_id) + REFERENCES workflow_status_actions (workflow_status_action_id) + ); + + + + -- ================================================================== + -- 6) proposal_has_workflow_status_changing_events (instance-scoped events) + -- ================================================================== + CREATE TABLE proposal_has_workflow_status_changing_events ( + proposal_has_workflow_status_changing_events_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + status_changing_event_id INT NOT NULL, + proposal_pk INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT fk_phsce_event + FOREIGN KEY (status_changing_event_id) + REFERENCES workflow_status_changing_events (status_changing_event_id), + + CONSTRAINT fk_phsce_proposal + FOREIGN KEY (proposal_pk) + REFERENCES proposals (proposal_pk) + ); + + -- Ensure at most one row per (proposal, event) in the current accumulation window. + -- (If you retain rows across state changes, you can reset the window in code + -- by comparing against proposals.state_entered_at.) + CREATE UNIQUE INDEX uq_phsce_proposal_event + ON proposal_has_workflow_status_changing_events (proposal_pk, status_changing_event_id); + + -- Optional helper index for proposal lookups: + CREATE INDEX ix_phsce_proposal + ON proposal_has_workflow_status_changing_events (proposal_pk); + + -- ============================================ + -- 7) workflow_status_changing_guards (catalog) + -- ============================================ + CREATE TABLE workflow_status_changing_guards ( + workflow_status_changing_guard_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL, + description TEXT + ); + + -- ===================================================================== + -- 8) workflow_status_connection_has_workflow_status_changing_guards (edge→guards) + -- ===================================================================== + CREATE TABLE workflow_status_connection_has_workflow_status_changing_guards ( + workflow_status_connection_id INT NOT NULL, + workflow_status_changing_guard_id INT NOT NULL, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + + CONSTRAINT pk_wsc_has_guards + PRIMARY KEY (workflow_status_connection_id, workflow_status_changing_guard_id), + + CONSTRAINT fk_wscg_connection + FOREIGN KEY (workflow_status_connection_id) + REFERENCES workflow_status_connections (workflow_status_connection_id), + + CONSTRAINT fk_wscg_guard + FOREIGN KEY (workflow_status_changing_guard_id) + REFERENCES workflow_status_changing_guards (workflow_status_changing_guard_id) + ); + + -- ================================================================== + -- 9) Link proposals 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); + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index d210940fea..d29c7b375b 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -53,7 +53,7 @@ export default class PostgresStatusActionsDataSource private createConnectionStatusActionObject( actionStatusRecord: WorkflowConnectionHasActionsRecord & { - status_action_id: number; + workflow_status_action_id: number; name: string; type: StatusActionType; config: typeof StatusActionConfig; @@ -61,7 +61,7 @@ export default class PostgresStatusActionsDataSource ) { return new ConnectionHasStatusAction( actionStatusRecord.connection_id, - actionStatusRecord.status_action_id, + actionStatusRecord.workflow_status_action_id, actionStatusRecord.workflow_id, actionStatusRecord.name, actionStatusRecord.type, @@ -74,7 +74,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 @@ -90,9 +90,9 @@ export default class PostgresStatusActionsDataSource config: typeof StatusActionConfig; })[] = await database .select() - .from('status_actions as sa') + .from('workflow_status_actions as wsa') .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'sa.status_action_id', + 'wca.action_id': 'wsa.workflow_status_action_id', }) .where('wca.workflow_id', workflowId) .andWhere('wca.connection_id', workflowConnectionId); @@ -113,9 +113,9 @@ export default class PostgresStatusActionsDataSource config: typeof StatusActionConfig; } = await database .select() - .from('status_actions as sa') + .from('workflow_status_actions as wsa') .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'sa.status_action_id', + 'wca.action_id': 'wsa.workflow_status_action_id', }) .where('wca.action_id', statusActionId) .andWhere('wca.connection_id', workflowConnectionId) @@ -151,7 +151,7 @@ export default class PostgresStatusActionsDataSource } return this.createConnectionStatusActionObject({ - status_action_id: statusAction.actionId, + workflow_status_action_id: statusAction.actionId, name: statusAction.name, type: statusAction.type, ...updatedStatusAction, @@ -161,8 +161,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) { @@ -175,7 +175,7 @@ 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) @@ -256,8 +256,8 @@ export default class PostgresStatusActionsDataSource const insertedStatusActions = await database .select('*') .from('workflow_connection_has_actions as wca') - .join('status_actions as sa', { - 'wca.action_id': 'sa.status_action_id ', + .join('workflow_status_actions as wsa', { + 'wca.action_id': 'wsa.workflow_status_action_id', }) .where('wca.connection_id', connectionStatusActionsInput.connectionId) .transacting(trx); diff --git a/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsLogsDataSource.ts index 04c5a45651..115adf2616 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/records.ts b/apps/backend/src/datasources/postgres/records.ts index d3abb964b8..f5029d22ed 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -753,7 +753,7 @@ export interface RedeemCodeRecord { } export interface StatusActionRecord { - readonly status_action_id: number; + readonly workflow_status_action_id: number; readonly name: string; readonly description: string; readonly type: StatusActionType; From 29ab66820e4e97ba6ddefc4e938cff7db589c3f8 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 22 Dec 2025 15:10:18 +0100 Subject: [PATCH 002/147] feat: rename and refactor status changing events handling in workflow connections --- .../src/datasources/WorkflowDataSource.ts | 2 +- .../datasources/mockups/WorkflowDataSource.ts | 2 +- .../postgres/WorkflowDataSource.ts | 93 ++++++++++++++----- .../ProposalSettingsMutations.spec.ts | 2 +- .../src/mutations/WorkflowMutations.ts | 8 +- ...atusChangingEventsOnConnectionMutation.ts} | 72 +++++++------- apps/e2e/cypress/e2e/calls.cy.ts | 2 +- .../cypress/e2e/experimentSafetyReview.cy.ts | 12 +-- apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts | 2 +- apps/e2e/cypress/e2e/settings.cy.ts | 26 +++--- apps/e2e/cypress/e2e/statusActions.cy.ts | 2 +- apps/e2e/cypress/support/workflow.ts | 18 ++-- apps/e2e/cypress/types/workflow.d.ts | 14 +-- ...> SetStatusChangingEventsOnConnection.tsx} | 20 ++-- .../workflow/StatusEventsAndActionsDialog.tsx | 8 +- .../settings/workflow/WorkflowEditorModel.tsx | 6 +- ...tStatusChangingEventsOnConnection.graphql} | 30 +++--- .../settings/usePersistWorkflowEditorModel.ts | 14 +-- 18 files changed, 188 insertions(+), 145 deletions(-) rename apps/backend/src/resolvers/mutations/settings/{AddStatusChangingEventsToConnectionMutation.ts => SetStatusChangingEventsOnConnectionMutation.ts} (53%) rename apps/frontend/src/components/settings/workflow/{AddStatusChangingEventsToConnection.tsx => SetStatusChangingEventsOnConnection.tsx} (91%) rename apps/frontend/src/graphql/settings/{addStatusChangingEventsToConnection.graphql => setStatusChangingEventsOnConnection.graphql} (63%) diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index e7bd47c9f9..58dc3164da 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -43,7 +43,7 @@ export interface WorkflowDataSource { workflowId: number, sortOrder: number ): Promise; - addStatusChangingEventsToConnection( + setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise; diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 317aadf095..441d743da6 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -161,7 +161,7 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return null; } - async addStatusChangingEventsToConnection( + async setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise { diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index e778ecc377..cd90c89840 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -400,7 +400,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ); } - async addStatusChangingEventsToConnection( + async setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] ): Promise { @@ -421,38 +421,81 @@ 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_workflow_status_changing_events' + ) + .where('workflow_status_connection_id', workflowConnectionId) .del(); - const statusChangingEventsResult: StatusChangingEventRecord[] = - await database('status_changing_events') - .insert(eventsToInsert) - .returning(['*']); + const eventsToReturn: StatusChangingEvent[] = []; + + for (const eventName of statusChangingEvents) { + let eventId: number; + const existingEvent = await database('workflow_status_changing_events') + .select('status_changing_event_id') + .where('name', eventName) + .first(); + + if (existingEvent) { + eventId = existingEvent.status_changing_event_id; + } else { + const [newEvent] = await database('workflow_status_changing_events') + .insert({ name: eventName }) + .returning('*'); + eventId = newEvent.status_changing_event_id; + } - return ( - statusChangingEventsResult?.map((statusChangingEventResult) => - this.createStatusChangingEventObject(statusChangingEventResult) - ) || [] - ); + await database( + 'workflow_status_connection_has_workflow_status_changing_events' + ).insert({ + workflow_status_connection_id: workflowConnectionId, + status_changing_event_id: eventId, + }); + + eventsToReturn.push( + new StatusChangingEvent(eventId, workflowConnectionId, eventName) + ); + } + + return eventsToReturn; } async getStatusChangingEventsByConnectionIds( workflowConnectionIds: number[] ): Promise { return database - .select('*') - .from('status_changing_events') - .whereIn('workflow_connection_id', workflowConnectionIds) - .then((statusChangingEvents: StatusChangingEventRecord[]) => { - return statusChangingEvents.map((statusChangingEvent) => - this.createStatusChangingEventObject(statusChangingEvent) - ); - }); + .select( + 'wsche.workflow_status_connection_id', + 'wsce.status_changing_event_id', + 'wsce.name' + ) + .from( + 'workflow_status_connection_has_workflow_status_changing_events as wsche' + ) + .join( + 'workflow_status_changing_events as wsce', + 'wsche.status_changing_event_id', + 'wsce.status_changing_event_id' + ) + .whereIn('wsche.workflow_status_connection_id', workflowConnectionIds) + .then( + ( + statusChangingEvents: { + workflow_status_connection_id: number; + status_changing_event_id: number; + name: string; + }[] + ) => { + return statusChangingEvents.map((statusChangingEvent) => + this.createStatusChangingEventObject({ + status_changing_event_id: + statusChangingEvent.status_changing_event_id, + workflow_connection_id: + statusChangingEvent.workflow_status_connection_id, + status_changing_event: statusChangingEvent.name, + }) + ); + } + ); } } diff --git a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 79aaea6d52..063e0b0915 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -174,7 +174,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can add next status event/s to workflow connection', () => { return expect( - workflowMutationsInstance.addStatusChangingEventsToConnection( + workflowMutationsInstance.setStatusChangingEventsOnConnection( dummyUserOfficerWithRole, { statusChangingEvents: ['PROPOSAL_SUBMITTED'], diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 6a6ca8e525..8e0e35bdeb 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -22,10 +22,10 @@ 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 { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { DeleteWorkflowStatusInput } from '../resolvers/mutations/settings/DeleteWorkflowStatusMutation'; +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'; @@ -140,12 +140,12 @@ export default class WorkflowMutations { // @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 ) 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/e2e/cypress/e2e/calls.cy.ts b/apps/e2e/cypress/e2e/calls.cy.ts index 4f467954da..70e69817e8 100644 --- a/apps/e2e/cypress/e2e/calls.cy.ts +++ b/apps/e2e/cypress/e2e/calls.cy.ts @@ -137,7 +137,7 @@ context('Calls tests', () => { posY: 200, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: ['CALL_ENDED'], }); diff --git a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts index 839205e3d6..29ca15b966 100644 --- a/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts +++ b/apps/e2e/cypress/e2e/experimentSafetyReview.cy.ts @@ -175,7 +175,7 @@ function createWorkflowForInstrumentScientist() { .then((result) => { if (result.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [TEST_CONSTANTS.EVENTS.ESF_SUBMITTED], }) @@ -193,7 +193,7 @@ function createWorkflowForInstrumentScientist() { .then((secondResult) => { if (secondResult.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: secondResult.addWorkflowStatus.id, statusChangingEvents: [ @@ -215,7 +215,7 @@ function createWorkflowForInstrumentScientist() { .then((thirdResult) => { if (thirdResult.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: thirdResult.addWorkflowStatus.id, statusChangingEvents: [ @@ -269,7 +269,7 @@ function createWorkflowForESR() { .then((result) => { if (result.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [TEST_CONSTANTS.EVENTS.ESF_SUBMITTED], }) @@ -287,7 +287,7 @@ function createWorkflowForESR() { .then((secondResult) => { if (secondResult.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: secondResult.addWorkflowStatus.id, statusChangingEvents: [ @@ -309,7 +309,7 @@ function createWorkflowForESR() { .then((thirdResult) => { if (thirdResult.addWorkflowStatus) { return cy - .addStatusChangingEventsToConnection({ + .setStatusChangingEventsOnConnection({ workflowConnectionId: thirdResult.addWorkflowStatus.id, statusChangingEvents: [ diff --git a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts index ca2faed7c6..ba654ea0af 100644 --- a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts +++ b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts @@ -119,7 +119,7 @@ context('Pregenerated PDF tests', () => { }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [PROPOSAL_EVENTS.PROPOSAL_SUBMITTED], }); diff --git a/apps/e2e/cypress/e2e/settings.cy.ts b/apps/e2e/cypress/e2e/settings.cy.ts index 0d96162945..fcc28f0d76 100644 --- a/apps/e2e/cypress/e2e/settings.cy.ts +++ b/apps/e2e/cypress/e2e/settings.cy.ts @@ -191,7 +191,7 @@ context('Settings tests', () => { }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -206,7 +206,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [ Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE, @@ -224,7 +224,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.fapSelection.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_FAPS_SELECTED], }); @@ -239,7 +239,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.fapReview.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_ALL_FAP_REVIEWS_SUBMITTED], }); @@ -258,7 +258,7 @@ context('Settings tests', () => { }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -273,7 +273,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_FEASIBILITY_REVIEW_FEASIBLE], }); @@ -288,7 +288,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.feasibilityReview.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [ Event.PROPOSAL_FEASIBILITY_REVIEW_UNFEASIBLE, @@ -345,7 +345,7 @@ context('Settings tests', () => { prevStatusId: prevStatusId, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -431,7 +431,7 @@ context('Settings tests', () => { prevStatusId: prevStatusId, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -525,7 +525,7 @@ context('Settings tests', () => { prevStatusId: prevStatusId, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -611,7 +611,7 @@ context('Settings tests', () => { prevStatusId: prevStatusId, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); @@ -626,7 +626,7 @@ context('Settings tests', () => { prevStatusId: initialDBData.proposalStatuses.editableSubmitted.id, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.CALL_ENDED], }); @@ -1541,7 +1541,7 @@ context('Settings tests', () => { prevStatusId: prevStatusId, }).then((result) => { if (result.addWorkflowStatus) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: result.addWorkflowStatus.id, statusChangingEvents: [Event.PROPOSAL_SUBMITTED], }); diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index 685e6ea897..2abc0c7665 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -715,7 +715,7 @@ context('Status actions tests', () => { }).then((result) => { const connection = result.addWorkflowStatus; if (connection) { - cy.addStatusChangingEventsToConnection({ + cy.setStatusChangingEventsOnConnection({ workflowConnectionId: connection.id, statusChangingEvents: [PROPOSAL_EVENTS.PROPOSAL_SUBMITTED], }); diff --git a/apps/e2e/cypress/support/workflow.ts b/apps/e2e/cypress/support/workflow.ts index 2e4dcbdc32..0d400a650b 100644 --- a/apps/e2e/cypress/support/workflow.ts +++ b/apps/e2e/cypress/support/workflow.ts @@ -3,8 +3,8 @@ import { AddConnectionStatusActionsMutationVariables, AddWorkflowStatusMutation, AddWorkflowStatusMutationVariables, - AddStatusChangingEventsToConnectionMutation, - AddStatusChangingEventsToConnectionMutationVariables, + SetStatusChangingEventsOnConnectionMutation, + SetStatusChangingEventsOnConnectionMutationVariables, CreateStatusMutation, CreateStatusMutationVariables, CreateWorkflowMutation, @@ -42,12 +42,12 @@ const addWorkflowStatus = ( return cy.wrap(request); }; -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); @@ -163,8 +163,8 @@ Cypress.Commands.add('createWorkflow', createWorkflow); Cypress.Commands.add('createStatus', createStatus); Cypress.Commands.add('addWorkflowStatus', addWorkflowStatus); 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..94856fc61f 100644 --- a/apps/e2e/cypress/types/workflow.d.ts +++ b/apps/e2e/cypress/types/workflow.d.ts @@ -1,6 +1,6 @@ import { - AddStatusChangingEventsToConnectionMutationVariables, - AddStatusChangingEventsToConnectionMutation, + SetStatusChangingEventsOnConnectionMutationVariables, + SetStatusChangingEventsOnConnectionMutation, CreateWorkflowMutationVariables, CreateWorkflowMutation, CreateStatusMutationVariables, @@ -42,14 +42,14 @@ 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. 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..84c6912ada 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,18 +76,18 @@ const StatusEventsAndActionsDialog = ({ } tabPanelPadding={theme.spacing(0, 3)} > - statusChangingEvent.statusChangingEvent ) as WorkflowEvent[] } statusName={workflowConnection?.status.name} - addStatusChangingEventsToConnection={( + setStatusChangingEventsOnConnection={( statusChangingEvents: string[] ) => dispatch({ - type: EventType.ADD_NEXT_STATUS_EVENTS_REQUESTED, + type: EventType.SET_STATUS_CHANGING_EVENTS_ON_CONNECTION_REQUESTED, payload: { statusChangingEvents, workflowConnection, diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx index a1954b0aae..e09afdd41c 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx @@ -21,8 +21,8 @@ export enum EventType { WORKFLOW_STATUS_DELETED, UPDATE_WORKFLOW_METADATA_REQUESTED, WORKFLOW_METADATA_UPDATED, - NEXT_STATUS_EVENTS_ADDED, - ADD_NEXT_STATUS_EVENTS_REQUESTED, + STATUS_CHANGING_EVENTS_ON_CONNECTION_SET, + SET_STATUS_CHANGING_EVENTS_ON_CONNECTION_REQUESTED, ADD_STATUS_ACTION_REQUESTED, STATUS_ACTION_ADDED, DELETE_WORKFLOW_CONNECTION_REQUESTED, @@ -112,7 +112,7 @@ 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( (conn) => conn.id === workflowConnection.id diff --git a/apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql similarity index 63% rename from apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql rename to apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql index e3e2bbea05..26598da293 100644 --- a/apps/frontend/src/graphql/settings/addStatusChangingEventsToConnection.graphql +++ b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql @@ -1,15 +1,15 @@ -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 + } + ) { + statusChangingEventId + workflowConnectionId + statusChangingEvent + } +} diff --git a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index 05c723d4b6..67041c2fc4 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -75,18 +75,18 @@ export function usePersistWorkflowEditorModel() { .then((data) => data.addWorkflowStatus); }; - 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) => { @@ -254,17 +254,17 @@ export function usePersistWorkflowEditorModel() { }); } - 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, From b18f078fe37d9bf0fa0a57d439a4b6cd1d2177c4 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 22 Dec 2025 15:24:47 +0100 Subject: [PATCH 003/147] fix: throw error when status changing event is not found in database --- .../backend/src/datasources/postgres/WorkflowDataSource.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index cd90c89840..b9957ef476 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -439,10 +439,9 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { if (existingEvent) { eventId = existingEvent.status_changing_event_id; } else { - const [newEvent] = await database('workflow_status_changing_events') - .insert({ name: eventName }) - .returning('*'); - eventId = newEvent.status_changing_event_id; + throw new GraphQLError( + `Status changing event with name ${eventName} not found` + ); } await database( From 23b55baefae41fa10ceda752f136fd2e4a907287 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 22 Dec 2025 16:04:35 +0100 Subject: [PATCH 004/147] feat: overhaul workflow status handling by renaming and refactoring related methods and types --- .../src/datasources/WorkflowDataSource.ts | 12 ++--- .../datasources/mockups/WorkflowDataSource.ts | 6 +-- .../postgres/WorkflowDataSource.ts | 47 ++++++++++++------- .../ProposalSettingsMutations.spec.ts | 2 +- .../src/mutations/WorkflowMutations.ts | 23 ++------- ...tion.ts => AddStatusToWorkflowMutation.ts} | 22 ++------- .../settings/addStatusToWorkflow.graphql | 17 +++++++ .../settings/addWorkflowStatus.graphql | 25 ---------- .../settings/usePersistWorkflowEditorModel.ts | 23 ++------- 9 files changed, 68 insertions(+), 109 deletions(-) rename apps/backend/src/resolvers/mutations/settings/{AddWorkflowStatusMutation.ts => AddStatusToWorkflowMutation.ts} (50%) create mode 100644 apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql delete mode 100644 apps/frontend/src/graphql/settings/addWorkflowStatus.graphql diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index 58dc3164da..647951042a 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -29,12 +29,12 @@ export interface WorkflowDataSource { statusId: Status['id'] | undefined, { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses ): Promise; - addWorkflowStatus( - newWorkflowStatusInput: Omit< - WorkflowConnection, - 'id' | 'entityType' | 'prevConnectionId' - > - ): Promise; + addStatusToWorkflow(newWorkflowStatusInput: { + workflowId: number; + statusId: number; + posX: number; + posY: number; + }): Promise; updateWorkflowStatus( workflowStatuses: WorkflowConnection ): Promise; diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 441d743da6..3d79a83b57 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -6,7 +6,7 @@ import { WorkflowConnection, WorkflowConnectionWithStatus, } from '../../models/WorkflowConnections'; -import { AddWorkflowStatusInput } from '../../resolvers/mutations/settings/AddWorkflowStatusMutation'; +import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; @@ -128,8 +128,8 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return [dummyWorkflowConnection]; } - async addWorkflowStatus( - newWorkflowStatusInput: AddWorkflowStatusInput + async addStatusToWorkflow( + newWorkflowStatusInput: AddStatusToWorkflowInput ): Promise { return dummyWorkflowConnection; } diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index b9957ef476..687c47e718 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -106,15 +106,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); @@ -268,9 +264,12 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { return workflowConnections; } - async addWorkflowStatus( - newWorkflowStatusInput: Omit - ): Promise { + async addStatusToWorkflow(newWorkflowStatusInput: { + workflowId: number; + statusId: number; + posX: number; + posY: number; + }): Promise { const workflow = await this.getWorkflow(newWorkflowStatusInput.workflowId); if (!workflow) { @@ -278,29 +277,41 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { `Could not find workflow with id: ${newWorkflowStatusInput.workflowId}` ); } - const [workflowConnectionRecord]: (WorkflowConnectionRecord & - StatusRecord)[] = await database + const [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') + .into('workflow_has_statuses') .returning('*') .join('statuses as s', { 's.status_id': newWorkflowStatusInput.statusId, }); - if (!workflowConnectionRecord) { + + if (!workflowStatusRecord) { throw new GraphQLError('Could not create workflow status'); } - return this.createWorkflowConnectionWithStatusObject( - workflowConnectionRecord + return new WorkflowConnectionWithStatus( + workflowStatusRecord.workflow_status_id, + 0, + workflowStatusRecord.workflow_id, + workflowStatusRecord.status_id, + { + id: workflowStatusRecord.status_id, + shortCode: workflowStatusRecord.short_code, + name: workflowStatusRecord.name, + description: workflowStatusRecord.description, + isDefault: workflowStatusRecord.is_default, + entityType: workflowStatusRecord.entity_type, + }, + null, + null, + workflowStatusRecord.pos_x, + workflowStatusRecord.pos_y, + null ); } diff --git a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 063e0b0915..018fd28567 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -165,7 +165,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can create new proposal workflow connection', () => { return expect( - workflowMutationsInstance.addWorkflowStatus( + workflowMutationsInstance.addStatusToWorkflow( dummyUserOfficerWithRole, dummyWorkflowConnection ) diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 8e0e35bdeb..1ea975800e 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -22,7 +22,7 @@ import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; -import { AddWorkflowStatusInput } from '../resolvers/mutations/settings/AddWorkflowStatusMutation'; +import { AddStatusToWorkflowInput } from '../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { DeleteWorkflowStatusInput } from '../resolvers/mutations/settings/DeleteWorkflowStatusMutation'; import { SetStatusChangingEventsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation'; @@ -74,27 +74,12 @@ export default class WorkflowMutations { } @Authorized([Roles.USER_OFFICER]) - async addWorkflowStatus( + async addStatusToWorkflow( agent: UserWithRole | null, - args: AddWorkflowStatusInput + 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); } diff --git a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts similarity index 50% rename from apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts rename to apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts index 21c24a4972..02195b44df 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddWorkflowStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts @@ -12,41 +12,29 @@ import { ResolverContext } from '../../../context'; import { WorkflowConnection } from '../../types/WorkflowConnection'; @InputType() -export class AddWorkflowStatusInput implements Partial { +export class AddStatusToWorkflowInput 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 { +export class AddStatusToWorkflowMutation { @Mutation(() => WorkflowConnection) - async addWorkflowStatus( + async addStatusToWorkflow( @Ctx() context: ResolverContext, @Arg('newWorkflowStatusInput') - newWorkflowStatusInput: AddWorkflowStatusInput + newWorkflowStatusInput: AddStatusToWorkflowInput ) { - return context.mutations.workflow.addWorkflowStatus( + return context.mutations.workflow.addStatusToWorkflow( context.user, newWorkflowStatusInput ); diff --git a/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql new file mode 100644 index 0000000000..d4197942a6 --- /dev/null +++ b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql @@ -0,0 +1,17 @@ +mutation addStatusToWorkflow( + $workflowId: Int! + $statusId: Int! + $posX: Int! + $posY: Int! +) { + addStatusToWorkflow( + newWorkflowStatusInput: { + workflowId: $workflowId + statusId: $statusId + posX: $posX + posY: $posY + } + ) { + id + } +} 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/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index 67041c2fc4..697dcfacf2 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -55,24 +55,18 @@ export function usePersistWorkflowEditorModel() { const insertNewStatusInWorkflow = async ( workflowId: number, - sortOrder: number, statusId: number, - nextStatusId: number, - prevStatusId: number, 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 setStatusChangingEventsOnConnection = async ( @@ -206,15 +200,7 @@ export function usePersistWorkflowEditorModel() { break; } case EventType.ADD_WORKFLOW_STATUS_REQUESTED: { - const { - workflowId, - sortOrder, - statusId, - nextStatusId, - prevStatusId, - posX, - posY, - } = action.payload; + const { workflowId, statusId, posX, posY } = action.payload; // Immediately add to state so it shows up in the UI dispatch({ @@ -228,10 +214,7 @@ export function usePersistWorkflowEditorModel() { try { const result = await insertNewStatusInWorkflow( workflowId, - sortOrder, statusId, - nextStatusId, - prevStatusId, posX, posY ); From 9e33025f057066e6aa4dfab2395655d6e810c8c4 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 24 Dec 2025 15:30:26 +0100 Subject: [PATCH 005/147] feat: overhaul workflow data structures and connections by refactoring and introducing WorkflowStatus model --- ...0203_Add_new_workflow_data_structures.sql} | 0 .../db_patches/0204_Migrate_workflow.sql | 22 ++ .../src/datasources/ProposalDataSource.ts | 6 - .../src/datasources/WorkflowDataSource.ts | 36 +- .../datasources/mockups/ProposalDataSource.ts | 14 - .../datasources/mockups/WorkflowDataSource.ts | 90 ++--- .../postgres/ProposalDataSource.ts | 118 +------ .../postgres/WorkflowDataSource.ts | 307 ++++++------------ .../src/datasources/postgres/records.ts | 13 +- .../backend/src/models/WorkflowConnections.ts | 32 +- apps/backend/src/models/WorkflowStatus.ts | 9 + .../src/mutations/ProposalMutations.ts | 6 - .../src/mutations/WorkflowMutations.ts | 50 ++- apps/backend/src/queries/FapQueries.ts | 41 ++- apps/backend/src/queries/WorkflowQueries.ts | 17 +- .../settings/AddConnectionToWorkflow.ts | 36 ++ .../settings/DeleteWorkflowStatusMutation.ts | 8 +- .../settings/UpdateWorkflowStatusMutation.ts | 21 +- apps/backend/src/resolvers/types/Workflow.ts | 19 +- .../src/resolvers/types/WorkflowConnection.ts | 68 ++-- .../src/resolvers/types/WorkflowStatus.ts | 45 +++ apps/backend/src/workflowEngine/experiment.ts | 42 ++- apps/backend/src/workflowEngine/proposal.ts | 42 ++- .../src/components/call/CallGeneralInfo.tsx | 6 +- .../components/proposal/ProposalSummary.tsx | 16 +- .../workflow/StatusEventsAndActionsDialog.tsx | 4 +- .../settings/workflow/WorkflowEditor.tsx | 168 ++++------ .../settings/workflow/WorkflowEditorModel.tsx | 89 +++-- .../call/getCallSubmissionDetails.graphql | 7 +- .../settings/addConnectionToWorkflow.graphql | 9 + .../graphql/settings/createWorkflow.graphql | 21 +- .../settings/deleteWorkflowConnection.graphql | 10 +- .../settings/deleteWorkflowStatus.graphql | 12 +- .../fragment.workflowConnection.graphql | 6 + .../settings/fragment.workflowStatus.graphql | 7 + .../src/graphql/settings/getWorkflow.graphql | 33 +- .../src/graphql/settings/getWorkflows.graphql | 7 +- .../graphql/settings/updateWorkflow.graphql | 41 ++- .../settings/updateWorkflowStatus.graphql | 27 +- .../settings/usePersistWorkflowEditorModel.ts | 87 +++-- 40 files changed, 747 insertions(+), 845 deletions(-) rename apps/backend/db_patches/{0201_Add_new_workflow_data_structures.sql => 0203_Add_new_workflow_data_structures.sql} (100%) create mode 100644 apps/backend/db_patches/0204_Migrate_workflow.sql create mode 100644 apps/backend/src/models/WorkflowStatus.ts create mode 100644 apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts create mode 100644 apps/backend/src/resolvers/types/WorkflowStatus.ts create mode 100644 apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql create mode 100644 apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql create mode 100644 apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql diff --git a/apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql similarity index 100% rename from apps/backend/db_patches/0201_Add_new_workflow_data_structures.sql rename to apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql diff --git a/apps/backend/db_patches/0204_Migrate_workflow.sql b/apps/backend/db_patches/0204_Migrate_workflow.sql new file mode 100644 index 0000000000..25be494015 --- /dev/null +++ b/apps/backend/db_patches/0204_Migrate_workflow.sql @@ -0,0 +1,22 @@ +DO +$$ +BEGIN + IF register_patch( + 'Migrate_workflow', + 'Jekabs Karklins', + 'Migrate workflow data to new structures.', + '2026-01-05' + ) THEN + BEGIN + + + + + + + DROP TABLE IF EXISTS status_changing_events; + END; + END IF; +END; +$$ +LANGUAGE plpgsql; diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 6cd43080cd..e1c215d8dc 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -70,12 +70,6 @@ export interface ProposalDataSource { ): 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, proposalPks: number[] diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index 647951042a..a62938feb9 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -1,13 +1,11 @@ -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 { AddConnectionToWorkflowInput } from '../resolvers/mutations/settings/AddConnectionToWorkflow'; 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; @@ -20,29 +18,25 @@ export interface WorkflowDataSource { ): Promise; getWorkflowConnections( workflowId: WorkflowConnection['workflowId'] - ): Promise; + ): Promise; + getWorkflowStatuses(workflowId: number): Promise; + getWorkflowStatus(workflowStatusId: number): Promise; getWorkflowConnection( connectionId: WorkflowConnection['id'] - ): Promise; - getWorkflowConnectionsById( - workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'] | undefined, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise; + ): Promise; addStatusToWorkflow(newWorkflowStatusInput: { workflowId: number; statusId: number; posX: number; posY: number; - }): Promise; - updateWorkflowStatus( - workflowStatuses: WorkflowConnection - ): Promise; - deleteWorkflowStatus( - statusId: number, - workflowId: number, - sortOrder: number + }): Promise; + addConnectionToWorkflow( + newWorkflowConnectionInput: AddConnectionToWorkflowInput ): Promise; + updateWorkflowStatus( + workflowStatus: UpdateWorkflowStatusInput + ): Promise; + deleteWorkflowStatus(workflowStatusId: number): Promise; setStatusChangingEventsOnConnection( workflowConnectionId: number, statusChangingEvents: string[] diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 6e496eb67a..9d5b7f3890 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -366,12 +366,6 @@ export class ProposalDataSourceMock implements ProposalDataSource { return [dummyProposalEvents]; } - async getProposalEvents( - proposalPk: number - ): Promise { - return dummyProposalEvents; - } - async getCount(callId: number): Promise { return 1; } @@ -380,14 +374,6 @@ export class ProposalDataSourceMock implements ProposalDataSource { return dummyProposal; } - async resetProposalEvents( - proposalPk: number, - callId: number, - statusId: number - ): Promise { - return true; - } - async changeProposalsStatus( statusId: number, proposalPks: number[] diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 3d79a83b57..53f8551a52 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -1,14 +1,13 @@ import { Status } from '../../models/Status'; import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow, WorkflowType } from '../../models/Workflow'; -import { - NextAndPreviousStatuses, - WorkflowConnection, - WorkflowConnectionWithStatus, -} from '../../models/WorkflowConnections'; +import { WorkflowConnection } from '../../models/WorkflowConnections'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; +import { AddConnectionToWorkflowInput } from '../../resolvers/mutations/settings/AddConnectionToWorkflow'; import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; export const dummyStatuses = [ @@ -30,46 +29,17 @@ export const dummyWorkflow = new Workflow( 'default' ); -export const dummyWorkflowConnection = new WorkflowConnectionWithStatus( - 1, - 1, - 1, - 1, - new Status( - 1, - 'TEST_STATUS', - 'Test status', - 'Test status', - false, - WorkflowType.PROPOSAL - ), - null, - null, - 100, - 100, - null -); +export const dummyWorkflowConnection = new WorkflowConnection(1, 1, 1, 2); -export const anotherDummyWorkflowConnection = new WorkflowConnectionWithStatus( - 2, +export const anotherDummyWorkflowConnection = new WorkflowConnection( 2, 1, 2, - new Status( - 2, - 'TEST_STATUS_2', - 'Test status 2', - 'Test status 2', - false, - WorkflowType.PROPOSAL - ), - null, - 1, - 200, - 150, - null + 1 ); +export const dummyWorkflowStatus = new WorkflowStatus(1, 1, 1, 100, 100); + export const dummyStatusChangingEvent = new StatusChangingEvent( 1, 1, @@ -77,6 +47,14 @@ export const dummyStatusChangingEvent = new StatusChangingEvent( ); export class WorkflowDataSourceMock implements WorkflowDataSource { + addConnectionToWorkflow( + newWorkflowConnectionInput: AddConnectionToWorkflowInput + ): Promise { + throw new Error('Method not implemented.'); + } + getWorkflowStatus(workflowStatusId: number): Promise { + throw new Error('Method not implemented.'); + } async createWorkflow(args: CreateWorkflowInput): Promise { return dummyWorkflow; } @@ -103,13 +81,17 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { async getWorkflowConnections( workflowId: number - ): Promise { + ): Promise { return [dummyWorkflowConnection, anotherDummyWorkflowConnection]; } + async getWorkflowStatuses(workflowId: number): Promise { + return [dummyWorkflowStatus]; + } + async getWorkflowConnection( connectionId: number - ): Promise { + ): Promise { if (connectionId === dummyWorkflowConnection.id) { return dummyWorkflowConnection; } @@ -120,32 +102,22 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { return null; } - async getWorkflowConnectionsById( - workflowId: number, - workflowConnectionId: number, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise { - return [dummyWorkflowConnection]; - } - async addStatusToWorkflow( newWorkflowStatusInput: AddStatusToWorkflowInput - ): Promise { - return dummyWorkflowConnection; + ): Promise { + return dummyWorkflowStatus; } async updateWorkflowStatus( - workflowStatus: WorkflowConnection - ): Promise { - return dummyWorkflowConnection; + workflowStatus: UpdateWorkflowStatusInput + ): Promise { + return dummyWorkflowStatus; } async deleteWorkflowStatus( - statusId: number, - workflowId: number, - sortOrder: number - ): Promise { - return dummyWorkflowConnection; + workflowStatusId: number + ): Promise { + return dummyWorkflowStatus; } async deleteWorkflowConnection( diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index e1609b21f0..ad7b58456a 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -16,7 +16,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 { AdminDataSource } from '../AdminDataSource'; @@ -28,15 +27,12 @@ import { } from './../../resolvers/queries/ProposalsQuery'; import database from './database'; import { - CallRecord, createProposalObject, createProposalViewObject, createTechnicalReviewObject, ProposalEventsRecord, ProposalRecord, ProposalViewRecord, - WorkflowConnectionRecord, - StatusChangingEventRecord, TechnicalReviewRecord, TechniqueRecord, createProposalViewObjectWithTechniques, @@ -803,22 +799,6 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } } - 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') @@ -869,93 +849,6 @@ 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, proposalPks: number[] @@ -1044,12 +937,13 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .first() .then((value) => value.proposal_workflow_id); - const proposalStatus: WorkflowConnectionWithStatus[] = - await this.workflowDataSource.getWorkflowConnections(proposalWorkflowId); + const result = await database('workflow_has_statuses') + .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') + .where('workflow_has_statuses.workflow_id', proposalWorkflowId) + .andWhere('statuses.short_code', workflowStatus) + .first(); - return !!proposalStatus.find((status) => - status.status.shortCode.match(workflowStatus) - ); + return !!result; } createTechniqueObject(technique: TechniqueRecord): Technique { diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 687c47e718..a8874e8534 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -2,23 +2,21 @@ 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 { AddConnectionToWorkflowInput } from '../../resolvers/mutations/settings/AddConnectionToWorkflow'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; +import { UpdateWorkflowStatusInput } from '../../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; import database from './database'; import { StatusChangingEventRecord, - StatusRecord, WorkflowConnectionRecord, WorkflowRecord, + WorkflowStatusRecord, } from './records'; import StatusDataSource from './StatusDataSource'; @@ -28,6 +26,12 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource ) {} + addConnectionToWorkflow( + newWorkflowConnectionInput: AddConnectionToWorkflowInput + ): Promise { + throw new Error('Method not implemented.'); + } + private createWorkflowObject(workflow: WorkflowRecord) { return new Workflow( workflow.workflow_id, @@ -42,39 +46,20 @@ 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 ); } - 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 ); } @@ -174,102 +159,48 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { }); } 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) - ) - : []; - } - - 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; + workflowId: number + ): Promise { + const workflowConnections: WorkflowConnectionRecord[] = await database + .select('*') + .from('workflow_status_connections') + .where('workflow_id', workflowId); - return workflowConnection - ? this.createWorkflowConnectionWithStatusObject(workflowConnection) - : null; + return workflowConnections.map((workflowConnection) => + this.createWorkflowConnectionObject(workflowConnection) + ); } - async getWorkflowConnectionsById( - workflowId: WorkflowConnection['workflowId'], - statusId: Status['id'] | undefined, - { nextStatusId, prevStatusId, sortOrder }: NextAndPreviousStatuses - ): Promise { - const workflowConnectionRecords: (WorkflowConnectionRecord & - StatusRecord)[] = await database - .select() - .from('workflow_connections as wc') - .join('statuses as s', { - 's.status_id': 'wc.status_id', - }) - .where('workflow_id', workflowId) - .modify((query) => { - if (statusId) { - query.andWhere('wc.status_id', statusId); - } - - if (nextStatusId) { - query.andWhere('wc.next_status_id', nextStatusId); - } - - if (prevStatusId) { - query.andWhere('wc.prev_status_id', prevStatusId); - } - - if (sortOrder) { - query.andWhere('wc.sort_order', sortOrder); - } - }); - if (!workflowConnectionRecords) { - throw new GraphQLError( - `Could not find wkflow connections with statusId: ${statusId}` - ); - } + async getWorkflowStatuses(workflowId: number): Promise { + const workflowStatuses: WorkflowStatusRecord[] = await database + .select('*') + .from('workflow_has_statuses') + .where('workflow_id', workflowId); - const workflowConnections = workflowConnectionRecords.map( - (workflowConnectionRecord) => - this.createWorkflowConnectionWithStatusObject(workflowConnectionRecord) + return workflowStatuses.map((workflowStatus) => + 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 addStatusToWorkflow(newWorkflowStatusInput: { workflowId: number; statusId: number; posX: number; posY: number; - }): Promise { + }): Promise { const workflow = await this.getWorkflow(newWorkflowStatusInput.workflowId); if (!workflow) { @@ -277,7 +208,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { `Could not find workflow with id: ${newWorkflowStatusInput.workflowId}` ); } - const [workflowStatusRecord] = await database + const [workflowStatusRecord]: WorkflowStatusRecord[] = await database .insert({ workflow_id: newWorkflowStatusInput.workflowId, status_id: newWorkflowStatusInput.statusId, @@ -285,120 +216,70 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { pos_y: newWorkflowStatusInput.posY, }) .into('workflow_has_statuses') - .returning('*') - .join('statuses as s', { - 's.status_id': newWorkflowStatusInput.statusId, - }); + .returning('*'); if (!workflowStatusRecord) { throw new GraphQLError('Could not create workflow status'); } - return new WorkflowConnectionWithStatus( - workflowStatusRecord.workflow_status_id, - 0, - workflowStatusRecord.workflow_id, - workflowStatusRecord.status_id, - { - id: workflowStatusRecord.status_id, - shortCode: workflowStatusRecord.short_code, - name: workflowStatusRecord.name, - description: workflowStatusRecord.description, - isDefault: workflowStatusRecord.is_default, - entityType: workflowStatusRecord.entity_type, - }, - null, - null, - workflowStatusRecord.pos_x, - workflowStatusRecord.pos_y, - null - ); + 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]); + return this.createWorkflowStatusObject(updatedStatus); } 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]); + 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], - }); - } - ); + return this.createWorkflowStatusObject(deletedStatus); } private createStatusChangingEventObject( @@ -417,8 +298,8 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ): 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 @@ -508,4 +389,18 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { } ); } + + 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; + } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index f5029d22ed..a6ea452f27 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -601,15 +601,18 @@ export interface WorkflowRecord { } 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; +} + +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 pos_x: number; readonly pos_y: number; - readonly prev_connection_id: number | null; } export interface StatusChangingEventRecord { diff --git a/apps/backend/src/models/WorkflowConnections.ts b/apps/backend/src/models/WorkflowConnections.ts index 450a2efebb..72e9982e66 100644 --- a/apps/backend/src/models/WorkflowConnections.ts +++ b/apps/backend/src/models/WorkflowConnections.ts @@ -1,36 +1,8 @@ -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 ) {} } diff --git a/apps/backend/src/models/WorkflowStatus.ts b/apps/backend/src/models/WorkflowStatus.ts new file mode 100644 index 0000000000..cd4dabd1d4 --- /dev/null +++ b/apps/backend/src/models/WorkflowStatus.ts @@ -0,0 +1,9 @@ +export class WorkflowStatus { + constructor( + public id: number, + public workflowId: number, + public statusId: number, + public posX: number, + public posY: number + ) {} +} diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index cbf9467167..adb2f5cda0 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -568,12 +568,6 @@ export default class ProposalMutations { return null; } - await this.proposalDataSource.resetProposalEvents( - proposalPk, - fullProposal.callId, - statusId - ); - const proposalWorkflow = await this.callDataSource.getProposalWorkflowByCall( fullProposal.callId diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 1ea975800e..797faf62d7 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -21,7 +21,9 @@ import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; +import { WorkflowStatus } from '../models/WorkflowStatus'; import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { AddConnectionToWorkflowInput } from '../resolvers/mutations/settings/AddConnectionToWorkflow'; import { AddStatusToWorkflowInput } from '../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; import { DeleteWorkflowStatusInput } from '../resolvers/mutations/settings/DeleteWorkflowStatusMutation'; @@ -77,7 +79,7 @@ export default class WorkflowMutations { async addStatusToWorkflow( agent: UserWithRole | null, args: AddStatusToWorkflowInput - ): Promise { + ): Promise { try { return await this.dataSource.addStatusToWorkflow(args); } catch (error) { @@ -86,34 +88,28 @@ export default class WorkflowMutations { } @Authorized([Roles.USER_OFFICER]) - async updateWorkflowStatus( + async addConnectionToWorkflow( agent: UserWithRole | null, - args: UpdateWorkflowStatusInput + args: AddConnectionToWorkflowInput ): 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.addConnectionToWorkflow(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', @@ -150,11 +146,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) { diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index 66bfbbe916..59f313fbf2 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -177,26 +177,31 @@ export default class FapQueries { proposalPk: number; } ) { - let reviewerId = null; + const reviewerId = null; - const proposalEvents = - await this.proposalDataSource.getProposalEvents(proposalPk); - - // NOTE: If not officer, Fap Chair or Fap Secretary should return all proposal assignments only if everything is submitted. Otherwise for Fap Reviewer return only it's own proposal reviews. - if ( - agent && - !this.userAuth.isUserOfficer(agent) && - !(await this.userAuth.isChairOrSecretaryOfFap(agent, fapId)) && - !proposalEvents?.proposal_all_fap_reviews_submitted - ) { - reviewerId = agent.id; - } - - return this.dataSource.getFapProposalAssignments( - fapId, - proposalPk, - reviewerId + throw new Error( + 'getProposalEvents does not exist any more, please inspect tables if all fap reviews are submitted instead or relying on events system.' ); + // TODO implement new logic here and get all fap reviews are submitted instead or relying on events system + + // const proposalEvents = + // await this.proposalDataSource.getProposalEvents(proposalPk); + + // // NOTE: If not officer, Fap Chair or Fap Secretary should return all proposal assignments only if everything is submitted. Otherwise for Fap Reviewer return only it's own proposal reviews. + // if ( + // agent && + // !this.userAuth.isUserOfficer(agent) && + // !(await this.userAuth.isChairOrSecretaryOfFap(agent, fapId)) && + // !proposalEvents?.proposal_all_fap_reviews_submitted + // ) { + // reviewerId = agent.id; + // } + + // return this.dataSource.getFapProposalAssignments( + // fapId, + // proposalPk, + // reviewerId + // ); } @Authorized([Roles.USER_OFFICER, Roles.FAP_CHAIR, Roles.FAP_SECRETARY]) diff --git a/apps/backend/src/queries/WorkflowQueries.ts b/apps/backend/src/queries/WorkflowQueries.ts index f867f28e39..cd04f8a7f8 100644 --- a/apps/backend/src/queries/WorkflowQueries.ts +++ b/apps/backend/src/queries/WorkflowQueries.ts @@ -40,10 +40,25 @@ 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 getStatuses(agent: UserWithRole | null, workflowId: number) { + return this.dataSource.getWorkflowStatuses(workflowId); + } + + @Authorized() + async getWorkflowStatus( + agent: UserWithRole | null, + workflowStatusId: number + ) { + const status = await this.dataSource.getWorkflowStatus(workflowStatusId); + + return status; + } + @Authorized([Roles.USER_OFFICER]) async getStatusChangingEventsByConnectionId( agent: UserWithRole | null, diff --git a/apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts b/apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts new file mode 100644 index 0000000000..be97475da5 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts @@ -0,0 +1,36 @@ +import { + Ctx, + Mutation, + Resolver, + Field, + InputType, + Arg, + Int, +} from 'type-graphql'; + +import { ResolverContext } from '../../../context'; +import { WorkflowConnection } from '../../types/WorkflowConnection'; + +@InputType() +export class AddConnectionToWorkflowInput { + @Field(() => Int) + public prevWorkflowStatusId: number; + + @Field(() => Int) + public nextWorkflowStatusId: number; +} + +@Resolver() +export class AddConnectionToWorkflowMutation { + @Mutation(() => WorkflowConnection) + async addConnectionToWorkflow( + @Ctx() context: ResolverContext, + @Arg('newWorkflowConnectionInput') + newWorkflowConnectionInput: AddConnectionToWorkflowInput + ) { + return context.mutations.workflow.addConnectionToWorkflow( + context.user, + newWorkflowConnectionInput + ); + } +} 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/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/types/Workflow.ts b/apps/backend/src/resolvers/types/Workflow.ts index e77b67cbd6..daabaef39d 100644 --- a/apps/backend/src/resolvers/types/Workflow.ts +++ b/apps/backend/src/resolvers/types/Workflow.ts @@ -16,6 +16,7 @@ import { WorkflowType, } from '../../models/Workflow'; import { WorkflowConnection } from './WorkflowConnection'; +import { WorkflowStatus } from './WorkflowStatus'; @ObjectType() export class Workflow implements Partial { @@ -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.getStatuses( + 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 2cefe1597e..28073a2d71 100644 --- a/apps/backend/src/resolvers/types/WorkflowConnection.ts +++ b/apps/backend/src/resolvers/types/WorkflowConnection.ts @@ -10,44 +10,24 @@ import { import { ResolverContext } from '../../context'; import { isRejection } from '../../models/Rejection'; -import { WorkflowConnectionWithStatus as WorkflowConnectionWithStatusOrigin } from '../../models/WorkflowConnections'; +import { WorkflowConnection as WorkflowConnectionOrigin } from '../../models/WorkflowConnections'; import { ConnectionStatusAction } from './ConnectionStatusAction'; -import { Status } from './Status'; import { StatusChangingEvent } from './StatusChangingEvent'; +import { WorkflowStatus } from './WorkflowStatus'; @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; - - @Field(() => Int) - public posY: number; - - @Field(() => Int, { nullable: true }) - public prevConnectionId: number | null; + public nextWorkflowStatusId: number; } @Resolver(() => WorkflowConnection) @@ -82,4 +62,42 @@ export class WorkflowConnectionResolver { 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..996527669b --- /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 { ResolverContext } from '../../context'; +import { WorkflowStatus as WorkflowStatusOrigin } from '../../models/WorkflowStatus'; +import { Status } from './Status'; + +@ObjectType() +export class WorkflowStatus implements Partial { + @Field(() => Int) + public id: number; + + @Field(() => Int) + public workflowId: number; + + @Field(() => Int) + public statusId: number; + + @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/workflowEngine/experiment.ts b/apps/backend/src/workflowEngine/experiment.ts index 4fb3465a3d..a774163317 100644 --- a/apps/backend/src/workflowEngine/experiment.ts +++ b/apps/backend/src/workflowEngine/experiment.ts @@ -10,7 +10,7 @@ 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 { WorkflowConnection } from '../models/WorkflowConnections'; const getExperimentWorkflowByCallId = (callId: number) => { const callDataSource = container.resolve( @@ -20,7 +20,7 @@ const getExperimentWorkflowByCallId = (callId: number) => { return callDataSource.getExperimentWorkflowByCall(callId); }; -export const getWorkflowConnectionByStatusId = ( +export const getWorkflowConnectionByStatusId = async ( workflowId: number, statusId?: number, prevStatusId?: number @@ -29,9 +29,18 @@ export const getWorkflowConnectionByStatusId = ( Tokens.WorkflowDataSource ); - return workflowDataSource.getWorkflowConnectionsById(workflowId, statusId, { - prevStatusId, - }); + const statuses = await workflowDataSource.getWorkflowStatuses(workflowId); + const connections = + await workflowDataSource.getWorkflowConnections(workflowId); + + const matchingWorkflowStatuses = statuses.filter( + (ws) => ws.statusId === statusId + ); + const matchingWorkflowStatusIds = matchingWorkflowStatuses.map((ws) => ws.id); + + return connections.filter((conn) => + matchingWorkflowStatusIds.includes(conn.prevWorkflowStatusId) + ); }; const shouldMoveToNextStatus = ( @@ -61,7 +70,7 @@ const checkIfConditionsForNextStatusAreMet = async ({ workflowDataSource, experimentSafetyWithEvents, }: { - nextWorkflowConnections: WorkflowConnectionWithStatus[]; + nextWorkflowConnections: WorkflowConnection[]; experimentWorkflow: Workflow; workflowDataSource: WorkflowDataSource; experimentSafetyWithEvents: { @@ -70,14 +79,22 @@ const checkIfConditionsForNextStatusAreMet = async ({ currentEvent: Event; }; }) => { + const statuses = await workflowDataSource.getWorkflowStatuses( + experimentWorkflow.id + ); + for (const nextWorkflowConnection of nextWorkflowConnections) { - if (!nextWorkflowConnection.nextStatusId) { + const nextStatusId = statuses.find( + (ws) => ws.id === nextWorkflowConnection.nextWorkflowStatusId + )?.statusId; + + if (!nextStatusId) { continue; } const nextNextWorkflowConnections = await getWorkflowConnectionByStatusId( experimentWorkflow.id, - nextWorkflowConnection.nextStatusId + nextStatusId ); const newStatusChangingEvents = await workflowDataSource.getStatusChangingEventsByConnectionIds( @@ -208,7 +225,8 @@ export const workflowEngine = async ( await getWorkflowConnectionByStatusId( experimentWorkflow.id, undefined, - currentWorkflowConnection.statusId + 0 // TODO fix this when new WF is implemented + // currentWorkflowConnection.statusId ); return Promise.all( @@ -249,7 +267,8 @@ export const workflowEngine = async ( const updatedExperimentSafety = await experimentDataSource.updateExperimentSafetyStatus( experimentSafety.experimentSafetyPk, - nextWorkflowConnection.statusId + 0 // TODO fix this when new WF is implemented + // nextWorkflowConnection.statusId ); if (updatedExperimentSafety) { @@ -263,7 +282,8 @@ export const workflowEngine = async ( return { ...updatedExperimentSafety, workflowId: experimentWorkflow.id, - prevStatusId: currentWorkflowConnection.statusId, + prevStatusId: 0, // TODO fix this when new WF is implemented + // prevStatusId: currentWorkflowConnection.statusId, callShortCode: call.shortCode, }; } diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 105607f331..9e93f5c462 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -10,7 +10,7 @@ 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 { WorkflowConnection } from '../models/WorkflowConnections'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; const getProposalWorkflowByCallId = (callId: number) => { @@ -21,7 +21,7 @@ const getProposalWorkflowByCallId = (callId: number) => { return callDataSource.getProposalWorkflowByCall(callId); }; -export const getProposalWorkflowConnectionByStatusId = ( +export const getProposalWorkflowConnectionByStatusId = async ( workflowId: number, statusId?: number, prevStatusId?: number @@ -30,9 +30,18 @@ export const getProposalWorkflowConnectionByStatusId = ( Tokens.WorkflowDataSource ); - return workflowDataSource.getWorkflowConnectionsById(workflowId, statusId, { - prevStatusId, - }); + const statuses = await workflowDataSource.getWorkflowStatuses(workflowId); + const connections = + await workflowDataSource.getWorkflowConnections(workflowId); + + const matchingWorkflowStatuses = statuses.filter( + (ws) => ws.statusId === statusId + ); + const matchingWorkflowStatusIds = matchingWorkflowStatuses.map((ws) => ws.id); + + return connections.filter((conn) => + matchingWorkflowStatusIds.includes(conn.prevWorkflowStatusId) + ); }; const shouldMoveToNextStatus = ( @@ -61,7 +70,7 @@ const checkIfConditionsForNextStatusAreMet = async ({ workflowDataSource, proposalWithEvents, }: { - nextWorkflowConnections: WorkflowConnectionWithStatus[]; + nextWorkflowConnections: WorkflowConnection[]; proposalWorkflow: Workflow; workflowDataSource: WorkflowDataSource; proposalWithEvents: { @@ -70,15 +79,23 @@ const checkIfConditionsForNextStatusAreMet = async ({ currentEvent: Event; }; }) => { + const statuses = await workflowDataSource.getWorkflowStatuses( + proposalWorkflow.id + ); + for (const nextWorkflowConnection of nextWorkflowConnections) { - if (!nextWorkflowConnection.nextStatusId) { + const nextStatusId = statuses.find( + (ws) => ws.id === nextWorkflowConnection.nextWorkflowStatusId + )?.statusId; + + if (!nextStatusId) { continue; } const nextNextWorkflowConnections = await getProposalWorkflowConnectionByStatusId( proposalWorkflow.id, - nextWorkflowConnection.nextStatusId + nextStatusId ); const newStatusChangingEvents = await workflowDataSource.getStatusChangingEventsByConnectionIds( @@ -188,7 +205,8 @@ export const workflowEngine = async ( await getProposalWorkflowConnectionByStatusId( proposalWorkflow.id, undefined, - currentWorkflowConnection.statusId + 0 // TODO fix this when new WF is implemented + // currentWorkflowConnection.statusId ); return Promise.all( @@ -230,7 +248,8 @@ export const workflowEngine = async ( const updatedProposal = await proposalDataSource.updateProposalStatus( proposalWithEvents.proposalPk, - nextWorkflowConnection.statusId + 0 // TODO fix this when new WF is implemented + // nextWorkflowConnection.statusId ); if (updatedProposal) { @@ -244,7 +263,8 @@ export const workflowEngine = async ( return { ...updatedProposal, workflowId: proposalWorkflow.id, - prevStatusId: currentWorkflowConnection.statusId, + prevStatusId: 0, // TODO fix this when new WF is implemented + // prevStatusId: currentWorkflowConnection.statusId, callShortCode: call.shortCode, }; } diff --git a/apps/frontend/src/components/call/CallGeneralInfo.tsx b/apps/frontend/src/components/call/CallGeneralInfo.tsx index 312e345be0..c5a964a772 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.status.shortCode === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED_INTERNAL ); } diff --git a/apps/frontend/src/components/proposal/ProposalSummary.tsx b/apps/frontend/src/components/proposal/ProposalSummary.tsx index 1213f67e9d..ad3a73f6ee 100644 --- a/apps/frontend/src/components/proposal/ProposalSummary.tsx +++ b/apps/frontend/src/components/proposal/ProposalSummary.tsx @@ -95,23 +95,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.status.shortCode) ); if (proposal.status != null && hasUpcomingEditableStatus) { diff --git a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx index 84c6912ada..94cb7d38ca 100644 --- a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx @@ -82,7 +82,7 @@ const StatusEventsAndActionsDialog = ({ (statusChangingEvent) => statusChangingEvent.statusChangingEvent ) as WorkflowEvent[] } - statusName={workflowConnection?.status.name} + statusName={workflowConnection?.nextStatus.status.name} setStatusChangingEventsOnConnection={( statusChangingEvents: string[] ) => @@ -118,7 +118,7 @@ const StatusEventsAndActionsDialog = ({ }); }} connectionStatusActions={workflowConnection?.statusActions} - statusName={workflowConnection?.status.name} + statusName={workflowConnection?.nextStatus.status.name} isLoading={isLoading} /> )} diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index 11c2a96abc..130373896f 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -38,7 +38,6 @@ interface EdgeData { workflowConnectionId?: number; statusActions: ConnectionStatusAction[]; connectionLineType?: ConnectionLineType; - prevConnectionId: number | null; } const edgeFactory = ( @@ -82,7 +81,7 @@ 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 = () => { @@ -99,17 +98,18 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { // 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,47 +127,38 @@ 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(); + state.statuses.forEach((workflowStatus) => { + const statusId = workflowStatus.status.id.toString(); + const nodeId = workflowStatus.id.toString(); // Use database coordinates if available, otherwise fall back to grid layout - const nodePositionX = connection.posX; - const nodePositionY = connection.posY; + const nodePositionX = workflowStatus.posX; + const nodePositionY = workflowStatus.posY; // Create node for the status const newNode = { id: nodeId, type: 'statusNode', data: { - label: connection.status.name, - status: connection.status, + label: workflowStatus.status.name, + status: workflowStatus.status, statusId: statusId, - onDelete: (connectionId: string) => { - // Since the node ID is the connection ID, use it directly + onDelete: (workflowStatusId: string) => { dispatch({ type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, payload: { - connectionId: Number(connectionId), + workflowStatusId: parseInt(workflowStatusId), }, }); }, @@ -176,49 +167,41 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { }; newNodes.push(newNode); + }); + state.connections.forEach((connection) => { + const edgeId = `edge-${connection.id}`; - // 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, - }, - }); + const newEdge = edgeFactory({ + id: edgeId, // Use connection ID to ensure unique edge identification + source: connection.prevStatus.id.toString(), + target: connection.nextStatus.id.toString(), + type: 'workflow', // Use custom workflow edge type + data: { + events: + connection.statusChangingEvents?.map( + (e) => e.statusChangingEvent + ) || [], + sourceStatusShortCode: connection.prevStatus.status.shortCode, + targetStatusShortCode: connection.nextStatus.status.shortCode, + workflowConnectionId: connection.id, // Use target connection ID (destination) + statusActions: connection.statusActions || [], + connectionLineType: state.connectionLineType as ConnectionLineType, + }, + }); - newEdges.push(newEdge); - } - } - } + 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( @@ -261,23 +244,12 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { 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 + (s) => s.id.toString() === connection.source ); const targetStatus = statuses.find( - (s) => s.id === targetConnection.statusId + (s) => s.id.toString() === connection.target ); if (!sourceStatus || !targetStatus) { @@ -296,7 +268,6 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { targetStatusShortCode: targetStatus.shortCode, statusActions: [], connectionLineType: state.connectionLineType as ConnectionLineType, - prevConnectionId: sourceConnection.id, }, }); @@ -307,20 +278,10 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { // 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({ - 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: sourceStatus.id, // Use connection ID for persistence + targetWorkflowStatusId: targetStatus.id, }, }); @@ -333,7 +294,6 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { setEdges, statuses, state.connectionLineType, - state.workflowConnections, ] ); @@ -342,17 +302,17 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { (event: React.MouseEvent, edge: Edge) => { setSelectedEdge(edge); - const targetWorkflowConnection = state.workflowConnections.find( + const clickedWorkflowConnection = state.connections.find( (connection) => connection.id.toString() === edge.target ); - if (!targetWorkflowConnection) { + if (!clickedWorkflowConnection) { return; } - setWorkflowConnection(targetWorkflowConnection); + setSelectedWorkflowConnection(clickedWorkflowConnection); }, - [state.workflowConnections] + [state.connections] ); // Handle status drag from picker to flow area @@ -450,20 +410,20 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { const newPosY = Math.round(node.position.y); // Find the workflow connection for this status to check current position - const workflowConnection = state.workflowConnections.find( + const workflowStatus = state.statuses.find( (connection) => connection.id === 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, + connectionId: workflowStatus.id, posX: newPosX, posY: newPosY, }, @@ -495,8 +455,8 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { {/* Status Events and Actions Dialog */} {selectedEdge && ( - conn.id === updatedConnection.id || - (conn.id === 0 && conn.statusId === updatedConnection.statusId) + const updatedStatus = action.payload; + const statusIndex = draft.statuses.findIndex( + (status) => + status.id === updatedStatus.id || + (status.id === 0 && status.statusId === updatedStatus.statusId) ); - if (connectionIndex !== -1) { - draft.workflowConnections[connectionIndex] = { - ...draft.workflowConnections[connectionIndex], - ...updatedConnection, + if (statusIndex !== -1) { + draft.statuses[statusIndex] = { + ...draft.statuses[statusIndex], + ...updatedStatus, }; } } @@ -100,10 +97,10 @@ 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 statusId + if (action.payload.workflowStatusId) { + draft.statuses = draft.statuses.filter( + (status) => status.id !== action.payload.workflowStatusId ); } @@ -114,32 +111,58 @@ const WorkflowEditorModel = ( } 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.id === sourceWorkflowStatusId + )!; + const nextStatus = draft.statuses.find( + (status) => status.id === 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: [], + }); + + return draft; + } + case EventType.WORKFLOW_CONNECTION_ADDED: { + return draft; + } 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 ); } diff --git a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql index a5b0bc7a96..7b06688029 100644 --- a/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql +++ b/apps/frontend/src/graphql/call/getCallSubmissionDetails.graphql @@ -3,8 +3,11 @@ query getCallSubmissionDetails($callId: Int!) { proposalWorkflowId submissionMessage proposalWorkflow { - workflowConnections { - prevStatusId + connections { + ...workflowConnection + } + statuses { + ...workflowStatus status { shortCode } diff --git a/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql b/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql new file mode 100644 index 0000000000..1c69cd1cf3 --- /dev/null +++ b/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql @@ -0,0 +1,9 @@ +mutation addConnectionToWorkflow( + $newWorkflowConnectionInput: AddConnectionToWorkflowInput! +) { + addConnectionToWorkflow( + newWorkflowConnectionInput: $newWorkflowConnectionInput + ) { + ...workflowConnection + } +} diff --git a/apps/frontend/src/graphql/settings/createWorkflow.graphql b/apps/frontend/src/graphql/settings/createWorkflow.graphql index 7b0eaddf9b..708460c1e3 100644 --- a/apps/frontend/src/graphql/settings/createWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/createWorkflow.graphql @@ -14,19 +14,8 @@ mutation createWorkflow( name description connectionLineType - workflowConnections { - id - sortOrder - workflowId - statusId - status { - ...status - } - nextStatusId - prevStatusId - prevConnectionId - posX - posY + connections { + ...workflowConnection statusChangingEvents { statusChangingEventId workflowConnectionId @@ -36,5 +25,11 @@ mutation createWorkflow( ...connectionStatusAction } } + statuses { + ...workflowStatus + status { + shortCode + } + } } } 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.workflowConnection.graphql b/apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql new file mode 100644 index 0000000000..12c54cd897 --- /dev/null +++ b/apps/frontend/src/graphql/settings/fragment.workflowConnection.graphql @@ -0,0 +1,6 @@ +fragment workflowConnection on WorkflowConnection { + id + workflowId + prevWorkflowStatusId + nextWorkflowStatusId +} 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..fc447969bf --- /dev/null +++ b/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql @@ -0,0 +1,7 @@ +fragment workflowStatus on WorkflowStatus { + id + workflowId + statusId + posX + posY +} diff --git a/apps/frontend/src/graphql/settings/getWorkflow.graphql b/apps/frontend/src/graphql/settings/getWorkflow.graphql index ece4de8b54..d8c2dd88fb 100644 --- a/apps/frontend/src/graphql/settings/getWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflow.graphql @@ -4,19 +4,21 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { name description connectionLineType - workflowConnections { - id - sortOrder - workflowId - statusId - status { - ...status + + connections { + ...workflowConnection + prevStatus { + ...workflowStatus + status { + shortCode + } + } + nextStatus { + ...workflowStatus + status { + shortCode + } } - nextStatusId - prevStatusId - prevConnectionId - posX - posY statusChangingEvents { statusChangingEventId workflowConnectionId @@ -26,5 +28,12 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { ...connectionStatusAction } } + + statuses { + ...workflowStatus + status { + shortCode + } + } } } diff --git a/apps/frontend/src/graphql/settings/getWorkflows.graphql b/apps/frontend/src/graphql/settings/getWorkflows.graphql index bd74930763..d55ab93bf8 100644 --- a/apps/frontend/src/graphql/settings/getWorkflows.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflows.graphql @@ -3,7 +3,12 @@ query getWorkflows($entityType: WorkflowType!) { id name description - workflowConnections { + connections { + ...workflowConnection + } + + statuses { + ...workflowStatus status { shortCode } diff --git a/apps/frontend/src/graphql/settings/updateWorkflow.graphql b/apps/frontend/src/graphql/settings/updateWorkflow.graphql index 19b850f23c..7d57e25ed9 100644 --- a/apps/frontend/src/graphql/settings/updateWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/updateWorkflow.graphql @@ -1,26 +1,35 @@ -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 + status { + shortCode + } + } + nextStatus { + ...workflowStatus + status { + shortCode + } } - nextStatusId - prevStatusId - prevConnectionId - posX - posY statusChangingEvents { statusChangingEventId workflowConnectionId 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 697dcfacf2..d7cee12e0a 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -39,7 +39,6 @@ export function usePersistWorkflowEditorModel() { type MonitorableServiceCall = () => Promise; const persistModel = ({ - getState, dispatch, }: MiddlewareInputParams) => { const executeAndMonitorCall = (call: MonitorableServiceCall) => { @@ -53,6 +52,22 @@ export function usePersistWorkflowEditorModel() { }); }; + const addConnectionToWorkflow = async ( + sourceWorkflowStatusId: number, + targetWorkflowStatusId: number + ) => { + return api({ + toastSuccessMessage: 'Workflow connection added successfully!', + }) + .addConnectionToWorkflow({ + newWorkflowConnectionInput: { + prevWorkflowStatusId: sourceWorkflowStatusId, + nextWorkflowStatusId: targetWorkflowStatusId, + }, + }) + .then((data) => data.addConnectionToWorkflow); + }; + const insertNewStatusInWorkflow = async ( workflowId: number, statusId: number, @@ -93,6 +108,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 @@ -112,7 +137,6 @@ export function usePersistWorkflowEditorModel() { return (next: FunctionType) => (action: Event) => { next(action); - const state = getState(); switch (action.type) { case EventType.UPDATE_WORKFLOW_METADATA_REQUESTED: { @@ -138,35 +162,23 @@ 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 - ); - 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 { id, posX, posY } = action.payload; return executeAndMonitorCall(async () => { try { @@ -174,12 +186,9 @@ export function usePersistWorkflowEditorModel() { toastErrorMessage: 'Failed to update workflow status', }) .updateWorkflowStatus({ - id: connectionId, + workflowStatusId: id, posX, posY, - prevStatusId, - nextStatusId, - prevConnectionId, }) .then((data) => data.updateWorkflowStatus); @@ -267,7 +276,7 @@ export function usePersistWorkflowEditorModel() { ); dispatch({ - type: EventType.STATUS_ACTION_ADDED, + type: EventType.STATUS_ACTIONS_UPDATED, payload: { workflowConnection: workflowConnection, statusActions: result, @@ -277,6 +286,24 @@ export function usePersistWorkflowEditorModel() { return result; }); } + case EventType.ADD_WORKFLOW_CONNECTION_REQUESTED: { + const { sourceWorkflowStatusId, targetWorkflowStatusId } = + action.payload; + + return executeAndMonitorCall(async () => { + const result = await addConnectionToWorkflow( + sourceWorkflowStatusId, + targetWorkflowStatusId + ); + + dispatch({ + type: EventType.WORKFLOW_CONNECTION_ADDED, + payload: result, + }); + + return result; + }); + } case EventType.DELETE_WORKFLOW_CONNECTION_REQUESTED: { const { connectionId } = action.payload; From 2d3b0b6add55faed0c346115e933e4406b5c114e Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 12:56:39 +0100 Subject: [PATCH 006/147] feat: update WorkflowStatus model and related components to use workflowStatusId instead of id --- apps/backend/src/models/WorkflowStatus.ts | 2 +- .../settings/AddStatusToWorkflowMutation.ts | 3 +- .../src/resolvers/types/WorkflowStatus.ts | 2 +- apps/backend/src/workflowEngine/experiment.ts | 7 +- apps/backend/src/workflowEngine/proposal.ts | 7 +- .../workflow/StatusEventsAndActionsDialog.tsx | 10 +- .../settings/workflow/WorkflowEditor.tsx | 20 +-- .../settings/workflow/WorkflowEditorModel.tsx | 149 +++++++++++++++--- .../settings/addStatusToWorkflow.graphql | 2 +- .../settings/fragment.workflowStatus.graphql | 2 +- .../src/graphql/settings/getWorkflow.graphql | 12 +- .../fragment.statusAction.graphql | 6 + .../settings/usePersistWorkflowEditorModel.ts | 53 +++---- 13 files changed, 191 insertions(+), 84 deletions(-) create mode 100644 apps/frontend/src/graphql/settings/statusActions/fragment.statusAction.graphql diff --git a/apps/backend/src/models/WorkflowStatus.ts b/apps/backend/src/models/WorkflowStatus.ts index cd4dabd1d4..0dab56031d 100644 --- a/apps/backend/src/models/WorkflowStatus.ts +++ b/apps/backend/src/models/WorkflowStatus.ts @@ -1,6 +1,6 @@ export class WorkflowStatus { constructor( - public id: number, + public workflowStatusId: number, public workflowId: number, public statusId: number, public posX: number, diff --git a/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts index 02195b44df..ed227c3e9c 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts @@ -10,6 +10,7 @@ import { import { ResolverContext } from '../../../context'; import { WorkflowConnection } from '../../types/WorkflowConnection'; +import { WorkflowStatus } from '../../types/WorkflowStatus'; @InputType() export class AddStatusToWorkflowInput implements Partial { @@ -28,7 +29,7 @@ export class AddStatusToWorkflowInput implements Partial { @Resolver() export class AddStatusToWorkflowMutation { - @Mutation(() => WorkflowConnection) + @Mutation(() => WorkflowStatus) async addStatusToWorkflow( @Ctx() context: ResolverContext, @Arg('newWorkflowStatusInput') diff --git a/apps/backend/src/resolvers/types/WorkflowStatus.ts b/apps/backend/src/resolvers/types/WorkflowStatus.ts index 996527669b..8c9ad408d5 100644 --- a/apps/backend/src/resolvers/types/WorkflowStatus.ts +++ b/apps/backend/src/resolvers/types/WorkflowStatus.ts @@ -15,7 +15,7 @@ import { Status } from './Status'; @ObjectType() export class WorkflowStatus implements Partial { @Field(() => Int) - public id: number; + public workflowStatusId: number; @Field(() => Int) public workflowId: number; diff --git a/apps/backend/src/workflowEngine/experiment.ts b/apps/backend/src/workflowEngine/experiment.ts index a774163317..72452669a0 100644 --- a/apps/backend/src/workflowEngine/experiment.ts +++ b/apps/backend/src/workflowEngine/experiment.ts @@ -36,7 +36,9 @@ export const getWorkflowConnectionByStatusId = async ( const matchingWorkflowStatuses = statuses.filter( (ws) => ws.statusId === statusId ); - const matchingWorkflowStatusIds = matchingWorkflowStatuses.map((ws) => ws.id); + const matchingWorkflowStatusIds = matchingWorkflowStatuses.map( + (ws) => ws.workflowStatusId + ); return connections.filter((conn) => matchingWorkflowStatusIds.includes(conn.prevWorkflowStatusId) @@ -85,7 +87,8 @@ const checkIfConditionsForNextStatusAreMet = async ({ for (const nextWorkflowConnection of nextWorkflowConnections) { const nextStatusId = statuses.find( - (ws) => ws.id === nextWorkflowConnection.nextWorkflowStatusId + (ws) => + ws.workflowStatusId === nextWorkflowConnection.nextWorkflowStatusId )?.statusId; if (!nextStatusId) { diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 9e93f5c462..289e917f7f 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -37,7 +37,9 @@ export const getProposalWorkflowConnectionByStatusId = async ( const matchingWorkflowStatuses = statuses.filter( (ws) => ws.statusId === statusId ); - const matchingWorkflowStatusIds = matchingWorkflowStatuses.map((ws) => ws.id); + const matchingWorkflowStatusIds = matchingWorkflowStatuses.map( + (ws) => ws.workflowStatusId + ); return connections.filter((conn) => matchingWorkflowStatusIds.includes(conn.prevWorkflowStatusId) @@ -85,7 +87,8 @@ const checkIfConditionsForNextStatusAreMet = async ({ for (const nextWorkflowConnection of nextWorkflowConnections) { const nextStatusId = statuses.find( - (ws) => ws.id === nextWorkflowConnection.nextWorkflowStatusId + (ws) => + ws.workflowStatusId === nextWorkflowConnection.nextWorkflowStatusId )?.statusId; if (!nextStatusId) { diff --git a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx index 94cb7d38ca..48d30225f6 100644 --- a/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusEventsAndActionsDialog.tsx @@ -86,19 +86,21 @@ const StatusEventsAndActionsDialog = ({ setStatusChangingEventsOnConnection={( statusChangingEvents: string[] ) => + workflowConnection && dispatch({ 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,7 +115,7 @@ const StatusEventsAndActionsDialog = ({ type: EventType.ADD_STATUS_ACTION_REQUESTED, payload: { statusActions, - workflowConnection, + workflowConnection: workflowConnection!, }, }); }} diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index 130373896f..4c79c84d02 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -141,7 +141,7 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { state.statuses.forEach((workflowStatus) => { const statusId = workflowStatus.status.id.toString(); - const nodeId = workflowStatus.id.toString(); + const nodeId = workflowStatus.workflowStatusId.toString(); // Use database coordinates if available, otherwise fall back to grid layout const nodePositionX = workflowStatus.posX; const nodePositionY = workflowStatus.posY; @@ -173,8 +173,8 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { const newEdge = edgeFactory({ id: edgeId, // Use connection ID to ensure unique edge identification - source: connection.prevStatus.id.toString(), - target: connection.nextStatus.id.toString(), + source: connection.prevStatus.workflowStatusId.toString(), + target: connection.nextStatus.workflowStatusId.toString(), type: 'workflow', // Use custom workflow edge type data: { events: @@ -350,11 +350,11 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { data: { label: status.name, status, - onDelete: (connectionId: string) => { + onDelete: (nodeId: string) => { dispatch({ type: EventType.DELETE_WORKFLOW_STATUS_REQUESTED, payload: { - connectionId: Number(connectionId), + workflowStatusId: parseInt(nodeId), }, }); }, @@ -369,14 +369,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, }, }); }, @@ -411,7 +407,7 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { // Find the workflow connection for this status to check current position const workflowStatus = state.statuses.find( - (connection) => connection.id === parseInt(node.id) + (status) => status.workflowStatusId === parseInt(node.id) ); // Only dispatch update if position has actually changed @@ -423,7 +419,7 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { dispatch({ type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED, payload: { - connectionId: workflowStatus.id, + workflowStatusId: workflowStatus.workflowStatusId, posX: newPosX, posY: newPosY, }, diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx index 1d227ff4c6..48722853c3 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx @@ -4,7 +4,16 @@ import { Reducer, useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { ConnectionLineType } from 'reactflow'; -import { Workflow, WorkflowStatus, WorkflowType } from 'generated/sdk'; +import { + ConnectionHasActionsInput, + ConnectionStatusAction, + Status, + StatusChangingEvent, + Workflow, + WorkflowConnection, + WorkflowStatus, + WorkflowType, +} from 'generated/sdk'; import { useDataApi } from 'hooks/common/useDataApi'; import { useReducerWithMiddleWares, @@ -31,10 +40,108 @@ export enum EventType { WORKFLOW_CONNECTION_DELETED, } -export interface Event { - type: EventType; - payload: any; -} +export type Event = + | { type: EventType.READY; payload: Workflow } + | { + type: EventType.ADD_WORKFLOW_STATUS_REQUESTED; + payload: { + workflowId: number; + posX: number; + posY: number; + status: Status; + }; + } + | { + type: EventType.WORKFLOW_STATUS_ADDED; + payload: { + workflowStatusId: number; + workflowId: number; + status: Status; + posX: number; + posY: number; + }; + } + | { + type: EventType.WORKFLOW_STATUS_UPDATE_REQUESTED; + payload: { workflowStatusId: number; posX: number; posY: number }; + } + | { + type: EventType.WORKFLOW_STATUS_UPDATED; + payload: Partial & { 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; + }; + } + | { + 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, @@ -58,15 +165,11 @@ const WorkflowEditorModel = ( return action.payload; case EventType.WORKFLOW_STATUS_ADDED: { // Add the new workflow status to the state - if ( - action.payload && - action.payload.statusId && - action.payload.status - ) { + if (action.payload && action.payload.status) { const newWorkflowStatus: WorkflowStatus = { - id: action.payload.id || 0, // Will be updated when API response comes back + workflowStatusId: action.payload.workflowStatusId, workflowId: action.payload.workflowId, - statusId: action.payload.statusId, + statusId: action.payload.status.id, status: action.payload.status, posX: action.payload.posX, posY: action.payload.posY, @@ -78,12 +181,11 @@ const WorkflowEditorModel = ( } case EventType.WORKFLOW_STATUS_UPDATED: { // If payload contains an updated status (from middleware response), update state - if (action.payload && action.payload.id) { + if (action.payload && action.payload.workflowStatusId) { const updatedStatus = action.payload; const statusIndex = draft.statuses.findIndex( (status) => - status.id === updatedStatus.id || - (status.id === 0 && status.statusId === updatedStatus.statusId) + status.workflowStatusId === updatedStatus.workflowStatusId ); if (statusIndex !== -1) { draft.statuses[statusIndex] = { @@ -100,7 +202,8 @@ const WorkflowEditorModel = ( // Remove the workflow status by statusId if (action.payload.workflowStatusId) { draft.statuses = draft.statuses.filter( - (status) => status.id !== action.payload.workflowStatusId + (status) => + status.workflowStatusId !== action.payload.workflowStatusId ); } @@ -137,10 +240,10 @@ const WorkflowEditorModel = ( action.payload; const prevStatus = draft.statuses.find( - (status) => status.id === sourceWorkflowStatusId + (status) => status.workflowStatusId === sourceWorkflowStatusId )!; const nextStatus = draft.statuses.find( - (status) => status.id === targetWorkflowStatusId + (status) => status.workflowStatusId === targetWorkflowStatusId )!; draft.connections.push({ @@ -195,10 +298,12 @@ const WorkflowEditorModel = ( 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/graphql/settings/addStatusToWorkflow.graphql b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql index d4197942a6..e6c905be3e 100644 --- a/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/addStatusToWorkflow.graphql @@ -12,6 +12,6 @@ mutation addStatusToWorkflow( posY: $posY } ) { - id + workflowStatusId } } diff --git a/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql b/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql index fc447969bf..bf7f350ea2 100644 --- a/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql +++ b/apps/frontend/src/graphql/settings/fragment.workflowStatus.graphql @@ -1,5 +1,5 @@ fragment workflowStatus on WorkflowStatus { - id + workflowStatusId workflowId statusId posX diff --git a/apps/frontend/src/graphql/settings/getWorkflow.graphql b/apps/frontend/src/graphql/settings/getWorkflow.graphql index d8c2dd88fb..3d98472734 100644 --- a/apps/frontend/src/graphql/settings/getWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflow.graphql @@ -10,13 +10,13 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { prevStatus { ...workflowStatus status { - shortCode + ...status } } nextStatus { ...workflowStatus status { - shortCode + ...status } } statusChangingEvents { @@ -26,13 +26,19 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { } statusActions { ...connectionStatusAction + action { + ...statusAction + defaultConfig { + ...statusActionDefaultConfig + } + } } } statuses { ...workflowStatus status { - shortCode + ...status } } } 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/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index d7cee12e0a..1c021ac226 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'; @@ -178,7 +179,7 @@ export function usePersistWorkflowEditorModel() { break; case EventType.WORKFLOW_STATUS_UPDATE_REQUESTED: { - const { id, posX, posY } = action.payload; + const { workflowStatusId, posX, posY } = action.payload; return executeAndMonitorCall(async () => { try { @@ -186,7 +187,7 @@ export function usePersistWorkflowEditorModel() { toastErrorMessage: 'Failed to update workflow status', }) .updateWorkflowStatus({ - workflowStatusId: id, + workflowStatusId, posX, posY, }) @@ -209,40 +210,23 @@ export function usePersistWorkflowEditorModel() { break; } case EventType.ADD_WORKFLOW_STATUS_REQUESTED: { - const { workflowId, statusId, 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, - statusId, - 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; }); } @@ -279,7 +263,8 @@ export function usePersistWorkflowEditorModel() { type: EventType.STATUS_ACTIONS_UPDATED, payload: { workflowConnection: workflowConnection, - statusActions: result, + statusActions: (result || + []) as unknown as ConnectionStatusAction[], }, }); From a34a1eb9b61309a63b24efcc5838a867b4661970 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 13:05:09 +0100 Subject: [PATCH 007/147] fix: comment out validation schema for deleteWorkflowStatus and add TODO for update --- apps/backend/src/mutations/WorkflowMutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 797faf62d7..aeb2a847ca 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -139,7 +139,7 @@ export default class WorkflowMutations { }); } - @ValidateArgs(deleteWorkflowStatusValidationSchema) + // @ValidateArgs(deleteWorkflowStatusValidationSchema) // TODO update schema @Authorized([Roles.USER_OFFICER]) async deleteWorkflowStatus( agent: UserWithRole | null, From 3cc35a9df1090ff18bd5cf428073827f888248a4 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 13:58:32 +0100 Subject: [PATCH 008/147] feat: refactor workflow connection handling by renaming methods and introducing CreateWorkflowConnection mutation --- .../src/datasources/WorkflowDataSource.ts | 6 +- .../datasources/mockups/WorkflowDataSource.ts | 6 +- .../postgres/WorkflowDataSource.ts | 56 ++++++++++++++++--- .../src/mutations/WorkflowMutations.ts | 9 ++- ...ts => CreateWorkflowConnectionMutation.ts} | 10 ++-- .../settings/addConnectionToWorkflow.graphql | 9 --- .../settings/createWorkflowConnection.graphql | 9 +++ .../settings/usePersistWorkflowEditorModel.ts | 8 +-- 8 files changed, 77 insertions(+), 36 deletions(-) rename apps/backend/src/resolvers/mutations/settings/{AddConnectionToWorkflow.ts => CreateWorkflowConnectionMutation.ts} (68%) delete mode 100644 apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql create mode 100644 apps/frontend/src/graphql/settings/createWorkflowConnection.graphql diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index a62938feb9..b39a7169ae 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -2,7 +2,7 @@ import { StatusChangingEvent } from '../models/StatusChangingEvent'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; import { WorkflowStatus } from '../models/WorkflowStatus'; -import { AddConnectionToWorkflowInput } from '../resolvers/mutations/settings/AddConnectionToWorkflow'; +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'; @@ -30,8 +30,8 @@ export interface WorkflowDataSource { posX: number; posY: number; }): Promise; - addConnectionToWorkflow( - newWorkflowConnectionInput: AddConnectionToWorkflowInput + createWorkflowConnection( + newWorkflowConnectionInput: CreateWorkflowConnectionInput ): Promise; updateWorkflowStatus( workflowStatus: UpdateWorkflowStatusInput diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 53f8551a52..7b6ff459c1 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -3,7 +3,7 @@ import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow, WorkflowType } from '../../models/Workflow'; import { WorkflowConnection } from '../../models/WorkflowConnections'; import { WorkflowStatus } from '../../models/WorkflowStatus'; -import { AddConnectionToWorkflowInput } from '../../resolvers/mutations/settings/AddConnectionToWorkflow'; +import { CreateWorkflowConnectionInput } from '../../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { AddStatusToWorkflowInput } from '../../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWorkflowMutation'; import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; @@ -47,8 +47,8 @@ export const dummyStatusChangingEvent = new StatusChangingEvent( ); export class WorkflowDataSourceMock implements WorkflowDataSource { - addConnectionToWorkflow( - newWorkflowConnectionInput: AddConnectionToWorkflowInput + createWorkflowConnection( + newWorkflowConnectionInput: CreateWorkflowConnectionInput ): Promise { throw new Error('Method not implemented.'); } diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index a8874e8534..f6207510fa 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -6,7 +6,7 @@ import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow } from '../../models/Workflow'; import { WorkflowConnection } from '../../models/WorkflowConnections'; import { WorkflowStatus } from '../../models/WorkflowStatus'; -import { AddConnectionToWorkflowInput } from '../../resolvers/mutations/settings/AddConnectionToWorkflow'; +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'; @@ -26,12 +26,6 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { @inject(Tokens.StatusDataSource) private statusDataSource: StatusDataSource ) {} - addConnectionToWorkflow( - newWorkflowConnectionInput: AddConnectionToWorkflowInput - ): Promise { - throw new Error('Method not implemented.'); - } - private createWorkflowObject(workflow: WorkflowRecord) { return new Workflow( workflow.workflow_id, @@ -246,6 +240,54 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { return this.createWorkflowStatusObject(updatedStatus); } + async createWorkflowConnection( + newWorkflowConnectionInput: CreateWorkflowConnectionInput + ): Promise { + const prevStatus = await this.getWorkflowStatus( + newWorkflowConnectionInput.prevWorkflowStatusId + ); + + if (!prevStatus) { + throw new GraphQLError( + `Could not find workflow status with id: ${newWorkflowConnectionInput.prevWorkflowStatusId}` + ); + } + + const nextStatus = await this.getWorkflowStatus( + newWorkflowConnectionInput.nextWorkflowStatusId + ); + + if (!nextStatus) { + throw new GraphQLError( + `Could not find workflow status with id: ${newWorkflowConnectionInput.nextWorkflowStatusId}` + ); + } + + if (prevStatus.workflowId !== nextStatus.workflowId) { + throw new GraphQLError( + 'Cannot connect statuses from different workflows' + ); + } + + const [createdConnection]: WorkflowConnectionRecord[] = await database( + 'workflow_status_connections' + ) + .insert({ + workflow_id: prevStatus.workflowId, + prev_workflow_status_id: + newWorkflowConnectionInput.prevWorkflowStatusId, + next_workflow_status_id: + newWorkflowConnectionInput.nextWorkflowStatusId, + }) + .returning('*'); + + if (!createdConnection) { + throw new GraphQLError('Could not create workflow connection'); + } + + return this.createWorkflowConnectionObject(createdConnection); + } + async deleteWorkflowConnection( connectionId: number ): Promise { diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index aeb2a847ca..70b1194724 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -1,7 +1,6 @@ import { addStatusActionsToConnectionValidationSchema, createWorkflowValidationSchema, - deleteWorkflowStatusValidationSchema, deleteWorkflowValidationSchema, updateWorkflowValidationSchema, } from '@user-office-software/duo-validation'; @@ -23,8 +22,8 @@ import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; import { WorkflowStatus } from '../models/WorkflowStatus'; import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; -import { AddConnectionToWorkflowInput } from '../resolvers/mutations/settings/AddConnectionToWorkflow'; 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 { SetStatusChangingEventsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusChangingEventsOnConnectionMutation'; @@ -88,12 +87,12 @@ export default class WorkflowMutations { } @Authorized([Roles.USER_OFFICER]) - async addConnectionToWorkflow( + async createWorkflowConnection( agent: UserWithRole | null, - args: AddConnectionToWorkflowInput + args: CreateWorkflowConnectionInput ): Promise { try { - return await this.dataSource.addConnectionToWorkflow(args); + return await this.dataSource.createWorkflowConnection(args); } catch (error) { return rejection( 'Could not add workflow connection', diff --git a/apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts b/apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts similarity index 68% rename from apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts rename to apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts index be97475da5..ec01aab562 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddConnectionToWorkflow.ts +++ b/apps/backend/src/resolvers/mutations/settings/CreateWorkflowConnectionMutation.ts @@ -12,7 +12,7 @@ import { ResolverContext } from '../../../context'; import { WorkflowConnection } from '../../types/WorkflowConnection'; @InputType() -export class AddConnectionToWorkflowInput { +export class CreateWorkflowConnectionInput { @Field(() => Int) public prevWorkflowStatusId: number; @@ -21,14 +21,14 @@ export class AddConnectionToWorkflowInput { } @Resolver() -export class AddConnectionToWorkflowMutation { +export class CreateWorkflowConnectionMutation { @Mutation(() => WorkflowConnection) - async addConnectionToWorkflow( + async createWorkflowConnection( @Ctx() context: ResolverContext, @Arg('newWorkflowConnectionInput') - newWorkflowConnectionInput: AddConnectionToWorkflowInput + newWorkflowConnectionInput: CreateWorkflowConnectionInput ) { - return context.mutations.workflow.addConnectionToWorkflow( + return context.mutations.workflow.createWorkflowConnection( context.user, newWorkflowConnectionInput ); diff --git a/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql b/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql deleted file mode 100644 index 1c69cd1cf3..0000000000 --- a/apps/frontend/src/graphql/settings/addConnectionToWorkflow.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation addConnectionToWorkflow( - $newWorkflowConnectionInput: AddConnectionToWorkflowInput! -) { - addConnectionToWorkflow( - newWorkflowConnectionInput: $newWorkflowConnectionInput - ) { - ...workflowConnection - } -} 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/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index 1c021ac226..a7cb5658fe 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -53,20 +53,20 @@ export function usePersistWorkflowEditorModel() { }); }; - const addConnectionToWorkflow = async ( + const createWorkflowConneciton = async ( sourceWorkflowStatusId: number, targetWorkflowStatusId: number ) => { return api({ toastSuccessMessage: 'Workflow connection added successfully!', }) - .addConnectionToWorkflow({ + .createWorkflowConnection({ newWorkflowConnectionInput: { prevWorkflowStatusId: sourceWorkflowStatusId, nextWorkflowStatusId: targetWorkflowStatusId, }, }) - .then((data) => data.addConnectionToWorkflow); + .then((data) => data.createWorkflowConnection); }; const insertNewStatusInWorkflow = async ( @@ -276,7 +276,7 @@ export function usePersistWorkflowEditorModel() { action.payload; return executeAndMonitorCall(async () => { - const result = await addConnectionToWorkflow( + const result = await createWorkflowConneciton( sourceWorkflowStatusId, targetWorkflowStatusId ); From e52d957c72df81f1dd6b303a968ea72cbc88c0bd Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 14:11:49 +0100 Subject: [PATCH 009/147] feat: update WorkflowEditor to use workflowStatusId for source and target status lookups --- .../settings/workflow/WorkflowEditor.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index 4c79c84d02..80b829d733 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -245,14 +245,14 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { } // Find source and target status names for the edge data - const sourceStatus = statuses.find( - (s) => s.id.toString() === connection.source + const sourceWfStatus = state.statuses.find( + (s) => s.workflowStatusId.toString() === connection.source ); - const targetStatus = statuses.find( - (s) => s.id.toString() === connection.target + const targetWfStatus = state.statuses.find( + (s) => s.workflowStatusId.toString() === connection.target ); - if (!sourceStatus || !targetStatus) { + if (!sourceWfStatus || !targetWfStatus) { return; } @@ -264,8 +264,8 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { type: 'workflow', // Use custom workflow edge type data: { events: [], // No events initially - sourceStatusShortCode: sourceStatus.shortCode, - targetStatusShortCode: targetStatus.shortCode, + sourceStatusShortCode: sourceWfStatus.status.shortCode, + targetStatusShortCode: targetWfStatus.status.shortCode, statusActions: [], connectionLineType: state.connectionLineType as ConnectionLineType, }, @@ -280,8 +280,8 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { dispatch({ type: EventType.ADD_WORKFLOW_CONNECTION_REQUESTED, payload: { - sourceWorkflowStatusId: sourceStatus.id, // Use connection ID for persistence - targetWorkflowStatusId: targetStatus.id, + sourceWorkflowStatusId: sourceWfStatus.workflowStatusId, // Use connection ID for persistence + targetWorkflowStatusId: targetWfStatus.workflowStatusId, }, }); From 5d30bee6f7d3f2589944a20fa70481a3b48fc278 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 14:31:15 +0100 Subject: [PATCH 010/147] feat: add ON DELETE CASCADE to foreign key constraints in workflow_status_connections --- .../db_patches/0203_Add_new_workflow_data_structures.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql index 95ebe00a99..c91e4e7478 100644 --- a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql +++ b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql @@ -51,11 +51,13 @@ BEGIN -- 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), + 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. From 26ef94b018c7d9635a980a903d748b92797cf4a6 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 14:48:34 +0100 Subject: [PATCH 011/147] fix: update edge identification logic to use connection ID directly --- .../components/settings/workflow/WorkflowEditor.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx index 80b829d733..94fb111b15 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditor.tsx @@ -169,7 +169,7 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { newNodes.push(newNode); }); state.connections.forEach((connection) => { - const edgeId = `edge-${connection.id}`; + const edgeId = `${connection.id}`; const newEdge = edgeFactory({ id: edgeId, // Use connection ID to ensure unique edge identification @@ -288,12 +288,12 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { // Note: Connection is persisted by updating both source and target statuses }, [ - dispatch, edges, - enqueueSnackbar, - setEdges, - statuses, + state.statuses, state.connectionLineType, + setEdges, + dispatch, + enqueueSnackbar, ] ); @@ -303,7 +303,7 @@ const WorkflowEditor = ({ entityType }: { entityType: WorkflowType }) => { setSelectedEdge(edge); const clickedWorkflowConnection = state.connections.find( - (connection) => connection.id.toString() === edge.target + (connection) => connection.id.toString() === edge.id ); if (!clickedWorkflowConnection) { From f9d1e686efc3252b11deb5082aa5508cffafc835 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Mon, 29 Dec 2025 15:40:51 +0100 Subject: [PATCH 012/147] feat: overhaul workflow data structures by removing status changing events table and updating related references --- .../0203_Add_new_workflow_data_structures.sql | 26 +++----- .../datasources/mockups/WorkflowDataSource.ts | 1 - .../postgres/WorkflowDataSource.ts | 62 ++++--------------- .../src/datasources/postgres/records.ts | 3 +- .../backend/src/models/StatusChangingEvent.ts | 1 - .../ProposalSettingsMutations.spec.ts | 1 - .../resolvers/types/StatusChangingEvent.ts | 3 - .../settings/workflow/WorkflowEditorModel.tsx | 17 +++++ .../graphql/settings/createWorkflow.graphql | 1 - .../src/graphql/settings/getWorkflow.graphql | 1 - ...etStatusChangingEventsOnConnection.graphql | 1 - .../graphql/settings/updateWorkflow.graphql | 1 - 12 files changed, 37 insertions(+), 81 deletions(-) diff --git a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql index c91e4e7478..1962da0145 100644 --- a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql +++ b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql @@ -69,11 +69,9 @@ BEGIN -- ============================================ -- 3) workflow_status_changing_events (catalog) -- ============================================ - CREATE TABLE workflow_status_changing_events ( - status_changing_event_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name TEXT NOT NULL, - description TEXT - ); + -- REMOVED: workflow_status_changing_events table is removed. + -- Events are now stored as strings in the code (apps/backend/src/events/event.enum.ts). + @@ -82,18 +80,14 @@ BEGIN -- ===================================================================== CREATE TABLE workflow_status_connection_has_workflow_status_changing_events ( workflow_status_connection_id BIGINT NOT NULL, - status_changing_event_id BIGINT NOT NULL, + status_changing_event TEXT NOT NULL, CONSTRAINT pk_wsc_has_events - PRIMARY KEY (workflow_status_connection_id, status_changing_event_id), + 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), - - CONSTRAINT fk_wsche_event - FOREIGN KEY (status_changing_event_id) - REFERENCES workflow_status_changing_events (status_changing_event_id) + REFERENCES workflow_status_connections (workflow_status_connection_id) ); -- The composite PK already prevents duplicates for (connection, event). @@ -136,14 +130,10 @@ BEGIN -- ================================================================== CREATE TABLE proposal_has_workflow_status_changing_events ( proposal_has_workflow_status_changing_events_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - status_changing_event_id INT NOT NULL, + status_changing_event TEXT NOT NULL, proposal_pk INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT fk_phsce_event - FOREIGN KEY (status_changing_event_id) - REFERENCES workflow_status_changing_events (status_changing_event_id), - CONSTRAINT fk_phsce_proposal FOREIGN KEY (proposal_pk) REFERENCES proposals (proposal_pk) @@ -153,7 +143,7 @@ BEGIN -- (If you retain rows across state changes, you can reset the window in code -- by comparing against proposals.state_entered_at.) CREATE UNIQUE INDEX uq_phsce_proposal_event - ON proposal_has_workflow_status_changing_events (proposal_pk, status_changing_event_id); + ON proposal_has_workflow_status_changing_events (proposal_pk, status_changing_event); -- Optional helper index for proposal lookups: CREATE INDEX ix_phsce_proposal diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 7b6ff459c1..5172b467cd 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -41,7 +41,6 @@ export const anotherDummyWorkflowConnection = new WorkflowConnection( export const dummyWorkflowStatus = new WorkflowStatus(1, 1, 1, 100, 100); export const dummyStatusChangingEvent = new StatusChangingEvent( - 1, 1, 'PROPOSAL_SUBMITTED' ); diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index f6207510fa..9e16040d88 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -328,8 +328,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { statusChangingEvent: StatusChangingEventRecord ) { return new StatusChangingEvent( - statusChangingEvent.status_changing_event_id, - statusChangingEvent.workflow_connection_id, + statusChangingEvent.workflow_status_connection_id, statusChangingEvent.status_changing_event ); } @@ -364,29 +363,15 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { const eventsToReturn: StatusChangingEvent[] = []; for (const eventName of statusChangingEvents) { - let eventId: number; - const existingEvent = await database('workflow_status_changing_events') - .select('status_changing_event_id') - .where('name', eventName) - .first(); - - if (existingEvent) { - eventId = existingEvent.status_changing_event_id; - } else { - throw new GraphQLError( - `Status changing event with name ${eventName} not found` - ); - } - await database( 'workflow_status_connection_has_workflow_status_changing_events' ).insert({ workflow_status_connection_id: workflowConnectionId, - status_changing_event_id: eventId, + status_changing_event: eventName, }); eventsToReturn.push( - new StatusChangingEvent(eventId, workflowConnectionId, eventName) + new StatusChangingEvent(workflowConnectionId, eventName) ); } @@ -397,39 +382,14 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { workflowConnectionIds: number[] ): Promise { return database - .select( - 'wsche.workflow_status_connection_id', - 'wsce.status_changing_event_id', - 'wsce.name' - ) - .from( - 'workflow_status_connection_has_workflow_status_changing_events as wsche' - ) - .join( - 'workflow_status_changing_events as wsce', - 'wsche.status_changing_event_id', - 'wsce.status_changing_event_id' - ) - .whereIn('wsche.workflow_status_connection_id', workflowConnectionIds) - .then( - ( - statusChangingEvents: { - workflow_status_connection_id: number; - status_changing_event_id: number; - name: string; - }[] - ) => { - return statusChangingEvents.map((statusChangingEvent) => - this.createStatusChangingEventObject({ - status_changing_event_id: - statusChangingEvent.status_changing_event_id, - workflow_connection_id: - statusChangingEvent.workflow_status_connection_id, - status_changing_event: statusChangingEvent.name, - }) - ); - } - ); + .select('workflow_status_connection_id', 'status_changing_event') + .from('workflow_status_connection_has_workflow_status_changing_events') + .whereIn('workflow_status_connection_id', workflowConnectionIds) + .then((statusChangingEvents: StatusChangingEventRecord[]) => { + return statusChangingEvents.map((statusChangingEvent) => + this.createStatusChangingEventObject(statusChangingEvent) + ); + }); } async getWorkflowStatus( diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index a6ea452f27..d5f27e72d5 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -616,8 +616,7 @@ export interface WorkflowStatusRecord { } 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; } 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/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 018fd28567..ade5ced3e5 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -20,7 +20,6 @@ const statusMutationsInstance = container.resolve(StatusMutations); const workflowMutationsInstance = container.resolve(WorkflowMutations); const dummyStatusChangingEvent = new StatusChangingEvent( - 1, 1, 'PROPOSAL_SUBMITTED' ); 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/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx index 48722853c3..26e4340abf 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEditorModel.tsx @@ -260,6 +260,23 @@ const WorkflowEditorModel = ( 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; } case EventType.WORKFLOW_CONNECTION_DELETED: { diff --git a/apps/frontend/src/graphql/settings/createWorkflow.graphql b/apps/frontend/src/graphql/settings/createWorkflow.graphql index 708460c1e3..1623f4a213 100644 --- a/apps/frontend/src/graphql/settings/createWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/createWorkflow.graphql @@ -17,7 +17,6 @@ mutation createWorkflow( connections { ...workflowConnection statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } diff --git a/apps/frontend/src/graphql/settings/getWorkflow.graphql b/apps/frontend/src/graphql/settings/getWorkflow.graphql index 3d98472734..fedef9e7dc 100644 --- a/apps/frontend/src/graphql/settings/getWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/getWorkflow.graphql @@ -20,7 +20,6 @@ query getWorkflow($workflowId: Int!, $entityType: WorkflowType!) { } } statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } diff --git a/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql index 26598da293..f2ca1f47cf 100644 --- a/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql +++ b/apps/frontend/src/graphql/settings/setStatusChangingEventsOnConnection.graphql @@ -8,7 +8,6 @@ mutation setStatusChangingEventsOnConnection( statusChangingEvents: $statusChangingEvents } ) { - statusChangingEventId workflowConnectionId statusChangingEvent } diff --git a/apps/frontend/src/graphql/settings/updateWorkflow.graphql b/apps/frontend/src/graphql/settings/updateWorkflow.graphql index 7d57e25ed9..ff6b0426e7 100644 --- a/apps/frontend/src/graphql/settings/updateWorkflow.graphql +++ b/apps/frontend/src/graphql/settings/updateWorkflow.graphql @@ -31,7 +31,6 @@ mutation updateWorkflow( } } statusChangingEvents { - statusChangingEventId workflowConnectionId statusChangingEvent } From ccbcb18ac89474dca8b8b1d9ae234075c8682ae5 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Dec 2025 11:05:33 +0100 Subject: [PATCH 013/147] feat: rename and update connection status actions mutation to setStatusActionsOnConnection --- .../src/datasources/StatusActionsDataSource.ts | 6 +++--- .../datasources/mockups/StatusActionsDataSource.ts | 6 +++--- .../postgres/StatusActionsDataSource.ts | 6 +++--- apps/backend/src/mutations/WorkflowMutations.ts | 10 ++++------ ....ts => SetStatusActionsOnConnectionMutation.ts} | 14 +++++++------- .../addConnectionStatusActions.graphql | 6 +++--- .../settings/usePersistWorkflowEditorModel.ts | 4 ++-- 7 files changed, 25 insertions(+), 27 deletions(-) rename apps/backend/src/resolvers/mutations/settings/{AddConnectionStatusActionsMutation.ts => SetStatusActionsOnConnectionMutation.ts} (70%) diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index 5419a86f18..503a8a8106 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -2,7 +2,7 @@ import { ConnectionHasStatusAction, StatusAction, } from '../models/StatusAction'; -import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; export interface StatusActionsDataSource { getConnectionStatusActions( @@ -18,7 +18,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/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index faa58b8c3e..07378a114d 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -5,7 +5,7 @@ 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( @@ -69,8 +69,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/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index d29c7b375b..8b4d88de43 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -9,7 +9,7 @@ import { StatusAction, StatusActionType, } from '../../models/StatusAction'; -import { AddConnectionStatusActionsInput } from '../../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; import { EmailActionConfig, StatusActionConfig, @@ -182,8 +182,8 @@ export default class PostgresStatusActionsDataSource ); } - async addConnectionStatusActions( - connectionStatusActionsInput: AddConnectionStatusActionsInput + async setStatusActionsOnConnection( + connectionStatusActionsInput: SetStatusActionsOnConnectionInput ): Promise { const workflow = await this.workflowDataSource.getWorkflow( connectionStatusActionsInput.workflowId diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 70b1194724..22aeac15f5 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -21,7 +21,7 @@ import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; import { WorkflowStatus } from '../models/WorkflowStatus'; -import { AddConnectionStatusActionsInput } from '../resolvers/mutations/settings/AddConnectionStatusActionsMutation'; +import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; import { AddStatusToWorkflowInput } from '../resolvers/mutations/settings/AddStatusToWorkflowMutation'; import { CreateWorkflowConnectionInput } from '../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; import { CreateWorkflowInput } from '../resolvers/mutations/settings/CreateWorkflowMutation'; @@ -197,12 +197,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/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/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/hooks/settings/usePersistWorkflowEditorModel.ts b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts index a7cb5658fe..f7aee86ea0 100644 --- a/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts +++ b/apps/frontend/src/hooks/settings/usePersistWorkflowEditorModel.ts @@ -128,12 +128,12 @@ 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) => { From 56b3eb13cac6e60d4b1e8bd24c3e497f45616e8d Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Dec 2025 11:38:26 +0100 Subject: [PATCH 014/147] refactor: update workflow connection action identifiers to use new naming conventions --- .../postgres/StatusActionsDataSource.ts | 79 +++++++++++-------- .../src/datasources/postgres/records.ts | 4 +- apps/backend/src/models/StatusAction.ts | 1 - 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index 8b4d88de43..dfee4c6da2 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -54,16 +54,14 @@ export default class PostgresStatusActionsDataSource private createConnectionStatusActionObject( actionStatusRecord: WorkflowConnectionHasActionsRecord & { workflow_status_action_id: number; - name: string; type: StatusActionType; config: typeof StatusActionConfig; } ) { return new ConnectionHasStatusAction( - actionStatusRecord.connection_id, + actionStatusRecord.workflow_status_connection_id, actionStatusRecord.workflow_status_action_id, actionStatusRecord.workflow_id, - actionStatusRecord.name, actionStatusRecord.type, this.createStatusActionConfig( actionStatusRecord.type, @@ -91,11 +89,11 @@ export default class PostgresStatusActionsDataSource })[] = await database .select() .from('workflow_status_actions as wsa') - .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'wsa.workflow_status_action_id', + .join('workflow_status_connection_has_workflow_status_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,11 +112,11 @@ export default class PostgresStatusActionsDataSource } = await database .select() .from('workflow_status_actions as wsa') - .join('workflow_connection_has_actions as wca', { - 'wca.action_id': 'wsa.workflow_status_action_id', + .join('workflow_status_connection_has_workflow_status_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) { @@ -142,17 +140,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_workflow_status_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({ - workflow_status_action_id: statusAction.actionId, - name: statusAction.name, type: statusAction.type, ...updatedStatusAction, }); @@ -197,8 +193,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, })); @@ -212,20 +209,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_workflow_status_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_workflow_status_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 = @@ -238,28 +240,41 @@ 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_workflow_status_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_workflow_status_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') + .from( + 'workflow_status_connection_has_workflow_status_actions as wsca' + ) .join('workflow_status_actions as wsa', { - 'wca.action_id': 'wsa.workflow_status_action_id', + '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/records.ts b/apps/backend/src/datasources/postgres/records.ts index d5f27e72d5..7b18868fc7 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -762,8 +762,8 @@ export interface StatusActionRecord { } 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; } 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 ) {} From 110979ac6dbec19dffd24dffe51ca3b4b8f60634 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Dec 2025 12:20:00 +0100 Subject: [PATCH 015/147] feat: add ON DELETE CASCADE to foreign key constraints in workflow data structures --- .../db_patches/0203_Add_new_workflow_data_structures.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql index 1962da0145..a0d9c1a624 100644 --- a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql +++ b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql @@ -88,6 +88,7 @@ BEGIN 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). @@ -116,7 +117,8 @@ BEGIN CONSTRAINT fk_wsca_connection FOREIGN KEY (workflow_status_connection_id) - REFERENCES workflow_status_connections (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) @@ -171,7 +173,8 @@ BEGIN CONSTRAINT fk_wscg_connection FOREIGN KEY (workflow_status_connection_id) - REFERENCES workflow_status_connections (workflow_status_connection_id), + REFERENCES workflow_status_connections (workflow_status_connection_id) + ON DELETE CASCADE, CONSTRAINT fk_wscg_guard FOREIGN KEY (workflow_status_changing_guard_id) From 51cd7a2c7725982481e231fe2f72a202c98edd29 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 30 Dec 2025 16:47:52 +0100 Subject: [PATCH 016/147] feat: add xstate for workflow state management and implement getWorkflowStructure method --- apps/backend/package-lock.json | 32 +- apps/backend/package.json | 3 +- .../src/datasources/WorkflowDataSource.ts | 12 + .../postgres/WorkflowDataSource.ts | 42 +++ apps/backend/src/workflowEngine/proposal.ts | 327 +++++------------- 5 files changed, 164 insertions(+), 252 deletions(-) diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 88fbf24509..34047f7166 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -61,6 +61,7 @@ "string-strip-html": "^8.4.0", "tsyringe": "^4.8.0", "type-graphql": "2.0.0-beta.1", + "xstate": "^5.25.0", "yup": "^0.32.11" }, "devDependencies": { @@ -563,7 +564,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "devOptional": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -2300,7 +2300,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3602,7 +3601,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.1.tgz", "integrity": "sha512-KwfdWXJBOviaBVhxO3p5TJiLpNuh2iyXyjmWN0f1nU87pwyvfS0EmjC6ukQVYVFJd/K1+0NWGPDXiyEyQorn0Q==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.7.1", @@ -3638,7 +3636,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.1.tgz", "integrity": "sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.7.1", "@typescript-eslint/types": "7.7.1", @@ -3957,7 +3954,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4523,7 +4519,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -4936,7 +4931,6 @@ "version": "0.14.0", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", - "peer": true, "dependencies": { "@types/validator": "^13.7.10", "libphonenumber-js": "^1.10.14", @@ -5910,7 +5904,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5966,7 +5959,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7250,7 +7242,6 @@ "version": "16.8.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7307,7 +7298,6 @@ "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "peer": true, "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", @@ -8123,7 +8113,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9177,8 +9166,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash-es": { "version": "4.17.21", @@ -10224,7 +10212,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "peer": true, "bin": { "mustache": "bin/mustache" } @@ -11168,7 +11155,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11340,7 +11326,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", - "peer": true, "dependencies": { "pug-code-gen": "^3.0.3", "pug-filters": "^4.0.0", @@ -13307,8 +13292,7 @@ "node_modules/underscore": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", - "peer": true + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" }, "node_modules/underscore.deep": { "version": "0.5.3", @@ -13774,6 +13758,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", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 07cb07fae5..3d119576c2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -83,6 +83,7 @@ "string-strip-html": "^8.4.0", "tsyringe": "^4.8.0", "type-graphql": "2.0.0-beta.1", + "xstate": "^5.25.0", "yup": "^0.32.11" }, "devDependencies": { @@ -129,4 +130,4 @@ "npm": ">=10.9.2", "node": ">=22.0.0" } -} \ No newline at end of file +} diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index b39a7169ae..efe6dc1f25 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -44,4 +44,16 @@ export interface WorkflowDataSource { getStatusChangingEventsByConnectionIds( workflowConnectionIds: number[] ): Promise; + getWorkflowStructure(workflowId: number): Promise<{ + workflowStatuses: { + workflowStatusId: number; + statusId: number; + shortCode: string; + }[]; + workflowConnections: { + prevWorkflowStatusId: number; + nextWorkflowStatusId: number; + statusChangingEvent: string; + }[]; + }>; } diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 9e16040d88..31bbd6e702 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -405,4 +405,46 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ? this.createWorkflowStatusObject(workflowStatus) : null; } + + async getWorkflowStructure(workflowId: number): Promise<{ + workflowStatuses: { + workflowStatusId: number; + statusId: number; + shortCode: string; + }[]; + workflowConnections: { + prevWorkflowStatusId: number; + nextWorkflowStatusId: number; + statusChangingEvent: string; + }[]; + }> { + const workflowStatuses = await database + .select( + 'workflow_has_statuses.workflow_status_id as workflowStatusId', + 'workflow_has_statuses.status_id as statusId', + 'statuses.short_code as shortCode' + ) + .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.prev_workflow_status_id as prevWorkflowStatusId', + 'workflow_status_connections.next_workflow_status_id as nextWorkflowStatusId', + 'workflow_status_connection_has_workflow_status_changing_events.status_changing_event as statusChangingEvent' + ) + .from('workflow_status_connections') + .join( + 'workflow_status_connection_has_workflow_status_changing_events', + 'workflow_status_connections.workflow_status_connection_id', + 'workflow_status_connection_has_workflow_status_changing_events.workflow_status_connection_id' + ) + .where('workflow_status_connections.workflow_id', workflowId); + + return { + workflowStatuses, + workflowConnections, + }; + } } diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 289e917f7f..ce1448c9b0 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,5 +1,6 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; +import { createActor, createMachine } from 'xstate'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; @@ -8,9 +9,6 @@ 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 { WorkflowConnection } from '../models/WorkflowConnections'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; const getProposalWorkflowByCallId = (callId: number) => { @@ -21,119 +19,57 @@ const getProposalWorkflowByCallId = (callId: number) => { return callDataSource.getProposalWorkflowByCall(callId); }; -export const getProposalWorkflowConnectionByStatusId = async ( +type StateNodeConfig = { + on: Record; + meta?: { statusId: number }; +}; + +const createProposalMachine = async ( workflowId: number, - statusId?: number, - prevStatusId?: number + currentStatusId: number ) => { const workflowDataSource = container.resolve( Tokens.WorkflowDataSource ); - const statuses = await workflowDataSource.getWorkflowStatuses(workflowId); - const connections = - await workflowDataSource.getWorkflowConnections(workflowId); - - const matchingWorkflowStatuses = statuses.filter( - (ws) => ws.statusId === statusId - ); - const matchingWorkflowStatusIds = matchingWorkflowStatuses.map( - (ws) => ws.workflowStatusId - ); - - return connections.filter((conn) => - matchingWorkflowStatusIds.includes(conn.prevWorkflowStatusId) - ); -}; - -const shouldMoveToNextStatus = ( - statusChangingEvents: StatusChangingEvent[], - proposalEvents: ProposalEventsRecord -): boolean => { - const proposalEventsKeys = Object.keys(proposalEvents); - const allProposalIncompleteEvents = proposalEventsKeys.filter( - (proposalEventsKey) => - !proposalEvents[proposalEventsKey as keyof ProposalEventsRecord] - ); + const { workflowStatuses, workflowConnections } = + await workflowDataSource.getWorkflowStructure(workflowId); - const allNextStatusRulesFulfilled = !statusChangingEvents.some( - (statusChangingEvent) => - allProposalIncompleteEvents.indexOf( - statusChangingEvent.statusChangingEvent.toLowerCase() - ) >= 0 - ); + const states: Record = {}; - return allNextStatusRulesFulfilled; -}; + // Map workflowStatusId to shortCode for easy lookup + const statusIdToShortCodeMap = new Map(); -const checkIfConditionsForNextStatusAreMet = async ({ - nextWorkflowConnections, - proposalWorkflow, - workflowDataSource, - proposalWithEvents, -}: { - nextWorkflowConnections: WorkflowConnection[]; - proposalWorkflow: Workflow; - workflowDataSource: WorkflowDataSource; - proposalWithEvents: { - proposalPk: number; - proposalEvents?: ProposalEventsRecord; - currentEvent: Event; - }; -}) => { - const statuses = await workflowDataSource.getWorkflowStatuses( - proposalWorkflow.id - ); + workflowStatuses.forEach((ws) => { + statusIdToShortCodeMap.set(ws.workflowStatusId, ws.shortCode); + states[ws.shortCode] = { + on: {}, + meta: { + statusId: ws.statusId, + }, + }; + }); - for (const nextWorkflowConnection of nextWorkflowConnections) { - const nextStatusId = statuses.find( - (ws) => - ws.workflowStatusId === nextWorkflowConnection.nextWorkflowStatusId - )?.statusId; + workflowConnections.forEach((conn) => { + const sourceState = statusIdToShortCodeMap.get(conn.prevWorkflowStatusId); + const targetState = statusIdToShortCodeMap.get(conn.nextWorkflowStatusId); + // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) + const event = conn.statusChangingEvent.toUpperCase(); - if (!nextStatusId) { - continue; + if (sourceState && targetState && event) { + states[sourceState].on[event] = { + target: targetState, + }; } + }); - const nextNextWorkflowConnections = - await getProposalWorkflowConnectionByStatusId( - proposalWorkflow.id, - nextStatusId - ); - const newStatusChangingEvents = - await workflowDataSource.getStatusChangingEventsByConnectionIds( - nextNextWorkflowConnections.map((connection) => connection.id) - ); - - if (!proposalWithEvents.proposalEvents) { - return; - } - - for (const sce of newStatusChangingEvents) { - const proposalEventsKeys = Object.keys( - proposalWithEvents.proposalEvents! - ); - const allProposalCompleteEvents = proposalEventsKeys.filter( - (proposalEventsKey) => - proposalWithEvents.proposalEvents![ - proposalEventsKey as keyof ProposalEventsRecord - ] - ); - - const nextStatusRulesFulfilled = allProposalCompleteEvents.includes( - sce.statusChangingEvent.toLowerCase() - ); + const currentShortCode = statusIdToShortCodeMap.get(currentStatusId); - if (sce.statusChangingEvent && nextStatusRulesFulfilled) - await workflowEngine([ - { - currentEvent: sce.statusChangingEvent as Event, - proposalEvents: proposalWithEvents.proposalEvents, - proposalPk: proposalWithEvents.proposalPk, - }, - ]); - } - } + return createMachine({ + id: `proposal-workflow-${workflowId}`, + initial: currentShortCode || 'DRAFT', + states, + }); }; export type WorkflowEngineProposalType = Proposal & { @@ -152,148 +88,75 @@ export const workflowEngine = async ( const proposalDataSource = container.resolve( Tokens.ProposalDataSource ); - const proposalsWithChangedStatuses = ( - await Promise.all( - args.map(async (proposalWithEvents) => { - const proposal = await proposalDataSource.get( - proposalWithEvents.proposalPk - ); - - if (!proposal) { - throw new Error( - `Proposal with id ${proposalWithEvents.proposalPk} not found` - ); - } + const callDataSource = container.resolve( + Tokens.CallDataSource + ); - const proposalWorkflow = await getProposalWorkflowByCallId( - proposal.callId - ); + const proposalsWithChangedStatuses = await Promise.all( + args.map(async (arg) => { + const proposal = await proposalDataSource.get(arg.proposalPk); - if (!proposalWorkflow) { - return; - } + if (!proposal) { + logger.logError('Proposal not found', { proposalPk: arg.proposalPk }); - const currentWorkflowConnections = - await getProposalWorkflowConnectionByStatusId( - proposalWorkflow.id, - proposal.statusId - ); + return; + } - if (!currentWorkflowConnections.length) { - return; - } + const proposalWorkflow = await getProposalWorkflowByCallId( + proposal.callId + ); + + if (!proposalWorkflow) { + return; + } + + const machine = await createProposalMachine( + proposalWorkflow.id, + proposal.statusId + ); + + const actor = createActor(machine).start(); + const snapshot = actor.getSnapshot(); + const currentShortCode = snapshot.value; + + actor.send({ type: arg.currentEvent.toUpperCase() }); - const callDataSource = container.resolve( - Tokens.CallDataSource - ); + const nextStateValue = actor.getSnapshot().value; - const call = await callDataSource.getCall(proposal.callId); + if ( + typeof nextStateValue === 'string' && + nextStateValue !== currentShortCode + ) { + const nextStatusId = machine.states[nextStateValue].meta?.statusId; - if (!call) { - return; + if (nextStatusId) { + const updatedProposal = await proposalDataSource.updateProposalStatus( + arg.proposalPk, + nextStatusId + ); + + const call = await callDataSource.getCall(proposal.callId); + + return { + ...updatedProposal, + workflowId: proposalWorkflow.id, + prevStatusId: proposal.statusId, + callShortCode: call?.shortCode || '', + }; } + } + }) + ); - /** - * 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, - 0 // TODO fix this when new WF is implemented - // 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, - 0 // TODO fix this when new WF is implemented - // nextWorkflowConnection.statusId - ); - - if (updatedProposal) { - await checkIfConditionsForNextStatusAreMet({ - nextWorkflowConnections, - proposalWorkflow, - workflowDataSource, - proposalWithEvents, - }); - - return { - ...updatedProposal, - workflowId: proposalWorkflow.id, - prevStatusId: 0, // TODO fix this when new WF is implemented - // 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 validProposals = proposalsWithChangedStatuses.filter( + (p): p is WorkflowEngineProposalType => !!p + ); - // NOTE: Call the actions engine here - if (filteredProposalsWithChangedStatuses.length) { - proposalStatusActionEngine(filteredProposalsWithChangedStatuses); + if (validProposals.length > 0) { + await proposalStatusActionEngine(validProposals); } - return filteredProposalsWithChangedStatuses; + return validProposals; }; export const markProposalsEventAsDoneAndCallWorkflowEngine = async ( From 8c563afbfffdb5ffa60855670707c0d3329456a3 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 31 Dec 2025 14:38:06 +0100 Subject: [PATCH 017/147] feat: enhance workflow management by adding workflow status handling and guards --- .../src/datasources/WorkflowDataSource.ts | 4 + .../datasources/mockups/ProposalDataSource.ts | 1 + .../postgres/ProposalDataSource.ts | 31 +- .../postgres/WorkflowDataSource.ts | 65 ++- .../src/datasources/postgres/records.ts | 2 + apps/backend/src/events/event.enum.ts | 388 +++++++++++++----- apps/backend/src/models/Proposal.ts | 1 + apps/backend/src/queries/SettingsQueries.ts | 24 +- .../guards/isProposalSubmittedGuard.ts | 14 + apps/backend/src/workflowEngine/proposal.ts | 55 ++- .../proposal/fragment.proposal.graphql | 1 + 11 files changed, 453 insertions(+), 133 deletions(-) create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index efe6dc1f25..c6ef5f7327 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -54,6 +54,10 @@ export interface WorkflowDataSource { prevWorkflowStatusId: number; nextWorkflowStatusId: number; statusChangingEvent: string; + guardNames: string[]; }[]; }>; + getDraftWorkflowStatusByCallId( + callId: number + ): Promise; } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 9d5b7f3890..72a0ed9046 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -37,6 +37,7 @@ const dummyProposalFactory = (values?: Partial) => { values?.abstract || 'abstract', values?.proposerId || 1, values?.statusId || 1, + values?.workflowStatusId || 1, values?.created || new Date(), values?.updated || new Date(), values?.proposalId || 'shortCode', diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index ad7b58456a..8f2f23028c 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -292,12 +292,19 @@ export default class PostgresProposalDataSource implements ProposalDataSource { async updateProposalStatus( proposalPk: number, - proposalStatusId: number + proposalWfStatusId: number ): Promise { + const statusId = await database('workflow_has_statuses') + .select('status_id') + .where('workflow_status_id', proposalWfStatusId) + .first() + .then((record) => record?.status_id); + return database .update( { - status_id: proposalStatusId, + status_id: statusId, + workflow_status_id: proposalWfStatusId, }, ['*'] ) @@ -339,8 +346,26 @@ export default class PostgresProposalDataSource implements ProposalDataSource { call_id: number, questionary_id: number ): Promise { + const draftWfStatus = + await this.workflowDataSource.getDraftWorkflowStatusByCallId(call_id); + + if (!draftWfStatus) { + 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, + status_id: 1, + workflow_status_id: draftWfStatus.workflowStatusId, + }, + ['*'] + ) .from('proposals') .then((resultSet: ProposalRecord[]) => { return createProposalObject(resultSet[0]); diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 31bbd6e702..aa7f68f358 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -416,6 +416,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { prevWorkflowStatusId: number; nextWorkflowStatusId: number; statusChangingEvent: string; + guardNames: string[]; }[]; }> { const workflowStatuses = await database @@ -430,6 +431,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { 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_workflow_status_changing_events.status_changing_event as statusChangingEvent' @@ -442,9 +444,70 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ) .where('workflow_status_connections.workflow_id', workflowId); + const connectionIds = workflowConnections.map( + (wc) => wc.workflowStatusConnectionId + ); + + let guards: { workflowStatusConnectionId: number; guardName: string }[] = + []; + if (connectionIds.length > 0) { + guards = await database + .select( + 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_connection_id as workflowStatusConnectionId', + 'workflow_status_changing_guards.name as guardName' + ) + .from('workflow_status_connection_has_workflow_status_changing_guards') + .join( + 'workflow_status_changing_guards', + 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_changing_guard_id', + 'workflow_status_changing_guards.workflow_status_changing_guard_id' + ) + .whereIn( + 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_connection_id', + connectionIds + ); + } + + const workflowConnectionsWithGuards = workflowConnections.map((wc) => { + const connectionGuards = guards + .filter( + (g) => g.workflowStatusConnectionId === wc.workflowStatusConnectionId + ) + .map((g) => g.guardName); + + return { + prevWorkflowStatusId: wc.prevWorkflowStatusId, + nextWorkflowStatusId: wc.nextWorkflowStatusId, + statusChangingEvent: wc.statusChangingEvent, + guardNames: connectionGuards, + }; + }); + return { workflowStatuses, - workflowConnections, + workflowConnections: workflowConnectionsWithGuards, }; } + + async getDraftWorkflowStatusByCallId( + callId: number + ): Promise { + const workflowStatus: WorkflowStatusRecord = await database + .select('workflow_has_statuses.*') + .from('call') + .join('workflows', 'call.proposal_workflow_id', 'workflows.workflow_id') + .join( + 'workflow_has_statuses', + 'workflows.workflow_id', + 'workflow_has_statuses.workflow_id' + ) + .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') + .where('call.call_id', callId) + .andWhere('statuses.short_code', 'DRAFT') + .first(); + + return workflowStatus + ? this.createWorkflowStatusObject(workflowStatus) + : null; + } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 7b18868fc7..0293ddd47d 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -131,6 +131,7 @@ export interface ProposalRecord { readonly abstract: string; readonly proposer_id: number; readonly status_id: number; + readonly workflow_status_id: number; readonly created_at: Date; readonly updated_at: Date; readonly full_count: number; @@ -812,6 +813,7 @@ export const createProposalObject = (proposal: ProposalRecord) => { proposal.abstract || '', proposal.proposer_id, proposal.status_id, + proposal.workflow_status_id, proposal.created_at, proposal.updated_at, proposal.proposal_id, diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index e98a24165e..eba623ef59 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,3 +1,5 @@ +import { isProposalSubmittedGuard } from '../workflowEngine/guards/isProposalSubmittedGuard'; + // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { PROPOSAL_CREATED = 'PROPOSAL_CREATED', @@ -100,325 +102,509 @@ 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'], +export type GuardFunction = (id: number) => Promise; +interface EventMetadata { + label: string; + guard?: GuardFunction; +} + +export const EventLabel = 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', + }, ], [ 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', + }, ], [ Event.PROPOSAL_FAPS_SELECTED, - 'Event occurs when FAPs are assigned to a proposal', + { label: 'Event occurs when FAPs are assigned to a proposal' }, ], [ 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' }, ], [ 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', + }, ], [ - 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 all proposal feasibility reviews are submitted with any value', + }, ], [ - 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 all proposal feasibility reviews are submitted with Feasible value', + }, ], [ 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', + }, ], [ 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', + }, ], [ Event.PROPOSAL_ALL_FAP_REVIEWERS_SELECTED, - 'Event occurs when all FAP reviewers are selected on a proposal', + { + label: 'Event occurs when all FAP reviewers are selected on a proposal', + }, ], [ 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', + }, ], [ 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 all FAP reviews on a proposal are submitted for the current FAP', + }, ], [ 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 all the FAP meetings are submitted for a specific proposal', + }, ], [ 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 all proposal FAP reviews are submitted throughout all the FAPs', + }, ], [ 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', + }, + ], + [ + Event.PROPOSAL_FAP_MEETING_INSTRUMENT_UNSUBMITTED, + { + label: 'Event occurs when instrument is unsubmitted in the FAP meeting', + }, ], [ 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 instrument is submitted after FAP meeting is finalized for all the FAPs proposal is part of', + }, ], [ - 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', + }, ], [ Event.PROPOSAL_ACCEPTED, - 'Event occurs when proposal gets final decision as accepted', + { label: 'Event occurs when proposal gets final decision as accepted' }, ], [ - Event.PROPOSAL_MANAGEMENT_DECISION_UPDATED, - 'Event occurs when proposal management decision is updated', + Event.PROPOSAL_RESERVED, + { label: 'Event occurs when proposal gets reserved' }, ], [ - Event.PROPOSAL_MANAGEMENT_DECISION_SUBMITTED, - 'Event occurs when proposal management decision is submitted', + Event.PROPOSAL_REJECTED, + { label: 'Event occurs when proposal gets rejected' }, ], - [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' }, ], [ 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', + }, ], - [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', + }, ], [ 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', + }, ], - [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.EMAIL_INVITE_LEGACY, - '[Deprecated] Event occurs when user is created using email invite', + { + label: + '[Deprecated] Event occurs when user is created using email invite', + }, + ], + [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, + { label: 'Event occurs when proposal is notified' }, ], - [Event.PROPOSAL_NOTIFIED, 'Event occurs when proposal is notified'], - [Event.PROPOSAL_CLONED, 'Event occurs when proposal is cloned'], + [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', + }, ], [ 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', + }, ], [ 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' }, ], - [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', + }, ], ]); diff --git a/apps/backend/src/models/Proposal.ts b/apps/backend/src/models/Proposal.ts index c5457f618c..14b5e9736e 100644 --- a/apps/backend/src/models/Proposal.ts +++ b/apps/backend/src/models/Proposal.ts @@ -34,6 +34,7 @@ export class Proposal { 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/queries/SettingsQueries.ts b/apps/backend/src/queries/SettingsQueries.ts index 91445c1c42..27166de071 100644 --- a/apps/backend/src/queries/SettingsQueries.ts +++ b/apps/backend/src/queries/SettingsQueries.ts @@ -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 = EventLabel.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 = EventLabel.get(eventItem as Event); + + return { + name: eventItem, + description: metadata?.label, + }; + }); return allExperimentSafetyEvents; } else { diff --git a/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts new file mode 100644 index 0000000000..3d4422c634 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts @@ -0,0 +1,14 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; + +export const isProposalSubmittedGuard = async (proposalPk: number) => { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(proposalPk); + + return proposal?.submitted === true; +}; diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index ce1448c9b0..7da0439568 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -19,14 +19,17 @@ const getProposalWorkflowByCallId = (callId: number) => { return callDataSource.getProposalWorkflowByCall(callId); }; -type StateNodeConfig = { +const createWfStatusName = (shortCode: string, workflowStatusId: number) => + `${shortCode}-${workflowStatusId}`; + +type StatusNodeConfig = { on: Record; - meta?: { statusId: number }; + meta?: { statusId: number; workflowStatusId: number }; }; const createProposalMachine = async ( workflowId: number, - currentStatusId: number + currentWfStatusId: number ) => { const workflowDataSource = container.resolve( Tokens.WorkflowDataSource @@ -35,40 +38,50 @@ const createProposalMachine = async ( const { workflowStatuses, workflowConnections } = await workflowDataSource.getWorkflowStructure(workflowId); - const states: Record = {}; + const draftWfStatus = workflowStatuses.find( + (ws) => ws.shortCode === 'DRAFT' + )!; + const draftWfStatusName = createWfStatusName( + draftWfStatus.shortCode, + draftWfStatus.workflowStatusId + ); + + const wfStatuses: Record = {}; // Map workflowStatusId to shortCode for easy lookup - const statusIdToShortCodeMap = new Map(); + const wfStatusIdToNameMap = new Map(); workflowStatuses.forEach((ws) => { - statusIdToShortCodeMap.set(ws.workflowStatusId, ws.shortCode); - states[ws.shortCode] = { + const wfStatusName = createWfStatusName(ws.shortCode, ws.workflowStatusId); + wfStatusIdToNameMap.set(ws.workflowStatusId, wfStatusName); + wfStatuses[wfStatusName] = { on: {}, meta: { + workflowStatusId: ws.workflowStatusId, statusId: ws.statusId, }, }; }); workflowConnections.forEach((conn) => { - const sourceState = statusIdToShortCodeMap.get(conn.prevWorkflowStatusId); - const targetState = statusIdToShortCodeMap.get(conn.nextWorkflowStatusId); + const sourceStatus = wfStatusIdToNameMap.get(conn.prevWorkflowStatusId); + const targetStatus = wfStatusIdToNameMap.get(conn.nextWorkflowStatusId); // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) const event = conn.statusChangingEvent.toUpperCase(); - if (sourceState && targetState && event) { - states[sourceState].on[event] = { - target: targetState, + if (sourceStatus && targetStatus && event) { + wfStatuses[sourceStatus].on[event] = { + target: targetStatus, }; } }); - const currentShortCode = statusIdToShortCodeMap.get(currentStatusId); + const currentWfStatusName = wfStatusIdToNameMap.get(currentWfStatusId); return createMachine({ id: `proposal-workflow-${workflowId}`, - initial: currentShortCode || 'DRAFT', - states, + initial: currentWfStatusName || draftWfStatusName, + states: wfStatuses, }); }; @@ -112,12 +125,12 @@ export const workflowEngine = async ( const machine = await createProposalMachine( proposalWorkflow.id, - proposal.statusId + proposal.workflowStatusId ); const actor = createActor(machine).start(); const snapshot = actor.getSnapshot(); - const currentShortCode = snapshot.value; + const currentWfStatus = snapshot.value; actor.send({ type: arg.currentEvent.toUpperCase() }); @@ -125,11 +138,13 @@ export const workflowEngine = async ( if ( typeof nextStateValue === 'string' && - nextStateValue !== currentShortCode + nextStateValue !== currentWfStatus ) { - const nextStatusId = machine.states[nextStateValue].meta?.statusId; + const meta = machine.states[nextStateValue].meta; + const nextStatusId = meta?.statusId; + const nextWfStatusId = meta?.workflowStatusId; - if (nextStatusId) { + if (nextStatusId && nextWfStatusId) { const updatedProposal = await proposalDataSource.updateProposalStatus( arg.proposalPk, nextStatusId 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 } From af465b5572d0965a45b9614428a1b00e30ef240d Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 31 Dec 2025 14:58:38 +0100 Subject: [PATCH 018/147] refactor: rename updateProposalStatus to updateProposalWfStatus for consistency in workflow management --- apps/backend/src/datasources/ProposalDataSource.ts | 2 +- .../src/datasources/mockups/ProposalDataSource.ts | 2 +- .../src/datasources/postgres/ProposalDataSource.ts | 2 +- apps/backend/src/workflowEngine/proposal.ts | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index e1c215d8dc..e7023525ab 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -45,7 +45,7 @@ export interface ProposalDataSource { questionary_id: number ): Promise; update(proposal: Proposal): Promise; - updateProposalStatus( + updateProposalWfStatus( proposalPk: number, proposalStatusId: number ): Promise; diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 72a0ed9046..72186ca829 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -244,7 +244,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { return proposal; } - async updateProposalStatus( + async updateProposalWfStatus( proposalPk: number, proposalStatusId: number ): Promise { diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index 8f2f23028c..aaf373876e 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -290,7 +290,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { }); } - async updateProposalStatus( + async updateProposalWfStatus( proposalPk: number, proposalWfStatusId: number ): Promise { diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 7da0439568..0b985d3dcb 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -141,14 +141,14 @@ export const workflowEngine = async ( nextStateValue !== currentWfStatus ) { const meta = machine.states[nextStateValue].meta; - const nextStatusId = meta?.statusId; const nextWfStatusId = meta?.workflowStatusId; - if (nextStatusId && nextWfStatusId) { - const updatedProposal = await proposalDataSource.updateProposalStatus( - arg.proposalPk, - nextStatusId - ); + if (nextWfStatusId) { + const updatedProposal = + await proposalDataSource.updateProposalWfStatus( + arg.proposalPk, + nextWfStatusId + ); const call = await callDataSource.getCall(proposal.callId); From b7551ab6fe2bd65ff9803148cba63a735b87fcab Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 31 Dec 2025 18:34:21 +0100 Subject: [PATCH 019/147] feat: overhaul workflow guards by implementing IGuard interface and refactoring IsProposalSubmittedGuard --- apps/backend/src/events/event.enum.ts | 2 +- .../src/workflowEngine/guards/IGuard.ts | 5 +++ .../guards/IsProposalSubmittedGuard.ts | 27 ++++++++++++ .../guards/isProposalSubmittedGuard.ts | 14 ------- apps/backend/src/workflowEngine/proposal.ts | 42 ++++++++++++------- 5 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 apps/backend/src/workflowEngine/guards/IGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts delete mode 100644 apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index eba623ef59..a9675713f7 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,4 +1,4 @@ -import { isProposalSubmittedGuard } from '../workflowEngine/guards/isProposalSubmittedGuard'; +import { isProposalSubmittedGuard } from '../workflowEngine/guards/IsProposalSubmittedGuard'; // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { diff --git a/apps/backend/src/workflowEngine/guards/IGuard.ts b/apps/backend/src/workflowEngine/guards/IGuard.ts new file mode 100644 index 0000000000..5d3346983a --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/IGuard.ts @@ -0,0 +1,5 @@ +export interface IGuard { + name: string; + initialize(proposalPk: number): Promise; + guard(): boolean; +} diff --git a/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts new file mode 100644 index 0000000000..d1091fa16b --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts @@ -0,0 +1,27 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { IGuard } from './IGuard'; + +export class IsProposalSubmittedGuard implements IGuard { + private isProposalSubmitted!: boolean; + + public async initialize(proposalPk: number) { + const proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); + + const proposal = await proposalDataSource.get(proposalPk); + + this.isProposalSubmitted = proposal ? proposal.submitted : false; + } + + public get name(): string { + return 'isProposalSubmittedGuard'; + } + + public guard(): boolean { + return this.isProposalSubmitted; + } +} diff --git a/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts deleted file mode 100644 index 3d4422c634..0000000000 --- a/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../../config/Tokens'; -import { ProposalDataSource } from '../../datasources/ProposalDataSource'; - -export const isProposalSubmittedGuard = async (proposalPk: number) => { - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - - const proposal = await proposalDataSource.get(proposalPk); - - return proposal?.submitted === true; -}; diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 0b985d3dcb..21eeae638c 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,6 +1,6 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; -import { createActor, createMachine } from 'xstate'; +import { createActor, createMachine, and } from 'xstate'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; @@ -10,6 +10,7 @@ import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; import { Event } from '../events/event.enum'; import { Proposal } from '../models/Proposal'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; +import { IsProposalSubmittedGuard } from './guards/IsProposalSubmittedGuard'; const getProposalWorkflowByCallId = (callId: number) => { const callDataSource = container.resolve( @@ -23,13 +24,13 @@ const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; type StatusNodeConfig = { - on: Record; + on: Record; meta?: { statusId: number; workflowStatusId: number }; }; const createProposalMachine = async ( workflowId: number, - currentWfStatusId: number + proposal: Pick ) => { const workflowDataSource = container.resolve( Tokens.WorkflowDataSource @@ -70,19 +71,33 @@ const createProposalMachine = async ( const event = conn.statusChangingEvent.toUpperCase(); if (sourceStatus && targetStatus && event) { + const guards = conn.guardNames.map((guardName) => ({ type: guardName })); wfStatuses[sourceStatus].on[event] = { target: targetStatus, + guard: guards.length > 0 ? and(guards) : undefined, }; } }); - const currentWfStatusName = wfStatusIdToNameMap.get(currentWfStatusId); + const currentWfStatusName = wfStatusIdToNameMap.get( + proposal.workflowStatusId + ); - return createMachine({ - id: `proposal-workflow-${workflowId}`, - initial: currentWfStatusName || draftWfStatusName, - states: wfStatuses, - }); + const isProposalSubmittedGuard = new IsProposalSubmittedGuard(); + await isProposalSubmittedGuard.initialize(proposal.primaryKey); + + return createMachine( + { + id: `proposal-workflow-${workflowId}`, + initial: currentWfStatusName || draftWfStatusName, + states: wfStatuses, + }, + { + guards: { + isProposalSubmittedGuard: () => isProposalSubmittedGuard.guard(), + }, + } + ); }; export type WorkflowEngineProposalType = Proposal & { @@ -125,7 +140,7 @@ export const workflowEngine = async ( const machine = await createProposalMachine( proposalWorkflow.id, - proposal.workflowStatusId + proposal ); const actor = createActor(machine).start(); @@ -134,12 +149,9 @@ export const workflowEngine = async ( actor.send({ type: arg.currentEvent.toUpperCase() }); - const nextStateValue = actor.getSnapshot().value; + const nextStateValue = actor.getSnapshot().value as string; - if ( - typeof nextStateValue === 'string' && - nextStateValue !== currentWfStatus - ) { + if (nextStateValue !== currentWfStatus) { const meta = machine.states[nextStateValue].meta; const nextWfStatusId = meta?.workflowStatusId; From 50cf348f99f249151a77fb3be1ef846ddf8bc44a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 31 Dec 2025 22:05:14 +0100 Subject: [PATCH 020/147] feat: enhance workflow management by adding default workflow status handling and refactoring related methods --- .../src/datasources/ProposalDataSource.ts | 6 --- .../src/datasources/StatusDataSource.ts | 2 + .../src/datasources/WorkflowDataSource.ts | 3 -- .../datasources/mockups/ProposalDataSource.ts | 9 ---- .../mockups/StatusActionsDataSource.ts | 1 - .../datasources/mockups/StatusDataSource.ts | 4 ++ .../datasources/mockups/WorkflowDataSource.ts | 17 +++++- .../postgres/ProposalDataSource.ts | 52 +++++++------------ .../datasources/postgres/StatusDataSource.ts | 40 ++++++++++++++ .../postgres/WorkflowDataSource.ts | 22 -------- .../stfc/StfcProposalDataSource.ts | 11 ++-- .../src/eventHandlers/messageBroker.ts | 6 +-- .../src/eventHandlers/proposalWorkflow.ts | 13 +++-- apps/backend/src/events/event.enum.ts | 8 +-- .../src/mutations/ProposalMutations.ts | 13 +++++ .../ProposalSettingsMutations.spec.ts | 7 ++- .../src/mutations/WorkflowMutations.ts | 2 +- apps/backend/src/workflowEngine/proposal.ts | 18 +------ 18 files changed, 115 insertions(+), 119 deletions(-) diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index e7023525ab..2d89507353 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 { Proposal, Proposals } from '../models/Proposal'; import { ProposalView } from '../models/ProposalView'; @@ -7,7 +6,6 @@ import { UserWithRole } from '../models/User'; import { UpdateTechnicalReviewAssigneeInput } from '../resolvers/mutations/UpdateTechnicalReviewAssigneeMutation'; import { UserProposalsFilter } from '../resolvers/types/User'; import { ProposalsFilter } from './../resolvers/queries/ProposalsQuery'; -import { ProposalEventsRecord } from './postgres/records'; export interface ProposalDataSource { getProposalsFromView( @@ -64,10 +62,6 @@ 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; changeProposalsStatus( diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index ef326d4d35..359cd17de9 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -1,4 +1,5 @@ import { Status } from '../models/Status'; +import { WorkflowStatus } from '../models/WorkflowStatus'; import { UpdateStatusInput } from '../resolvers/mutations/settings/UpdateStatusMutation'; export interface StatusDataSource { @@ -10,4 +11,5 @@ export interface StatusDataSource { updateStatus(status: UpdateStatusInput): Promise; deleteStatus(statusId: number): Promise; getDefaultStatus(entityType: Status['entityType']): Promise; + getDefaultWorkflowStatus(workflowId: number): Promise; } diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index c6ef5f7327..21346da278 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -57,7 +57,4 @@ export interface WorkflowDataSource { guardNames: string[]; }[]; }>; - getDraftWorkflowStatusByCallId( - callId: number - ): Promise; } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 72186ca829..50c026f8a4 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 { Proposal, ProposalEndStatus, Proposals } from '../../models/Proposal'; @@ -10,7 +9,6 @@ import { } from '../../models/TechnicalReview'; import { UserWithRole } from '../../models/User'; import { UpdateTechnicalReviewAssigneeInput } from '../../resolvers/mutations/UpdateTechnicalReviewAssigneeMutation'; -import { ProposalEventsRecord } from '../postgres/records'; import { ProposalDataSource } from '../ProposalDataSource'; import { ProposalsFilter } from './../../resolvers/queries/ProposalsQuery'; import { basicDummyUser } from './UserDataSource'; @@ -360,13 +358,6 @@ export class ProposalDataSourceMock implements ProposalDataSource { return { totalCount: 1, proposals: [dummyProposalView] }; } - async markEventAsDoneOnProposals( - event: Event, - proposalPk: number[] - ): Promise { - return [dummyProposalEvents]; - } - async getCount(callId: number): Promise { return 1; } diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index 07378a114d..b1d70f3fb4 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -12,7 +12,6 @@ export const dummyConnectionHasStatusAction = new ConnectionHasStatusAction( 1, 1, 1, - 'Dummy action', StatusActionType.EMAIL, {} ); diff --git a/apps/backend/src/datasources/mockups/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index c27dadad05..ef81c82930 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -1,5 +1,6 @@ import { Status } from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; import { StatusDataSource } from '../StatusDataSource'; export const dummyStatuses = [ @@ -15,6 +16,9 @@ export const dummyStatuses = [ ]; export class StatusDataSourceMock implements StatusDataSource { + getDefaultWorkflowStatus(workflowId: number): Promise { + throw new Error('Method not implemented.'); + } // TODO: This needs to be implemented async createStatus( newStatusInput: Omit diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 5172b467cd..9e0217527b 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -3,8 +3,8 @@ import { StatusChangingEvent } from '../../models/StatusChangingEvent'; import { Workflow, WorkflowType } from '../../models/Workflow'; import { WorkflowConnection } from '../../models/WorkflowConnections'; import { WorkflowStatus } from '../../models/WorkflowStatus'; -import { CreateWorkflowConnectionInput } from '../../resolvers/mutations/settings/CreateWorkflowConnectionMutation'; 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'; @@ -46,6 +46,21 @@ export const dummyStatusChangingEvent = new StatusChangingEvent( ); export class WorkflowDataSourceMock implements WorkflowDataSource { + getWorkflowStructure(workflowId: number): Promise<{ + workflowStatuses: { + workflowStatusId: number; + statusId: number; + shortCode: string; + }[]; + workflowConnections: { + prevWorkflowStatusId: number; + nextWorkflowStatusId: number; + statusChangingEvent: string; + guardNames: string[]; + }[]; + }> { + throw new Error('Method not implemented.'); + } createWorkflowConnection( newWorkflowConnectionInput: CreateWorkflowConnectionInput ): Promise { diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index aaf373876e..ec6de51a0f 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 { Proposal, Proposals } from '../../models/Proposal'; import { ProposalView } from '../../models/ProposalView'; @@ -20,23 +19,23 @@ import { UpdateTechnicalReviewAssigneeInput } from '../../resolvers/mutations/Up import { UserProposalsFilter } from '../../resolvers/types/User'; import { AdminDataSource } from '../AdminDataSource'; import { ProposalDataSource } from '../ProposalDataSource'; -import { WorkflowDataSource } from '../WorkflowDataSource'; import { ProposalsFilter, QuestionFilterInput, } from './../../resolvers/queries/ProposalsQuery'; +import CallDataSource from './CallDataSource'; import database from './database'; import { createProposalObject, createProposalViewObject, + createProposalViewObjectWithTechniques, createTechnicalReviewObject, - ProposalEventsRecord, ProposalRecord, ProposalViewRecord, TechnicalReviewRecord, TechniqueRecord, - createProposalViewObjectWithTechniques, } from './records'; +import StatusDataSource from './StatusDataSource'; const fieldMap: { [key: string]: string } = { finalStatus: 'final_status', @@ -94,10 +93,12 @@ export async function calculateReferenceNumber( @injectable() export default class PostgresProposalDataSource implements ProposalDataSource { constructor( - @inject(Tokens.WorkflowDataSource) - private workflowDataSource: WorkflowDataSource, @inject(Tokens.AdminDataSource) - private AdminDataSource: AdminDataSource + private adminDataSource: AdminDataSource, + @inject(Tokens.CallDataSource) + protected callDataSource: CallDataSource, + @inject(Tokens.StatusDataSource) + private statusDataSource: StatusDataSource ) {} async updateProposalTechnicalReviewer({ @@ -346,8 +347,15 @@ export default class PostgresProposalDataSource implements ProposalDataSource { call_id: number, questionary_id: number ): Promise { - const draftWfStatus = - await this.workflowDataSource.getDraftWorkflowStatusByCallId(call_id); + const call = await this.callDataSource.getCall(call_id); + + if (!call) { + throw new GraphQLError(`Call not found with id: ${call_id}`); + } + + const draftWfStatus = await this.statusDataSource.getDefaultWorkflowStatus( + call.proposalWorkflowId + ); if (!draftWfStatus) { throw new GraphQLError( @@ -800,30 +808,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 getCount(callId: number): Promise { return database('proposals') .count('call_id') @@ -945,7 +929,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; diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 496cfe19c3..6203d92873 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -3,6 +3,7 @@ import { injectable } from 'tsyringe'; import { Status } from '../../models/Status'; import { WorkflowType } from '../../models/Workflow'; +import { WorkflowStatus } from '../../models/WorkflowStatus'; import { UpdateStatusInput } from '../../resolvers/mutations/settings/UpdateStatusMutation'; import { StatusDataSource } from '../StatusDataSource'; import database from './database'; @@ -107,6 +108,45 @@ export default class PostgresStatusDataSource implements StatusDataSource { return status ? this.createStatusObject(status) : null; } + async getDefaultWorkflowStatus( + workflowId: number + ): Promise { + const workflow = await database + .select() + .from('workflows') + .where('workflow_id', workflowId) + .first(); + + if (!workflow) { + throw new GraphQLError(`Workflow not found with id: ${workflowId}`); + } + + const defaultStatus = await this.getDefaultStatus(workflow.entity_type); + + if (!defaultStatus) { + return null; + } + + const workflowStatus: WorkflowStatus | null = await database + .select() + .from('workflow_statuses') + .where('workflow_id', workflowId) + .andWhere('status_id', defaultStatus.id) + .first(); + + if (!workflowStatus) { + return null; + } + + return new WorkflowStatus( + workflowStatus.workflowStatusId, + workflowStatus.workflowId, + workflowStatus.statusId, + workflowStatus.posX, + workflowStatus.posY + ); + } + async getInitialStatus( entityType: Status['entityType'] ): Promise { diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index aa7f68f358..3d05c37e4e 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -488,26 +488,4 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { workflowConnections: workflowConnectionsWithGuards, }; } - - async getDraftWorkflowStatusByCallId( - callId: number - ): Promise { - const workflowStatus: WorkflowStatusRecord = await database - .select('workflow_has_statuses.*') - .from('call') - .join('workflows', 'call.proposal_workflow_id', 'workflows.workflow_id') - .join( - 'workflow_has_statuses', - 'workflows.workflow_id', - 'workflow_has_statuses.workflow_id' - ) - .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') - .where('call.call_id', callId) - .andWhere('statuses.short_code', 'DRAFT') - .first(); - - return workflowStatus - ? this.createWorkflowStatusObject(workflowStatus) - : null; - } } diff --git a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts index 0303aa7b6e..0ce332aa6d 100644 --- a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts @@ -9,8 +9,8 @@ import { Roles } from '../../models/Role'; import { UserWithRole } from '../../models/User'; import { ProposalViewTechnicalReview } from '../../resolvers/types/ProposalView'; import { removeDuplicates } from '../../utils/helperFunctions'; -import { CallDataSource } from '../CallDataSource'; import PostgresAdminDataSource from '../postgres/AdminDataSource'; +import PostgresCallDataSource from '../postgres/CallDataSource'; import database from '../postgres/database'; import { CallRecord, @@ -19,14 +19,14 @@ import { ProposalViewRecord, } from '../postgres/records'; import PostgresStatusDataSource from '../postgres/StatusDataSource'; -import PostgresWorkflowDataSource from '../postgres/WorkflowDataSource'; import { ProposalsFilter } from './../../resolvers/queries/ProposalsQuery'; import PostgresProposalDataSource from './../postgres/ProposalDataSource'; import { StfcUserDataSource } from './StfcUserDataSource'; const postgresProposalDataSource = new PostgresProposalDataSource( - new PostgresWorkflowDataSource(new PostgresStatusDataSource()), - new PostgresAdminDataSource() + new PostgresAdminDataSource(), + new PostgresCallDataSource(), + new PostgresStatusDataSource() ); const fieldMap: { [key: string]: string } = { @@ -46,9 +46,6 @@ 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, diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index edd4b88b0f..7a2fe62bc5 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -30,7 +30,7 @@ import { Institution } from '../models/Institution'; import { Proposal } from '../models/Proposal'; import { Visit } from '../models/Visit'; import { VisitRegistrationStatus } from '../models/VisitRegistration'; -import { markProposalsEventAsDoneAndCallWorkflowEngine } from '../workflowEngine/proposal'; +import { callWorkflowEngine } from '../workflowEngine/proposal'; export const QUEUE_NAME = (process.env.RABBITMQ_CORE_QUEUE_NAME as Queue) || @@ -436,9 +436,7 @@ export async function createListenToRabbitMQHandler() { throw new Error('Proposal id not found in the message'); } - await markProposalsEventAsDoneAndCallWorkflowEngine(eventType, [ - proposalPk, - ]); + await callWorkflowEngine(eventType, [proposalPk]); }; const cancelVisit = async (visit: Visit) => { diff --git a/apps/backend/src/eventHandlers/proposalWorkflow.ts b/apps/backend/src/eventHandlers/proposalWorkflow.ts index d6b6555794..75c44186e9 100644 --- a/apps/backend/src/eventHandlers/proposalWorkflow.ts +++ b/apps/backend/src/eventHandlers/proposalWorkflow.ts @@ -10,7 +10,7 @@ import { Proposal } from '../models/Proposal'; import { searchObjectByKey } from '../utils/helperFunctions'; import { WorkflowEngineProposalType, - markProposalsEventAsDoneAndCallWorkflowEngine, + callWorkflowEngine, } from '../workflowEngine/proposal'; enum ProposalInformationKeys { @@ -77,11 +77,10 @@ const handleSubmittedProposalsAfterCallEnded = async ( return; } - const updatedSubmittedProposals = - await markProposalsEventAsDoneAndCallWorkflowEngine( - Event.CALL_ENDED, - proposalPks - ); + const updatedSubmittedProposals = await callWorkflowEngine( + Event.CALL_ENDED, + proposalPks + ); if (updatedSubmittedProposals) { await publishProposalStatusChange(updatedSubmittedProposals); } @@ -93,7 +92,7 @@ export const handleWorkflowEngineChange = async ( ) => { const isArray = Array.isArray(proposalPks); - const updatedProposals = await markProposalsEventAsDoneAndCallWorkflowEngine( + const updatedProposals = await callWorkflowEngine( event.type, isArray ? proposalPks : [proposalPks] ); diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index a9675713f7..0c1bb35e90 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,4 +1,5 @@ -import { isProposalSubmittedGuard } from '../workflowEngine/guards/IsProposalSubmittedGuard'; +import { IGuard } from '../workflowEngine/guards/IGuard'; +import { IsProposalSubmittedGuard } from '../workflowEngine/guards/IsProposalSubmittedGuard'; // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { @@ -102,10 +103,9 @@ export enum Event { VISIT_CREATED = 'VISIT_CREATED', } -export type GuardFunction = (id: number) => Promise; interface EventMetadata { label: string; - guard?: GuardFunction; + guard?: new () => IGuard; } export const EventLabel = new Map([ @@ -115,7 +115,7 @@ export const EventLabel = new Map([ Event.PROPOSAL_SUBMITTED, { label: 'Event occurs when proposal is submitted', - guard: isProposalSubmittedGuard, + guard: IsProposalSubmittedGuard, }, ], [Event.PROPOSAL_DELETED, { label: 'Event occurs when proposal is removed' }], diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index adb2f5cda0..50732ba00a 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -841,6 +841,18 @@ export default class ProposalMutations { true ); + const defaultWfStatus = + await this.statusDataSource.getDefaultWorkflowStatus( + call.proposalWorkflowId + ); + + if (!defaultWfStatus) { + 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, @@ -848,6 +860,7 @@ export default class ProposalMutations { abstract: clonedProposal.abstract, proposerId: sourceProposal.proposerId, statusId: 1, + workflowStatusId: defaultWfStatus.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 ade5ced3e5..97e5604b4d 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -8,6 +8,7 @@ import { import { dummyWorkflow, dummyWorkflowConnection, + dummyWorkflowStatus, } from '../datasources/mockups/WorkflowDataSource'; import { Rejection } from '../models/Rejection'; import { StatusChangingEvent } from '../models/StatusChangingEvent'; @@ -166,7 +167,7 @@ describe('Test Proposal settings mutations', () => { return expect( workflowMutationsInstance.addStatusToWorkflow( dummyUserOfficerWithRole, - dummyWorkflowConnection + dummyWorkflowStatus ) ).resolves.toStrictEqual(dummyWorkflowConnection); }); @@ -186,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); }); diff --git a/apps/backend/src/mutations/WorkflowMutations.ts b/apps/backend/src/mutations/WorkflowMutations.ts index 22aeac15f5..f0f7730d63 100644 --- a/apps/backend/src/mutations/WorkflowMutations.ts +++ b/apps/backend/src/mutations/WorkflowMutations.ts @@ -21,11 +21,11 @@ import { UserWithRole } from '../models/User'; import { Workflow } from '../models/Workflow'; import { WorkflowConnection } from '../models/WorkflowConnections'; import { WorkflowStatus } from '../models/WorkflowStatus'; -import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settings/SetStatusActionsOnConnectionMutation'; 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'; diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 21eeae638c..38d02e65cc 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,10 +1,9 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; -import { createActor, createMachine, and } from 'xstate'; +import { and, createActor, createMachine } from 'xstate'; 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'; @@ -109,7 +108,6 @@ export type WorkflowEngineProposalType = Proposal & { export const workflowEngine = async ( args: { proposalPk: number; - proposalEvents?: ProposalEventsRecord; currentEvent: Event; }[] ): Promise | void> => { @@ -186,7 +184,7 @@ export const workflowEngine = async ( return validProposals; }; -export const markProposalsEventAsDoneAndCallWorkflowEngine = async ( +export const callWorkflowEngine = async ( eventType: Event, proposalPks: number[] ) => { @@ -199,21 +197,9 @@ export const markProposalsEventAsDoneAndCallWorkflowEngine = async ( return; } - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - - const allProposalEvents = await proposalDataSource.markEventAsDoneOnProposals( - eventType, - proposalPks - ); - const proposalPksWithEvents = proposalPks.map((proposalPk) => { return { proposalPk, - proposalEvents: allProposalEvents?.find( - (proposalEvents) => proposalEvents.proposal_pk === proposalPk - ), currentEvent: eventType, }; }); From ad2becb3d491d3a92062ac08d518ba97d4dedb63 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 1 Jan 2026 15:25:45 +0100 Subject: [PATCH 021/147] refactor: remove unused getProposalWorkflowByCallId function and log error for missing proposal workflow --- apps/backend/src/workflowEngine/proposal.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 38d02e65cc..5851f495eb 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -11,14 +11,6 @@ import { Proposal } from '../models/Proposal'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; import { IsProposalSubmittedGuard } from './guards/IsProposalSubmittedGuard'; -const getProposalWorkflowByCallId = (callId: number) => { - const callDataSource = container.resolve( - Tokens.CallDataSource - ); - - return callDataSource.getProposalWorkflowByCall(callId); -}; - const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; @@ -128,11 +120,16 @@ export const workflowEngine = async ( return; } - const proposalWorkflow = await getProposalWorkflowByCallId( + const proposalWorkflow = await callDataSource.getProposalWorkflowByCall( proposal.callId ); if (!proposalWorkflow) { + logger.logError('Proposal workflow not found for the proposal', { + proposalPk: arg.proposalPk, + callId: proposal.callId, + }); + return; } From d21d38b2e1a7a6408a1bea4d8cd09e5f7654541a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 1 Jan 2026 17:24:51 +0100 Subject: [PATCH 022/147] feat: overhaul workflow management by replacing IGuard interface with GuardFn and refactoring related guards --- .../0203_Add_new_workflow_data_structures.sql | 32 +----- apps/backend/src/events/event.enum.ts | 8 +- .../src/workflowEngine/guards/IGuard.ts | 5 - .../guards/IsProposalSubmittedGuard.ts | 27 ------ .../proposal/isProposalSubmittedGuard.ts | 21 ++++ apps/backend/src/workflowEngine/proposal.ts | 86 ++++++++-------- .../workflowEngine/simpleStateMachine.spec.ts | 89 +++++++++++++++++ .../src/workflowEngine/simpleStateMachine.ts | 97 +++++++++++++++++++ 8 files changed, 255 insertions(+), 110 deletions(-) delete mode 100644 apps/backend/src/workflowEngine/guards/IGuard.ts delete mode 100644 apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/simpleStateMachine.spec.ts create mode 100644 apps/backend/src/workflowEngine/simpleStateMachine.ts diff --git a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql index a0d9c1a624..0b6afbdf41 100644 --- a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql +++ b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql @@ -151,38 +151,8 @@ BEGIN CREATE INDEX ix_phsce_proposal ON proposal_has_workflow_status_changing_events (proposal_pk); - -- ============================================ - -- 7) workflow_status_changing_guards (catalog) - -- ============================================ - CREATE TABLE workflow_status_changing_guards ( - workflow_status_changing_guard_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name TEXT NOT NULL, - description TEXT - ); - - -- ===================================================================== - -- 8) workflow_status_connection_has_workflow_status_changing_guards (edge→guards) - -- ===================================================================== - CREATE TABLE workflow_status_connection_has_workflow_status_changing_guards ( - workflow_status_connection_id INT NOT NULL, - workflow_status_changing_guard_id INT NOT NULL, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - - CONSTRAINT pk_wsc_has_guards - PRIMARY KEY (workflow_status_connection_id, workflow_status_changing_guard_id), - - CONSTRAINT fk_wscg_connection - FOREIGN KEY (workflow_status_connection_id) - REFERENCES workflow_status_connections (workflow_status_connection_id) - ON DELETE CASCADE, - - CONSTRAINT fk_wscg_guard - FOREIGN KEY (workflow_status_changing_guard_id) - REFERENCES workflow_status_changing_guards (workflow_status_changing_guard_id) - ); - -- ================================================================== - -- 9) Link proposals to the new workflow graph + -- 7) Link proposals to the new workflow graph -- ================================================================== ALTER TABLE proposals ADD COLUMN workflow_status_id INT NULL; diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 0c1bb35e90..5348ae4722 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,5 +1,5 @@ -import { IGuard } from '../workflowEngine/guards/IGuard'; -import { IsProposalSubmittedGuard } from '../workflowEngine/guards/IsProposalSubmittedGuard'; +import { isProposalSubmittedGuard } from '../workflowEngine/guards/proposal/isProposalSubmittedGuard'; +import { GuardFn } from '../workflowEngine/simpleStateMachine'; // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { @@ -105,7 +105,7 @@ export enum Event { interface EventMetadata { label: string; - guard?: new () => IGuard; + guard?: GuardFn; } export const EventLabel = new Map([ @@ -115,7 +115,7 @@ export const EventLabel = new Map([ Event.PROPOSAL_SUBMITTED, { label: 'Event occurs when proposal is submitted', - guard: IsProposalSubmittedGuard, + guard: isProposalSubmittedGuard, }, ], [Event.PROPOSAL_DELETED, { label: 'Event occurs when proposal is removed' }], diff --git a/apps/backend/src/workflowEngine/guards/IGuard.ts b/apps/backend/src/workflowEngine/guards/IGuard.ts deleted file mode 100644 index 5d3346983a..0000000000 --- a/apps/backend/src/workflowEngine/guards/IGuard.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IGuard { - name: string; - initialize(proposalPk: number): Promise; - guard(): boolean; -} diff --git a/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts deleted file mode 100644 index d1091fa16b..0000000000 --- a/apps/backend/src/workflowEngine/guards/IsProposalSubmittedGuard.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { container } from 'tsyringe'; - -import { Tokens } from '../../config/Tokens'; -import { ProposalDataSource } from '../../datasources/ProposalDataSource'; -import { IGuard } from './IGuard'; - -export class IsProposalSubmittedGuard implements IGuard { - private isProposalSubmitted!: boolean; - - public async initialize(proposalPk: number) { - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - - const proposal = await proposalDataSource.get(proposalPk); - - this.isProposalSubmitted = proposal ? proposal.submitted : false; - } - - public get name(): string { - return 'isProposalSubmittedGuard'; - } - - public guard(): boolean { - return this.isProposalSubmitted; - } -} diff --git a/apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts new file mode 100644 index 0000000000..0e24e172a1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../../config/Tokens'; +import { ProposalDataSource } from '../../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../../simpleStateMachine'; + +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/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 5851f495eb..6457eef215 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,28 +1,32 @@ import { logger } from '@user-office-software/duo-logger'; import { container } from 'tsyringe'; -import { and, createActor, createMachine } from 'xstate'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; -import { Event } from '../events/event.enum'; +import { Event, EventLabel } from '../events/event.enum'; import { Proposal } from '../models/Proposal'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; -import { IsProposalSubmittedGuard } from './guards/IsProposalSubmittedGuard'; +import { + createActor, + createMachine, + GuardFn, + StateConfig, +} from './simpleStateMachine'; const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; -type StatusNodeConfig = { - on: Record; - meta?: { statusId: number; workflowStatusId: number }; +type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; + +const getEventGuard = (eventName: string): GuardFn | undefined => { + const eventMeta = EventLabel.get(eventName as Event); + + return eventMeta?.guard; }; -const createProposalMachine = async ( - workflowId: number, - proposal: Pick -) => { +const createProposalMachine = async (workflowId: number) => { const workflowDataSource = container.resolve( Tokens.WorkflowDataSource ); @@ -38,7 +42,7 @@ const createProposalMachine = async ( draftWfStatus.workflowStatusId ); - const wfStatuses: Record = {}; + const wfStatuses: Record = {}; // Map workflowStatusId to shortCode for easy lookup const wfStatusIdToNameMap = new Map(); @@ -62,33 +66,20 @@ const createProposalMachine = async ( const event = conn.statusChangingEvent.toUpperCase(); if (sourceStatus && targetStatus && event) { - const guards = conn.guardNames.map((guardName) => ({ type: guardName })); - wfStatuses[sourceStatus].on[event] = { + const guard = getEventGuard(event); + wfStatuses[sourceStatus].on = wfStatuses[sourceStatus].on || {}; + wfStatuses[sourceStatus].on![event] = { target: targetStatus, - guard: guards.length > 0 ? and(guards) : undefined, + guard, }; } }); - const currentWfStatusName = wfStatusIdToNameMap.get( - proposal.workflowStatusId - ); - - const isProposalSubmittedGuard = new IsProposalSubmittedGuard(); - await isProposalSubmittedGuard.initialize(proposal.primaryKey); - - return createMachine( - { - id: `proposal-workflow-${workflowId}`, - initial: currentWfStatusName || draftWfStatusName, - states: wfStatuses, - }, - { - guards: { - isProposalSubmittedGuard: () => isProposalSubmittedGuard.guard(), - }, - } - ); + return createMachine({ + id: `proposal-workflow-${workflowId}`, + initial: draftWfStatusName, + states: wfStatuses, + }); }; export type WorkflowEngineProposalType = Proposal & { @@ -133,21 +124,30 @@ export const workflowEngine = async ( return; } - const machine = await createProposalMachine( - proposalWorkflow.id, - proposal - ); + const machine = await createProposalMachine(proposalWorkflow.id); - const actor = createActor(machine).start(); - const snapshot = actor.getSnapshot(); - const currentWfStatus = snapshot.value; + const proposalStartStatus = Object.entries(machine.schema.states).find( + ([, state]) => { + return ( + (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === + proposal.workflowStatusId + ); + } + )?.[0]; - actor.send({ type: arg.currentEvent.toUpperCase() }); + const actor = createActor( + machine, + { id: proposal.primaryKey }, + proposalStartStatus + ); + const currentWfStatus = actor.getState(); - const nextStateValue = actor.getSnapshot().value as string; + const nextStateValue = await actor.event(arg.currentEvent.toUpperCase()); if (nextStateValue !== currentWfStatus) { - const meta = machine.states[nextStateValue].meta; + const meta = machine.schema.states[nextStateValue]?.meta as + | WorkflowStateMeta + | undefined; const nextWfStatusId = meta?.workflowStatusId; if (nextWfStatusId) { diff --git a/apps/backend/src/workflowEngine/simpleStateMachine.spec.ts b/apps/backend/src/workflowEngine/simpleStateMachine.spec.ts new file mode 100644 index 0000000000..a52261e699 --- /dev/null +++ b/apps/backend/src/workflowEngine/simpleStateMachine.spec.ts @@ -0,0 +1,89 @@ +import { createActor, createMachine } from './simpleStateMachine'; + +describe('simpleStateMachine', () => { + it('throws when the initial state is missing from the schema', () => { + expect(() => + createMachine({ + initial: 'missing', + states: {}, + }) + ).toThrow('Unknown initial state "missing"'); + }); + + it('runs entry actions for the initial and target states', async () => { + const initialAction = jest.fn().mockResolvedValue(undefined); + const approvedAction = jest.fn().mockResolvedValue(undefined); + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + action: initialAction, + on: { + APPROVE: { target: 'approved' }, + }, + }, + approved: { + action: approvedAction, + }, + }, + }); + + const actor = createActor(machine, { id: 1 }); + await Promise.resolve(); + expect(initialAction).toHaveBeenCalledWith({ id: 1 }); + + const nextState = await actor.event('APPROVE'); + expect(nextState).toBe('approved'); + expect(actor.getState()).toBe('approved'); + expect(approvedAction).toHaveBeenCalledWith({ id: 1 }); + }); + + 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', guard }, + }, + }, + submitted: {}, + }, + }); + + const actor = createActor(machine, { id: 2 }); + + const firstAttempt = await actor.event('SUBMIT'); + expect(firstAttempt).toBe('draft'); + expect(actor.getState()).toBe('draft'); + + const secondAttempt = await actor.event('SUBMIT'); + expect(secondAttempt).toBe('submitted'); + expect(actor.getState()).toBe('submitted'); + expect(guard).toHaveBeenCalledTimes(2); + expect(guard).toHaveBeenLastCalledWith({ id: 2 }); + }); + + it('throws when a transition targets an unknown state', async () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + NEXT: { target: 'missing' }, + }, + }, + }, + }); + + const actor = createActor(machine, { id: 1 }); + + await expect(actor.event('NEXT')).rejects.toThrow( + 'Unknown target state "missing"' + ); + }); +}); diff --git a/apps/backend/src/workflowEngine/simpleStateMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine.ts new file mode 100644 index 0000000000..b5503aadb3 --- /dev/null +++ b/apps/backend/src/workflowEngine/simpleStateMachine.ts @@ -0,0 +1,97 @@ +export type Entity = { id: number }; + +export type GuardFn = (entity: Entity) => boolean | Promise; +export type ActionFn = (entity: Entity) => void | Promise; + +export type TransitionConfig = { + target: string; + guard?: 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; +}; + +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}"`); + } + + const runAction = async (stateName: string) => { + const action = schema.states[stateName]?.action; + if (action) { + await action(entity); + } + }; + + void runAction(currentState); + + const getState = () => currentState; + + const event = async (eventName: string) => { + const stateConfig = schema.states[currentState]; + const transition = stateConfig?.on?.[eventName]; + + if (!transition) { + return currentState; + } + + if (transition.guard) { + const result = await transition.guard(entity); + if (!result) { + return currentState; + } + } + + if (!schema.states[transition.target]) { + throw new Error(`Unknown target state "${transition.target}"`); + } + + currentState = transition.target; + await runAction(currentState); + + return currentState; + }; + + return { getState, event }; +}; From bfd52655f2aa25134b35a67764af06c70f6aa72e Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 1 Jan 2026 17:35:56 +0100 Subject: [PATCH 023/147] refactor: remove guardNames from WorkflowDataSource and related mock implementations --- .../src/datasources/WorkflowDataSource.ts | 1 - .../datasources/mockups/WorkflowDataSource.ts | 1 - .../postgres/WorkflowDataSource.ts | 46 +++---------------- apps/backend/src/workflowEngine/proposal.ts | 4 +- .../index.spec.ts} | 2 +- .../index.ts} | 0 6 files changed, 9 insertions(+), 45 deletions(-) rename apps/backend/src/workflowEngine/{simpleStateMachine.spec.ts => simpleStateMachine/index.spec.ts} (97%) rename apps/backend/src/workflowEngine/{simpleStateMachine.ts => simpleStateMachine/index.ts} (100%) diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index 21346da278..efe6dc1f25 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -54,7 +54,6 @@ export interface WorkflowDataSource { prevWorkflowStatusId: number; nextWorkflowStatusId: number; statusChangingEvent: string; - guardNames: string[]; }[]; }>; } diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 9e0217527b..62dba7aa5f 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -56,7 +56,6 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { prevWorkflowStatusId: number; nextWorkflowStatusId: number; statusChangingEvent: string; - guardNames: string[]; }[]; }> { throw new Error('Method not implemented.'); diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 3d05c37e4e..380d78c198 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -416,7 +416,6 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { prevWorkflowStatusId: number; nextWorkflowStatusId: number; statusChangingEvent: string; - guardNames: string[]; }[]; }> { const workflowStatuses = await database @@ -444,48 +443,15 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { ) .where('workflow_status_connections.workflow_id', workflowId); - const connectionIds = workflowConnections.map( - (wc) => wc.workflowStatusConnectionId - ); - - let guards: { workflowStatusConnectionId: number; guardName: string }[] = - []; - if (connectionIds.length > 0) { - guards = await database - .select( - 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_connection_id as workflowStatusConnectionId', - 'workflow_status_changing_guards.name as guardName' - ) - .from('workflow_status_connection_has_workflow_status_changing_guards') - .join( - 'workflow_status_changing_guards', - 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_changing_guard_id', - 'workflow_status_changing_guards.workflow_status_changing_guard_id' - ) - .whereIn( - 'workflow_status_connection_has_workflow_status_changing_guards.workflow_status_connection_id', - connectionIds - ); - } - - const workflowConnectionsWithGuards = workflowConnections.map((wc) => { - const connectionGuards = guards - .filter( - (g) => g.workflowStatusConnectionId === wc.workflowStatusConnectionId - ) - .map((g) => g.guardName); - - return { - prevWorkflowStatusId: wc.prevWorkflowStatusId, - nextWorkflowStatusId: wc.nextWorkflowStatusId, - statusChangingEvent: wc.statusChangingEvent, - guardNames: connectionGuards, - }; - }); + const normalizedWorkflowConnections = workflowConnections.map((wc) => ({ + prevWorkflowStatusId: wc.prevWorkflowStatusId, + nextWorkflowStatusId: wc.nextWorkflowStatusId, + statusChangingEvent: wc.statusChangingEvent, + })); return { workflowStatuses, - workflowConnections: workflowConnectionsWithGuards, + workflowConnections: normalizedWorkflowConnections, }; } } diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 6457eef215..b52565ee04 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -15,11 +15,11 @@ import { StateConfig, } from './simpleStateMachine'; +type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; + const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; -type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; - const getEventGuard = (eventName: string): GuardFn | undefined => { const eventMeta = EventLabel.get(eventName as Event); diff --git a/apps/backend/src/workflowEngine/simpleStateMachine.spec.ts b/apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts similarity index 97% rename from apps/backend/src/workflowEngine/simpleStateMachine.spec.ts rename to apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts index a52261e699..e0082130b0 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine.spec.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts @@ -1,4 +1,4 @@ -import { createActor, createMachine } from './simpleStateMachine'; +import { createActor, createMachine } from '.'; describe('simpleStateMachine', () => { it('throws when the initial state is missing from the schema', () => { diff --git a/apps/backend/src/workflowEngine/simpleStateMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/index.ts similarity index 100% rename from apps/backend/src/workflowEngine/simpleStateMachine.ts rename to apps/backend/src/workflowEngine/simpleStateMachine/index.ts From 72d06a1e7d0a735596a14bca0a2c08b933b98546 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 1 Jan 2026 20:01:18 +0100 Subject: [PATCH 024/147] feat: implement workflow machine and guards with refactored state management --- apps/backend/src/events/event.enum.ts | 4 +- .../isProposalSubmittedGuard.ts | 6 +- apps/backend/src/workflowEngine/proposal.ts | 88 ++----------------- .../createWorkflowMachine.ts | 71 +++++++++++++++ .../{index.spec.ts => stateMachine.spec.ts} | 2 +- .../{index.ts => stateMachnine.ts} | 0 6 files changed, 86 insertions(+), 85 deletions(-) rename apps/backend/src/workflowEngine/guards/{proposal => }/isProposalSubmittedGuard.ts (68%) create mode 100644 apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts rename apps/backend/src/workflowEngine/simpleStateMachine/{index.spec.ts => stateMachine.spec.ts} (97%) rename apps/backend/src/workflowEngine/simpleStateMachine/{index.ts => stateMachnine.ts} (100%) diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index 5348ae4722..b33a66c4ba 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,5 +1,5 @@ -import { isProposalSubmittedGuard } from '../workflowEngine/guards/proposal/isProposalSubmittedGuard'; -import { GuardFn } from '../workflowEngine/simpleStateMachine'; +import { isProposalSubmittedGuard } from '../workflowEngine/guards/isProposalSubmittedGuard'; +import { GuardFn } from '../workflowEngine/simpleStateMachine/stateMachnine'; // NOTE: When creating new event we need to follow the same name standardization/convention: [WHERE]_[WHAT] export enum Event { diff --git a/apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts similarity index 68% rename from apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts rename to apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts index 0e24e172a1..1556aebcb1 100644 --- a/apps/backend/src/workflowEngine/guards/proposal/isProposalSubmittedGuard.ts +++ b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.ts @@ -1,8 +1,8 @@ import { container } from 'tsyringe'; -import { Tokens } from '../../../config/Tokens'; -import { ProposalDataSource } from '../../../datasources/ProposalDataSource'; -import { Entity, GuardFn } from '../../simpleStateMachine'; +import { Tokens } from '../../config/Tokens'; +import { ProposalDataSource } from '../../datasources/ProposalDataSource'; +import { Entity, GuardFn } from '../simpleStateMachine/stateMachnine'; export const isProposalSubmittedGuard: GuardFn = async ( entity: Entity diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index b52565ee04..92c96f1650 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -4,84 +4,14 @@ import { container } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; -import { WorkflowDataSource } from '../datasources/WorkflowDataSource'; -import { Event, EventLabel } from '../events/event.enum'; +import { Event } from '../events/event.enum'; import { Proposal } from '../models/Proposal'; import { proposalStatusActionEngine } from '../statusActionEngine/proposal'; -import { - createActor, - createMachine, - GuardFn, - StateConfig, -} from './simpleStateMachine'; +import { createWorkflowMachine } from './simpleStateMachine/createWorkflowMachine'; +import { createActor } from './simpleStateMachine/stateMachnine'; type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; -const createWfStatusName = (shortCode: string, workflowStatusId: number) => - `${shortCode}-${workflowStatusId}`; - -const getEventGuard = (eventName: string): GuardFn | undefined => { - const eventMeta = EventLabel.get(eventName as Event); - - return eventMeta?.guard; -}; - -const createProposalMachine = async (workflowId: number) => { - const workflowDataSource = container.resolve( - Tokens.WorkflowDataSource - ); - - const { workflowStatuses, workflowConnections } = - await workflowDataSource.getWorkflowStructure(workflowId); - - const draftWfStatus = workflowStatuses.find( - (ws) => ws.shortCode === 'DRAFT' - )!; - const draftWfStatusName = createWfStatusName( - draftWfStatus.shortCode, - draftWfStatus.workflowStatusId - ); - - const wfStatuses: Record = {}; - - // Map workflowStatusId to shortCode for easy lookup - const wfStatusIdToNameMap = new Map(); - - workflowStatuses.forEach((ws) => { - const wfStatusName = createWfStatusName(ws.shortCode, ws.workflowStatusId); - wfStatusIdToNameMap.set(ws.workflowStatusId, wfStatusName); - wfStatuses[wfStatusName] = { - on: {}, - meta: { - workflowStatusId: ws.workflowStatusId, - statusId: ws.statusId, - }, - }; - }); - - workflowConnections.forEach((conn) => { - const sourceStatus = wfStatusIdToNameMap.get(conn.prevWorkflowStatusId); - const targetStatus = wfStatusIdToNameMap.get(conn.nextWorkflowStatusId); - // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) - const event = conn.statusChangingEvent.toUpperCase(); - - if (sourceStatus && targetStatus && event) { - const guard = getEventGuard(event); - wfStatuses[sourceStatus].on = wfStatuses[sourceStatus].on || {}; - wfStatuses[sourceStatus].on![event] = { - target: targetStatus, - guard, - }; - } - }); - - return createMachine({ - id: `proposal-workflow-${workflowId}`, - initial: draftWfStatusName, - states: wfStatuses, - }); -}; - export type WorkflowEngineProposalType = Proposal & { workflowId: number; prevStatusId: number; @@ -111,11 +41,11 @@ export const workflowEngine = async ( return; } - const proposalWorkflow = await callDataSource.getProposalWorkflowByCall( - proposal.callId - ); + const proposalWorkflowId = ( + await callDataSource.getProposalWorkflowByCall(proposal.callId) + )?.id; - if (!proposalWorkflow) { + if (!proposalWorkflowId) { logger.logError('Proposal workflow not found for the proposal', { proposalPk: arg.proposalPk, callId: proposal.callId, @@ -124,7 +54,7 @@ export const workflowEngine = async ( return; } - const machine = await createProposalMachine(proposalWorkflow.id); + const machine = await createWorkflowMachine(proposalWorkflowId); const proposalStartStatus = Object.entries(machine.schema.states).find( ([, state]) => { @@ -161,7 +91,7 @@ export const workflowEngine = async ( return { ...updatedProposal, - workflowId: proposalWorkflow.id, + workflowId: proposalWorkflowId, prevStatusId: proposal.statusId, callShortCode: call?.shortCode || '', }; diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts new file mode 100644 index 0000000000..0e352af230 --- /dev/null +++ b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts @@ -0,0 +1,71 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { StatusDataSource } from '../../datasources/StatusDataSource'; +import { WorkflowDataSource } from '../../datasources/WorkflowDataSource'; +import { Event, EventLabel } from '../../events/event.enum'; +import { createMachine, GuardFn, StateConfig } from './stateMachnine'; + +const createWfStatusName = (shortCode: string, workflowStatusId: number) => + `${shortCode}-${workflowStatusId}`; + +const getEventGuard = (eventName: string): GuardFn | undefined => { + const eventMeta = EventLabel.get(eventName as Event); + + return eventMeta?.guard; +}; + +export const createWorkflowMachine = async (workflowId: number) => { + const workflowDataSource = container.resolve( + Tokens.WorkflowDataSource + ); + + const statusDataSource = container.resolve( + Tokens.StatusActionsDataSource + ); + + const { workflowStatuses, workflowConnections } = + await workflowDataSource.getWorkflowStructure(workflowId); + + const wfStatuses: Record = {}; + + // Map workflowStatusId to shortCode for easy lookup + const wfStatusIdToNameMap = new Map(); + + workflowStatuses.forEach((ws) => { + const wfStatusName = createWfStatusName(ws.shortCode, ws.workflowStatusId); + wfStatusIdToNameMap.set(ws.workflowStatusId, wfStatusName); + wfStatuses[wfStatusName] = { + on: {}, + meta: { + workflowStatusId: ws.workflowStatusId, + statusId: ws.statusId, + }, + }; + }); + + workflowConnections.forEach((conn) => { + const sourceStatus = wfStatusIdToNameMap.get(conn.prevWorkflowStatusId); + const targetStatus = wfStatusIdToNameMap.get(conn.nextWorkflowStatusId); + // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) + const event = conn.statusChangingEvent.toUpperCase(); + + if (sourceStatus && targetStatus && event) { + const guard = getEventGuard(event); + wfStatuses[sourceStatus].on = wfStatuses[sourceStatus].on || {}; + wfStatuses[sourceStatus].on![event] = { + target: targetStatus, + guard, + }; + } + }); + + const defaultWfStatus = + (await statusDataSource.getDefaultWorkflowStatus(workflowId))!; + + return createMachine({ + id: `workflow-${workflowId}`, + initial: wfStatusIdToNameMap.get(defaultWfStatus.workflowStatusId)!, + states: wfStatuses, + }); +}; diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts similarity index 97% rename from apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts rename to apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts index e0082130b0..4186ef9f38 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/index.spec.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts @@ -1,4 +1,4 @@ -import { createActor, createMachine } from '.'; +import { createActor, createMachine } from './stateMachnine'; describe('simpleStateMachine', () => { it('throws when the initial state is missing from the schema', () => { diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/index.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts similarity index 100% rename from apps/backend/src/workflowEngine/simpleStateMachine/index.ts rename to apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts From 4f2c8af318ababf3e7fc9958faa9163d6ea7fbde Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 2 Jan 2026 10:23:22 +0100 Subject: [PATCH 025/147] feat: enhance workflow management by adding guards and refactoring event metadata handling --- .../datasources/postgres/StatusDataSource.ts | 16 +++++++------- apps/backend/src/events/event.enum.ts | 8 +++++-- apps/backend/src/queries/SettingsQueries.ts | 6 ++--- .../isProposalInstrumentsSelectedGuard.ts | 17 ++++++++++++++ .../createWorkflowMachine.ts | 22 +++++++++++++++---- .../simpleStateMachine/stateMachnine.ts | 21 ++++++++++-------- 6 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 6203d92873..ae6d2731f9 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -7,7 +7,7 @@ import { WorkflowStatus } from '../../models/WorkflowStatus'; import { UpdateStatusInput } from '../../resolvers/mutations/settings/UpdateStatusMutation'; import { StatusDataSource } from '../StatusDataSource'; import database from './database'; -import { StatusRecord } from './records'; +import { StatusRecord, WorkflowStatusRecord } from './records'; @injectable() export default class PostgresStatusDataSource implements StatusDataSource { @@ -127,9 +127,9 @@ export default class PostgresStatusDataSource implements StatusDataSource { return null; } - const workflowStatus: WorkflowStatus | null = await database + const workflowStatus: WorkflowStatusRecord | null = await database .select() - .from('workflow_statuses') + .from('workflow_has_statuses') .where('workflow_id', workflowId) .andWhere('status_id', defaultStatus.id) .first(); @@ -139,11 +139,11 @@ export default class PostgresStatusDataSource implements StatusDataSource { } return new WorkflowStatus( - workflowStatus.workflowStatusId, - workflowStatus.workflowId, - workflowStatus.statusId, - workflowStatus.posX, - workflowStatus.posY + workflowStatus.workflow_status_id, + workflowStatus.workflow_id, + workflowStatus.status_id, + workflowStatus.pos_x, + workflowStatus.pos_y ); } diff --git a/apps/backend/src/events/event.enum.ts b/apps/backend/src/events/event.enum.ts index b33a66c4ba..958980f52b 100644 --- a/apps/backend/src/events/event.enum.ts +++ b/apps/backend/src/events/event.enum.ts @@ -1,3 +1,4 @@ +import { isProposalInstrumentsSelectedGuard } from '../workflowEngine/guards/isProposalInstrumentsSelectedGuard'; import { isProposalSubmittedGuard } from '../workflowEngine/guards/isProposalSubmittedGuard'; import { GuardFn } from '../workflowEngine/simpleStateMachine/stateMachnine'; @@ -108,7 +109,7 @@ interface EventMetadata { guard?: GuardFn; } -export const EventLabel = new Map([ +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' }], [ @@ -143,7 +144,10 @@ export const EventLabel = new Map([ ], [ Event.PROPOSAL_INSTRUMENTS_SELECTED, - { label: '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, diff --git a/apps/backend/src/queries/SettingsQueries.ts b/apps/backend/src/queries/SettingsQueries.ts index 27166de071..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'; @@ -21,7 +21,7 @@ export default class SettingsQueries { eventItem.startsWith('PROPOSAL_') || eventItem.startsWith('CALL_') ) .map((eventItem) => { - const metadata = EventLabel.get(eventItem as Event); + const metadata = EventMetadataByEvent.get(eventItem as Event); return { name: eventItem, @@ -34,7 +34,7 @@ export default class SettingsQueries { const allExperimentSafetyEvents = allEventsArray .filter((eventItem) => eventItem.startsWith('EXPERIMENT_')) .map((eventItem) => { - const metadata = EventLabel.get(eventItem as Event); + const metadata = EventMetadataByEvent.get(eventItem as Event); return { name: eventItem, diff --git a/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.ts new file mode 100644 index 0000000000..f8894c4985 --- /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 '../simpleStateMachine/stateMachnine'; + +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/simpleStateMachine/createWorkflowMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts index 0e352af230..70da2a4232 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts @@ -3,25 +3,35 @@ import { container } from 'tsyringe'; import { Tokens } from '../../config/Tokens'; import { StatusDataSource } from '../../datasources/StatusDataSource'; import { WorkflowDataSource } from '../../datasources/WorkflowDataSource'; -import { Event, EventLabel } from '../../events/event.enum'; +import { Event, EventMetadataByEvent } from '../../events/event.enum'; import { createMachine, GuardFn, StateConfig } from './stateMachnine'; +const workflowMachineCache = new Map< + number, + ReturnType +>(); + const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; const getEventGuard = (eventName: string): GuardFn | undefined => { - const eventMeta = EventLabel.get(eventName as Event); + const eventMeta = EventMetadataByEvent.get(eventName as Event); return eventMeta?.guard; }; export const createWorkflowMachine = async (workflowId: number) => { + const cachedMachine = workflowMachineCache.get(workflowId); + if (cachedMachine) { + return cachedMachine; + } + const workflowDataSource = container.resolve( Tokens.WorkflowDataSource ); const statusDataSource = container.resolve( - Tokens.StatusActionsDataSource + Tokens.StatusDataSource ); const { workflowStatuses, workflowConnections } = @@ -63,9 +73,13 @@ export const createWorkflowMachine = async (workflowId: number) => { const defaultWfStatus = (await statusDataSource.getDefaultWorkflowStatus(workflowId))!; - return createMachine({ + const machine = createMachine({ id: `workflow-${workflowId}`, initial: wfStatusIdToNameMap.get(defaultWfStatus.workflowStatusId)!, states: wfStatuses, }); + + workflowMachineCache.set(workflowId, machine); + + return machine; }; diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts index b5503aadb3..3517d31bdd 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts @@ -72,21 +72,24 @@ export const createActor = ( const stateConfig = schema.states[currentState]; const transition = stateConfig?.on?.[eventName]; - if (!transition) { + if (!stateConfig || !transition) { return currentState; } - if (transition.guard) { - const result = await transition.guard(entity); - if (!result) { - return currentState; + // all Guards from current state to target state must pass + const allGuardsFromCurrentState = Object.values( + stateConfig.on || {} + ).filter((t) => t.target === transition.target && t.guard); + + for (const guardTransition of allGuardsFromCurrentState) { + if (guardTransition.guard) { + const result = await guardTransition.guard(entity); + if (!result) { + return currentState; + } } } - if (!schema.states[transition.target]) { - throw new Error(`Unknown target state "${transition.target}"`); - } - currentState = transition.target; await runAction(currentState); From 05d2f5a7f89494b97c74e716dd240b4f732cdcb3 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 2 Jan 2026 15:14:10 +0100 Subject: [PATCH 026/147] feat: update workflow and status action data sources to use workflowStatusConnectionId and refactor related properties --- .../src/datasources/ProposalDataSource.ts | 2 +- .../datasources/StatusActionsDataSource.ts | 3 +- .../src/datasources/WorkflowDataSource.ts | 3 +- .../mockups/StatusActionsDataSource.ts | 3 +- .../datasources/mockups/WorkflowDataSource.ts | 3 +- .../postgres/StatusActionsDataSource.ts | 4 +- .../postgres/WorkflowDataSource.ts | 44 +++++++++++++++--- .../src/mutations/ProposalMutations.ts | 1 - .../mutations/StatusActionsLogsMutations.ts | 1 - .../src/queries/StatusActionQueries.ts | 4 +- .../src/resolvers/types/WorkflowConnection.ts | 3 +- .../src/statusActionEngine/proposal.ts | 17 +++---- apps/backend/src/workflowEngine/proposal.ts | 17 +++---- .../createWorkflowMachine.ts | 46 +++++++++++++------ .../simpleStateMachine/stateMachine.spec.ts | 18 ++++++-- .../simpleStateMachine/stateMachnine.ts | 43 +++++++++-------- 16 files changed, 137 insertions(+), 75 deletions(-) diff --git a/apps/backend/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 2d89507353..0cf4a85892 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -45,7 +45,7 @@ export interface ProposalDataSource { update(proposal: Proposal): Promise; updateProposalWfStatus( proposalPk: number, - proposalStatusId: number + wfStatusId: number ): Promise; updateProposalTechnicalReviewer( args: UpdateTechnicalReviewAssigneeInput diff --git a/apps/backend/src/datasources/StatusActionsDataSource.ts b/apps/backend/src/datasources/StatusActionsDataSource.ts index 503a8a8106..0280a2e464 100644 --- a/apps/backend/src/datasources/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/StatusActionsDataSource.ts @@ -6,8 +6,7 @@ import { SetStatusActionsOnConnectionInput } from '../resolvers/mutations/settin export interface StatusActionsDataSource { getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise; getConnectionStatusAction( workflowConnectionId: number, diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index efe6dc1f25..04ffecfe1b 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -51,9 +51,10 @@ export interface WorkflowDataSource { shortCode: string; }[]; workflowConnections: { + workflowStatusConnectionId: number; prevWorkflowStatusId: number; nextWorkflowStatusId: number; - statusChangingEvent: string; + statusChangingEvents: string[]; }[]; }>; } diff --git a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts index b1d70f3fb4..26f9e16a8a 100644 --- a/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusActionsDataSource.ts @@ -40,8 +40,7 @@ export class StatusActionsDataSourceMock implements StatusActionsDataSource { return dummyConnectionHasStatusAction; } async getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise { return [dummyConnectionHasStatusAction]; } diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 62dba7aa5f..6311edbfbb 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -53,9 +53,10 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { shortCode: string; }[]; workflowConnections: { + workflowStatusConnectionId: number; prevWorkflowStatusId: number; nextWorkflowStatusId: number; - statusChangingEvent: string; + statusChangingEvents: string[]; }[]; }> { throw new Error('Method not implemented.'); diff --git a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts index dfee4c6da2..9c0ff13337 100644 --- a/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusActionsDataSource.ts @@ -80,8 +80,7 @@ export default class PostgresStatusActionsDataSource } async getConnectionStatusActions( - workflowConnectionId: number, - workflowId: number + workflowConnectionId: number ): Promise { const statusActionRecords: (StatusActionRecord & WorkflowConnectionHasActionsRecord & { @@ -92,7 +91,6 @@ export default class PostgresStatusActionsDataSource .join('workflow_status_connection_has_workflow_status_actions as wca', { 'wca.workflow_status_action_id': 'wsa.workflow_status_action_id', }) - .where('wca.workflow_id', workflowId) .andWhere('wca.workflow_status_connection_id', workflowConnectionId); const statusActions = statusActionRecords.map((statusActionRecord) => diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 380d78c198..19992aeaba 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -413,9 +413,10 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { shortCode: string; }[]; workflowConnections: { + workflowStatusConnectionId: number; prevWorkflowStatusId: number; nextWorkflowStatusId: number; - statusChangingEvent: string; + statusChangingEvents: string[]; }[]; }> { const workflowStatuses = await database @@ -436,18 +437,47 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { 'workflow_status_connection_has_workflow_status_changing_events.status_changing_event as statusChangingEvent' ) .from('workflow_status_connections') - .join( + .leftJoin( 'workflow_status_connection_has_workflow_status_changing_events', 'workflow_status_connections.workflow_status_connection_id', 'workflow_status_connection_has_workflow_status_changing_events.workflow_status_connection_id' ) .where('workflow_status_connections.workflow_id', workflowId); - const normalizedWorkflowConnections = workflowConnections.map((wc) => ({ - prevWorkflowStatusId: wc.prevWorkflowStatusId, - nextWorkflowStatusId: wc.nextWorkflowStatusId, - statusChangingEvent: wc.statusChangingEvent, - })); + 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() + ); return { workflowStatuses, diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index 50732ba00a..f4c75ba9a1 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -585,7 +585,6 @@ export default class ProposalMutations { return { ...fullProposal, - workflowId: proposalWorkflow.id, }; }) ); diff --git a/apps/backend/src/mutations/StatusActionsLogsMutations.ts b/apps/backend/src/mutations/StatusActionsLogsMutations.ts index 0973d3ccfe..46fdbbced0 100644 --- a/apps/backend/src/mutations/StatusActionsLogsMutations.ts +++ b/apps/backend/src/mutations/StatusActionsLogsMutations.ts @@ -56,7 +56,6 @@ export default class StatusActionsLogsMutations { return { ...proposal, - workflowId: proposalWorkflow.id, }; }) ); diff --git a/apps/backend/src/queries/StatusActionQueries.ts b/apps/backend/src/queries/StatusActionQueries.ts index 7bf1463966..1e8a0f461c 100644 --- a/apps/backend/src/queries/StatusActionQueries.ts +++ b/apps/backend/src/queries/StatusActionQueries.ts @@ -41,9 +41,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/resolvers/types/WorkflowConnection.ts b/apps/backend/src/resolvers/types/WorkflowConnection.ts index 28073a2d71..80cab2388a 100644 --- a/apps/backend/src/resolvers/types/WorkflowConnection.ts +++ b/apps/backend/src/resolvers/types/WorkflowConnection.ts @@ -55,8 +55,7 @@ export class WorkflowConnectionResolver { await context.queries.statusAction.getConnectionStatusActions( context.user, { - connectionId: workflowConnection.id, - workflowId: workflowConnection.workflowId, + workflowConnectionId: workflowConnection.id, } ); diff --git a/apps/backend/src/statusActionEngine/proposal.ts b/apps/backend/src/statusActionEngine/proposal.ts index d53a6722f0..5018d18f97 100644 --- a/apps/backend/src/statusActionEngine/proposal.ts +++ b/apps/backend/src/statusActionEngine/proposal.ts @@ -2,8 +2,8 @@ import { container } from 'tsyringe'; 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'; import { emailActionHandler } from './emailActionHandler'; import { proposalDownloadActionHandler } from './proposalDownloadActionHandler'; @@ -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/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 92c96f1650..2b469cfb79 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -13,9 +13,8 @@ import { createActor } from './simpleStateMachine/stateMachnine'; type WorkflowStateMeta = { statusId: number; workflowStatusId: number }; export type WorkflowEngineProposalType = Proposal & { - workflowId: number; prevStatusId: number; - callShortCode: string; + workflowStatusConnectionId: number; }; export const workflowEngine = async ( @@ -56,7 +55,7 @@ export const workflowEngine = async ( const machine = await createWorkflowMachine(proposalWorkflowId); - const proposalStartStatus = Object.entries(machine.schema.states).find( + const proposalWfStatus = Object.entries(machine.schema.states).find( ([, state]) => { return ( (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === @@ -68,11 +67,13 @@ export const workflowEngine = async ( const actor = createActor( machine, { id: proposal.primaryKey }, - proposalStartStatus + proposalWfStatus ); const currentWfStatus = actor.getState(); - const nextStateValue = await actor.event(arg.currentEvent.toUpperCase()); + const { nextStateValue, connectionId } = await actor.event( + arg.currentEvent.toUpperCase() + ); if (nextStateValue !== currentWfStatus) { const meta = machine.schema.states[nextStateValue]?.meta as @@ -87,13 +88,9 @@ export const workflowEngine = async ( nextWfStatusId ); - const call = await callDataSource.getCall(proposal.callId); - return { ...updatedProposal, - workflowId: proposalWorkflowId, - prevStatusId: proposal.statusId, - callShortCode: call?.shortCode || '', + workflowStatusConnectionId: connectionId, }; } } diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts index 70da2a4232..45fdc62557 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts @@ -14,10 +14,22 @@ const workflowMachineCache = new Map< const createWfStatusName = (shortCode: string, workflowStatusId: number) => `${shortCode}-${workflowStatusId}`; -const getEventGuard = (eventName: string): GuardFn | undefined => { - const eventMeta = EventMetadataByEvent.get(eventName as Event); +const getEventsGuards = (events: string[]): GuardFn[] => { + const guards: GuardFn[] = []; - return eventMeta?.guard; + 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) => { @@ -38,9 +50,7 @@ export const createWorkflowMachine = async (workflowId: number) => { await workflowDataSource.getWorkflowStructure(workflowId); const wfStatuses: Record = {}; - - // Map workflowStatusId to shortCode for easy lookup - const wfStatusIdToNameMap = new Map(); + const wfStatusIdToNameMap = new Map(); // Map workflowStatusId to shortCode for easy lookup workflowStatuses.forEach((ws) => { const wfStatusName = createWfStatusName(ws.shortCode, ws.workflowStatusId); @@ -57,17 +67,27 @@ export const createWorkflowMachine = async (workflowId: number) => { workflowConnections.forEach((conn) => { const sourceStatus = wfStatusIdToNameMap.get(conn.prevWorkflowStatusId); const targetStatus = wfStatusIdToNameMap.get(conn.nextWorkflowStatusId); - // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) - const event = conn.statusChangingEvent.toUpperCase(); - if (sourceStatus && targetStatus && event) { - const guard = getEventGuard(event); + if (!sourceStatus || !targetStatus) { + return; + } + + conn.statusChangingEvents.forEach((eventName) => { + // Events are stored as strings in the DB, ensuring they match the Event enum format (usually uppercase) + const event = eventName.toUpperCase(); + + if (!event) { + return; + } + + const guards = getEventsGuards(conn.statusChangingEvents); wfStatuses[sourceStatus].on = wfStatuses[sourceStatus].on || {}; wfStatuses[sourceStatus].on![event] = { + connectionId: conn.workflowStatusConnectionId, target: targetStatus, - guard, + guards, }; - } + }); }); const defaultWfStatus = @@ -79,7 +99,7 @@ export const createWorkflowMachine = async (workflowId: number) => { states: wfStatuses, }); - workflowMachineCache.set(workflowId, machine); + // workflowMachineCache.set(workflowId, machine); // TODO enable cache after testing return machine; }; diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts index 4186ef9f38..4beac8a1e4 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts @@ -19,7 +19,11 @@ describe('simpleStateMachine', () => { pending: { action: initialAction, on: { - APPROVE: { target: 'approved' }, + APPROVE: { + target: 'approved', + guards: [], + connectionId: 0, + }, }, }, approved: { @@ -48,7 +52,11 @@ describe('simpleStateMachine', () => { states: { draft: { on: { - SUBMIT: { target: 'submitted', guard }, + SUBMIT: { + target: 'submitted', + guards: [guard], + connectionId: 0, + }, }, }, submitted: {}, @@ -74,7 +82,11 @@ describe('simpleStateMachine', () => { states: { start: { on: { - NEXT: { target: 'missing' }, + NEXT: { + target: 'missing', + guards: [], + connectionId: 0, + }, }, }, }, diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts index 3517d31bdd..8dcd2d38b9 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts @@ -4,8 +4,9 @@ export type GuardFn = (entity: Entity) => boolean | Promise; export type ActionFn = (entity: Entity) => void | Promise; export type TransitionConfig = { + connectionId: number; target: string; - guard?: GuardFn; + guards: GuardFn[]; }; export type StateConfig = { @@ -38,7 +39,9 @@ export const createMachine = (schema: MachineSchema): Machine => { export type Actor = { getState: () => string; - event: (eventName: string) => Promise; + event: ( + eventName: string + ) => Promise<{ nextStateValue: string; connectionId: number }>; }; export const createActor = ( @@ -51,7 +54,7 @@ export const createActor = ( } const { schema } = machine; - let currentState = startingState ?? schema.initial; + const currentState = startingState ?? schema.initial; if (!schema.states[currentState]) { throw new Error(`Unknown state "${currentState}"`); @@ -68,32 +71,36 @@ export const createActor = ( const getState = () => currentState; - const event = async (eventName: string) => { + const event = async ( + eventName: string + ): Promise<{ nextStateValue: string; connectionId: number }> => { const stateConfig = schema.states[currentState]; const transition = stateConfig?.on?.[eventName]; if (!stateConfig || !transition) { - return currentState; + return { + nextStateValue: currentState, + connectionId: -1, + }; } // all Guards from current state to target state must pass - const allGuardsFromCurrentState = Object.values( - stateConfig.on || {} - ).filter((t) => t.target === transition.target && t.guard); - - for (const guardTransition of allGuardsFromCurrentState) { - if (guardTransition.guard) { - const result = await guardTransition.guard(entity); - if (!result) { - return currentState; - } + for (const guardTransition of transition.guards) { + const result = await guardTransition(entity); + if (!result) { + return { + nextStateValue: currentState, + connectionId: transition.connectionId, + }; } } - currentState = transition.target; - await runAction(currentState); + await runAction(transition.target); - return currentState; + return { + nextStateValue: transition.target, + connectionId: transition.connectionId, + }; }; return { getState, event }; From 44fae96ad58d6c8ceaf286d106cd47dcf31f2d06 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 2 Jan 2026 15:46:49 +0100 Subject: [PATCH 027/147] feat: enhance FapQueries to include review data source and implement authorization checks for proposal assignments --- apps/backend/src/queries/FapQueries.ts | 64 +++++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/queries/FapQueries.ts b/apps/backend/src/queries/FapQueries.ts index 59f313fbf2..9e5b4ea1ff 100644 --- a/apps/backend/src/queries/FapQueries.ts +++ b/apps/backend/src/queries/FapQueries.ts @@ -4,7 +4,9 @@ import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; import { FapDataSource } from '../datasources/FapDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; +import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { Authorized } from '../decorators'; +import { ReviewStatus } from '../models/Review'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { FapsFilter } from '../resolvers/queries/FapsQuery'; @@ -15,6 +17,8 @@ export default class FapQueries { @inject(Tokens.FapDataSource) public dataSource: FapDataSource, @inject(Tokens.ProposalDataSource) public proposalDataSource: ProposalDataSource, + @inject(Tokens.ReviewDataSource) + private reviewDataSource: ReviewDataSource, @inject(Tokens.UserAuthorization) private userAuth: UserAuthorization ) {} @@ -177,31 +181,45 @@ export default class FapQueries { proposalPk: number; } ) { - const reviewerId = null; + if (!agent) { + return null; + } + + const isApiToken = this.userAuth.isApiToken(agent); + const isUserOfficer = this.userAuth.isUserOfficer(agent); + const isChairOrSecretary = await this.userAuth.isChairOrSecretaryOfFap( + agent, + fapId + ); + const isFapMember = await this.userAuth.isMemberOfFap(agent, fapId); + + if (!isApiToken && !isUserOfficer && !isFapMember) { + return null; + } + + let reviewerId: number | null = null; + const canSeeAllAssignments = + isApiToken || isUserOfficer || isChairOrSecretary; - throw new Error( - 'getProposalEvents does not exist any more, please inspect tables if all fap reviews are submitted instead or relying on events system.' + if (!canSeeAllAssignments) { + const reviews = await this.reviewDataSource.getProposalReviews( + proposalPk, + fapId + ); + const allReviewsSubmitted = + reviews.length > 0 && + reviews.every((review) => review.status === ReviewStatus.SUBMITTED); + + if (!allReviewsSubmitted && agent.id) { + reviewerId = agent.id; + } + } + + return this.dataSource.getFapProposalAssignments( + fapId, + proposalPk, + reviewerId ); - // TODO implement new logic here and get all fap reviews are submitted instead or relying on events system - - // const proposalEvents = - // await this.proposalDataSource.getProposalEvents(proposalPk); - - // // NOTE: If not officer, Fap Chair or Fap Secretary should return all proposal assignments only if everything is submitted. Otherwise for Fap Reviewer return only it's own proposal reviews. - // if ( - // agent && - // !this.userAuth.isUserOfficer(agent) && - // !(await this.userAuth.isChairOrSecretaryOfFap(agent, fapId)) && - // !proposalEvents?.proposal_all_fap_reviews_submitted - // ) { - // reviewerId = agent.id; - // } - - // return this.dataSource.getFapProposalAssignments( - // fapId, - // proposalPk, - // reviewerId - // ); } @Authorized([Roles.USER_OFFICER, Roles.FAP_CHAIR, Roles.FAP_SECRETARY]) From dfb28114fcc3570b881df14ee85578fe595a9236 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 6 Jan 2026 10:50:25 +0100 Subject: [PATCH 028/147] refactor: remove unused ProposalEventsRecord interface and clean up workflow migration scripts --- .../0203_Add_new_workflow_data_structures.sql | 26 ------------- .../db_patches/0204_Migrate_workflow.sql | 1 + .../src/datasources/postgres/records.ts | 38 ------------------- 3 files changed, 1 insertion(+), 64 deletions(-) diff --git a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql index 0b6afbdf41..4191216713 100644 --- a/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql +++ b/apps/backend/db_patches/0203_Add_new_workflow_data_structures.sql @@ -125,32 +125,6 @@ BEGIN REFERENCES workflow_status_actions (workflow_status_action_id) ); - - - -- ================================================================== - -- 6) proposal_has_workflow_status_changing_events (instance-scoped events) - -- ================================================================== - CREATE TABLE proposal_has_workflow_status_changing_events ( - proposal_has_workflow_status_changing_events_id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - status_changing_event TEXT NOT NULL, - proposal_pk INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT fk_phsce_proposal - FOREIGN KEY (proposal_pk) - REFERENCES proposals (proposal_pk) - ); - - -- Ensure at most one row per (proposal, event) in the current accumulation window. - -- (If you retain rows across state changes, you can reset the window in code - -- by comparing against proposals.state_entered_at.) - CREATE UNIQUE INDEX uq_phsce_proposal_event - ON proposal_has_workflow_status_changing_events (proposal_pk, status_changing_event); - - -- Optional helper index for proposal lookups: - CREATE INDEX ix_phsce_proposal - ON proposal_has_workflow_status_changing_events (proposal_pk); - -- ================================================================== -- 7) Link proposals to the new workflow graph -- ================================================================== diff --git a/apps/backend/db_patches/0204_Migrate_workflow.sql b/apps/backend/db_patches/0204_Migrate_workflow.sql index 25be494015..ebd399ae7e 100644 --- a/apps/backend/db_patches/0204_Migrate_workflow.sql +++ b/apps/backend/db_patches/0204_Migrate_workflow.sql @@ -15,6 +15,7 @@ BEGIN DROP TABLE IF EXISTS status_changing_events; + DROP TABLE IF EXISTS proposal_events; END; END IF; END; diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 0293ddd47d..4596fe6e2d 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -639,44 +639,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 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; From ba5da92725482cb4297a40470c2fc4e870df45f6 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 6 Jan 2026 13:52:36 +0100 Subject: [PATCH 029/147] fix: update join table in CallDataSource to use workflow_has_statuses for proposal status filtering --- .../db_patches/0204_Migrate_workflow.sql | 194 +++++++++++++++++- .../datasources/postgres/CallDataSource.ts | 2 +- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/apps/backend/db_patches/0204_Migrate_workflow.sql b/apps/backend/db_patches/0204_Migrate_workflow.sql index ebd399ae7e..f2b847cdbe 100644 --- a/apps/backend/db_patches/0204_Migrate_workflow.sql +++ b/apps/backend/db_patches/0204_Migrate_workflow.sql @@ -1,5 +1,18 @@ 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; + node_rec RECORD; + edge_rec RECORD; BEGIN IF register_patch( 'Migrate_workflow', @@ -8,14 +21,189 @@ BEGIN '2026-01-05' ) THEN BEGIN + SELECT COUNT(*) INTO v_node_count FROM workflow_connections; + IF v_node_count = 0 THEN + RAISE NOTICE 'No workflow connections to migrate. Continuing with cleanup.'; + END IF; - + PERFORM 1 FROM workflow_has_statuses LIMIT 1; + IF FOUND THEN + RAISE EXCEPTION 'workflow_has_statuses already contains rows. Migration must run on an empty target table.'; + END IF; + PERFORM 1 FROM workflow_status_connections LIMIT 1; + IF FOUND THEN + RAISE EXCEPTION 'workflow_status_connections already contains rows. Migration must run on an empty target table.'; + END IF; + PERFORM 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'status_actions_logs'; - DROP TABLE IF EXISTS status_changing_events; - DROP TABLE IF EXISTS proposal_events; + IF FOUND THEN + has_status_actions_logs := TRUE; + + 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; + END IF; + + 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_workflow_status_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 '% 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_workflow_status_changing_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 '% 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 '% 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; + + 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; END; END IF; END; diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index 00520c5e18..9d3ae560f0 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -167,7 +167,7 @@ export default class PostgresCallDataSource implements CallDataSource { if (filter?.proposalStatusShortCode) { query .join( - 'workflow_connections as w', + 'workflow_has_statuses as w', 'call.proposal_workflow_id', 'w.workflow_id' ) From 9e359b59d33c398ff8fbeb4098549e0c248d069a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 6 Jan 2026 13:56:58 +0100 Subject: [PATCH 030/147] fix: update edge style logic in WorkflowEdge component for better visual representation --- .../components/settings/workflow/WorkflowEdge.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx index 705982488d..9f3803c754 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx @@ -77,10 +77,23 @@ const WorkflowEdge: React.FC> = ({ const events = data?.events || []; const statusActions = data?.statusActions || []; + const edgeStyle = + events.length > 0 + ? style + : { + ...style, + strokeDasharray: '4 4', + stroke: style.stroke ?? 'rgba(168, 168, 168, 0.5)', + }; return ( <> - +
Date: Tue, 6 Jan 2026 13:58:05 +0100 Subject: [PATCH 031/147] fix: adjust edge style in WorkflowEdge component for improved visibility --- .../frontend/src/components/settings/workflow/WorkflowEdge.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx index 9f3803c754..becbe8f485 100644 --- a/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx +++ b/apps/frontend/src/components/settings/workflow/WorkflowEdge.tsx @@ -82,8 +82,7 @@ const WorkflowEdge: React.FC> = ({ ? style : { ...style, - strokeDasharray: '4 4', - stroke: style.stroke ?? 'rgba(168, 168, 168, 0.5)', + strokeDasharray: '2 4', }; return ( From d5695b767c41516881aa01d52979838cf6e84431 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 7 Jan 2026 15:17:35 +0100 Subject: [PATCH 032/147] feat: implement ProposalWorkflowEngine and integrate into dependency configurations --- apps/backend/src/config/Tokens.ts | 1 + .../src/config/dependencyConfigDefault.ts | 3 + .../backend/src/config/dependencyConfigE2E.ts | 3 + .../backend/src/config/dependencyConfigELI.ts | 2 + .../backend/src/config/dependencyConfigESS.ts | 2 + .../src/config/dependencyConfigSTFC.ts | 3 + .../src/config/dependencyConfigTest.ts | 3 + .../src/eventHandlers/messageBroker.ts | 9 +- .../src/eventHandlers/proposalWorkflow.ts | 22 +- apps/backend/src/workflowEngine/proposal.ts | 220 ++++++++++-------- 10 files changed, 155 insertions(+), 113 deletions(-) diff --git a/apps/backend/src/config/Tokens.ts b/apps/backend/src/config/Tokens.ts index 44cbf95bc4..36af21baf3 100644 --- a/apps/backend/src/config/Tokens.ts +++ b/apps/backend/src/config/Tokens.ts @@ -62,4 +62,5 @@ export const Tokens = { ExperimentDataSource: Symbol('ExperimentDataSource'), BasicUserDetailsLoader: Symbol('BasicUserDetailsLoader'), TagDataSource: Symbol('TagDataSource'), + ProposalWorkflowEngine: Symbol('ProposalWorkflowEngine'), }; diff --git a/apps/backend/src/config/dependencyConfigDefault.ts b/apps/backend/src/config/dependencyConfigDefault.ts index f271d12ed8..deaf788d7d 100644 --- a/apps/backend/src/config/dependencyConfigDefault.ts +++ b/apps/backend/src/config/dependencyConfigDefault.ts @@ -62,6 +62,7 @@ import { } from '../factory/xlsx/FapDataRow'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { configureBaseEnvironment } from './base/configureBaseEnvironment'; import { Tokens } from './Tokens'; import { mapClass, mapValue } from './utils'; @@ -148,4 +149,6 @@ mapValue(Tokens.ListenToMessageQueue, createSkipListeningHandler()); mapValue(Tokens.ConfigureEnvironment, configureBaseEnvironment); mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); + mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); diff --git a/apps/backend/src/config/dependencyConfigE2E.ts b/apps/backend/src/config/dependencyConfigE2E.ts index f99f9948cd..43dc74712f 100644 --- a/apps/backend/src/config/dependencyConfigE2E.ts +++ b/apps/backend/src/config/dependencyConfigE2E.ts @@ -58,6 +58,7 @@ import { } from '../factory/xlsx/FapDataRow'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { configureESSDevelopmentEnvironment } from './ess/configureESSEnvironment'; import { Tokens } from './Tokens'; import { mapClass, mapValue } from './utils'; @@ -139,4 +140,6 @@ mapValue(Tokens.ListenToMessageQueue, createSkipListeningHandler()); mapValue(Tokens.ConfigureEnvironment, configureESSDevelopmentEnvironment); mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger())); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); + mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index 6a158f0b8c..c233c49946 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -57,6 +57,7 @@ import { } from '../factory/xlsx/FapDataRow'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { EAMAssetRegistrar } from '../services/assetRegistrar/eam/EAMAssetRegistrar'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { configureELIDevelopmentEnvironment } from './eli/configureELIEnvironment'; import { configureGraylogLogger } from './ess/configureGrayLogLogger'; import { Tokens } from './Tokens'; @@ -147,3 +148,4 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); diff --git a/apps/backend/src/config/dependencyConfigESS.ts b/apps/backend/src/config/dependencyConfigESS.ts index ddf4cdaa05..03fd5a1944 100644 --- a/apps/backend/src/config/dependencyConfigESS.ts +++ b/apps/backend/src/config/dependencyConfigESS.ts @@ -59,6 +59,7 @@ import { import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { EAMAssetRegistrar } from '../services/assetRegistrar/eam/EAMAssetRegistrar'; import { isDevelopment } from '../utils/helperFunctions'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { configureESSDevelopmentEnvironment } from './ess/configureESSEnvironment'; import { configureGraylogLogger } from './ess/configureGrayLogLogger'; import { Tokens } from './Tokens'; @@ -147,3 +148,4 @@ mapValue(Tokens.ConfigureLogger, configureGraylogLogger); mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); diff --git a/apps/backend/src/config/dependencyConfigSTFC.ts b/apps/backend/src/config/dependencyConfigSTFC.ts index bcc7995ff9..79bb7c3728 100644 --- a/apps/backend/src/config/dependencyConfigSTFC.ts +++ b/apps/backend/src/config/dependencyConfigSTFC.ts @@ -57,6 +57,7 @@ import { } from '../factory/xlsx/stfc/StfcFapDataRow'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { configureSTFCEnvironment } from './stfc/configureSTFCEnvironment'; import { configureSTFCWinstonLogger } from './stfc/configureSTFCWinstonLogger'; import { Tokens } from './Tokens'; @@ -139,4 +140,6 @@ mapValue(Tokens.ListenToMessageQueue, createSkipListeningHandler()); mapValue(Tokens.ConfigureEnvironment, configureSTFCEnvironment); mapValue(Tokens.ConfigureLogger, configureSTFCWinstonLogger); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); + mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); diff --git a/apps/backend/src/config/dependencyConfigTest.ts b/apps/backend/src/config/dependencyConfigTest.ts index b3f0c39c0a..eaadb31031 100644 --- a/apps/backend/src/config/dependencyConfigTest.ts +++ b/apps/backend/src/config/dependencyConfigTest.ts @@ -52,6 +52,7 @@ import { import { createApplicationEventBus } from '../events'; import BasicUserDetailsLoader from '../loaders/BasicUserDetailsLoader'; import { SkipAssetRegistrar } from '../services/assetRegistrar/skip/SkipAssetRegistrar'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; import { VisitDataSourceMock } from './../datasources/mockups/VisitDataSource'; import { Tokens } from './Tokens'; import { mapClass, mapValue } from './utils'; @@ -126,5 +127,7 @@ mapValue(Tokens.ConfigureLogger, () => setLogger(new ConsoleLogger({ colorize: true })) ); +mapClass(Tokens.ProposalWorkflowEngine, ProposalWorkflowEngine); + mapClass(Tokens.BasicUserDetailsLoader, BasicUserDetailsLoader); mapValue(Tokens.EventBus, createApplicationEventBus()); diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index 7a2fe62bc5..b24584cd4c 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -30,7 +30,7 @@ import { Institution } from '../models/Institution'; import { Proposal } from '../models/Proposal'; import { Visit } from '../models/Visit'; import { VisitRegistrationStatus } from '../models/VisitRegistration'; -import { callWorkflowEngine } from '../workflowEngine/proposal'; +import { ProposalWorkflowEngine } from '../workflowEngine/proposal'; export const QUEUE_NAME = (process.env.RABBITMQ_CORE_QUEUE_NAME as Queue) || @@ -428,6 +428,8 @@ export async function createListenToRabbitMQHandler() { Tokens.VisitDataSource ); + const workflowEngine = container.resolve(ProposalWorkflowEngine); + const handleWorkflowEngineChange = async ( eventType: Event, proposalPk: number | null @@ -436,7 +438,10 @@ export async function createListenToRabbitMQHandler() { throw new Error('Proposal id not found in the message'); } - await callWorkflowEngine(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 75c44186e9..b44f17a639 100644 --- a/apps/backend/src/eventHandlers/proposalWorkflow.ts +++ b/apps/backend/src/eventHandlers/proposalWorkflow.ts @@ -9,8 +9,8 @@ import { Event } from '../events/event.enum'; import { Proposal } from '../models/Proposal'; import { searchObjectByKey } from '../utils/helperFunctions'; import { + ProposalWorkflowEngine, WorkflowEngineProposalType, - callWorkflowEngine, } from '../workflowEngine/proposal'; enum ProposalInformationKeys { @@ -60,6 +60,9 @@ const handleSubmittedProposalsAfterCallEnded = async ( const callDataSource = container.resolve( Tokens.CallDataSource ); + + const workflowEngine = container.resolve(ProposalWorkflowEngine); + const notEndedInternalCalls = await callDataSource .getCalls({ isEnded: true, @@ -77,10 +80,10 @@ const handleSubmittedProposalsAfterCallEnded = async ( return; } - const updatedSubmittedProposals = await callWorkflowEngine( - Event.CALL_ENDED, - proposalPks - ); + const updatedSubmittedProposals = await workflowEngine.run({ + event: Event.CALL_ENDED, + proposalPks, + }); if (updatedSubmittedProposals) { await publishProposalStatusChange(updatedSubmittedProposals); } @@ -92,10 +95,11 @@ export const handleWorkflowEngineChange = async ( ) => { const isArray = Array.isArray(proposalPks); - const updatedProposals = await callWorkflowEngine( - 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 && diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 2b469cfb79..e4b0cb0088 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -1,5 +1,5 @@ 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'; @@ -17,118 +17,134 @@ export type WorkflowEngineProposalType = Proposal & { workflowStatusConnectionId: number; }; -export const workflowEngine = async ( - args: { - proposalPk: number; - currentEvent: Event; - }[] -): Promise | void> => { - const proposalDataSource = container.resolve( - Tokens.ProposalDataSource - ); - const callDataSource = container.resolve( - Tokens.CallDataSource - ); - - const proposalsWithChangedStatuses = await Promise.all( - args.map(async (arg) => { - const proposal = await proposalDataSource.get(arg.proposalPk); - - if (!proposal) { - logger.logError('Proposal not found', { proposalPk: arg.proposalPk }); - - return; - } +type WorkflowRunSingleInput = { + proposalPk: number; + currentEvent: Event; +}; - const proposalWorkflowId = ( - await callDataSource.getProposalWorkflowByCall(proposal.callId) - )?.id; +type WorkflowRunBatchInput = { + proposalPks: number[]; + event: Event; +}; - if (!proposalWorkflowId) { - logger.logError('Proposal workflow not found for the proposal', { - proposalPk: arg.proposalPk, - callId: proposal.callId, - }); +export type WorkflowRunInput = + | WorkflowRunSingleInput + | WorkflowRunSingleInput[] + | WorkflowRunBatchInput; - return; - } +const isBatchWorkflowInput = ( + input: WorkflowRunInput +): input is WorkflowRunBatchInput => { + return Array.isArray((input as WorkflowRunBatchInput).proposalPks); +}; - const machine = await createWorkflowMachine(proposalWorkflowId); +@injectable() +export class ProposalWorkflowEngine { + constructor( + @inject(Tokens.ProposalDataSource) + private readonly proposalDataSource: ProposalDataSource, + @inject(Tokens.CallDataSource) + private readonly callDataSource: CallDataSource + ) {} + + async run( + input: WorkflowRunInput + ): Promise | void> { + let normalizedInput: WorkflowRunSingleInput[]; + + if (Array.isArray(input)) { + normalizedInput = input; + } else if (isBatchWorkflowInput(input)) { + normalizedInput = input.proposalPks.map((proposalPk) => ({ + proposalPk, + currentEvent: input.event, + })); + } else { + normalizedInput = [input]; + } + + const proposalsWithChangedStatuses = await Promise.all( + normalizedInput.map(({ proposalPk, currentEvent }) => + this.runInternal(proposalPk, currentEvent) + ) + ); - const proposalWfStatus = Object.entries(machine.schema.states).find( - ([, state]) => { - return ( - (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === - proposal.workflowStatusId - ); - } - )?.[0]; - - const actor = createActor( - machine, - { id: proposal.primaryKey }, - proposalWfStatus - ); - const currentWfStatus = actor.getState(); - - const { nextStateValue, connectionId } = await actor.event( - arg.currentEvent.toUpperCase() - ); - - if (nextStateValue !== currentWfStatus) { - const meta = machine.schema.states[nextStateValue]?.meta as - | WorkflowStateMeta - | undefined; - const nextWfStatusId = meta?.workflowStatusId; - - if (nextWfStatusId) { - const updatedProposal = - await proposalDataSource.updateProposalWfStatus( - arg.proposalPk, - nextWfStatusId - ); - - return { - ...updatedProposal, - workflowStatusConnectionId: connectionId, - }; - } - } - }) - ); + const validProposals = proposalsWithChangedStatuses.filter( + (p): p is WorkflowEngineProposalType => !!p + ); - const validProposals = proposalsWithChangedStatuses.filter( - (p): p is WorkflowEngineProposalType => !!p - ); + if (validProposals.length > 0) { + await proposalStatusActionEngine(validProposals); + } - if (validProposals.length > 0) { - await proposalStatusActionEngine(validProposals); + return validProposals; } - return validProposals; -}; + private async runInternal( + proposalPk: number, + event: Event + ): Promise { + if (event === Event.PROPOSAL_DELETED) { + return; + } -export const callWorkflowEngine = 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 } - ); + const proposal = await this.proposalDataSource.get(proposalPk); - return; - } + if (!proposal) { + logger.logError('Proposal not found', { proposalPk }); - const proposalPksWithEvents = proposalPks.map((proposalPk) => { - return { - proposalPk, - currentEvent: eventType, - }; - }); + return; + } - const updatedProposals = await workflowEngine(proposalPksWithEvents); + const proposalWorkflowId = ( + await this.callDataSource.getProposalWorkflowByCall(proposal.callId) + )?.id; - return updatedProposals; -}; + if (!proposalWorkflowId) { + return; + } + + const machine = await createWorkflowMachine(proposalWorkflowId); + + const proposalWfStatus = Object.entries(machine.schema.states).find( + ([, state]) => { + return ( + (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === + proposal.workflowStatusId + ); + } + )?.[0]; + + const actor = createActor( + machine, + { id: proposal.primaryKey }, + proposalWfStatus + ); + const currentWfStatus = actor.getState(); + + const { nextStateValue, connectionId } = await actor.event( + event.toUpperCase() + ); + + if (nextStateValue !== currentWfStatus) { + const meta = machine.schema.states[nextStateValue]?.meta as + | WorkflowStateMeta + | undefined; + const nextWfStatusId = meta?.workflowStatusId; + + if (nextWfStatusId) { + const updatedProposal = + await this.proposalDataSource.updateProposalWfStatus( + proposalPk, + nextWfStatusId + ); + + return { + ...updatedProposal, + prevStatusId: proposal.statusId, + workflowStatusConnectionId: connectionId, + }; + } + } + } +} From 69322c9c929d9e8933d693fe24e8710b10501e03 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Fri, 9 Jan 2026 11:10:00 +0100 Subject: [PATCH 033/147] Add guards for proposal workflow validation --- apps/backend/src/resolvers/types/Proposal.ts | 3 + .../guards/isCallEndedGuard.spec.ts | 54 +++++++++++ .../workflowEngine/guards/isCallEndedGuard.ts | 28 ++++++ .../guards/isCallEndedInternalGuard.spec.ts | 54 +++++++++++ .../guards/isCallEndedInternalGuard.ts | 28 ++++++ .../guards/isCallFapReviewEndedGuard.spec.ts | 54 +++++++++++ .../guards/isCallFapReviewEndedGuard.ts | 28 ++++++ .../guards/isCallReviewEndedGuard.spec.ts | 54 +++++++++++ .../guards/isCallReviewEndedGuard.ts | 28 ++++++ .../guards/isProposalAcceptedGuard.spec.ts | 57 ++++++++++++ .../guards/isProposalAcceptedGuard.ts | 22 +++++ ...FapMeetingInstrumentSubmittedGuard.spec.ts | 52 +++++++++++ ...alAllFapMeetingInstrumentSubmittedGuard.ts | 19 ++++ ...oposalAllFapMeetingsSubmittedGuard.spec.ts | 46 ++++++++++ .../isProposalAllFapMeetingsSubmittedGuard.ts | 21 +++++ ...oposalAllFapReviewersSelectedGuard.spec.ts | 76 ++++++++++++++++ .../isProposalAllFapReviewersSelectedGuard.ts | 28 ++++++ ...roposalAllFapReviewsSubmittedGuard.spec.ts | 47 ++++++++++ .../isProposalAllFapReviewsSubmittedGuard.ts | 22 +++++ ...AllFeasibilityReviewsFeasibleGuard.spec.ts | 59 ++++++++++++ ...posalAllFeasibilityReviewsFeasibleGuard.ts | 27 ++++++ ...llFeasibilityReviewsSubmittedGuard.spec.ts | 52 +++++++++++ ...osalAllFeasibilityReviewsSubmittedGuard.ts | 23 +++++ ...AllReviewsSubmittedForAllFapsGuard.spec.ts | 91 +++++++++++++++++++ ...posalAllReviewsSubmittedForAllFapsGuard.ts | 33 +++++++ ...isProposalAllReviewsSubmittedGuard.spec.ts | 47 ++++++++++ .../isProposalAllReviewsSubmittedGuard.ts | 22 +++++ ...sProposalAssignedToTechniquesGuard.spec.ts | 34 +++++++ .../isProposalAssignedToTechniquesGuard.ts | 19 ++++ ...sProposalBookingTimeActivatedGuard.spec.ts | 46 ++++++++++ .../isProposalBookingTimeActivatedGuard.ts | 26 ++++++ ...sProposalBookingTimeCompletedGuard.spec.ts | 46 ++++++++++ .../isProposalBookingTimeCompletedGuard.ts | 26 ++++++ ...FapMeetingInstrumentSubmittedGuard.spec.ts | 50 ++++++++++ ...posalFapMeetingInstrumentSubmittedGuard.ts | 19 ++++ ...pMeetingInstrumentUnsubmittedGuard.spec.ts | 50 ++++++++++ ...salFapMeetingInstrumentUnsubmittedGuard.ts | 19 ++++ .../isProposalFapReviewSubmittedGuard.spec.ts | 45 +++++++++ .../isProposalFapReviewSubmittedGuard.ts | 22 +++++ .../isProposalFapsSelectedGuard.spec.ts | 32 +++++++ .../guards/isProposalFapsSelectedGuard.ts | 13 +++ ...osalFeasibilityReviewFeasibleGuard.spec.ts | 44 +++++++++ ...sProposalFeasibilityReviewFeasibleGuard.ts | 27 ++++++ ...salFeasibilityReviewSubmittedGuard.spec.ts | 43 +++++++++ ...ProposalFeasibilityReviewSubmittedGuard.ts | 23 +++++ ...alFeasibilityReviewUnfeasibleGuard.spec.ts | 44 +++++++++ ...roposalFeasibilityReviewUnfeasibleGuard.ts | 27 ++++++ ...isProposalInstrumentsSelectedGuard.spec.ts | 35 +++++++ ...alManagementDecisionSubmittedGuard.spec.ts | 43 +++++++++ ...roposalManagementDecisionSubmittedGuard.ts | 21 +++++ .../guards/isProposalNotifiedGuard.spec.ts | 39 ++++++++ .../guards/isProposalNotifiedGuard.ts | 19 ++++ .../guards/isProposalRejectedGuard.spec.ts | 44 +++++++++ .../guards/isProposalRejectedGuard.ts | 20 ++++ .../guards/isProposalReservedGuard.spec.ts | 44 +++++++++ .../guards/isProposalReservedGuard.ts | 20 ++++ ...ProposalSampleReviewSubmittedGuard.spec.ts | 44 +++++++++ .../isProposalSampleReviewSubmittedGuard.ts | 26 ++++++ .../guards/isProposalSampleSafeGuard.spec.ts | 44 +++++++++ .../guards/isProposalSampleSafeGuard.ts | 24 +++++ .../guards/isProposalSubmittedGuard.spec.ts | 39 ++++++++ ...osalTechnicalReviewsSubmittedGuard.spec.ts | 44 +++++++++ ...sProposalTechnicalReviewsSubmittedGuard.ts | 23 +++++ .../components/review/ReviewQuestionary.tsx | 1 + .../review/TechnicalReviewQuestionary.tsx | 1 + 65 files changed, 2261 insertions(+) create mode 100644 apps/backend/src/workflowEngine/guards/isCallEndedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallEndedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalReservedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalReservedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts create mode 100644 apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.ts diff --git a/apps/backend/src/resolvers/types/Proposal.ts b/apps/backend/src/resolvers/types/Proposal.ts index f14402443e..8560c86dcc 100644 --- a/apps/backend/src/resolvers/types/Proposal.ts +++ b/apps/backend/src/resolvers/types/Proposal.ts @@ -55,6 +55,9 @@ export class Proposal implements Partial { @Field(() => Int) public statusId: number; + @Field(() => Int) + public workflowStatusId: number; + @Field(() => Date) public created: Date; 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..b69db6405d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isCallEndedGuard } from './isCallEndedGuard'; + +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..65e86c75b6 --- /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 '../simpleStateMachine/stateMachnine'; + +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..694f782057 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallEndedInternalGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isCallEndedInternalGuard } from './isCallEndedInternalGuard'; + +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..59328af136 --- /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 '../simpleStateMachine/stateMachnine'; + +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..550ac7c92d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallFapReviewEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isCallFapReviewEndedGuard } from './isCallFapReviewEndedGuard'; + +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..142330b8a5 --- /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 '../simpleStateMachine/stateMachnine'; + +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..938598d57d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isCallReviewEndedGuard.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isCallReviewEndedGuard } from './isCallReviewEndedGuard'; + +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..c21f2748ed --- /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 '../simpleStateMachine/stateMachnine'; + +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/isProposalAcceptedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts new file mode 100644 index 0000000000..90d796ada7 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAcceptedGuard.spec.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { isProposalAcceptedGuard } from './isProposalAcceptedGuard'; + +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..c4a113cde6 --- /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 '../simpleStateMachine/stateMachnine'; + +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/isProposalAllFapMeetingInstrumentSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.spec.ts new file mode 100644 index 0000000000..6fa3f6d386 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalAllFapMeetingInstrumentSubmittedGuard } from './isProposalAllFapMeetingInstrumentSubmittedGuard'; + +describe('isProposalAllFapMeetingInstrumentSubmittedGuard', () => { + 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 fap proposals found', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isProposalAllFapMeetingInstrumentSubmittedGuard({ + 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 isProposalAllFapMeetingInstrumentSubmittedGuard({ + 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 isProposalAllFapMeetingInstrumentSubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.ts new file mode 100644 index 0000000000..d678ff05c8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingInstrumentSubmittedGuard.ts @@ -0,0 +1,19 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFapMeetingInstrumentSubmittedGuard: 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/isProposalAllFapMeetingsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..42bccf1e4d --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalAllFapMeetingsSubmittedGuard } from './isProposalAllFapMeetingsSubmittedGuard'; + +describe('isProposalAllFapMeetingsSubmittedGuard', () => { + 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 fap proposals found', async () => { + mockFapDataSource.getFapsByProposalPks.mockResolvedValue([]); + const result = await isProposalAllFapMeetingsSubmittedGuard({ 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 isProposalAllFapMeetingsSubmittedGuard({ 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 isProposalAllFapMeetingsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.ts new file mode 100644 index 0000000000..4042c462e6 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapMeetingsSubmittedGuard.ts @@ -0,0 +1,21 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFapMeetingsSubmittedGuard: 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/isProposalAllFapReviewersSelectedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.spec.ts new file mode 100644 index 0000000000..2d8f4a008f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.spec.ts @@ -0,0 +1,76 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalAllFapReviewersSelectedGuard } from './isProposalAllFapReviewersSelectedGuard'; + +describe('isProposalAllFapReviewersSelectedGuard', () => { + 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 any; + }); + + it('returns false if no faps found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([]); + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([]); + + const result = await isProposalAllFapReviewersSelectedGuard({ 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 isProposalAllFapReviewersSelectedGuard({ 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 isProposalAllFapReviewersSelectedGuard({ 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 isProposalAllFapReviewersSelectedGuard({ id: 1 }); + expect(result).toBe(false); + + // Both have assignments + mockFapDataSource.getAllFapProposalAssignments.mockResolvedValue([ + { fapId: 1 }, + { fapId: 2 }, + ]); + const result2 = await isProposalAllFapReviewersSelectedGuard({ id: 1 }); + expect(result2).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.ts new file mode 100644 index 0000000000..546d9ac4c2 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewersSelectedGuard.ts @@ -0,0 +1,28 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { FapDataSource } from '../../datasources/FapDataSource'; +import { Entity, GuardFn } from '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFapReviewersSelectedGuard: 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/isProposalAllFapReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..c054ca5a6c --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; +import { isProposalAllFapReviewsSubmittedGuard } from './isProposalAllFapReviewsSubmittedGuard'; + +describe('isProposalAllFapReviewsSubmittedGuard', () => { + 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 isProposalAllFapReviewsSubmittedGuard({ 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 isProposalAllFapReviewsSubmittedGuard({ 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 isProposalAllFapReviewsSubmittedGuard({ id: 1 }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.ts new file mode 100644 index 0000000000..b41a4e00db --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFapReviewsSubmittedGuard.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 '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFapReviewsSubmittedGuard: 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/isProposalAllFeasibilityReviewsFeasibleGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.spec.ts new file mode 100644 index 0000000000..e2b458bd84 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.spec.ts @@ -0,0 +1,59 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { isProposalAllFeasibilityReviewsFeasibleGuard } from './isProposalAllFeasibilityReviewsFeasibleGuard'; + +describe('isProposalAllFeasibilityReviewsFeasibleGuard', () => { + 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 isProposalAllFeasibilityReviewsFeasibleGuard({ + id: 1, + }); + expect(result).toBe(false); + + mockReviewDataSource.getTechnicalReviews.mockResolvedValue(null); + const resultNull = await isProposalAllFeasibilityReviewsFeasibleGuard({ + 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 isProposalAllFeasibilityReviewsFeasibleGuard({ + 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 isProposalAllFeasibilityReviewsFeasibleGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.ts new file mode 100644 index 0000000000..114274efc7 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsFeasibleGuard.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 '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFeasibilityReviewsFeasibleGuard: 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/isProposalAllFeasibilityReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..d74c0593eb --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalAllFeasibilityReviewsSubmittedGuard } from './isProposalAllFeasibilityReviewsSubmittedGuard'; + +describe('isProposalAllFeasibilityReviewsSubmittedGuard', () => { + 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 isProposalAllFeasibilityReviewsSubmittedGuard({ + 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 isProposalAllFeasibilityReviewsSubmittedGuard({ + 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 isProposalAllFeasibilityReviewsSubmittedGuard({ + id: 1, + }); + expect(result).toBe(false); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.ts new file mode 100644 index 0000000000..fe5c8fb149 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllFeasibilityReviewsSubmittedGuard.ts @@ -0,0 +1,23 @@ +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewDataSource } from '../../datasources/ReviewDataSource'; +import { Entity, GuardFn } from '../simpleStateMachine/stateMachnine'; + +export const isProposalAllFeasibilityReviewsSubmittedGuard: 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/isProposalAllReviewsSubmittedForAllFapsGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.spec.ts new file mode 100644 index 0000000000..e32d631e48 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.spec.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; +import { isProposalAllReviewsSubmittedForAllFapsGuard } from './isProposalAllReviewsSubmittedForAllFapsGuard'; + +describe('isProposalAllReviewsSubmittedForAllFapsGuard', () => { + 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 any; + }); + + it('returns false if no faps found for proposal', async () => { + mockFapDataSource.getFapsByProposalPk.mockResolvedValue([]); + mockReviewDataSource.getProposalReviews.mockResolvedValue([]); + + const result = await isProposalAllReviewsSubmittedForAllFapsGuard({ + 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 isProposalAllReviewsSubmittedForAllFapsGuard({ + 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 isProposalAllReviewsSubmittedForAllFapsGuard({ + 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 isProposalAllReviewsSubmittedForAllFapsGuard({ + 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 isProposalAllReviewsSubmittedForAllFapsGuard({ + id: 1, + }); + expect(result2).toBe(true); + }); +}); diff --git a/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.ts b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.ts new file mode 100644 index 0000000000..ccc8db4378 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedForAllFapsGuard.ts @@ -0,0 +1,33 @@ +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 '../simpleStateMachine/stateMachnine'; + +export const isProposalAllReviewsSubmittedForAllFapsGuard: 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/isProposalAllReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..5a75c2c6d2 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAllReviewsSubmittedGuard.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; +import { isProposalAllReviewsSubmittedGuard } from './isProposalAllReviewsSubmittedGuard'; + +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..8a57f191d8 --- /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 '../simpleStateMachine/stateMachnine'; + +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..751660fd23 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalAssignedToTechniquesGuard.spec.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalAssignedToTechniquesGuard } from './isProposalAssignedToTechniquesGuard'; + +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..aed2b05c5f --- /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 '../simpleStateMachine/stateMachnine'; + +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..2b55b6cc2f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeActivatedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentStatus } from '../../models/Experiment'; +import { isProposalBookingTimeActivatedGuard } from './isProposalBookingTimeActivatedGuard'; + +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..4b74c29c47 --- /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 '../simpleStateMachine/stateMachnine'; + +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..be7499d845 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalBookingTimeCompletedGuard.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ExperimentStatus } from '../../models/Experiment'; +import { isProposalBookingTimeCompletedGuard } from './isProposalBookingTimeCompletedGuard'; + +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..7fc30b297c --- /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 '../simpleStateMachine/stateMachnine'; + +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..d8ce3087e5 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentSubmittedGuard.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalFapMeetingInstrumentSubmittedGuard } from './isProposalFapMeetingInstrumentSubmittedGuard'; + +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..ca96201063 --- /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 '../simpleStateMachine/stateMachnine'; + +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..096485b7ae --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapMeetingInstrumentUnsubmittedGuard.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalFapMeetingInstrumentUnsubmittedGuard } from './isProposalFapMeetingInstrumentUnsubmittedGuard'; + +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..595fed8dba --- /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 '../simpleStateMachine/stateMachnine'; + +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..9e0861880e --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapReviewSubmittedGuard.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ReviewStatus } from '../../models/Review'; +import { isProposalFapReviewSubmittedGuard } from './isProposalFapReviewSubmittedGuard'; + +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..059010c24f --- /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 '../simpleStateMachine/stateMachnine'; + +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..6471dd6f04 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFapsSelectedGuard.spec.ts @@ -0,0 +1,32 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalFapsSelectedGuard } from './isProposalFapsSelectedGuard'; + +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..5cba6de45d --- /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 '../simpleStateMachine/stateMachnine'; + +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..f6af09e5a5 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewFeasibleGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { isProposalFeasibilityReviewFeasibleGuard } from './isProposalFeasibilityReviewFeasibleGuard'; + +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..7e147c3c34 --- /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 '../simpleStateMachine/stateMachnine'; + +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..762ed4b8d1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewSubmittedGuard.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalFeasibilityReviewSubmittedGuard } from './isProposalFeasibilityReviewSubmittedGuard'; + +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..94ddeae500 --- /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 '../simpleStateMachine/stateMachnine'; + +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..9df9d2e2a1 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalFeasibilityReviewUnfeasibleGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { TechnicalReviewStatus } from '../../models/TechnicalReview'; +import { isProposalFeasibilityReviewUnfeasibleGuard } from './isProposalFeasibilityReviewUnfeasibleGuard'; + +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..b20613637c --- /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 '../simpleStateMachine/stateMachnine'; + +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..2e6781da40 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalInstrumentsSelectedGuard.spec.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalInstrumentsSelectedGuard } from './isProposalInstrumentsSelectedGuard'; + +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/isProposalManagementDecisionSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts new file mode 100644 index 0000000000..913625f720 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalManagementDecisionSubmittedGuard.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalManagementDecisionSubmittedGuard } from './isProposalManagementDecisionSubmittedGuard'; + +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..c5968124a2 --- /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 '../simpleStateMachine/stateMachnine'; + +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..40ffed08b8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalNotifiedGuard.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalNotifiedGuard } from './isProposalNotifiedGuard'; + +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..013cc31d2e --- /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 '../simpleStateMachine/stateMachnine'; + +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..b428d5ab0b --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalRejectedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { isProposalRejectedGuard } from './isProposalRejectedGuard'; + +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..8d66604401 --- /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 '../simpleStateMachine/stateMachnine'; + +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..92672ab86f --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalReservedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { ProposalEndStatus } from '../../models/Proposal'; +import { isProposalReservedGuard } from './isProposalReservedGuard'; + +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..c6b9212cae --- /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 '../simpleStateMachine/stateMachnine'; + +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..56f9f3b7fb --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleReviewSubmittedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { SampleStatus } from '../../models/Sample'; +import { isProposalSampleReviewSubmittedGuard } from './isProposalSampleReviewSubmittedGuard'; + +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..cb368b6524 --- /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 '../simpleStateMachine/stateMachnine'; + +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..315791adf4 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSampleSafeGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { SampleStatus } from '../../models/Sample'; +import { isProposalSampleSafeGuard } from './isProposalSampleSafeGuard'; + +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..02681c6603 --- /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 '../simpleStateMachine/stateMachnine'; + +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..92db7311e8 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalSubmittedGuard.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalSubmittedGuard } from './isProposalSubmittedGuard'; + +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/isProposalTechnicalReviewsSubmittedGuard.spec.ts b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts new file mode 100644 index 0000000000..d6cef6e2e0 --- /dev/null +++ b/apps/backend/src/workflowEngine/guards/isProposalTechnicalReviewsSubmittedGuard.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; + +import { Tokens } from '../../config/Tokens'; +import { isProposalTechnicalReviewsSubmittedGuard } from './isProposalTechnicalReviewsSubmittedGuard'; + +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..20292652c2 --- /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 '../simpleStateMachine/stateMachnine'; + +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/frontend/src/components/review/ReviewQuestionary.tsx b/apps/frontend/src/components/review/ReviewQuestionary.tsx index a20940859a..1351203dc0 100644 --- a/apps/frontend/src/components/review/ReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/ReviewQuestionary.tsx @@ -93,6 +93,7 @@ export function createFapReviewStub( proposerId: 0, technicalReviews: [], statusId: 0, + 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 112cfe3b80..b320268916 100644 --- a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx +++ b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx @@ -106,6 +106,7 @@ export function createTechnicalReviewStub( proposerId: 0, technicalReviews: [], statusId: 0, + workflowStatusId: 0, visits: [], updated: new Date(), submittedDate: new Date(), From bbec4d3355f5c4d62ed047afdc182783e7ade414 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Tue, 13 Jan 2026 22:18:48 +0100 Subject: [PATCH 034/147] Refactor status handling in experiments and proposals - Updated statusId fields to use string type instead of number in various models including Experiment, Proposal, and Status. - Changed methods to retrieve workflow statuses instead of regular statuses in experiment and proposal workflows. - Adjusted mutation and query resolvers to accommodate the new statusId type and workflow status logic. - Modified tests to reflect changes in status handling and ensure proper functionality with the new string-based status IDs. - Updated state machine logic to handle transitions based on the new workflow status structure. --- .../0205_MigrateStatusesIdToShortCode.sql | 345 ++++++++++++++++++ .../src/datasources/ProposalDataSource.ts | 4 +- .../src/datasources/StatusDataSource.ts | 5 +- .../src/datasources/WorkflowDataSource.ts | 4 +- .../mockups/ExperimentDataSource.ts | 5 +- .../datasources/mockups/ProposalDataSource.ts | 21 +- .../datasources/mockups/StatusDataSource.ts | 35 +- .../datasources/mockups/WorkflowDataSource.ts | 101 ++--- .../postgres/ProposalDataSource.ts | 2 +- .../datasources/postgres/StatusDataSource.ts | 26 +- .../postgres/WorkflowDataSource.ts | 4 +- .../src/datasources/postgres/records.ts | 10 +- .../eventHandlers/experimentSafetyWorkflow.ts | 4 +- .../src/eventHandlers/proposalWorkflow.ts | 8 +- .../src/factory/pdf/experimentSafety.ts | 2 +- apps/backend/src/models/Experiment.ts | 2 +- apps/backend/src/models/Proposal.ts | 2 +- apps/backend/src/models/ProposalView.ts | 2 +- apps/backend/src/models/Status.ts | 2 +- apps/backend/src/models/WorkflowStatus.ts | 2 +- .../src/mutations/ExperimentMutation.ts | 17 +- .../src/mutations/InstrumentMutations.spec.ts | 25 +- .../src/mutations/InstrumentMutations.ts | 11 +- .../src/mutations/ProposalMutations.spec.ts | 130 ++++--- .../src/mutations/ProposalMutations.ts | 73 ++-- .../ProposalSettingsMutations.spec.ts | 23 +- apps/backend/src/mutations/StatusMutations.ts | 13 +- apps/backend/src/queries/StatusQueries.ts | 2 +- .../ChangeProposalsStatusMutation.ts | 2 +- .../settings/AddStatusToWorkflowMutation.ts | 4 +- .../settings/DeleteStatusMutation.ts | 4 +- .../settings/UpdateStatusMutation.ts | 14 +- .../src/resolvers/queries/StatusQuery.ts | 2 +- .../src/resolvers/types/ExperimentSafety.ts | 4 +- apps/backend/src/resolvers/types/Proposal.ts | 4 +- .../src/resolvers/types/ProposalView.ts | 4 +- apps/backend/src/resolvers/types/Status.ts | 6 +- .../src/resolvers/types/WorkflowStatus.ts | 4 +- apps/backend/src/workflowEngine/experiment.ts | 6 +- apps/backend/src/workflowEngine/proposal.ts | 2 +- .../simpleStateMachine/stateMachine.spec.ts | 6 +- .../simpleStateMachine/stateMachnine.ts | 7 +- 42 files changed, 677 insertions(+), 272 deletions(-) create mode 100644 apps/backend/db_patches/0205_MigrateStatusesIdToShortCode.sql diff --git a/apps/backend/db_patches/0205_MigrateStatusesIdToShortCode.sql b/apps/backend/db_patches/0205_MigrateStatusesIdToShortCode.sql new file mode 100644 index 0000000000..090f0b7d51 --- /dev/null +++ b/apps/backend/db_patches/0205_MigrateStatusesIdToShortCode.sql @@ -0,0 +1,345 @@ +DO +$$ +DECLARE + -- Variable to hold count of affected rows for logging/verification + v_count bigint; +BEGIN + IF register_patch( + '0205_MigrateStatusesIdToShortCode', + 'Jekabs Karklins', + 'Move short_code for statuses table 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. Migrate 'experiment_safety' + -- ========================================== + + ALTER TABLE experiment_safety ADD COLUMN new_status_id VARCHAR(50); + + UPDATE experiment_safety es + SET new_status_id = s.status_id + FROM statuses s + WHERE es.status_id = s.old_id; + + ALTER TABLE experiment_safety DROP COLUMN status_id; + ALTER TABLE experiment_safety RENAME COLUMN new_status_id TO status_id; + -- ALTER TABLE experiment_safety ALTER COLUMN status_id SET NOT NULL; -- User requested nullable + + ALTER TABLE experiment_safety + ADD CONSTRAINT experiment_safety_status_id_fkey FOREIGN KEY (status_id) REFERENCES statuses (status_id) ON UPDATE CASCADE; + + + -- ========================================== + -- 4. Migrate 'proposals' + -- ========================================== + + ALTER TABLE proposals ADD COLUMN new_status_id VARCHAR(50); + + -- Map existing valid status IDs + UPDATE proposals p + SET new_status_id = s.status_id + FROM statuses s + WHERE p.status_id = s.old_id; + + + -- Clean up old column + ALTER TABLE proposals DROP COLUMN status_id; + ALTER TABLE proposals RENAME COLUMN new_status_id TO status_id; + + -- Set new default + ALTER TABLE proposals ALTER COLUMN status_id SET DEFAULT 'DRAFT'; + ALTER TABLE proposals ALTER COLUMN status_id SET NOT NULL; + + -- Re-add Foreign Key + ALTER TABLE proposals + ADD CONSTRAINT proposals_status_id_fkey FOREIGN KEY (status_id) REFERENCES statuses (status_id) ON UPDATE CASCADE; + + + -- ========================================== + -- 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.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 statuses s ON s.status_id = p.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; + + + + -- ========================================== + -- 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 + WHERE p.status_id <> 'EXPIRED' + AND p.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/src/datasources/ProposalDataSource.ts b/apps/backend/src/datasources/ProposalDataSource.ts index 0cf4a85892..70d57d5656 100644 --- a/apps/backend/src/datasources/ProposalDataSource.ts +++ b/apps/backend/src/datasources/ProposalDataSource.ts @@ -64,8 +64,8 @@ export interface ProposalDataSource { deleteProposal(primaryKey: number): Promise; getCount(callId: number): Promise; cloneProposal(sourceProposal: Proposal, call: Call): Promise; - changeProposalsStatus( - statusId: number, + changeProposalsWorkflowStatus( + workflowStatusId: number, proposalPks: number[] ): Promise; getRelatedUsersOnProposals(id: number): Promise; diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index 359cd17de9..45da257d8d 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -6,10 +6,11 @@ export interface StatusDataSource { createStatus( newStatusInput: Omit ): Promise; - getStatus(statusId: number): Promise; + getStatus(statusId: string): Promise; + getWorkflowStatus(workflowStatusId: number): Promise; getAllStatuses(entityType: Status['entityType']): Promise; updateStatus(status: UpdateStatusInput): Promise; - deleteStatus(statusId: number): Promise; + deleteStatus(statusId: string): Promise; getDefaultStatus(entityType: Status['entityType']): Promise; getDefaultWorkflowStatus(workflowId: number): Promise; } diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index 04ffecfe1b..b478590d72 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -26,7 +26,7 @@ export interface WorkflowDataSource { ): Promise; addStatusToWorkflow(newWorkflowStatusInput: { workflowId: number; - statusId: number; + statusId: string; posX: number; posY: number; }): Promise; @@ -47,7 +47,7 @@ export interface WorkflowDataSource { getWorkflowStructure(workflowId: number): Promise<{ workflowStatuses: { workflowStatusId: number; - statusId: number; + statusId: string; shortCode: string; }[]; workflowConnections: { diff --git a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts index 67b9c50d54..c2f308bea0 100644 --- a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts @@ -412,14 +412,15 @@ 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; + // TODO fix this + // experimentSafety.workflowStatusId = workflowStatusId; return experimentSafety; } diff --git a/apps/backend/src/datasources/mockups/ProposalDataSource.ts b/apps/backend/src/datasources/mockups/ProposalDataSource.ts index 50c026f8a4..248f3f9917 100644 --- a/apps/backend/src/datasources/mockups/ProposalDataSource.ts +++ b/apps/backend/src/datasources/mockups/ProposalDataSource.ts @@ -11,6 +11,7 @@ import { UserWithRole } from '../../models/User'; import { UpdateTechnicalReviewAssigneeInput } from '../../resolvers/mutations/UpdateTechnicalReviewAssigneeMutation'; import { ProposalDataSource } from '../ProposalDataSource'; import { ProposalsFilter } from './../../resolvers/queries/ProposalsQuery'; +import { dummyWorkflowStatuses } from './StatusDataSource'; import { basicDummyUser } from './UserDataSource'; export let dummyProposal: Proposal; @@ -34,7 +35,7 @@ const dummyProposalFactory = (values?: Partial) => { values?.title || 'title', values?.abstract || 'abstract', values?.proposerId || 1, - values?.statusId || 1, + values?.statusId || 'DRAFT', values?.workflowStatusId || 1, values?.created || new Date(), values?.updated || new Date(), @@ -150,7 +151,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { finalStatus: ProposalEndStatus.ACCEPTED, notified: true, managementDecisionSubmitted: true, - statusId: 2, + statusId: 'FEASIBILITY_REVIEW', }); dummyProposalWithNotActiveCall = dummyProposalFactory({ @@ -163,7 +164,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { 1, '', 1, - 1, + 'DRAFT', '', '', 'shortCode', @@ -251,7 +252,7 @@ export class ProposalDataSourceMock implements ProposalDataSource { if (!proposal) { throw new Error('Proposal does not exist'); } - proposal.statusId = proposalStatusId; + proposal.workflowStatusId = proposalStatusId; return proposal; } @@ -366,12 +367,18 @@ export class ProposalDataSourceMock implements ProposalDataSource { return dummyProposal; } - 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/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index ef81c82930..3bd0b07378 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -4,9 +4,9 @@ import { WorkflowStatus } from '../../models/WorkflowStatus'; import { StatusDataSource } from '../StatusDataSource'; export const dummyStatuses = [ - new Status(1, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 2, + 'FEASIBILITY_REVIEW', 'FEASIBILITY_REVIEW', 'Feasibility review', '', @@ -15,17 +15,38 @@ 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 { - getDefaultWorkflowStatus(workflowId: number): Promise { - throw new Error('Method not implemented.'); + async getWorkflowStatus( + workflowStatusId: number + ): Promise { + return ( + dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + ) || null + ); + } + async getDefaultWorkflowStatus( + workflowId: number + ): Promise { + return dummyWorkflowStatuses[0]; } // TODO: This needs to be implemented async createStatus( newStatusInput: Omit ): Promise { - return { ...newStatusInput, id: 1, isDefault: false }; + return { ...newStatusInput, id: 'DRAFT', isDefault: false }; } - async getStatus(statusId: number): Promise { + async getStatus(statusId: string): Promise { return dummyStatuses.find((s) => s.id === statusId) as Status; } async getAllStatuses(): Promise { @@ -34,7 +55,7 @@ export class StatusDataSourceMock implements StatusDataSource { 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 diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index 6311edbfbb..fce15cbf65 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -9,11 +9,20 @@ import { CreateWorkflowInput } from '../../resolvers/mutations/settings/CreateWo import { UpdateWorkflowInput } from '../../resolvers/mutations/settings/UpdateWorkflowMutation'; import { UpdateWorkflowStatusInput } from '../../resolvers/mutations/settings/UpdateWorkflowStatusMutation'; import { WorkflowDataSource } from '../WorkflowDataSource'; +import { dummyWorkflowStatuses } from './StatusDataSource'; + +export const dummyWorkflow = new Workflow( + 1, + 'Test workflow', + 'This is description', + WorkflowType.PROPOSAL, + 'default' +); export const dummyStatuses = [ - new Status(1, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 2, + 'FEASIBILITY_REVIEW', 'FEASIBILITY_REVIEW', 'Feasibility review', '', @@ -21,24 +30,11 @@ export const dummyStatuses = [ WorkflowType.PROPOSAL ), ]; -export const dummyWorkflow = new Workflow( - 1, - 'Test workflow', - 'This is description', - WorkflowType.PROPOSAL, - 'default' -); - -export const dummyWorkflowConnection = new WorkflowConnection(1, 1, 1, 2); - -export const anotherDummyWorkflowConnection = new WorkflowConnection( - 2, - 1, - 2, - 1 -); -export const dummyWorkflowStatus = new WorkflowStatus(1, 1, 1, 100, 100); +export const dummyWorkflowConnections = [ + new WorkflowConnection(1, 1, 1, 2), + new WorkflowConnection(2, 1, 2, 1), +]; export const dummyStatusChangingEvent = new StatusChangingEvent( 1, @@ -46,10 +42,10 @@ export const dummyStatusChangingEvent = new StatusChangingEvent( ); export class WorkflowDataSourceMock implements WorkflowDataSource { - getWorkflowStructure(workflowId: number): Promise<{ + async getWorkflowStructure(workflowId: number): Promise<{ workflowStatuses: { workflowStatusId: number; - statusId: number; + statusId: string; shortCode: string; }[]; workflowConnections: { @@ -59,15 +55,34 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { statusChangingEvents: string[]; }[]; }> { - throw new Error('Method not implemented.'); - } - createWorkflowConnection( + return { + workflowStatuses: dummyWorkflowStatuses.map((ws) => ({ + workflowStatusId: ws.workflowStatusId, + statusId: ws.statusId, + shortCode: + dummyStatuses.find((s) => s.id === ws.statusId)?.shortCode || '', + })), + workflowConnections: dummyWorkflowConnections.map((wc) => ({ + workflowStatusConnectionId: wc.id, + prevWorkflowStatusId: wc.prevWorkflowStatusId, + nextWorkflowStatusId: wc.nextWorkflowStatusId, + statusChangingEvents: ['PROPOSAL_SUBMITTED'], + })), + }; + } + async createWorkflowConnection( newWorkflowConnectionInput: CreateWorkflowConnectionInput ): Promise { - throw new Error('Method not implemented.'); + return dummyWorkflowConnections[0]; } - getWorkflowStatus(workflowStatusId: number): Promise { - throw new Error('Method not implemented.'); + async getWorkflowStatus( + workflowStatusId: number + ): Promise { + return ( + dummyWorkflowStatuses.find( + (ws) => ws.workflowStatusId === workflowStatusId + ) || null + ); } async createWorkflow(args: CreateWorkflowInput): Promise { return dummyWorkflow; @@ -96,55 +111,45 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { async getWorkflowConnections( workflowId: number ): Promise { - return [dummyWorkflowConnection, anotherDummyWorkflowConnection]; + return dummyWorkflowConnections; } async getWorkflowStatuses(workflowId: number): Promise { - return [dummyWorkflowStatus]; + return dummyWorkflowStatuses; } async getWorkflowConnection( 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 addStatusToWorkflow( newWorkflowStatusInput: AddStatusToWorkflowInput ): Promise { - return dummyWorkflowStatus; + return dummyWorkflowStatuses[0]; } async updateWorkflowStatus( workflowStatus: UpdateWorkflowStatusInput ): Promise { - return dummyWorkflowStatus; + return dummyWorkflowStatuses[0]; } async deleteWorkflowStatus( workflowStatusId: number ): Promise { - return dummyWorkflowStatus; + 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 setStatusChangingEventsOnConnection( diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index ec6de51a0f..f48bcabc44 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -858,7 +858,7 @@ export default class PostgresProposalDataSource implements ProposalDataSource { return createProposalObject(newProposal); } - async changeProposalsStatus( + async changeProposalsWorkflowStatus( statusId: number, proposalPks: number[] ): Promise { diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index ae6d2731f9..0348d1a7e5 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -42,7 +42,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') @@ -52,6 +52,28 @@ export default class PostgresStatusDataSource implements StatusDataSource { return status ? this.createStatusObject(status) : null; } + async getWorkflowStatus( + workflowStatusId: number + ): Promise { + const workflowStatus: WorkflowStatusRecord = await database + .select() + .from('workflow_has_statuses') + .where('workflow_status_id', workflowStatusId) + .first(); + + if (!workflowStatus) { + return null; + } + + return new WorkflowStatus( + workflowStatus.workflow_status_id, + workflowStatus.workflow_id, + workflowStatus.status_id, + workflowStatus.pos_x, + workflowStatus.pos_y + ); + } + async getAllStatuses(entityType: Status['entityType']): Promise { const statuses: StatusRecord[] = await database .select('*') @@ -81,7 +103,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) diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index 19992aeaba..c3fbfbec03 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -191,7 +191,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { } async addStatusToWorkflow(newWorkflowStatusInput: { workflowId: number; - statusId: number; + statusId: string; posX: number; posY: number; }): Promise { @@ -409,7 +409,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { async getWorkflowStructure(workflowId: number): Promise<{ workflowStatuses: { workflowStatusId: number; - statusId: number; + statusId: string; shortCode: string; }[]; workflowConnections: { diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 4596fe6e2d..9813b03390 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -130,7 +130,7 @@ 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; @@ -154,7 +154,7 @@ export interface ProposalViewRecord { readonly proposal_pk: number; readonly title: string; readonly principal_investigator: number; - readonly proposal_status_id: number; + readonly proposal_status_id: string; readonly proposal_status_name: string; readonly proposal_status_description: string; readonly proposal_id: string; @@ -583,7 +583,7 @@ export interface ShipmentRecord { } export interface StatusRecord { - readonly status_id: number; + readonly status_id: string; readonly short_code: string; readonly name: string; readonly description: string; @@ -611,7 +611,7 @@ export interface WorkflowConnectionRecord { export interface WorkflowStatusRecord { readonly workflow_status_id: number; readonly workflow_id: number; - readonly status_id: number; + readonly status_id: string; readonly pos_x: number; readonly pos_y: number; } @@ -1485,7 +1485,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 status_id: string | null; readonly safety_review_questionary_id: number; readonly reviewed_by: number; readonly created_at: Date; diff --git a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts index 38e0382518..e53e71a379 100644 --- a/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts +++ b/apps/backend/src/eventHandlers/experimentSafetyWorkflow.ts @@ -31,7 +31,7 @@ const publishExperimentSafetyStatusChange = async ( const experimentSafetyStatus = await statusDataSource.getStatus( updatedExperimentSafety.statusId ); - const previousExperimentStatus = await statusDataSource.getStatus( + const previousExperimentStatus = await statusDataSource.getWorkflowStatus( updatedExperimentSafety.prevStatusId ); @@ -41,7 +41,7 @@ const publishExperimentSafetyStatusChange = async ( isRejection: false, key: 'experimentsafety', loggedInUserId: null, - description: `From "${previousExperimentStatus?.name}" to "${experimentSafetyStatus?.name}"`, + description: `From "${previousExperimentStatus?.statusId}" to "${experimentSafetyStatus}.workflowStatus"`, // TODO: fix to use workflowStatus }); } }); diff --git a/apps/backend/src/eventHandlers/proposalWorkflow.ts b/apps/backend/src/eventHandlers/proposalWorkflow.ts index b44f17a639..8c9e3a29c7 100644 --- a/apps/backend/src/eventHandlers/proposalWorkflow.ts +++ b/apps/backend/src/eventHandlers/proposalWorkflow.ts @@ -32,10 +32,10 @@ const publishProposalStatusChange = async ( ); updatedProposals.map(async (updatedProposal) => { if (updatedProposal) { - const proposalStatus = await statusDataSource.getStatus( - updatedProposal.statusId + const proposalStatus = await statusDataSource.getWorkflowStatus( + updatedProposal.workflowStatusId ); - const previousProposalStatus = await statusDataSource.getStatus( + const previousProposalStatus = await statusDataSource.getWorkflowStatus( updatedProposal.prevStatusId ); @@ -45,7 +45,7 @@ const publishProposalStatusChange = async ( isRejection: false, key: 'proposal', loggedInUserId: null, - description: `From "${previousProposalStatus?.name}" to "${proposalStatus?.name}"`, + description: `From "${previousProposalStatus?.statusId}" to "${proposalStatus?.statusId}"`, }); } }); diff --git a/apps/backend/src/factory/pdf/experimentSafety.ts b/apps/backend/src/factory/pdf/experimentSafety.ts index 1ca2718e48..ebbe6a4451 100644 --- a/apps/backend/src/factory/pdf/experimentSafety.ts +++ b/apps/backend/src/factory/pdf/experimentSafety.ts @@ -119,7 +119,7 @@ export const collectExperimentPDFData = async ( // Get the status of the experiment safety const experimentSafetyStatus = await baseContext.queries.status.getStatus( user, - experimentSafety.statusId ?? 0 + experimentSafety.statusId ?? 'NULL' ); const esiQuestionarySteps = diff --git a/apps/backend/src/models/Experiment.ts b/apps/backend/src/models/Experiment.ts index 6563330abf..dc07c8411f 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 statusId: string | null, 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 14b5e9736e..5345103d0d 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 statusId: string, // proposal status id while it moving though proposal workflow public workflowStatusId: number, // current workflow status id public created: Date, public updated: Date, diff --git a/apps/backend/src/models/ProposalView.ts b/apps/backend/src/models/ProposalView.ts index f12a53be2f..d7948796da 100644 --- a/apps/backend/src/models/ProposalView.ts +++ b/apps/backend/src/models/ProposalView.ts @@ -11,7 +11,7 @@ export class ProposalView { public primaryKey: number, public title: string, public principalInvestigatorId: number, - public statusId: 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..8b7dc19e17 100644 --- a/apps/backend/src/models/Status.ts +++ b/apps/backend/src/models/Status.ts @@ -18,7 +18,7 @@ export enum ProposalStatusDefaultShortCodes { export class Status { constructor( - public id: number, + public id: string, public shortCode: string, public name: string, public description: string, diff --git a/apps/backend/src/models/WorkflowStatus.ts b/apps/backend/src/models/WorkflowStatus.ts index 0dab56031d..69e7b770bb 100644 --- a/apps/backend/src/models/WorkflowStatus.ts +++ b/apps/backend/src/models/WorkflowStatus.ts @@ -2,7 +2,7 @@ export class WorkflowStatus { constructor( public workflowStatusId: number, public workflowId: number, - public statusId: 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..aa505454d5 100644 --- a/apps/backend/src/mutations/ExperimentMutation.ts +++ b/apps/backend/src/mutations/ExperimentMutation.ts @@ -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'; @@ -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 experimentSafetyDefaultWorkflowStatus = + await this.statusDataSource.getDefaultWorkflowStatus( + call.experimentWorkflowId + ); + if (!experimentSafetyDefaultWorkflowStatus) { 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 + experimentSafetyDefaultWorkflowStatus.workflowStatusId ); } diff --git a/apps/backend/src/mutations/InstrumentMutations.spec.ts b/apps/backend/src/mutations/InstrumentMutations.spec.ts index cefba9f173..934c416b4b 100644 --- a/apps/backend/src/mutations/InstrumentMutations.spec.ts +++ b/apps/backend/src/mutations/InstrumentMutations.spec.ts @@ -6,6 +6,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 { @@ -17,6 +18,7 @@ import { WorkflowType } from '../models/Workflow'; import InstrumentMutations from './InstrumentMutations'; let statusDataSource: StatusDataSourceMock; +let proposalDataSource: ProposalDataSourceMock; let techniqueDataSource: TechniqueDataSourceMock; const instrumentMutations = container.resolve(InstrumentMutations); @@ -28,6 +30,9 @@ beforeEach(() => { techniqueDataSource = container.resolve( Tokens.TechniqueDataSource ); + proposalDataSource = container.resolve( + Tokens.ProposalDataSource + ); }); describe('Test Instrument Mutations', () => { @@ -200,7 +205,7 @@ 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 }; + const proposal = { statusId: 'DRAFT' }; jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ { @@ -230,7 +235,7 @@ describe('Test Instrument Mutations', () => { }); test('A scientist cannot change the instrument of a technique proposal from any status', () => { - const proposal = { statusId: 1 }; + const proposal = { statusId: 'DRAFT' }; jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ { @@ -259,18 +264,10 @@ 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 }; - - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'UNDER_REVIEW', - name: 'Under review', - description: '', - isDefault: true, - entityType: WorkflowType.PROPOSAL, - }, - ]); + jest.spyOn(proposalDataSource, 'get').mockResolvedValue({ + proposalPk: 1, + statusId: 'UNDER_REVIEW', + } as any); return expect( instrumentMutations.assignTechniqueProposalsToInstruments( diff --git a/apps/backend/src/mutations/InstrumentMutations.ts b/apps/backend/src/mutations/InstrumentMutations.ts index 803db8eb75..11e48388d9 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,13 @@ 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') { + if (proposal?.statusId !== 'UNDER_REVIEW') { return rejection( 'Could not assign instrument: forbidden current status', { agent, args, - currentStatus: currentStatus, + currentStatus: proposal?.statusId, } ); } diff --git a/apps/backend/src/mutations/ProposalMutations.spec.ts b/apps/backend/src/mutations/ProposalMutations.spec.ts index b2dda3b247..e04feed6da 100644 --- a/apps/backend/src/mutations/ProposalMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalMutations.spec.ts @@ -21,6 +21,7 @@ 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'; import ProposalMutations from './ProposalMutations'; const proposalMutations = container.resolve(ProposalMutations); @@ -414,71 +415,81 @@ describe('Test technique proposal change status', () => { const expiredId = 7; const dummyProposalStatuses = [ - new Status(draftId, 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - submittedId, + 'SUBMITTED_LOCKED', 'SUBMITTED_LOCKED', 'Submitted (locked)', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - underReviewId, + 'UNDER_REVIEW', 'UNDER_REVIEW', 'Under review', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - approvedId, + 'APPROVED', 'APPROVED', 'Approved', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - unsuccessfulId, + 'UNSUCCESSFUL', 'UNSUCCESSFUL', 'Unsuccessful', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - finishedId, + 'FINISHED', 'FINISHED', 'Finished', '', - true, + false, WorkflowType.PROPOSAL ), new Status( - nonTechniqueProposalId, 'NON-TP', - 'A non-technique proposal status', - '', - true, - WorkflowType.PROPOSAL - ), - new Status( - expiredId, - 'EXPIRED', - 'Expired', + 'NON-TP', + '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(statusDataSource, '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 () => { @@ -486,12 +497,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: draftId, + workflowStatusId: draftId, }, ]); @@ -499,7 +510,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -515,12 +526,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: finishedId, + workflowStatusId: finishedId, }, ]); @@ -528,7 +539,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -544,12 +555,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: unsuccessfulId, + workflowStatusId: unsuccessfulId, }, ]); @@ -557,7 +568,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -573,12 +584,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -586,7 +597,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -602,12 +613,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: submittedId, + workflowStatusId: submittedId, }, ]); @@ -615,7 +626,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: nonTechniqueProposalId, + workflowStatusId: nonTechniqueProposalId, proposalPks: [1, 2], } ) @@ -631,12 +642,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -644,7 +655,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: draftId, + workflowStatusId: draftId, proposalPks: [1, 2], } ) @@ -660,12 +671,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -673,7 +684,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: expiredId, + workflowStatusId: expiredId, proposalPks: [1, 2], } ) @@ -689,12 +700,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: underReviewId, + workflowStatusId: underReviewId, }, ]); @@ -702,7 +713,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: submittedId, + workflowStatusId: submittedId, proposalPks: [1, 2], } ) @@ -718,12 +729,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: underReviewId, + workflowStatusId: underReviewId, }, { ...dummyProposal, primaryKey: 2, - statusId: approvedId, + workflowStatusId: approvedId, }, ]); @@ -731,7 +742,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: finishedId, + workflowStatusId: finishedId, proposalPks: [1, 2], } ) @@ -747,12 +758,12 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: submittedId, + workflowStatusId: submittedId, }, { ...dummyProposal, primaryKey: 2, - statusId: submittedId, + workflowStatusId: submittedId, }, ]); @@ -760,7 +771,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyInstrumentScientist, { - statusId: underReviewId, + workflowStatusId: underReviewId, proposalPks: [1, 2], } ) @@ -769,11 +780,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, }), ]), }) @@ -785,12 +796,13 @@ describe('Test technique proposal change status', () => { { ...dummyProposal, primaryKey: 1, - statusId: finishedId, + workflowStatusId: submittedId, + statusId: 'FINISHED', }, { ...dummyProposal, primaryKey: 2, - statusId: finishedId, + workflowStatusId: finishedId, }, ]); @@ -798,7 +810,7 @@ describe('Test technique proposal change status', () => { proposalMutations.changeTechniqueProposalsStatus( dummyUserOfficerWithRole, { - statusId: draftId, + workflowStatusId: draftId, proposalPks: [1, 2], } ) @@ -807,11 +819,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 f4c75ba9a1..79f1a9ef74 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -32,7 +32,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'; @@ -550,9 +549,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) ); @@ -652,35 +651,37 @@ export default class ProposalMutations { ); } - const allStatuses = await this.statusDataSource.getAllStatuses( - WorkflowType.PROPOSAL + const newWorkflowStatus = await this.statusDataSource.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.statusDataSource.getWorkflowStatus( + proposal.workflowStatusId + ); - const context = { - currentStatus: currentStatus, - newStatus: newStatus, + const logContext = { + currentWorkflowStatus: currentWorkflowStatus, + newStatus: 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 ); } @@ -694,21 +695,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 ); } @@ -720,20 +722,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 ); } @@ -747,18 +750,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 ); } } @@ -858,7 +861,7 @@ export default class ProposalMutations { title: `Copy of ${clonedProposal.title}`, abstract: clonedProposal.abstract, proposerId: sourceProposal.proposerId, - statusId: 1, + statusId: 'DRAFT', workflowStatusId: defaultWfStatus.workflowStatusId, created: new Date(), updated: new Date(), diff --git a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 97e5604b4d..9357de92d1 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -5,11 +5,7 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; -import { - dummyWorkflow, - dummyWorkflowConnection, - dummyWorkflowStatus, -} 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'; @@ -68,7 +64,7 @@ describe('Test Proposal settings mutations', () => { const result = (await statusMutationsInstance.updateStatus( dummyUserWithRole, { - id: 1, + id: 'DRAFT', shortCode: 'UPDATE', name: 'update', description: 'update', @@ -81,7 +77,7 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can update proposal status', () => { const updatedStatus = { - id: 1, + id: 'DRAFT', shortCode: 'UPDATE', name: 'update', description: 'update', @@ -100,7 +96,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, { @@ -165,11 +161,14 @@ describe('Test Proposal settings mutations', () => { test('A userofficer can create new proposal workflow connection', () => { return expect( - workflowMutationsInstance.addStatusToWorkflow( + workflowMutationsInstance.createWorkflowConnection( dummyUserOfficerWithRole, - dummyWorkflowStatus + { nextWorkflowStatusId: 2, prevWorkflowStatusId: 1 } ) - ).resolves.toStrictEqual(dummyWorkflowConnection); + ).resolves.toMatchObject({ + nextWorkflowStatusId: 2, + prevWorkflowStatusId: 1, + }); }); test('A userofficer can add next status event/s to workflow connection', () => { @@ -198,6 +197,6 @@ describe('Test Proposal settings mutations', () => { dummyUserOfficerWithRole, 1 ) - ).resolves.toStrictEqual(dummyWorkflowConnection); + ).resolves.toMatchObject({ id: 1 }); }); }); diff --git a/apps/backend/src/mutations/StatusMutations.ts b/apps/backend/src/mutations/StatusMutations.ts index 99fa70158a..42d0e31c19 100644 --- a/apps/backend/src/mutations/StatusMutations.ts +++ b/apps/backend/src/mutations/StatusMutations.ts @@ -1,8 +1,4 @@ -import { - createStatusValidationSchema, - deleteStatusValidationSchema, - updateStatusValidationSchema, -} from '@user-office-software/duo-validation'; +import { createStatusValidationSchema } from '@user-office-software/duo-validation'; import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; @@ -32,8 +28,7 @@ export default class StatusMutations { return rejection('Could not create status', { agent, args }, error); }); } - - @ValidateArgs(updateStatusValidationSchema) + // @ValidateArgs(updateStatusValidationSchema) // TODO update validation schema @Authorized([Roles.USER_OFFICER]) async updateStatus( agent: UserWithRole | null, @@ -44,11 +39,11 @@ export default class StatusMutations { }); } - @ValidateArgs(deleteStatusValidationSchema) + // @ValidateArgs(deleteStatusValidationSchema) // TODO update validation schema @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/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/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 index ed227c3e9c..c9eb968386 100644 --- a/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/AddStatusToWorkflowMutation.ts @@ -17,8 +17,8 @@ export class AddStatusToWorkflowInput implements Partial { @Field(() => Int) public workflowId: number; - @Field(() => Int) - public statusId: number; + @Field(() => String) + public statusId: string; @Field(() => Int) public posX: number; 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/UpdateStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts index e907bbc495..4e2276d2b4 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts @@ -1,20 +1,12 @@ -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) + public id: string; @Field(() => String, { nullable: true }) public shortCode?: string; diff --git a/apps/backend/src/resolvers/queries/StatusQuery.ts b/apps/backend/src/resolvers/queries/StatusQuery.ts index c965b5e939..9c5835495e 100644 --- a/apps/backend/src/resolvers/queries/StatusQuery.ts +++ b/apps/backend/src/resolvers/queries/StatusQuery.ts @@ -7,7 +7,7 @@ import { Status } from '../types/Status'; @ArgsType() export class StatusArgs { @Field(() => Int) - statusId: number; + statusId: string; } @ArgsType() diff --git a/apps/backend/src/resolvers/types/ExperimentSafety.ts b/apps/backend/src/resolvers/types/ExperimentSafety.ts index cc72d0b405..4d489fa83b 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(() => String, { nullable: true }) + public statusId: string | null; @Field(() => Number, { nullable: true }) public safetyReviewQuestionaryId: number | null; diff --git a/apps/backend/src/resolvers/types/Proposal.ts b/apps/backend/src/resolvers/types/Proposal.ts index 8560c86dcc..b3ab436bc7 100644 --- a/apps/backend/src/resolvers/types/Proposal.ts +++ b/apps/backend/src/resolvers/types/Proposal.ts @@ -52,8 +52,8 @@ export class Proposal implements Partial { @Field(() => String) public abstract: string; - @Field(() => Int) - public statusId: number; + @Field(() => String) + public statusId: string; @Field(() => Int) public workflowStatusId: number; diff --git a/apps/backend/src/resolvers/types/ProposalView.ts b/apps/backend/src/resolvers/types/ProposalView.ts index 0718274e68..ed383f0082 100644 --- a/apps/backend/src/resolvers/types/ProposalView.ts +++ b/apps/backend/src/resolvers/types/ProposalView.ts @@ -117,8 +117,8 @@ export class ProposalView implements Partial { @Field(() => Int) public principalInvestigatorId: number; - @Field(() => Int) - public statusId: 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..62c918d1ca 100644 --- a/apps/backend/src/resolvers/types/Status.ts +++ b/apps/backend/src/resolvers/types/Status.ts @@ -1,12 +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 id: string; @Field(() => String) public shortCode: string; diff --git a/apps/backend/src/resolvers/types/WorkflowStatus.ts b/apps/backend/src/resolvers/types/WorkflowStatus.ts index 8c9ad408d5..406fbc6107 100644 --- a/apps/backend/src/resolvers/types/WorkflowStatus.ts +++ b/apps/backend/src/resolvers/types/WorkflowStatus.ts @@ -20,8 +20,8 @@ export class WorkflowStatus implements Partial { @Field(() => Int) public workflowId: number; - @Field(() => Int) - public statusId: number; + @Field(() => String) + public statusId: string; @Field(() => Int) public posX: number; diff --git a/apps/backend/src/workflowEngine/experiment.ts b/apps/backend/src/workflowEngine/experiment.ts index 72452669a0..fead1a8712 100644 --- a/apps/backend/src/workflowEngine/experiment.ts +++ b/apps/backend/src/workflowEngine/experiment.ts @@ -34,7 +34,7 @@ export const getWorkflowConnectionByStatusId = async ( await workflowDataSource.getWorkflowConnections(workflowId); const matchingWorkflowStatuses = statuses.filter( - (ws) => ws.statusId === statusId + (ws) => 0 //ws.statusId === statusId ); const matchingWorkflowStatusIds = matchingWorkflowStatuses.map( (ws) => ws.workflowStatusId @@ -97,7 +97,7 @@ const checkIfConditionsForNextStatusAreMet = async ({ const nextNextWorkflowConnections = await getWorkflowConnectionByStatusId( experimentWorkflow.id, - nextStatusId + 0 //nextStatusId ); const newStatusChangingEvents = await workflowDataSource.getStatusChangingEventsByConnectionIds( @@ -197,7 +197,7 @@ export const workflowEngine = async ( const currentWorkflowConnections = await getWorkflowConnectionByStatusId( experimentWorkflow.id, - experimentSafety.statusId + 0 //experimentSafety.statusId ); if (!currentWorkflowConnections.length) { diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index e4b0cb0088..3462f07cca 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -141,7 +141,7 @@ export class ProposalWorkflowEngine { return { ...updatedProposal, - prevStatusId: proposal.statusId, + prevStatusId: proposal.workflowStatusId, workflowStatusConnectionId: connectionId, }; } diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts index 4beac8a1e4..2db41631c5 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachine.spec.ts @@ -37,7 +37,7 @@ describe('simpleStateMachine', () => { expect(initialAction).toHaveBeenCalledWith({ id: 1 }); const nextState = await actor.event('APPROVE'); - expect(nextState).toBe('approved'); + expect(nextState.nextStateValue).toBe('approved'); expect(actor.getState()).toBe('approved'); expect(approvedAction).toHaveBeenCalledWith({ id: 1 }); }); @@ -66,11 +66,11 @@ describe('simpleStateMachine', () => { const actor = createActor(machine, { id: 2 }); const firstAttempt = await actor.event('SUBMIT'); - expect(firstAttempt).toBe('draft'); + expect(firstAttempt.nextStateValue).toBe('draft'); expect(actor.getState()).toBe('draft'); const secondAttempt = await actor.event('SUBMIT'); - expect(secondAttempt).toBe('submitted'); + expect(secondAttempt.nextStateValue).toBe('submitted'); expect(actor.getState()).toBe('submitted'); expect(guard).toHaveBeenCalledTimes(2); expect(guard).toHaveBeenLastCalledWith({ id: 2 }); diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts index 8dcd2d38b9..268a8b34b4 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/stateMachnine.ts @@ -54,7 +54,7 @@ export const createActor = ( } const { schema } = machine; - const currentState = startingState ?? schema.initial; + let currentState = startingState ?? schema.initial; if (!schema.states[currentState]) { throw new Error(`Unknown state "${currentState}"`); @@ -95,7 +95,12 @@ export const createActor = ( } } + if (!schema.states[transition.target]) { + throw new Error(`Unknown target state "${transition.target}"`); + } + await runAction(transition.target); + currentState = transition.target; return { nextStateValue: transition.target, From 0e66af0a1a1d69665dd0df5d4286bf888908c7ad Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 14 Jan 2026 08:33:14 +0100 Subject: [PATCH 035/147] refactor: remove shortCode from Status and related data sources --- .../backend/src/auth/ProposalAuthorization.ts | 7 +---- .../datasources/mockups/StatusDataSource.ts | 3 +-- .../datasources/mockups/WorkflowDataSource.ts | 6 ++--- .../datasources/postgres/StatusDataSource.ts | 5 ++-- .../src/datasources/postgres/records.ts | 1 - .../src/eventHandlers/messageBroker.ts | 2 +- apps/backend/src/models/Status.ts | 1 - .../src/mutations/InstrumentMutations.spec.ts | 27 ------------------- .../src/mutations/ProposalMutations.spec.ts | 24 +++-------------- apps/backend/src/resolvers/types/Status.ts | 3 --- 10 files changed, 10 insertions(+), 69 deletions(-) diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index e98aa30a1a..7d1118e0c5 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -6,7 +6,6 @@ import { DataAccessUsersDataSource } from '../datasources/DataAccessUsersDataSou import { FapDataSource } from '../datasources/FapDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { ReviewDataSource } from '../datasources/ReviewDataSource'; -import { StatusDataSource } from '../datasources/StatusDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; import { Roles } from '../models/Role'; import { ProposalStatusDefaultShortCodes } from '../models/Status'; @@ -31,8 +30,6 @@ export class ProposalAuthorization { private visitDataSource: VisitDataSource, @inject(Tokens.CallDataSource) private callDataSource: CallDataSource, - @inject(Tokens.StatusDataSource) - private statusDataSource: StatusDataSource, @inject(Tokens.DataAccessUsersDataSource) private dataAccessUsersDataSource: DataAccessUsersDataSource, @inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization @@ -313,9 +310,7 @@ export class ProposalAuthorization { callId, checkIfInternalEditable ); - const proposalStatus = ( - await this.statusDataSource.getStatus(proposal.statusId) - )?.shortCode; + const proposalStatus = proposal.statusId; if ( proposalStatus === ProposalStatusDefaultShortCodes.EDITABLE_SUBMITTED || (checkIfInternalEditable && diff --git a/apps/backend/src/datasources/mockups/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index 3bd0b07378..574428f47f 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -4,9 +4,8 @@ import { WorkflowStatus } from '../../models/WorkflowStatus'; import { StatusDataSource } from '../StatusDataSource'; export const dummyStatuses = [ - new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 'FEASIBILITY_REVIEW', 'FEASIBILITY_REVIEW', 'Feasibility review', '', diff --git a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts index fce15cbf65..7e49eaae74 100644 --- a/apps/backend/src/datasources/mockups/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/mockups/WorkflowDataSource.ts @@ -20,9 +20,8 @@ export const dummyWorkflow = new Workflow( ); export const dummyStatuses = [ - new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 'FEASIBILITY_REVIEW', 'FEASIBILITY_REVIEW', 'Feasibility review', '', @@ -59,8 +58,7 @@ export class WorkflowDataSourceMock implements WorkflowDataSource { workflowStatuses: dummyWorkflowStatuses.map((ws) => ({ workflowStatusId: ws.workflowStatusId, statusId: ws.statusId, - shortCode: - dummyStatuses.find((s) => s.id === ws.statusId)?.shortCode || '', + shortCode: dummyStatuses.find((s) => s.id === ws.statusId)?.id || '', })), workflowConnections: dummyWorkflowConnections.map((wc) => ({ workflowStatusConnectionId: wc.id, diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 0348d1a7e5..6d00686e2c 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -14,7 +14,6 @@ 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, @@ -23,11 +22,11 @@ export default class PostgresStatusDataSource implements StatusDataSource { } async createStatus( - newStatusInput: Omit + newStatusInput: Omit ): Promise { const [addedStatus]: StatusRecord[] = await database .insert({ - short_code: newStatusInput.shortCode, + id: newStatusInput.id, name: newStatusInput.name, description: newStatusInput.description, entity_type: newStatusInput.entityType, diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index 9813b03390..c74eb8138e 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -584,7 +584,6 @@ export interface ShipmentRecord { export interface StatusRecord { readonly status_id: string; - readonly short_code: string; readonly name: string; readonly description: string; readonly is_default: boolean; diff --git a/apps/backend/src/eventHandlers/messageBroker.ts b/apps/backend/src/eventHandlers/messageBroker.ts index b24584cd4c..d110740e48 100644 --- a/apps/backend/src/eventHandlers/messageBroker.ts +++ b/apps/backend/src/eventHandlers/messageBroker.ts @@ -185,7 +185,7 @@ export const getProposalMessageData = async (proposal: Proposal) => { mapUserWithInstitutionToMember ), visitors: visitorsWithInstitution.map(mapUserWithInstitutionToMember), - newStatus: proposalStatus?.shortCode, + newStatus: proposalStatus?.id, submitted: proposal.submitted, }; diff --git a/apps/backend/src/models/Status.ts b/apps/backend/src/models/Status.ts index 8b7dc19e17..419695fe05 100644 --- a/apps/backend/src/models/Status.ts +++ b/apps/backend/src/models/Status.ts @@ -19,7 +19,6 @@ export enum ProposalStatusDefaultShortCodes { export class Status { constructor( public id: string, - public shortCode: string, public name: string, public description: string, public isDefault: boolean, diff --git a/apps/backend/src/mutations/InstrumentMutations.spec.ts b/apps/backend/src/mutations/InstrumentMutations.spec.ts index 934c416b4b..5af2909752 100644 --- a/apps/backend/src/mutations/InstrumentMutations.spec.ts +++ b/apps/backend/src/mutations/InstrumentMutations.spec.ts @@ -14,7 +14,6 @@ import { dummyUserOfficerWithRole, dummyUserWithRole, } from '../datasources/mockups/UserDataSource'; -import { WorkflowType } from '../models/Workflow'; import InstrumentMutations from './InstrumentMutations'; let statusDataSource: StatusDataSourceMock; @@ -205,19 +204,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: 'DRAFT' }; - - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'EXPIRED', - name: 'Expired', - description: '', - isDefault: true, - entityType: WorkflowType.PROPOSAL, - }, - ]); - return expect( instrumentMutations.assignTechniqueProposalsToInstruments( dummyUserOfficerWithRole, @@ -235,19 +221,6 @@ describe('Test Instrument Mutations', () => { }); test('A scientist cannot change the instrument of a technique proposal from any status', () => { - const proposal = { statusId: 'DRAFT' }; - - jest.spyOn(statusDataSource, 'getAllStatuses').mockResolvedValue([ - { - id: proposal.statusId, - shortCode: 'EXPIRED', - name: 'Expired', - description: '', - isDefault: true, - entityType: WorkflowType.PROPOSAL, - }, - ]); - return expect( instrumentMutations.assignTechniqueProposalsToInstruments( dummyInstrumentScientist, diff --git a/apps/backend/src/mutations/ProposalMutations.spec.ts b/apps/backend/src/mutations/ProposalMutations.spec.ts index e04feed6da..b43d27d761 100644 --- a/apps/backend/src/mutations/ProposalMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalMutations.spec.ts @@ -415,9 +415,8 @@ describe('Test technique proposal change status', () => { const expiredId = 7; const dummyProposalStatuses = [ - new Status('DRAFT', 'DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), + new Status('DRAFT', 'Draft', '', true, WorkflowType.PROPOSAL), new Status( - 'SUBMITTED_LOCKED', 'SUBMITTED_LOCKED', 'Submitted (locked)', '', @@ -425,39 +424,22 @@ describe('Test technique proposal change status', () => { WorkflowType.PROPOSAL ), new Status( - 'UNDER_REVIEW', 'UNDER_REVIEW', 'Under review', '', false, WorkflowType.PROPOSAL ), - new Status( - 'APPROVED', - 'APPROVED', - 'Approved', - '', - false, - WorkflowType.PROPOSAL - ), + new Status('APPROVED', 'Approved', '', false, WorkflowType.PROPOSAL), new Status( - 'UNSUCCESSFUL', 'UNSUCCESSFUL', 'Unsuccessful', '', false, WorkflowType.PROPOSAL ), + new Status('FINISHED', 'Finished', '', false, WorkflowType.PROPOSAL), new Status( - 'FINISHED', - 'FINISHED', - 'Finished', - '', - false, - WorkflowType.PROPOSAL - ), - new Status( - 'NON-TP', 'NON-TP', 'Non-technique proposal', '', diff --git a/apps/backend/src/resolvers/types/Status.ts b/apps/backend/src/resolvers/types/Status.ts index 62c918d1ca..d1a10a9638 100644 --- a/apps/backend/src/resolvers/types/Status.ts +++ b/apps/backend/src/resolvers/types/Status.ts @@ -8,9 +8,6 @@ export class Status implements Partial { @Field(() => String) public id: string; - @Field(() => String) - public shortCode: string; - @Field(() => String) public name: string; From e14e95530bc5f5567e1daa2fb65094b0b848438a Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Wed, 14 Jan 2026 09:58:46 +0100 Subject: [PATCH 036/147] refactor: replace shortCode with id in status-related data structures and mutations --- apps/backend/src/datasources/StatusDataSource.ts | 4 +--- apps/backend/src/datasources/WorkflowDataSource.ts | 1 - apps/backend/src/datasources/mockups/StatusDataSource.ts | 4 ++-- apps/backend/src/datasources/postgres/CallDataSource.ts | 4 ++-- .../backend/src/datasources/stfc/StfcProposalDataSource.ts | 2 +- .../src/mutations/ProposalSettingsMutations.spec.ts | 7 +++---- apps/backend/src/mutations/StatusMutations.ts | 2 +- .../resolvers/mutations/settings/CreateStatusMutation.ts | 2 +- .../resolvers/mutations/settings/UpdateStatusMutation.ts | 3 --- apps/backend/src/resolvers/queries/CallsQuery.ts | 2 +- apps/backend/src/statusActionEngine/statusActionUtils.ts | 2 +- .../simpleStateMachine/createWorkflowMachine.ts | 6 +++--- 12 files changed, 16 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index 45da257d8d..0a010f9e41 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -3,9 +3,7 @@ import { WorkflowStatus } from '../models/WorkflowStatus'; import { UpdateStatusInput } from '../resolvers/mutations/settings/UpdateStatusMutation'; export interface StatusDataSource { - createStatus( - newStatusInput: Omit - ): Promise; + createStatus(newStatusInput: Omit): Promise; getStatus(statusId: string): Promise; getWorkflowStatus(workflowStatusId: number): Promise; getAllStatuses(entityType: Status['entityType']): Promise; diff --git a/apps/backend/src/datasources/WorkflowDataSource.ts b/apps/backend/src/datasources/WorkflowDataSource.ts index b478590d72..b4934d7d4b 100644 --- a/apps/backend/src/datasources/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/WorkflowDataSource.ts @@ -48,7 +48,6 @@ export interface WorkflowDataSource { workflowStatuses: { workflowStatusId: number; statusId: string; - shortCode: string; }[]; workflowConnections: { workflowStatusConnectionId: number; diff --git a/apps/backend/src/datasources/mockups/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index 574428f47f..06f70ad037 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -41,9 +41,9 @@ export class StatusDataSourceMock implements StatusDataSource { } // TODO: This needs to be implemented async createStatus( - newStatusInput: Omit + newStatusInput: Omit ): Promise { - return { ...newStatusInput, id: 'DRAFT', isDefault: false }; + return { ...newStatusInput, isDefault: false }; } async getStatus(statusId: string): Promise { return dummyStatuses.find((s) => s.id === statusId) as Status; diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index 9d3ae560f0..72d8002d25 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -164,7 +164,7 @@ export default class PostgresCallDataSource implements CallDataSource { query.where('call_ended', false); } - if (filter?.proposalStatusShortCode) { + if (filter?.proposalStatus) { query .join( 'workflow_has_statuses as w', @@ -172,7 +172,7 @@ export default class PostgresCallDataSource implements CallDataSource { 'w.workflow_id' ) .leftJoin('statuses as s', 'w.status_id', 's.status_id') - .where('s.short_code', filter.proposalStatusShortCode) + .where('s.short_code', filter.proposalStatus) .distinctOn('call.call_id'); } diff --git a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts index 0ce332aa6d..3037c07e7c 100644 --- a/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcProposalDataSource.ts @@ -63,7 +63,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/mutations/ProposalSettingsMutations.spec.ts b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts index 9357de92d1..f049afc094 100644 --- a/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts +++ b/apps/backend/src/mutations/ProposalSettingsMutations.spec.ts @@ -26,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, @@ -39,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, @@ -49,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, @@ -65,7 +65,6 @@ describe('Test Proposal settings mutations', () => { dummyUserWithRole, { id: 'DRAFT', - shortCode: 'UPDATE', name: 'update', description: 'update', isDefault: false, diff --git a/apps/backend/src/mutations/StatusMutations.ts b/apps/backend/src/mutations/StatusMutations.ts index 42d0e31c19..0baa31a5f8 100644 --- a/apps/backend/src/mutations/StatusMutations.ts +++ b/apps/backend/src/mutations/StatusMutations.ts @@ -18,7 +18,7 @@ export default class StatusMutations { private dataSource: StatusDataSource ) {} - @ValidateArgs(createStatusValidationSchema) + @ValidateArgs(createStatusValidationSchema) // TODO: update validation schema @Authorized([Roles.USER_OFFICER]) async createStatus( agent: UserWithRole | null, 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/UpdateStatusMutation.ts b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts index 4e2276d2b4..a117211f5f 100644 --- a/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/settings/UpdateStatusMutation.ts @@ -8,9 +8,6 @@ export class UpdateStatusInput { @Field(() => String) public id: string; - @Field(() => String, { nullable: true }) - public shortCode?: string; - @Field(() => String, { nullable: true }) public name?: string; diff --git a/apps/backend/src/resolvers/queries/CallsQuery.ts b/apps/backend/src/resolvers/queries/CallsQuery.ts index 845c7a516f..6c61ffb403 100644 --- a/apps/backend/src/resolvers/queries/CallsQuery.ts +++ b/apps/backend/src/resolvers/queries/CallsQuery.ts @@ -9,7 +9,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/statusActionEngine/statusActionUtils.ts b/apps/backend/src/statusActionEngine/statusActionUtils.ts index 1fa7af8e38..df69c94b8d 100644 --- a/apps/backend/src/statusActionEngine/statusActionUtils.ts +++ b/apps/backend/src/statusActionEngine/statusActionUtils.ts @@ -172,7 +172,7 @@ export const getEmailReadyArrayOfUsersAndProposals = async ( let sampleAnswers: Answer[] = []; const quickReviewCalls = await callDataSource .getCalls({ - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', }) .then((calls) => calls.map((call) => call.id)); if (quickReviewCalls.includes(proposal.callId)) { diff --git a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts index 45fdc62557..bcbd6d4c97 100644 --- a/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts +++ b/apps/backend/src/workflowEngine/simpleStateMachine/createWorkflowMachine.ts @@ -11,8 +11,8 @@ const workflowMachineCache = new Map< ReturnType >(); -const createWfStatusName = (shortCode: string, workflowStatusId: number) => - `${shortCode}-${workflowStatusId}`; +const createWfStatusName = (statusId: string, workflowStatusId: number) => + `${statusId}-${workflowStatusId}`; const getEventsGuards = (events: string[]): GuardFn[] => { const guards: GuardFn[] = []; @@ -53,7 +53,7 @@ export const createWorkflowMachine = async (workflowId: number) => { const wfStatusIdToNameMap = new Map(); // Map workflowStatusId to shortCode for easy lookup workflowStatuses.forEach((ws) => { - const wfStatusName = createWfStatusName(ws.shortCode, ws.workflowStatusId); + const wfStatusName = createWfStatusName(ws.statusId, ws.workflowStatusId); wfStatusIdToNameMap.set(ws.workflowStatusId, wfStatusName); wfStatuses[wfStatusName] = { on: {}, From 6d5ead17005c44ba06c8a1821d17107d6d8a6280 Mon Sep 17 00:00:00 2001 From: jekabskarklins Date: Thu, 15 Jan 2026 13:38:39 +0100 Subject: [PATCH 037/147] refactor: update workflow and status handling to use 'id' instead of 'shortCode' --- .../db_patches/0204_Migrate_workflow.sql | 112 +++++++++++++++++- .../src/datasources/StatusDataSource.ts | 1 + .../datasources/mockups/StatusDataSource.ts | 3 + .../datasources/postgres/CallDataSource.ts | 3 +- .../postgres/ProposalDataSource.ts | 19 ++- .../datasources/postgres/StatusDataSource.ts | 38 +++--- .../postgres/WorkflowDataSource.ts | 4 +- apps/backend/src/queries/StatusQueries.ts | 14 +++ .../src/resolvers/queries/ProposalsQuery.ts | 8 +- .../src/resolvers/queries/StatusQuery.ts | 40 ++++++- apps/backend/src/workflowEngine/proposal.ts | 6 +- .../src/components/call/CallGeneralInfo.tsx | 2 +- .../ExperimentSafetyStatusFilter.tsx | 4 +- .../common/proposalFilters/StatusFilter.tsx | 16 +-- .../ExperimentSafetyReviewSummary.tsx | 6 +- .../src/components/menu/MenuItems.tsx | 2 +- .../components/menu/ProposalMenuListItem.tsx | 2 +- .../proposal/ChangeProposalStatus.tsx | 20 ++-- .../components/proposal/ProposalCreate.tsx | 3 +- .../components/proposal/ProposalFilterBar.tsx | 6 +- .../src/components/proposal/ProposalPage.tsx | 2 +- .../components/proposal/ProposalSummary.tsx | 4 +- .../src/components/proposal/ProposalTable.tsx | 4 +- .../ProposalTableInstrumentScientist.tsx | 6 +- .../proposal/ProposalTableOfficer.tsx | 8 +- .../proposal/ProposalQuestionaryWizardStep.ts | 2 +- .../components/review/ReviewQuestionary.tsx | 5 +- .../review/TechnicalReviewQuestionary.tsx | 5 +- .../CreateUpdateProposalStatus.tsx | 7 +- .../proposalStatus/ProposalStatusesTable.tsx | 2 +- .../settings/workflow/StatusNode.tsx | 9 +- .../settings/workflow/StatusPicker.tsx | 4 +- .../settings/workflow/WorkflowEditor.tsx | 27 +---- .../TechniqueProposalFilterBar.tsx | 4 +- .../TechniqueProposalTable.tsx | 52 ++++---- .../call/getCallSubmissionDetails.graphql | 3 - .../proposal/changeProposalsStatus.graphql | 10 +- .../changeTechniqueProposalsStatus.graphql | 7 +- .../settings/addStatusToWorkflow.graphql | 2 +- .../src/graphql/settings/createStatus.graphql | 4 +- .../graphql/settings/createWorkflow.graphql | 3 - .../src/graphql/settings/deleteStatus.graphql | 2 +- .../graphql/settings/fragment.status.graphql | 1 - .../settings/getWorkflowStatuses.graphql | 8 ++ .../src/graphql/settings/getWorkflows.graphql | 3 - .../src/graphql/settings/updateStatus.graphql | 14 +-- .../graphql/settings/updateWorkflow.graphql | 6 - .../settings/usePersistWorkflowEditorModel.ts | 2 +- .../hooks/settings/useWorkflowStatusesData.ts | 49 ++++++++ .../proposal/ProposalSubmissionState.ts | 4 +- 50 files changed, 368 insertions(+), 200 deletions(-) create mode 100644 apps/frontend/src/graphql/settings/getWorkflowStatuses.graphql create mode 100644 apps/frontend/src/hooks/settings/useWorkflowStatusesData.ts diff --git a/apps/backend/db_patches/0204_Migrate_workflow.sql b/apps/backend/db_patches/0204_Migrate_workflow.sql index f2b847cdbe..e11a67437e 100644 --- a/apps/backend/db_patches/0204_Migrate_workflow.sql +++ b/apps/backend/db_patches/0204_Migrate_workflow.sql @@ -8,9 +8,13 @@ DECLARE 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_logs_updated BIGINT := 0; + v_unmapped_logs BIGINT := 0; + has_status_actions_logs BOOLEAN := FALSE; + call_has_workflow_id BOOLEAN := FALSE; + call_has_proposal_workflow_id BOOLEAN := FALSE; + v_proposals_updated BIGINT := 0; + v_unmapped_proposals BIGINT := 0; node_rec RECORD; edge_rec RECORD; BEGIN @@ -51,6 +55,28 @@ BEGIN DROP CONSTRAINT IF EXISTS status_actions_logs_action_id_fkey; END IF; + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'call' + AND column_name = 'workflow_id' + ) + INTO call_has_workflow_id; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'call' + AND column_name = 'proposal_workflow_id' + ) + INTO call_has_proposal_workflow_id; + + IF NOT call_has_workflow_id AND NOT call_has_proposal_workflow_id THEN + RAISE EXCEPTION 'call table must expose either workflow_id or proposal_workflow_id to migrate proposals.'; + END IF; + CREATE TEMP TABLE tmp_workflow_status_map ( workflow_connection_id INT PRIMARY KEY, workflow_status_id INT NOT NULL @@ -141,7 +167,7 @@ BEGIN WHERE edge_map.workflow_connection_id IS NULL; IF v_unmapped_actions > 0 THEN - RAISE WARNING '% workflow actions could not be migrated because their connection does not reference a previous node.', v_unmapped_actions; + 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_workflow_status_changing_events ( @@ -161,7 +187,7 @@ BEGIN WHERE edge_map.workflow_connection_id IS NULL; IF v_unmapped_events > 0 THEN - RAISE WARNING '% workflow events could not be migrated because their connection does not reference a previous node.', v_unmapped_events; + 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 @@ -185,7 +211,7 @@ BEGIN WHERE edge_map.workflow_connection_id IS NULL; IF v_unmapped_logs > 0 THEN - RAISE WARNING '% status action logs could not be remapped because their workflow connection is missing a previous node.', v_unmapped_logs; + 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 @@ -199,6 +225,80 @@ BEGIN ON DELETE CASCADE; END IF; + CREATE TEMP TABLE tmp_call_workflow_map ( + call_id INT PRIMARY KEY, + workflow_id INT + ) ON COMMIT DROP; + + IF call_has_proposal_workflow_id THEN + 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; + END IF; + + IF call_has_workflow_id THEN + INSERT INTO tmp_call_workflow_map (call_id, workflow_id) + SELECT call_id, workflow_id + FROM call + WHERE workflow_id IS NOT NULL + ON CONFLICT (call_id) DO UPDATE + SET workflow_id = COALESCE(EXCLUDED.workflow_id, tmp_call_workflow_map.workflow_id); + END IF; + + 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; + DROP TABLE IF EXISTS workflow_connection_has_actions; DROP TABLE IF EXISTS status_changing_events; DROP TABLE IF EXISTS workflow_connections; diff --git a/apps/backend/src/datasources/StatusDataSource.ts b/apps/backend/src/datasources/StatusDataSource.ts index 0a010f9e41..c670b9835d 100644 --- a/apps/backend/src/datasources/StatusDataSource.ts +++ b/apps/backend/src/datasources/StatusDataSource.ts @@ -7,6 +7,7 @@ export interface StatusDataSource { getStatus(statusId: string): Promise; getWorkflowStatus(workflowStatusId: number): Promise; getAllStatuses(entityType: Status['entityType']): Promise; + getAllWorkflowStatuses(workflowId: number): Promise; updateStatus(status: UpdateStatusInput): Promise; deleteStatus(statusId: string): Promise; getDefaultStatus(entityType: Status['entityType']): Promise; diff --git a/apps/backend/src/datasources/mockups/StatusDataSource.ts b/apps/backend/src/datasources/mockups/StatusDataSource.ts index 06f70ad037..c165326792 100644 --- a/apps/backend/src/datasources/mockups/StatusDataSource.ts +++ b/apps/backend/src/datasources/mockups/StatusDataSource.ts @@ -51,6 +51,9 @@ export class StatusDataSourceMock implements StatusDataSource { async getAllStatuses(): Promise { return dummyStatuses; } + async getAllWorkflowStatuses(workflowId: number): Promise { + return dummyWorkflowStatuses; + } async updateStatus(status: Omit): Promise { return { ...status, entityType: WorkflowType.PROPOSAL }; } diff --git a/apps/backend/src/datasources/postgres/CallDataSource.ts b/apps/backend/src/datasources/postgres/CallDataSource.ts index 72d8002d25..162aa3f902 100644 --- a/apps/backend/src/datasources/postgres/CallDataSource.ts +++ b/apps/backend/src/datasources/postgres/CallDataSource.ts @@ -171,8 +171,7 @@ export default class PostgresCallDataSource implements CallDataSource { 'call.proposal_workflow_id', 'w.workflow_id' ) - .leftJoin('statuses as s', 'w.status_id', 's.status_id') - .where('s.short_code', filter.proposalStatus) + .where('w.status_id', filter.proposalStatus) .distinctOn('call.call_id'); } diff --git a/apps/backend/src/datasources/postgres/ProposalDataSource.ts b/apps/backend/src/datasources/postgres/ProposalDataSource.ts index f48bcabc44..091e1a46d2 100644 --- a/apps/backend/src/datasources/postgres/ProposalDataSource.ts +++ b/apps/backend/src/datasources/postgres/ProposalDataSource.ts @@ -859,17 +859,17 @@ export default class PostgresProposalDataSource implements ProposalDataSource { } async changeProposalsWorkflowStatus( - statusId: number, + workflowStatusId: number, proposalPks: number[] ): Promise { - const dataToUpdate: { status_id: number; submitted?: boolean } = { - status_id: statusId, - }; + const workflowStatus = + await this.statusDataSource.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, + status_id: workflowStatus?.statusId, + submitted: workflowStatus?.statusId === 'DRAFT' ? false : undefined, + }; const result: ProposalRecord[] = await database .update(dataToUpdate, ['*']) @@ -947,9 +947,8 @@ export default class PostgresProposalDataSource implements ProposalDataSource { .then((value) => value.proposal_workflow_id); const result = await database('workflow_has_statuses') - .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') .where('workflow_has_statuses.workflow_id', proposalWorkflowId) - .andWhere('statuses.short_code', workflowStatus) + .andWhere('workflow_has_statuses.status_id', workflowStatus) .first(); return !!result; diff --git a/apps/backend/src/datasources/postgres/StatusDataSource.ts b/apps/backend/src/datasources/postgres/StatusDataSource.ts index 6d00686e2c..8ac3f6c8a8 100644 --- a/apps/backend/src/datasources/postgres/StatusDataSource.ts +++ b/apps/backend/src/datasources/postgres/StatusDataSource.ts @@ -21,6 +21,18 @@ export default class PostgresStatusDataSource implements StatusDataSource { ); } + private createWorkflowStatusObject = ( + workflowStatus: WorkflowStatusRecord + ) => { + return new WorkflowStatus( + workflowStatus.workflow_status_id, + workflowStatus.workflow_id, + workflowStatus.status_id, + workflowStatus.pos_x, + workflowStatus.pos_y + ); + }; + async createStatus( newStatusInput: Omit ): Promise { @@ -64,13 +76,7 @@ export default class PostgresStatusDataSource implements StatusDataSource { return null; } - return new WorkflowStatus( - workflowStatus.workflow_status_id, - workflowStatus.workflow_id, - workflowStatus.status_id, - workflowStatus.pos_x, - workflowStatus.pos_y - ); + return this.createWorkflowStatusObject(workflowStatus); } async getAllStatuses(entityType: Status['entityType']): Promise { @@ -83,6 +89,16 @@ export default class PostgresStatusDataSource implements StatusDataSource { return statuses.map((status) => this.createStatusObject(status)); } + async getAllWorkflowStatuses(workflowId: number): Promise { + const workflowStatuses: WorkflowStatusRecord[] = await database + .select() + .from('workflow_has_statuses') + .where('workflow_id', workflowId) + .orderBy('workflow_status_id', 'asc'); + + return workflowStatuses.map(this.createWorkflowStatusObject); + } + async updateStatus(status: UpdateStatusInput): Promise { const [updatedStatus]: StatusRecord[] = await database .update( @@ -159,13 +175,7 @@ export default class PostgresStatusDataSource implements StatusDataSource { return null; } - return new WorkflowStatus( - workflowStatus.workflow_status_id, - workflowStatus.workflow_id, - workflowStatus.status_id, - workflowStatus.pos_x, - workflowStatus.pos_y - ); + return this.createWorkflowStatusObject(workflowStatus); } async getInitialStatus( diff --git a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts index c3fbfbec03..dc003b1d2d 100644 --- a/apps/backend/src/datasources/postgres/WorkflowDataSource.ts +++ b/apps/backend/src/datasources/postgres/WorkflowDataSource.ts @@ -410,7 +410,6 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { workflowStatuses: { workflowStatusId: number; statusId: string; - shortCode: string; }[]; workflowConnections: { workflowStatusConnectionId: number; @@ -422,8 +421,7 @@ export default class PostgresWorkflowDataSource implements WorkflowDataSource { const workflowStatuses = await database .select( 'workflow_has_statuses.workflow_status_id as workflowStatusId', - 'workflow_has_statuses.status_id as statusId', - 'statuses.short_code as shortCode' + 'workflow_has_statuses.status_id as statusId' ) .from('workflow_has_statuses') .join('statuses', 'workflow_has_statuses.status_id', 'statuses.status_id') diff --git a/apps/backend/src/queries/StatusQueries.ts b/apps/backend/src/queries/StatusQueries.ts index 75f4ccfe8f..18dc70ab34 100644 --- a/apps/backend/src/queries/StatusQueries.ts +++ b/apps/backend/src/queries/StatusQueries.ts @@ -29,4 +29,18 @@ export default class StatusQueries { return statuses; } + + @Authorized() + async getWorkflowStatus(agent: UserWithRole | null, id: number) { + const status = await this.dataSource.getWorkflowStatus(id); + + return status; + } + + @Authorized() + async getAllWorkflowStatuses(agent: UserWithRole | null, workflowId: number) { + const statuses = await this.dataSource.getAllWorkflowStatuses(workflowId); + + return statuses; + } } diff --git a/apps/backend/src/resolvers/queries/ProposalsQuery.ts b/apps/backend/src/resolvers/queries/ProposalsQuery.ts index 562579c127..7b267c795d 100644 --- a/apps/backend/src/resolvers/queries/ProposalsQuery.ts +++ b/apps/backend/src/resolvers/queries/ProposalsQuery.ts @@ -84,11 +84,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 9c5835495e..44ff0d2c37 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) + @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.status.getWorkflowStatus(context.user, args.id); + } + + @Query(() => [WorkflowStatus]) + workflowStatuses( + @Args() args: WorkflowStatusesArgs, + @Ctx() context: ResolverContext + ) { + return context.queries.status.getAllWorkflowStatuses( + context.user, + args.workflowId + ); + } } diff --git a/apps/backend/src/workflowEngine/proposal.ts b/apps/backend/src/workflowEngine/proposal.ts index 3462f07cca..e08731d2c4 100644 --- a/apps/backend/src/workflowEngine/proposal.ts +++ b/apps/backend/src/workflowEngine/proposal.ts @@ -106,19 +106,19 @@ export class ProposalWorkflowEngine { const machine = await createWorkflowMachine(proposalWorkflowId); - const proposalWfStatus = Object.entries(machine.schema.states).find( + const currentProposalState = Object.entries(machine.schema.states).find( ([, state]) => { return ( (state.meta as WorkflowStateMeta | undefined)?.workflowStatusId === proposal.workflowStatusId ); } - )?.[0]; + )?.[0]; // find the state matching proposalWorkflowStatusId in the state machine const actor = createActor( machine, { id: proposal.primaryKey }, - proposalWfStatus + currentProposalState ); const currentWfStatus = actor.getState(); diff --git a/apps/frontend/src/components/call/CallGeneralInfo.tsx b/apps/frontend/src/components/call/CallGeneralInfo.tsx index c5a964a772..25508559df 100644 --- a/apps/frontend/src/components/call/CallGeneralInfo.tsx +++ b/apps/frontend/src/components/call/CallGeneralInfo.tsx @@ -185,7 +185,7 @@ const CallGeneralInfo = ({ const result = selectedProposalWorkFlow.statuses.some( (workflowStatus) => { return ( - workflowStatus.status.shortCode === + workflowStatus.status.id === 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..37ed8a2796 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 || ''} + defaultValue={''} data-cy="status-filter" > - {shouldShowAll && All} + {shouldShowAll && All} {statuses.map( (status) => isStatusVisible(hiddenStatuses, status) && ( diff --git a/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx b/apps/frontend/src/components/experimentSafetyReview/ExperimentSafetyReviewSummary.tsx index 4d891043ac..f5fa9d75dd 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/menu/MenuItems.tsx b/apps/frontend/src/components/menu/MenuItems.tsx index e60c5e4141..720b9bcdc6 100644 --- a/apps/frontend/src/components/menu/MenuItems.tsx +++ b/apps/frontend/src/components/menu/MenuItems.tsx @@ -70,7 +70,7 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { const calls = useCallsData( { - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', }, CallsDataQuantity.MINIMAL ).calls; diff --git a/apps/frontend/src/components/menu/ProposalMenuListItem.tsx b/apps/frontend/src/components/menu/ProposalMenuListItem.tsx index 97a13cf68c..6cace04dfb 100644 --- a/apps/frontend/src/components/menu/ProposalMenuListItem.tsx +++ b/apps/frontend/src/components/menu/ProposalMenuListItem.tsx @@ -27,7 +27,7 @@ export function ProposalMenuListItem() { const calls = useCallsData( { - proposalStatusShortCode: 'QUICK_REVIEW', + proposalStatus: 'QUICK_REVIEW', }, CallsDataQuantity.MINIMAL ).calls; diff --git a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx index 302731e38a..c2982440a8 100644 --- a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx +++ b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx @@ -10,8 +10,8 @@ import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; -import { Status, WorkflowType } from 'generated/sdk'; -import { useStatusesData } from 'hooks/settings/useStatusesData'; +import { WorkflowStatus } from 'generated/sdk'; +import { useWorkflowStatusesData } from 'hooks/settings/useWorkflowStatusesData'; const changeProposalStatusValidationSchema = yup.object().shape({ selectedStatusId: yup.string().required('You must select proposal status'), @@ -19,9 +19,9 @@ const changeProposalStatusValidationSchema = yup.object().shape({ type ChangeProposalStatusProps = { close: () => void; - changeStatusOnProposals: (status: Status) => Promise; + changeStatusOnProposals: (workflowStatus: WorkflowStatus) => Promise; allSelectedProposalsHaveInstrument: boolean; - selectedProposalStatuses: number[]; + selectedProposalStatuses: string[]; }; const ChangeProposalStatus = ({ @@ -34,7 +34,7 @@ const ChangeProposalStatus = ({ const { statuses: proposalStatuses, loadingStatuses: loadingProposalStatuses, - } = useStatusesData(WorkflowType.PROPOSAL); + } = useWorkflowStatusesData(1); // TODO use real workflow id const allSelectedProposalsHaveSameStatus = selectedProposalStatuses.every( (item) => item === selectedProposalStatuses[0] @@ -52,7 +52,7 @@ const ChangeProposalStatus = ({ }} onSubmit={async (values, actions): Promise => { const selectedStatus = proposalStatuses.find( - (call) => call.id === values.selectedStatusId + (status) => status.statusId === values.selectedStatusId ); if (!selectedStatus) { @@ -86,8 +86,8 @@ const ChangeProposalStatus = ({ label="Select proposal status" loading={loadingProposalStatuses} items={proposalStatuses.map((status) => ({ - value: status.id, - text: status.name, + value: status.statusId, + text: status.status.name, }))} required disabled={isSubmitting} @@ -95,13 +95,13 @@ const ChangeProposalStatus = ({ /> - {values.selectedStatusId === 1 && ( + {values.selectedStatusId === 'DRAFT' && ( Be aware that changing status to "DRAFT" will reopen proposal for changes and submission. )} - {values.selectedStatusId === 8 && + {values.selectedStatusId === 'SCHEDULING' && !allSelectedProposalsHaveInstrument && ( {`Be aware that proposal/s not assigned to an ${i18n.format( diff --git a/apps/frontend/src/components/proposal/ProposalCreate.tsx b/apps/frontend/src/components/proposal/ProposalCreate.tsx index b1a6bd1d14..a4ed67dc20 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 ad3a73f6ee..3133c3b774 100644 --- a/apps/frontend/src/components/proposal/ProposalSummary.tsx +++ b/apps/frontend/src/components/proposal/ProposalSummary.tsx @@ -111,7 +111,7 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { status.statusId && currentStatusId && status.statusId === currentStatusId && - editableStatusesShortCodes?.includes(status.status.shortCode) + editableStatusesShortCodes?.includes(status.statusId) ); if (proposal.status != null && hasUpcomingEditableStatus) { @@ -142,7 +142,7 @@ function ProposalReview({ confirm }: ProposalSummaryProps) { <> diff --git a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx index 5d0564bf50..2463db0120 100644 --- a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx @@ -55,7 +55,7 @@ import { InstrumentMinimalFragment, ProposalViewInstrument, ProposalsFilter, - Status, + WorkflowStatus, } from 'generated/sdk'; import { useLocalStorage } from 'hooks/common/useLocalStorage'; import { useDownloadPDFProposal } from 'hooks/proposal/useDownloadPDFProposal'; @@ -607,15 +607,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(); } 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 1351203dc0..387996870f 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,7 @@ export function createFapReviewStub( reviews: [], proposerId: 0, technicalReviews: [], - statusId: 0, + statusId: 'DRAFT', workflowStatusId: 0, visits: [], updated: new Date(), diff --git a/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx b/apps/frontend/src/components/review/TechnicalReviewQuestionary.tsx index b320268916..7c29c29cce 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,7 @@ export function createTechnicalReviewStub( reviews: [], proposerId: 0, technicalReviews: [], - statusId: 0, + statusId: 'DRAFT', workflowStatusId: 0, visits: [], updated: new Date(), diff --git a/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx b/apps/frontend/src/components/settings/proposalStatus/CreateUpdateProposalStatus.tsx index 9ddc5f834a..285b6c5aac 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, }); @@ -79,7 +78,7 @@ const CreateUpdateProposalStatus = ({ label="Short code" type="text" sx={{ - ...(!!initialValues.shortCode && { + ...(!!initialValues.id && { '& .MuiInputBase-root.Mui-disabled': { color: 'rgba(0, 0, 0, 0.7) !important', }, @@ -89,7 +88,7 @@ const CreateUpdateProposalStatus = ({ fullWidth data-cy="shortCode" required - disabled={!!initialValues.shortCode || isExecutingCall} + disabled={!!initialValues.id || isExecutingCall} /> { /> ); - 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/StatusNode.tsx b/apps/frontend/src/components/settings/workflow/StatusNode.tsx index 023c80dc02..e8f575864c 100644 --- a/apps/frontend/src/components/settings/workflow/StatusNode.tsx +++ b/apps/frontend/src/components/settings/workflow/StatusNode.tsx @@ -75,11 +75,11 @@ const StatusNode: React.FC = ({ id, data }) => { }; return ( -
+