From 85f7b13706b6965ed28b401dc743653475e5f046 Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Tue, 18 Jun 2024 15:57:33 -0500 Subject: [PATCH 01/19] Engagement Workflow v2 --- dbschema/engagement-workflow.esdl | 65 +++ dbschema/engagement.esdl | 34 +- dbschema/migrations/00012-m1pqxkv.edgeql | 68 +++ .../policies/by-role/controller.policy.ts | 8 + .../by-role/financial-analyst.policy.ts | 4 + .../by-role/project-manager.policy.ts | 17 + .../engagement/engagement-status.resolver.ts | 41 -- .../engagement/engagement.module.ts | 6 +- src/components/engagement/engagement.rules.ts | 518 ------------------ .../engagement/engagement.service.ts | 39 +- src/components/engagement/index.ts | 1 - .../dto/engagement-workflow-event.dto.ts | 32 ++ .../dto/engagement-workflow-transition.dto.ts | 10 + .../execute-engagement-transition.input.ts | 14 + .../engagement/workflow/dto/index.ts | 3 + .../engagement-workflow-event.loader.ts | 15 + .../workflow/engagement-workflow.flowchart.ts | 8 + .../workflow/engagement-workflow.granter.ts | 15 + .../workflow/engagement-workflow.module.ts | 36 ++ .../engagement-workflow.neo4j.repository.ts | 195 +++++++ .../engagement-workflow.repository.ts | 79 +++ .../workflow/engagement-workflow.service.ts | 112 ++++ .../workflow/engagement-workflow.ts | 137 +++++ ...us-history-to-workflow-events.migration.ts | 146 +++++ .../engagement-execute-transition.resolver.ts | 18 + .../engagement-transitions.resolver.ts | 55 ++ .../engagement-workflow-event.resolver.ts | 17 + .../engagement-workflow-events.resolver.ts | 18 + .../engagement/workflow/transitions/back.ts | 21 + .../workflow/transitions/context.ts | 8 + 30 files changed, 1131 insertions(+), 609 deletions(-) create mode 100644 dbschema/engagement-workflow.esdl create mode 100644 dbschema/migrations/00012-m1pqxkv.edgeql delete mode 100644 src/components/engagement/engagement-status.resolver.ts delete mode 100644 src/components/engagement/engagement.rules.ts create mode 100644 src/components/engagement/workflow/dto/engagement-workflow-event.dto.ts create mode 100644 src/components/engagement/workflow/dto/engagement-workflow-transition.dto.ts create mode 100644 src/components/engagement/workflow/dto/execute-engagement-transition.input.ts create mode 100644 src/components/engagement/workflow/dto/index.ts create mode 100644 src/components/engagement/workflow/engagement-workflow-event.loader.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.flowchart.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.granter.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.module.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.repository.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.service.ts create mode 100644 src/components/engagement/workflow/engagement-workflow.ts create mode 100644 src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts create mode 100644 src/components/engagement/workflow/resolvers/engagement-execute-transition.resolver.ts create mode 100644 src/components/engagement/workflow/resolvers/engagement-transitions.resolver.ts create mode 100644 src/components/engagement/workflow/resolvers/engagement-workflow-event.resolver.ts create mode 100644 src/components/engagement/workflow/resolvers/engagement-workflow-events.resolver.ts create mode 100644 src/components/engagement/workflow/transitions/back.ts create mode 100644 src/components/engagement/workflow/transitions/context.ts diff --git a/dbschema/engagement-workflow.esdl b/dbschema/engagement-workflow.esdl new file mode 100644 index 0000000000..852341ecd1 --- /dev/null +++ b/dbschema/engagement-workflow.esdl @@ -0,0 +1,65 @@ +module Engagement { + type WorkflowEvent extending Project::ContextAware { + required engagement: default::Engagement { + readonly := true; + on target delete delete source; + }; + required who: default::Actor { + readonly := true; + default := global default::currentActor; + }; + required at: datetime { + readonly := true; + default := datetime_of_statement(); + }; + transitionKey: uuid { + readonly := true; + }; + required to: Status { + readonly := true; + }; + notes: default::RichText { + readonly := true; + }; + + trigger setEngagementStatus after insert for all do ( + update default::Engagement + filter default::Engagement in __new__.engagement + set { + status := default::Engagement.latestWorkflowEvent.to ?? Engagement::Status.InDevelopment + } + ); + trigger refreshEngagementStatus after delete for all do ( + update default::Engagement + filter default::Engagement in __old__.engagement + set { + status := default::Engagement.latestWorkflowEvent.to ?? Engagement::Status.InDevelopment + } + ); + } + + scalar type Status extending enum< + InDevelopment, + DidNotDevelop, + Rejected, + + Active, + ActiveChangedPlan, + + DiscussingTermination, + DiscussingReactivation, + DiscussingChangeToPlan, + DiscussingSuspension, + Suspended, + + FinalizingCompletion, + Terminated, + Completed, + + # deprecated / legacy + Converted, + Unapproved, + Transferred, + NotRenewed, + >; +} \ No newline at end of file diff --git a/dbschema/engagement.esdl b/dbschema/engagement.esdl index ee2e13c016..df47dad229 100644 --- a/dbschema/engagement.esdl +++ b/dbschema/engagement.esdl @@ -3,6 +3,15 @@ module default { required status: Engagement::Status { default := Engagement::Status.InDevelopment; } + latestWorkflowEvent := (select .workflowEvents order by .at desc limit 1); + workflowEvents := .; scalar type InternPosition extending enum< ConsultantInTraining, diff --git a/dbschema/migrations/00012-m1pqxkv.edgeql b/dbschema/migrations/00012-m1pqxkv.edgeql new file mode 100644 index 0000000000..a18cc46c5c --- /dev/null +++ b/dbschema/migrations/00012-m1pqxkv.edgeql @@ -0,0 +1,68 @@ +CREATE MIGRATION m1pqxkvfgki4526meuwugacot76zuerjw34bynv25qbjbga3t7ezta + ONTO m17u4aufxga7wgmcebhiaapulc7xrqg34hcbmucddbvm4tw5c63kyq +{ + CREATE TYPE Engagement::WorkflowEvent EXTENDING Project::ContextAware { + CREATE ACCESS POLICY CanDeleteGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW DELETE USING ((default::Role.Administrator IN GLOBAL default::currentRoles)); + CREATE PROPERTY transitionKey: std::uuid { + SET readonly := true; + }; + CREATE ACCESS POLICY CanInsertGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW INSERT USING (((((default::Role.Administrator IN GLOBAL default::currentRoles) OR ((default::Role.Controller IN GLOBAL default::currentRoles) AND ((.transitionKey IN {'6eb17e2e-da0a-5277-9db0-e0f12396ff73', '539c7ca2-7c8d-57e1-a262-d385b4d88a6c', '6fa472fc-cf6a-5186-b2f1-46d2ed7fee54', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67'}) ?? false))) OR (EXISTS (({'FinancialAnalyst', 'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR (EXISTS (({'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'a535fba1-23c4-5c56-bb82-da6318aeda4d', '80b08b5d-93af-5f1b-b500-817c3624ad5b', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67', '30bb2a26-9b91-5fcd-8732-e305325eb1fe', 'd4dbcbb1-704b-5a93-961a-c302bba97866', 'b54cd0d5-942a-5e98-8a71-f5be87bc50b1', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'a0456858-07ec-59a2-9918-22ee106e2a20', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false)))); + CREATE ACCESS POLICY CanSelectUpdateReadGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW SELECT, UPDATE READ USING (EXISTS (({'Administrator', 'Controller', 'FinancialAnalyst', 'LeadFinancialAnalyst', 'Leadership', 'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles))); + CREATE ACCESS POLICY CanUpdateWriteGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW UPDATE WRITE ; + CREATE REQUIRED LINK engagement: default::Engagement { + ON TARGET DELETE DELETE SOURCE; + SET readonly := true; + }; + CREATE REQUIRED PROPERTY at: std::datetime { + SET default := (std::datetime_of_statement()); + SET readonly := true; + }; + CREATE REQUIRED PROPERTY to: Engagement::Status { + SET readonly := true; + }; + CREATE REQUIRED LINK who: default::Actor { + SET default := (GLOBAL default::currentActor); + SET readonly := true; + }; + CREATE PROPERTY notes: default::RichText { + SET readonly := true; + }; + }; + ALTER TYPE default::Engagement { + CREATE LINK workflowEvents := (.; +}; diff --git a/src/components/authorization/policies/by-role/controller.policy.ts b/src/components/authorization/policies/by-role/controller.policy.ts index 91947a9fe7..b0568fd50b 100644 --- a/src/components/authorization/policies/by-role/controller.policy.ts +++ b/src/components/authorization/policies/by-role/controller.policy.ts @@ -3,6 +3,14 @@ import { Policy, Role } from '../util'; // NOTE: There could be other permissions for this role from other policies @Policy(Role.Controller, (r) => [ // keep multiline format + r.EngagementWorkflowEvent.read.transitions( + 'Reject', + 'End Development', + 'Approve to Active', + 'Approve Change to Plan', + 'Will Not Change Plan', + 'Discussing Change to Plan -> Discussing Suspension', + ).execute, r.Organization.delete, r.Partner.delete, r.ProjectWorkflowEvent.read.transitions( diff --git a/src/components/authorization/policies/by-role/financial-analyst.policy.ts b/src/components/authorization/policies/by-role/financial-analyst.policy.ts index 243d9c8783..5e78580275 100644 --- a/src/components/authorization/policies/by-role/financial-analyst.policy.ts +++ b/src/components/authorization/policies/by-role/financial-analyst.policy.ts @@ -31,6 +31,10 @@ export const projectTransitions = () => ]), r.LanguageEngagement.specifically((p) => p.paratextRegistryId.none), ), + r.EngagementWorkflowEvent.read.transitions( + 'Not Ready for Completion', + 'Complete', + ).execute, r.FieldRegion.read, r.FieldZone.read, r.FinancialReport.edit, diff --git a/src/components/authorization/policies/by-role/project-manager.policy.ts b/src/components/authorization/policies/by-role/project-manager.policy.ts index 66165de2ac..80005d0625 100644 --- a/src/components/authorization/policies/by-role/project-manager.policy.ts +++ b/src/components/authorization/policies/by-role/project-manager.policy.ts @@ -82,6 +82,23 @@ export const momentumProjectsTransitions = () => p.paratextRegistryId.when(member).read, ]), ), + r.EngagementWorkflowEvent.read.transitions( + 'Discuss Change To Plan', + 'Discuss Suspension', + 'Discuss Termination', + 'Finalize Completion', + 'Approve Change to Plan', + 'Will Not Change Plan', + 'Discussing Change to Plan -> Discussing Suspension', + 'Will Not Suspend', + 'Approve Suspension', + 'Approve Reactivation', + 'Discuss Reactivation', + 'End Termination Discussion', + 'Approve Termination', + 'Not Ready for Completion', + 'Complete', + ).execute, r.EthnologueLanguage.read, r.FieldRegion.read, r.FieldZone.read, diff --git a/src/components/engagement/engagement-status.resolver.ts b/src/components/engagement/engagement-status.resolver.ts deleted file mode 100644 index e83bd925d6..0000000000 --- a/src/components/engagement/engagement-status.resolver.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { stripIndent } from 'common-tags'; -import { AnonSession, ParentIdMiddlewareAdditions, Session } from '~/common'; -import { EngagementStatusTransition, SecuredEngagementStatus } from './dto'; -import { EngagementRules } from './engagement.rules'; - -@Resolver(SecuredEngagementStatus) -export class EngagementStatusResolver { - constructor(private readonly engagementRules: EngagementRules) {} - - @ResolveField(() => [EngagementStatusTransition], { - description: 'The available statuses a engagement can be transitioned to.', - }) - async transitions( - @Parent() - status: SecuredEngagementStatus & ParentIdMiddlewareAdditions, - @AnonSession() session: Session, - ): Promise { - if (!status.canRead || !status.canEdit || !status.value) { - return []; - } - return await this.engagementRules.getAvailableTransitions( - status.parentId, - session, - undefined, - status.changeset, - ); - } - - @ResolveField(() => Boolean, { - description: stripIndent` - Is the current user allowed to bypass transitions entirely - and change the status to any other status? - `, - }) - async canBypassTransitions( - @AnonSession() session: Session, - ): Promise { - return await this.engagementRules.canBypassWorkflow(session); - } -} diff --git a/src/components/engagement/engagement.module.ts b/src/components/engagement/engagement.module.ts index d38e7acb2e..9c0bff87c2 100644 --- a/src/components/engagement/engagement.module.ts +++ b/src/components/engagement/engagement.module.ts @@ -6,11 +6,9 @@ import { LanguageModule } from '../language/language.module'; import { LocationModule } from '../location/location.module'; import { ProductModule } from '../product/product.module'; import { ProjectModule } from '../project/project.module'; -import { EngagementStatusResolver } from './engagement-status.resolver'; import { EngagementLoader } from './engagement.loader'; import { EngagementRepository } from './engagement.repository'; import { EngagementResolver } from './engagement.resolver'; -import { EngagementRules } from './engagement.rules'; import { EngagementService } from './engagement.service'; import * as handlers from './handlers'; import { InternshipEngagementResolver } from './internship-engagement.resolver'; @@ -18,6 +16,7 @@ import { InternshipPositionResolver } from './internship-position.resolver'; import { LanguageEngagementResolver } from './language-engagement.resolver'; import { FixNullMethodologiesMigration } from './migrations/fix-null-methodologies.migration'; import { EngagementProductConnectionResolver } from './product-connection.resolver'; +import { EngagementWorkflowModule } from './workflow/engagement-workflow.module'; @Module({ imports: [ @@ -28,15 +27,14 @@ import { EngagementProductConnectionResolver } from './product-connection.resolv forwardRef(() => LanguageModule), forwardRef(() => LocationModule), forwardRef(() => ProjectModule), + EngagementWorkflowModule, ], providers: [ EngagementResolver, LanguageEngagementResolver, InternshipEngagementResolver, - EngagementStatusResolver, InternshipPositionResolver, EngagementProductConnectionResolver, - EngagementRules, EngagementService, EngagementRepository, EngagementLoader, diff --git a/src/components/engagement/engagement.rules.ts b/src/components/engagement/engagement.rules.ts deleted file mode 100644 index 1bafa60e6b..0000000000 --- a/src/components/engagement/engagement.rules.ts +++ /dev/null @@ -1,518 +0,0 @@ -/* eslint-disable no-case-declarations */ -import { Injectable } from '@nestjs/common'; -import { node, relation } from 'cypher-query-builder'; -import { first, intersection } from 'lodash'; -import { - ID, - Role, - ServerException, - Session, - UnauthorizedException, -} from '~/common'; -import { ILogger, Logger } from '~/core'; -import { DatabaseService } from '~/core/database'; -import { ACTIVE, INACTIVE } from '~/core/database/query'; -import { withoutScope } from '../authorization/dto'; -import { ProjectStep } from '../project/dto'; -import { - EngagementStatus, - EngagementStatusTransition, - EngagementTransitionType, -} from './dto'; - -interface Transition extends EngagementStatusTransition { - projectStepRequirements?: ProjectStep[]; -} - -interface StatusRule { - approvers: Role[]; - transitions: Transition[]; -} - -const rolesThatCanBypassWorkflow: Role[] = [Role.Administrator]; - -@Injectable() -export class EngagementRules { - constructor( - private readonly db: DatabaseService, - // eslint-disable-next-line @seedcompany/no-unused-vars - @Logger('engagement:rules') private readonly logger: ILogger, - ) {} - - private async getStatusRule( - status: EngagementStatus, - id: ID, - ): Promise { - const mostRecentPreviousStatus = (steps: EngagementStatus[]) => - this.getMostRecentPreviousStatus(id, steps); - - switch (status) { - case EngagementStatus.InDevelopment: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - Role.Controller, - ], - transitions: [ - { - to: EngagementStatus.Active, - type: EngagementTransitionType.Approve, - label: 'Approve', - projectStepRequirements: [ProjectStep.Active], - }, - { - to: EngagementStatus.DidNotDevelop, - type: EngagementTransitionType.Reject, - label: 'End Development', - projectStepRequirements: [ProjectStep.DidNotDevelop], - }, - { - to: EngagementStatus.Rejected, - type: EngagementTransitionType.Reject, - label: 'Reject', - projectStepRequirements: [ProjectStep.Rejected], - }, - ], - }; - case EngagementStatus.Active: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, //Zone Director - ], - transitions: [ - { - to: EngagementStatus.DiscussingChangeToPlan, - type: EngagementTransitionType.Neutral, - label: 'Discuss Change to Plan', - }, - { - to: EngagementStatus.DiscussingSuspension, - type: EngagementTransitionType.Neutral, - label: 'Discuss Suspension', - }, - { - to: EngagementStatus.DiscussingTermination, - type: EngagementTransitionType.Neutral, - label: 'Discuss Termination', - }, - { - to: EngagementStatus.FinalizingCompletion, - type: EngagementTransitionType.Approve, - label: 'Finalize Completion', - }, - ], - }; - case EngagementStatus.ActiveChangedPlan: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, //Zone Director - ], - transitions: [ - { - to: EngagementStatus.DiscussingChangeToPlan, - type: EngagementTransitionType.Neutral, - label: 'Discuss Change to Plan', - }, - { - to: EngagementStatus.DiscussingTermination, - type: EngagementTransitionType.Neutral, - label: 'Discuss Termination', - }, - { - to: EngagementStatus.DiscussingSuspension, - type: EngagementTransitionType.Neutral, - label: 'Discuss Suspension', - }, - { - to: EngagementStatus.FinalizingCompletion, - type: EngagementTransitionType.Approve, - label: 'Finalize Completion', - }, - ], - }; - case EngagementStatus.DiscussingChangeToPlan: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - Role.Controller, - ], - transitions: [ - { - to: EngagementStatus.DiscussingSuspension, - type: EngagementTransitionType.Neutral, - label: 'Discuss Suspension', - }, - { - to: EngagementStatus.ActiveChangedPlan, - type: EngagementTransitionType.Approve, - label: 'Approve Change to Plan', - }, - { - to: await mostRecentPreviousStatus([ - EngagementStatus.Active, - EngagementStatus.ActiveChangedPlan, - ]), - type: EngagementTransitionType.Neutral, - label: 'Will Not Change Plan', - }, - ], - }; - case EngagementStatus.DiscussingSuspension: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - ], - transitions: [ - { - to: EngagementStatus.Suspended, - type: EngagementTransitionType.Approve, - label: 'Approve Suspension', - }, - { - to: await mostRecentPreviousStatus([ - EngagementStatus.Active, - EngagementStatus.ActiveChangedPlan, - ]), - type: EngagementTransitionType.Neutral, - label: 'Will Not Suspend', - }, - { - to: EngagementStatus.DiscussingTermination, - type: EngagementTransitionType.Neutral, - label: 'Discussing Termination', - }, - ], - }; - case EngagementStatus.Suspended: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - ], - transitions: [ - { - to: EngagementStatus.DiscussingReactivation, - type: EngagementTransitionType.Neutral, - label: 'Discuss Reactivation', - }, - { - to: EngagementStatus.DiscussingTermination, - type: EngagementTransitionType.Neutral, - label: 'Discuss Termination', - }, - ], - }; - case EngagementStatus.DiscussingReactivation: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - ], - transitions: [ - { - to: EngagementStatus.ActiveChangedPlan, - type: EngagementTransitionType.Approve, - label: 'Approve ReActivation', - }, - { - to: EngagementStatus.DiscussingTermination, - type: EngagementTransitionType.Neutral, - label: 'Discuss Termination', - }, - ], - }; - case EngagementStatus.DiscussingTermination: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - ], - transitions: [ - { - to: await mostRecentPreviousStatus([ - EngagementStatus.Active, - EngagementStatus.ActiveChangedPlan, - EngagementStatus.DiscussingReactivation, - EngagementStatus.Suspended, - ]), - type: EngagementTransitionType.Neutral, - label: 'Will Not Terminate', - }, - { - to: EngagementStatus.Terminated, - type: EngagementTransitionType.Approve, - label: 'Approve Termination', - }, - ], - }; - case EngagementStatus.FinalizingCompletion: - return { - approvers: [ - Role.Administrator, - Role.ProjectManager, - Role.RegionalDirector, - Role.FieldOperationsDirector, - Role.FinancialAnalyst, - Role.LeadFinancialAnalyst, - ], - transitions: [ - { - to: await mostRecentPreviousStatus([ - EngagementStatus.Active, - EngagementStatus.ActiveChangedPlan, - ]), - type: EngagementTransitionType.Neutral, - label: 'Still Working', - }, - { - to: EngagementStatus.Completed, - type: EngagementTransitionType.Approve, - label: 'Complete 🎉', - }, - ], - }; - case EngagementStatus.Terminated: - return { - approvers: [Role.Administrator], - transitions: [], - }; - case EngagementStatus.Completed: - return { - approvers: [Role.Administrator], - transitions: [], - }; - default: - return { - approvers: [Role.Administrator], - transitions: [], - }; - } - } - - async getAvailableTransitions( - engagementId: ID, - session: Session, - currentUserRoles?: Role[], - changeset?: ID, - ): Promise { - if (session.anonymous) { - return []; - } - - const currentStatus = await this.getCurrentStatus(engagementId, changeset); - // get roles that can approve the current status - const { approvers, transitions } = await this.getStatusRule( - currentStatus, - engagementId, - ); - - // If current user is not an approver (based on roles) then don't allow any transitions - currentUserRoles ??= session.roles.map(withoutScope); - if (intersection(approvers, currentUserRoles).length === 0) { - return []; - } - - // If transitions don't need project's step then dont fetch or filter it. - if ( - !transitions.some( - (transition) => transition.projectStepRequirements?.length, - ) - ) { - return transitions; - } - - const currentStep = await this.getCurrentProjectStep( - engagementId, - changeset, - ); - const availableTransitionsAccordingToProject = transitions.filter( - (transition) => - !transition.projectStepRequirements?.length || - transition.projectStepRequirements.includes(currentStep), - ); - return availableTransitionsAccordingToProject; - } - - async canBypassWorkflow(session: Session) { - const roles = session.roles.map(withoutScope); - return intersection(rolesThatCanBypassWorkflow, roles).length > 0; - } - - async verifyStatusChange( - engagementId: ID, - session: Session, - nextStatus: EngagementStatus, - changeset?: ID, - ) { - // If current user's roles include a role that can bypass workflow - // stop the check here. - const currentUserRoles = session.roles.map(withoutScope); - if (intersection(rolesThatCanBypassWorkflow, currentUserRoles).length > 0) { - return; - } - - const transitions = await this.getAvailableTransitions( - engagementId, - session, - currentUserRoles, - changeset, - ); - - const validNextStatus = transitions.some( - (transition) => transition.to === nextStatus, - ); - if (!validNextStatus) { - throw new UnauthorizedException( - `One or more engagements cannot be changed to ${ - EngagementStatus.entry(nextStatus).label - }. Please check engagement statuses.`, - 'engagement.status', - ); - } - } - - private async getCurrentStatus(id: ID, changeset?: ID) { - let currentStatus; - - if (changeset) { - const result = await this.db - .query() - .match([ - node('engagement', 'Engagement', { id }), - relation('out', '', 'status', INACTIVE), - node('status', 'Property'), - relation('in', '', 'changeset', ACTIVE), - node('', 'Changeset', { id: changeset }), - ]) - .raw('return status.value as status') - .asResult<{ status: EngagementStatus }>() - .first(); - currentStatus = result?.status; - } - if (!currentStatus) { - const result = await this.db - .query() - .match([ - node('engagement', 'Engagement', { id }), - relation('out', '', 'status', ACTIVE), - node('status', 'Property'), - ]) - .raw('return status.value as status') - .asResult<{ status: EngagementStatus }>() - .first(); - currentStatus = result?.status; - } - - if (!currentStatus) { - throw new ServerException('current status not found'); - } - - return currentStatus; - } - - private async getCurrentProjectStep(engagementId: ID, changeset?: ID) { - const result = await this.db - .query() - .match([ - node('engagement', 'Engagement', { id: engagementId }), - relation('in', '', 'engagement'), // Removed active true due to changeset aware - node('project', 'Project'), - ]) - .raw('return project.id as projectId') - .asResult<{ projectId: ID }>() - .first(); - - if (!result?.projectId) { - throw new ServerException(`Could not find project`); - } - const projectId = result.projectId; - - let currentStep; - if (changeset) { - const result = await this.db - .query() - .match([ - node('project', 'Project', { id: projectId }), - relation('out', '', 'step', INACTIVE), - node('step', 'Property'), - relation('in', '', 'changeset', ACTIVE), - node('', 'Changeset', { id: changeset }), - ]) - .raw('return step.value as step') - .asResult<{ step: ProjectStep }>() - .first(); - currentStep = result?.step; - } - if (!currentStep) { - const result = await this.db - .query() - .match([ - node('project', 'Project', { id: projectId }), - relation('out', '', 'step', ACTIVE), - node('step', 'Property'), - ]) - .raw('return step.value as step') - .asResult<{ step: ProjectStep }>() - .first(); - currentStep = result?.step; - } - - if (!currentStep) { - throw new ServerException(`Could not find project's step`); - } - - return currentStep; - } - - /** Of the given status which one was the most recent previous status */ - private async getMostRecentPreviousStatus( - id: ID, - statuses: EngagementStatus[], - ): Promise { - const prevStatus = await this.getPreviousStatus(id); - return first(intersection(prevStatus, statuses)) ?? statuses[0]; - } - - /** A list of the engagement's previous status ordered most recent to furthest in the past */ - private async getPreviousStatus(id: ID): Promise { - const result = await this.db - .query() - .match([ - node('node', 'Engagement', { id }), - relation('out', '', 'status', INACTIVE), - node('prop'), - ]) - .with('prop') - .orderBy('prop.createdAt', 'DESC') - .raw(`RETURN collect(prop.value) as status`) - .asResult<{ status: EngagementStatus[] }>() - .first(); - if (!result) { - throw new ServerException( - "Failed to determine engagement's previous status", - ); - } - return result.status; - } -} diff --git a/src/components/engagement/engagement.service.ts b/src/components/engagement/engagement.service.ts index 0d4431da9b..8a05556931 100644 --- a/src/components/engagement/engagement.service.ts +++ b/src/components/engagement/engagement.service.ts @@ -46,12 +46,12 @@ import { EngagementRepository, LanguageOrEngagementId, } from './engagement.repository'; -import { EngagementRules } from './engagement.rules'; import { EngagementCreatedEvent, EngagementUpdatedEvent, EngagementWillDeleteEvent, } from './events'; +import { EngagementWorkflowService } from './workflow/engagement-workflow.service'; @Injectable() export class EngagementService { @@ -62,7 +62,7 @@ export class EngagementService { private readonly products: ProductService & {}, private readonly config: ConfigService, private readonly files: FileService, - private readonly engagementRules: EngagementRules, + private readonly engagementWorkflow: EngagementWorkflowService, private readonly privileges: Privileges, @Inject(forwardRef(() => ProjectService)) private readonly projectService: ProjectService & {}, @@ -263,15 +263,6 @@ export class EngagementService { await this.verifyFirstScripture({ engagementId: input.id }); } - if (input.status) { - await this.engagementRules.verifyStatusChange( - input.id, - session, - input.status, - changeset, - ); - } - const previous = await this.repo.readOne(input.id, session, view); const object = (await this.secure(previous, session)) as LanguageEngagement; @@ -288,6 +279,13 @@ export class EngagementService { session, ); + if (changes.status) { + await this.engagementWorkflow.executeTransitionLegacy( + object, + changes.status, + session, + ); + } await this.repo.updateLanguage(object, changes, changeset); const updated = (await this.repo.readOne( @@ -308,14 +306,6 @@ export class EngagementService { changeset?: ID, ): Promise { const view: ObjectView = viewOfChangeset(changeset); - if (input.status) { - await this.engagementRules.verifyStatusChange( - input.id, - session, - input.status, - changeset, - ); - } const previous = await this.repo.readOne(input.id, session, view); const object = (await this.secure( @@ -328,6 +318,15 @@ export class EngagementService { .for(session, InternshipEngagement, object) .verifyChanges(changes, { pathPrefix: 'engagement' }); + let updated = previous; + if (changes.status) { + await this.engagementWorkflow.executeTransitionLegacy( + object, + changes.status, + session, + ); + } + await this.files.updateDefinedFile( object.growthPlan, 'engagement.growthPlan', @@ -337,7 +336,7 @@ export class EngagementService { await this.repo.updateInternship(object, changes, changeset); - const updated = (await this.repo.readOne( + updated = (await this.repo.readOne( input.id, session, view, diff --git a/src/components/engagement/index.ts b/src/components/engagement/index.ts index 77f7e10093..74aeddc7f3 100644 --- a/src/components/engagement/index.ts +++ b/src/components/engagement/index.ts @@ -1,3 +1,2 @@ export * from './engagement.service'; -export * from './engagement.rules'; export * from './engagement.loader'; diff --git a/src/components/engagement/workflow/dto/engagement-workflow-event.dto.ts b/src/components/engagement/workflow/dto/engagement-workflow-event.dto.ts new file mode 100644 index 0000000000..0a60aec983 --- /dev/null +++ b/src/components/engagement/workflow/dto/engagement-workflow-event.dto.ts @@ -0,0 +1,32 @@ +import { ObjectType } from '@nestjs/graphql'; +import { keys as keysOf } from 'ts-transformer-keys'; +import { SecuredProps } from '~/common'; +import { e } from '~/core/edgedb'; +import { RegisterResource } from '~/core/resources'; +import { WorkflowEvent } from '../../../workflow/dto'; +import { EngagementStatus, IEngagement } from '../../dto'; +import { EngagementWorkflowTransition } from './engagement-workflow-transition.dto'; + +@RegisterResource({ db: e.Engagement.WorkflowEvent }) +@ObjectType() +export abstract class EngagementWorkflowEvent extends WorkflowEvent( + EngagementStatus, + EngagementWorkflowTransition, +) { + static readonly Props = keysOf(); + static readonly SecuredProps = + keysOf>(); + static readonly BaseNodeProps = WorkflowEvent.BaseNodeProps; + static readonly ConfirmThisClassPassesSensitivityToPolicies = true; + + readonly engagement: Pick; +} + +declare module '~/core/resources/map' { + interface ResourceMap { + EngagementWorkflowEvent: typeof EngagementWorkflowEvent; + } + interface ResourceDBMap { + EngagementWorkflowEvent: typeof e.Engagement.WorkflowEvent; + } +} diff --git a/src/components/engagement/workflow/dto/engagement-workflow-transition.dto.ts b/src/components/engagement/workflow/dto/engagement-workflow-transition.dto.ts new file mode 100644 index 0000000000..f1dc64bb05 --- /dev/null +++ b/src/components/engagement/workflow/dto/engagement-workflow-transition.dto.ts @@ -0,0 +1,10 @@ +import { ObjectType } from '@nestjs/graphql'; +import { WorkflowTransition } from '../../../workflow/dto'; +import { EngagementStatus } from '../../dto'; + +@ObjectType('EngagementWorkflowTransition', { + description: WorkflowTransition.descriptionFor('engagement'), +}) +export abstract class EngagementWorkflowTransition extends WorkflowTransition( + EngagementStatus, +) {} diff --git a/src/components/engagement/workflow/dto/execute-engagement-transition.input.ts b/src/components/engagement/workflow/dto/execute-engagement-transition.input.ts new file mode 100644 index 0000000000..4502ffcaba --- /dev/null +++ b/src/components/engagement/workflow/dto/execute-engagement-transition.input.ts @@ -0,0 +1,14 @@ +import { InputType } from '@nestjs/graphql'; +import { ID, IdField } from '~/common'; +import { ExecuteTransitionInput } from '../../../workflow/dto'; +import { EngagementStatus } from '../../dto'; + +@InputType() +export abstract class ExecuteEngagementTransitionInput extends ExecuteTransitionInput( + EngagementStatus, +) { + @IdField({ + description: 'The engagement ID to transition', + }) + readonly engagement: ID; +} diff --git a/src/components/engagement/workflow/dto/index.ts b/src/components/engagement/workflow/dto/index.ts new file mode 100644 index 0000000000..f38550b46f --- /dev/null +++ b/src/components/engagement/workflow/dto/index.ts @@ -0,0 +1,3 @@ +export * from './execute-engagement-transition.input'; +export * from './engagement-workflow-event.dto'; +export * from './engagement-workflow-transition.dto'; diff --git a/src/components/engagement/workflow/engagement-workflow-event.loader.ts b/src/components/engagement/workflow/engagement-workflow-event.loader.ts new file mode 100644 index 0000000000..bbdd96cd7a --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow-event.loader.ts @@ -0,0 +1,15 @@ +import { ID } from '~/common'; +import { LoaderFactory, SessionAwareLoaderStrategy } from '~/core'; +import { EngagementWorkflowEvent as WorkflowEvent } from './dto'; +import { EngagementWorkflowService } from './engagement-workflow.service'; + +@LoaderFactory(() => WorkflowEvent) +export class EngagementWorkflowEventLoader extends SessionAwareLoaderStrategy { + constructor(private readonly service: EngagementWorkflowService) { + super(); + } + + async loadMany(ids: readonly ID[]) { + return await this.service.readMany(ids, this.session); + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.flowchart.ts b/src/components/engagement/workflow/engagement-workflow.flowchart.ts new file mode 100644 index 0000000000..69e1388179 --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.flowchart.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; +import { WorkflowFlowchart } from '../../workflow/workflow.flowchart'; +import { EngagementWorkflow } from './engagement-workflow'; + +@Injectable() +export class EngagementWorkflowFlowchart extends WorkflowFlowchart( + () => EngagementWorkflow, +) {} diff --git a/src/components/engagement/workflow/engagement-workflow.granter.ts b/src/components/engagement/workflow/engagement-workflow.granter.ts new file mode 100644 index 0000000000..251ddd61fa --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.granter.ts @@ -0,0 +1,15 @@ +import { Granter } from '../../authorization'; +import { WorkflowEventGranter } from '../../workflow/workflow.granter'; +import { EngagementWorkflowEvent as Event } from './dto'; +import { EngagementWorkflow } from './engagement-workflow'; + +@Granter(Event) +export class EngagementWorkflowEventGranter extends WorkflowEventGranter( + () => EngagementWorkflow, +) {} + +declare module '../../authorization/policy/granters' { + interface GrantersOverride { + EngagementWorkflowEvent: EngagementWorkflowEventGranter; + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.module.ts b/src/components/engagement/workflow/engagement-workflow.module.ts new file mode 100644 index 0000000000..b54ee222ea --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.module.ts @@ -0,0 +1,36 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { splitDb2 } from '~/core'; +import { UserModule } from '../../user/user.module'; +import { EngagementModule } from '../engagement.module'; +import { EngagementWorkflowEventLoader } from './engagement-workflow-event.loader'; +import { EngagementWorkflowFlowchart } from './engagement-workflow.flowchart'; +import { EngagementWorkflowEventGranter } from './engagement-workflow.granter'; +import { EngagementWorkflowNeo4jRepository } from './engagement-workflow.neo4j.repository'; +import { EngagementWorkflowRepository } from './engagement-workflow.repository'; +import { EngagementWorkflowService } from './engagement-workflow.service'; +import { EngagementStatusHistoryToWorkflowEventsMigration } from './migrations/engagement-status-history-to-workflow-events.migration'; +import { EngagementExecuteTransitionResolver } from './resolvers/engagement-execute-transition.resolver'; +import { EngagementTransitionsResolver } from './resolvers/engagement-transitions.resolver'; +import { EngagementWorkflowEventResolver } from './resolvers/engagement-workflow-event.resolver'; +import { EngagementWorkflowEventsResolver } from './resolvers/engagement-workflow-events.resolver'; + +@Module({ + imports: [forwardRef(() => UserModule), forwardRef(() => EngagementModule)], + providers: [ + EngagementTransitionsResolver, + EngagementExecuteTransitionResolver, + EngagementWorkflowEventsResolver, + EngagementWorkflowEventResolver, + EngagementWorkflowEventLoader, + EngagementWorkflowService, + EngagementWorkflowEventGranter, + splitDb2(EngagementWorkflowRepository, { + neo4j: EngagementWorkflowNeo4jRepository, + edge: EngagementWorkflowRepository, + }), + EngagementWorkflowFlowchart, + EngagementStatusHistoryToWorkflowEventsMigration, + ], + exports: [EngagementWorkflowService], +}) +export class EngagementWorkflowModule {} diff --git a/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts b/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts new file mode 100644 index 0000000000..018b4517f5 --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts @@ -0,0 +1,195 @@ +import { Injectable } from '@nestjs/common'; +import { inArray, node, Query, relation } from 'cypher-query-builder'; +import { + ID, + Order, + PublicOf, + ServerException, + Session, + UnsecuredDto, +} from '~/common'; +import { DtoRepository } from '~/core/database'; +import { + ACTIVE, + createNode, + createRelationships, + INACTIVE, + merge, + requestingUser, + sorting, +} from '~/core/database/query'; +import { ProjectStep } from '../../project/dto'; +import { EngagementStatus, IEngagement } from '../dto'; +import { + ExecuteEngagementTransitionInput, + EngagementWorkflowEvent as WorkflowEvent, +} from './dto'; +import { EngagementWorkflowRepository } from './engagement-workflow.repository'; + +@Injectable() +export class EngagementWorkflowNeo4jRepository + extends DtoRepository(WorkflowEvent) + implements PublicOf +{ + // @ts-expect-error It doesn't have match base signature + async readMany(ids: readonly ID[], session: Session) { + return await this.db + .query() + .apply(this.matchEvent()) + .where({ 'node.id': inArray(ids) }) + .apply(this.privileges.forUser(session).filterToReadable()) + .apply(this.hydrate()) + .map('dto') + .run(); + } + + async list(engagementId: ID, session: Session) { + return await this.db + .query() + .apply(this.matchEvent()) + .where({ 'engagement.id': engagementId }) + .match(requestingUser(session)) + .apply(this.privileges.forUser(session).filterToReadable()) + .apply(sorting(WorkflowEvent, { sort: 'createdAt', order: Order.ASC })) + .apply(this.hydrate()) + .map('dto') + .run(); + } + + protected matchEvent() { + return (query: Query) => + query.match([ + node('node', this.resource.dbLabel), + relation('in', '', ACTIVE), + node('engagement', 'Engagement'), + ]); + } + + protected hydrate() { + return (query: Query) => + query + .match([ + node('engagement', 'Engagement'), + relation('out', '', 'workflowEvent', ACTIVE), + node('node'), + relation('out', undefined, 'who'), + node('who', 'Actor'), + ]) + .return<{ dto: UnsecuredDto }>( + merge('node', { + at: 'node.createdAt', + who: 'who { .id }', + engagement: 'engagement { .id }', + }).as('dto'), + ); + } + + async recordEvent( + { + engagement, + ...props + }: Omit & { + to: EngagementStatus; + }, + session: Session, + ) { + const result = await this.db + .query() + .apply( + await createNode(WorkflowEvent, { + baseNodeProps: props, + }), + ) + .apply( + createRelationships(WorkflowEvent, { + in: { workflowEvent: ['Engagement', engagement] }, + out: { who: ['Actor', session.userId] }, + }), + ) + .apply(this.hydrate()) + .first(); + const event = result!.dto; + + await this.db.updateProperties({ + type: IEngagement, + object: { id: engagement }, + changes: { status: event.to, statusModifiedAt: event.at }, + permanentAfter: null, + }); + + return event; + } + + async mostRecentStep( + engagementId: ID<'Engagement'>, + steps: readonly EngagementStatus[], + ) { + const result = await this.db + .query() + .match([ + node('node', 'Engagement', { id: engagementId }), + relation('out', '', 'status', INACTIVE), + node('prop'), + ]) + .where({ 'prop.value': inArray(steps) }) + .with('prop') + .orderBy('prop.createdAt', 'DESC') + .return<{ step: EngagementStatus }>(`prop.value as step`) + .first(); + return result?.step ?? null; + } + + async getCurrentProjectStep(engagementId: ID, changeset?: ID) { + const result = await this.db + .query() + .match([ + node('engagement', 'Engagement', { id: engagementId }), + relation('in', '', 'engagement'), // Removed active true due to changeset aware + node('project', 'Project'), + ]) + .raw('return project.id as projectId') + .asResult<{ projectId: ID }>() + .first(); + + if (!result?.projectId) { + throw new ServerException(`Could not find project`); + } + const projectId = result.projectId; + + let currentStep; + if (changeset) { + const result = await this.db + .query() + .match([ + node('project', 'Project', { id: projectId }), + relation('out', '', 'step', INACTIVE), + node('step', 'Property'), + relation('in', '', 'changeset', ACTIVE), + node('', 'Changeset', { id: changeset }), + ]) + .raw('return step.value as step') + .asResult<{ step: ProjectStep }>() + .first(); + currentStep = result?.step; + } + if (!currentStep) { + const result = await this.db + .query() + .match([ + node('project', 'Project', { id: projectId }), + relation('out', '', 'step', ACTIVE), + node('step', 'Property'), + ]) + .raw('return step.value as step') + .asResult<{ step: ProjectStep }>() + .first(); + currentStep = result?.step; + } + + if (!currentStep) { + throw new ServerException(`Could not find project's step`); + } + + return currentStep; + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.repository.ts b/src/components/engagement/workflow/engagement-workflow.repository.ts new file mode 100644 index 0000000000..5b593e2190 --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { ID, Session, UnsecuredDto } from '~/common'; +import { e, edgeql, RepoFor } from '~/core/edgedb'; +import { EngagementStatus } from '../dto'; +import { + EngagementWorkflowEvent, + ExecuteEngagementTransitionInput, +} from './dto'; + +@Injectable() +export class EngagementWorkflowRepository extends RepoFor( + EngagementWorkflowEvent, + { + hydrate: (event) => ({ + id: true, + who: true, + at: true, + transition: event.transitionKey, + to: true, + notes: true, + engagement: true, + }), + omit: ['list', 'create', 'update', 'delete', 'readMany'], + }, +) { + async readMany(ids: readonly ID[], _session: Session) { + return await this.defaults.readMany(ids); + } + + async list(engagementId: ID, _session: Session) { + const engagement = e.cast(e.Engagement, e.uuid(engagementId)); + const query = e.select(engagement.workflowEvents, this.hydrate); + return await this.db.run(query); + } + + async recordEvent( + input: Omit & { + to: EngagementStatus; + }, + _session: Session, + ): Promise> { + const engagement = e.cast(e.Engagement, e.uuid(input.engagement)); + const created = e.insert(e.Engagement.WorkflowEvent, { + engagement, + projectContext: engagement.projectContext, + transitionKey: input.transition, + to: input.to, + notes: input.notes, + }); + const query = e.select(created, this.hydrate); + return await this.db.run(query); + } + + async mostRecentStep( + engagementId: ID<'Engagement'>, + steps: readonly EngagementStatus[], + ) { + const query = edgeql(` + with + engagement := $engagementId, + steps := array_unpack(>$steps), + mostRecentEvent := ( + select engagement.workflowEvents + filter .to in steps if exists steps else true + order by .at desc + limit 1 + ) + select mostRecentEvent.to + `); + return await this.db.run(query, { engagementId, steps }); + } + + async getCurrentProjectStep(engagementId: ID) { + const engagement = e.cast(e.Engagement, e.uuid(engagementId)); + const project = e.cast(e.Project, engagement.project.id); + const query = e.select(project.step); + return await this.db.run(query); + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.service.ts b/src/components/engagement/workflow/engagement-workflow.service.ts new file mode 100644 index 0000000000..266c814a16 --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.service.ts @@ -0,0 +1,112 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { ID, Session, UnsecuredDto } from '~/common'; +import { + findTransition, + WorkflowService, +} from '../../workflow/workflow.service'; +import { Engagement, EngagementStatus } from '../dto'; +import { EngagementService } from '../engagement.service'; +import { + ExecuteEngagementTransitionInput, + EngagementWorkflowEvent as WorkflowEvent, +} from './dto'; +import { EngagementWorkflow } from './engagement-workflow'; +import { EngagementWorkflowRepository } from './engagement-workflow.repository'; + +@Injectable() +export class EngagementWorkflowService extends WorkflowService( + () => EngagementWorkflow, +) { + constructor( + @Inject(forwardRef(() => EngagementService)) + private readonly engagements: EngagementService & {}, + private readonly repo: EngagementWorkflowRepository, + private readonly moduleRef: ModuleRef, + ) { + super(); + } + + async list( + engagement: Engagement, + session: Session, + ): Promise { + const dtos = await this.repo.list(engagement.id, session); + return dtos.map((dto) => this.secure(dto, session)); + } + + async readMany(ids: readonly ID[], session: Session) { + const dtos = await this.repo.readMany(ids, session); + return dtos.map((dto) => this.secure(dto, session)); + } + + private secure( + dto: UnsecuredDto, + session: Session, + ): WorkflowEvent { + return { + ...this.privileges.for(session, WorkflowEvent).secure(dto), + transition: this.transitionByKey(dto.transition, dto.to), + }; + } + + async getAvailableTransitions(engagement: Engagement, session: Session) { + return await this.resolveAvailable( + engagement.status.value!, + { engagement, moduleRef: this.moduleRef }, + { ...engagement, engagement }, + session, + ); + } + + async executeTransition( + input: ExecuteEngagementTransitionInput, + session: Session, + ) { + const { engagement: engagementId, notes } = input; + + const previous = await this.engagements.readOne(engagementId, session); + + const next = + this.getBypassIfValid(input, session) ?? + findTransition( + await this.getAvailableTransitions(previous, session), + input.transition, + ); + + await this.repo.recordEvent( + { + engagement: engagementId, + ...(typeof next !== 'string' + ? { transition: next.key, to: next.to } + : { to: next }), + notes, + }, + session, + ); + + return await this.engagements.readOne(engagementId, session); + } + + /** @deprecated */ + async executeTransitionLegacy( + currentEngagement: Engagement, + step: EngagementStatus, + session: Session, + ) { + const transitions = await this.getAvailableTransitions( + currentEngagement, + session, + ); + + const transition = transitions.find((t) => t.to === step); + + await this.executeTransition( + { + engagement: currentEngagement.id, + ...(transition ? { transition: transition.key } : { bypassTo: step }), + }, + session, + ); + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.ts b/src/components/engagement/workflow/engagement-workflow.ts new file mode 100644 index 0000000000..6d8669a781 --- /dev/null +++ b/src/components/engagement/workflow/engagement-workflow.ts @@ -0,0 +1,137 @@ +import { defineContext, defineWorkflow } from '../../workflow/define-workflow'; +import { TransitionType as Type } from '../../workflow/dto'; +import { EngagementStatus as Status } from '../dto'; +import { EngagementWorkflowEvent } from './dto'; +import { BackTo, BackToActive } from './transitions/back'; +import { EngagementWorkflowContext } from './transitions/context'; + +// This also controls the order shown in the UI. +// Therefore, these should generally flow down. +// "Back" transitions should come before/above "forward" transitions. +export const EngagementWorkflow = defineWorkflow({ + id: '0d0a59f6-c5f0-4c1d-bd5f-7b2814c76812', + name: 'Engagement', + states: Status, + event: EngagementWorkflowEvent, + context: defineContext, +})({ + Reject: { + from: Status.InDevelopment, + to: Status.Rejected, + label: 'Reject', + type: Type.Reject, + }, + 'End Development': { + from: Status.InDevelopment, + to: Status.DidNotDevelop, + label: 'End Development', + type: Type.Reject, + }, + 'Approve to Active': { + from: Status.InDevelopment, + to: Status.Active, + label: 'Approve', + type: Type.Approve, + }, + 'Discuss Change To Plan': { + from: [Status.Active, Status.ActiveChangedPlan], + to: Status.DiscussingChangeToPlan, + label: 'Discuss Change to Plan', + type: Type.Neutral, + }, + 'Discuss Suspension': { + from: [Status.Active, Status.ActiveChangedPlan], + to: Status.DiscussingSuspension, + label: 'Discuss Susupension', + type: Type.Neutral, + }, + 'Discuss Termination': { + from: [ + Status.Active, + Status.ActiveChangedPlan, + Status.DiscussingSuspension, + Status.Suspended, + Status.DiscussingReactivation, + ], + to: Status.DiscussingTermination, + label: 'Discuss Termination', + type: Type.Neutral, + }, + 'Finalize Completion': { + from: [Status.Active, Status.ActiveChangedPlan], + to: Status.FinalizingCompletion, + label: 'Finalize Completion', + type: Type.Approve, + }, + 'Approve Change to Plan': { + from: Status.DiscussingChangeToPlan, + to: Status.ActiveChangedPlan, + label: 'Approve Change to Plan', + type: Type.Approve, + }, + 'Will Not Change Plan': { + from: Status.DiscussingChangeToPlan, + to: BackToActive, + label: 'Will Not Change Plan', + type: Type.Neutral, + }, + 'Discussing Change to Plan -> Discussing Suspension': { + from: [Status.DiscussingChangeToPlan], + to: Status.DiscussingSuspension, + label: 'Discuss Susupension', + type: Type.Neutral, + }, + 'Approve Suspension': { + from: Status.DiscussingSuspension, + to: Status.Suspended, + label: 'Approve Suspension', + type: Type.Approve, + }, + 'Will Not Suspend': { + from: Status.DiscussingSuspension, + to: BackToActive, + label: 'Will Not Suspend', + type: Type.Neutral, + }, + 'Discuss Reactivation': { + from: Status.Suspended, + to: Status.DiscussingReactivation, + label: 'Discuss Reactivation', + type: Type.Neutral, + }, + 'Approve Reactivation': { + from: Status.DiscussingReactivation, + to: Status.ActiveChangedPlan, + label: 'Approve Reactivation', + type: Type.Approve, + }, + 'End Termination Discussion': { + from: Status.DiscussingTermination, + to: BackTo( + Status.Active, + Status.ActiveChangedPlan, + Status.DiscussingReactivation, + Status.Suspended, + ), + label: 'Will Not Terminate', + type: Type.Neutral, + }, + 'Approve Termination': { + from: Status.DiscussingTermination, + to: Status.Terminated, + label: 'Approve Termination', + type: Type.Approve, + }, + 'Not Ready for Completion': { + from: Status.FinalizingCompletion, + to: BackToActive, + label: 'Still Working', + type: Type.Neutral, + }, + Complete: { + from: Status.FinalizingCompletion, + to: Status.Completed, + label: 'Complete 🎉', + type: Type.Approve, + }, +}); diff --git a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts new file mode 100644 index 0000000000..e6b2a45c04 --- /dev/null +++ b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts @@ -0,0 +1,146 @@ +import { ModuleRef } from '@nestjs/core'; +import { node, relation } from 'cypher-query-builder'; +import { chunk } from 'lodash'; +import { DateTime } from 'luxon'; +import { Disabled, ID } from '~/common'; +import { BaseMigration, Migration } from '~/core/database'; +import { ACTIVE, variable } from '~/core/database/query'; +import { SystemAgentRepository } from '../../../user/system-agent.repository'; +import { Engagement, EngagementStatus } from '../../dto'; +import { EngagementWorkflowRepository } from '../engagement-workflow.repository'; +import { EngagementWorkflowService } from '../engagement-workflow.service'; + +@Disabled('Until Carson reviews')(Migration('2024-07-05T09:00:02')) +export class EngagementStatusHistoryToWorkflowEventsMigration extends BaseMigration { + constructor( + private readonly agents: SystemAgentRepository, + private readonly workflow: EngagementWorkflowService, + private readonly moduleRef: ModuleRef, + ) { + super(); + } + + async up() { + const ghost = await this.agents.getGhost(); + const engagements = await this.db + .query() + .match(node('engagement', 'Engagement')) + .match(node('ghost', 'Actor', { id: ghost.id })) + .subQuery('engagement', (sub) => + sub + .match([ + node('engagement', 'Engagement'), + relation('out', '', 'status'), + node('status'), + ]) + .with('status') + .orderBy('status.createdAt', 'asc') + .return('collect(apoc.convert.toMap(status)) as steps'), + ) + .with('engagement, steps') + .raw('where size(steps) > 1') + .return<{ + engagement: { id: ID }; + steps: ReadonlyArray<{ value: EngagementStatus; createdAt: DateTime }>; + }>('apoc.convert.toMap(engagement) as engagement, steps') + .run(); + this.logger.notice( + `Found ${engagements.length} engagements to add event history to.`, + ); + + const events: Array< + Parameters[0] & { + at: DateTime; + } + > = []; + + for (const [i, { engagement, steps }] of engagements.entries()) { + if (i % 100 === 0) { + this.logger.notice( + `Processing engagement ${i + 1}/${engagements.length}`, + ); + } + + for (const [i, next] of steps.entries()) { + if (i === 0) { + continue; + } + const current = steps[i - 1]!; + const prev = steps + .slice(0, Math.max(0, i - 2)) + .map((s) => s.value) + .reverse(); + const fakeEngagement: Engagement = { + id: engagement.id, + step: { value: current.value, canRead: true, canEdit: true }, + } as any; + // @ts-expect-error private but this is a migration + const transitions = await this.workflow.resolveAvailable( + current.value, + { + engagement: fakeEngagement, + moduleRef: this.moduleRef, + migrationPrevStates: prev, + }, + engagement, + // We don't know who did it, so we can't confirm this was an official + // transition instead of a bypass. + // Guess that it was if a transition exists. + this.fakeAdminSession, + ); + + const transition = transitions.find((t) => t.to === next.value)?.key; + + events.push({ + engagement: engagement.id, + to: next.value, + transition, + at: next.createdAt, + }); + } + } + + const transitionsCount = events.filter((e) => e.transition).length; + this.logger.notice(`Resolved events to save`, { + events: events.length, + transitions: transitionsCount, + bypasses: events.length - transitionsCount, + }); + + for (const [i, someEvents] of chunk(events, 1000).entries()) { + this.logger.notice(`Saving events ${i + 1}k`); + + const query = this.db + .query() + .match(node('ghost', 'Actor', { id: ghost.id })) + .unwind(someEvents, 'input') + .match( + node('engagement', 'Engagement', { + id: variable('input.engagement'), + }), + ) + .create([ + node('engagement'), + relation('out', '', 'workflowEvent', { + ...ACTIVE, + createdAt: variable('input.at'), + }), + node('event', ['EngagementWorkflowEvent', 'BaseNode'], { + id: variable('apoc.create.uuid()'), + createdAt: variable('input.at'), + to: variable('input.to'), + transition: variable('input.transition'), + notes: null, + migrated: true, + }), + relation('out', '', 'who', { + ...ACTIVE, + createdAt: variable('input.at'), + }), + node('ghost'), + ]) + .return('count(event) as event'); + await query.executeAndLogStats(); + } + } +} diff --git a/src/components/engagement/workflow/resolvers/engagement-execute-transition.resolver.ts b/src/components/engagement/workflow/resolvers/engagement-execute-transition.resolver.ts new file mode 100644 index 0000000000..a25270f76d --- /dev/null +++ b/src/components/engagement/workflow/resolvers/engagement-execute-transition.resolver.ts @@ -0,0 +1,18 @@ +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { LoggedInSession, Session } from '~/common'; +import { Engagement, IEngagement } from '../../dto'; +import { ExecuteEngagementTransitionInput } from '../dto'; +import { EngagementWorkflowService } from '../engagement-workflow.service'; + +@Resolver() +export class EngagementExecuteTransitionResolver { + constructor(private readonly workflow: EngagementWorkflowService) {} + + @Mutation(() => IEngagement) + async transitionEngagement( + @Args({ name: 'input' }) input: ExecuteEngagementTransitionInput, + @LoggedInSession() session: Session, + ): Promise { + return await this.workflow.executeTransition(input, session); + } +} diff --git a/src/components/engagement/workflow/resolvers/engagement-transitions.resolver.ts b/src/components/engagement/workflow/resolvers/engagement-transitions.resolver.ts new file mode 100644 index 0000000000..f21f3d6998 --- /dev/null +++ b/src/components/engagement/workflow/resolvers/engagement-transitions.resolver.ts @@ -0,0 +1,55 @@ +import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; +import { Loader, LoaderOf } from '@seedcompany/data-loader'; +import { stripIndent } from 'common-tags'; +import { + AnonSession, + ParentIdMiddlewareAdditions, + Session, + viewOfChangeset, +} from '~/common'; +import { SerializedWorkflow } from '../../../workflow/dto'; +import { SecuredEngagementStatus } from '../../dto'; +import { EngagementLoader } from '../../engagement.loader'; +import { EngagementWorkflowTransition } from '../dto'; +import { EngagementWorkflowService } from '../engagement-workflow.service'; + +@Resolver(SecuredEngagementStatus) +export class EngagementTransitionsResolver { + constructor(private readonly workflow: EngagementWorkflowService) {} + + @Query(() => SerializedWorkflow) + async engagementWorkflow() { + return this.workflow.serialize(); + } + + @ResolveField(() => [EngagementWorkflowTransition], { + description: + 'The transitions currently available to execute for this engagement', + }) + async transitions( + @Parent() status: SecuredEngagementStatus & ParentIdMiddlewareAdditions, + @Loader(EngagementLoader) engagements: LoaderOf, + @AnonSession() session: Session, + ): Promise { + if (!status.canRead || !status.value) { + return []; + } + const engagement = await engagements.load({ + id: status.parentId, + view: viewOfChangeset(status.changeset), + }); + return await this.workflow.getAvailableTransitions(engagement, session); + } + + @ResolveField(() => Boolean, { + description: stripIndent` + Is the current user allowed to bypass transitions entirely + and change to any other state? + `, + }) + async canBypassTransitions( + @AnonSession() session: Session, + ): Promise { + return this.workflow.canBypass(session); + } +} diff --git a/src/components/engagement/workflow/resolvers/engagement-workflow-event.resolver.ts b/src/components/engagement/workflow/resolvers/engagement-workflow-event.resolver.ts new file mode 100644 index 0000000000..fec278c684 --- /dev/null +++ b/src/components/engagement/workflow/resolvers/engagement-workflow-event.resolver.ts @@ -0,0 +1,17 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { mapSecuredValue } from '~/common'; +import { Loader, LoaderOf } from '~/core'; +import { ActorLoader } from '../../../user/actor.loader'; +import { SecuredActor } from '../../../user/dto'; +import { EngagementWorkflowEvent as WorkflowEvent } from '../dto'; + +@Resolver(WorkflowEvent) +export class EngagementWorkflowEventResolver { + @ResolveField(() => SecuredActor) + async who( + @Parent() event: WorkflowEvent, + @Loader(ActorLoader) actors: LoaderOf, + ): Promise { + return await mapSecuredValue(event.who, ({ id }) => actors.load(id)); + } +} diff --git a/src/components/engagement/workflow/resolvers/engagement-workflow-events.resolver.ts b/src/components/engagement/workflow/resolvers/engagement-workflow-events.resolver.ts new file mode 100644 index 0000000000..a7c6c43ea5 --- /dev/null +++ b/src/components/engagement/workflow/resolvers/engagement-workflow-events.resolver.ts @@ -0,0 +1,18 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { AnonSession, Session } from '~/common'; +import { Engagement, IEngagement } from '../../dto'; +import { EngagementWorkflowEvent as WorkflowEvent } from '../dto'; +import { EngagementWorkflowService } from '../engagement-workflow.service'; + +@Resolver(IEngagement) +export class EngagementWorkflowEventsResolver { + constructor(private readonly service: EngagementWorkflowService) {} + + @ResolveField(() => [WorkflowEvent]) + async workflowEvents( + @Parent() engagement: Engagement, + @AnonSession() session: Session, + ): Promise { + return await this.service.list(engagement, session); + } +} diff --git a/src/components/engagement/workflow/transitions/back.ts b/src/components/engagement/workflow/transitions/back.ts new file mode 100644 index 0000000000..7acb779bca --- /dev/null +++ b/src/components/engagement/workflow/transitions/back.ts @@ -0,0 +1,21 @@ +import { DynamicState } from '../../../workflow/transitions/dynamic-state'; +import { EngagementStatus, EngagementStatus as Step } from '../../dto'; +import { EngagementWorkflowRepository } from '../engagement-workflow.repository'; +import { EngagementWorkflowContext } from './context'; + +export const BackTo = ( + ...states: EngagementStatus[] +): DynamicState => ({ + description: 'Back', + relatedStates: states, + async resolve({ engagement, moduleRef, migrationPrevStates }) { + if (migrationPrevStates) { + return migrationPrevStates.find((s) => states.includes(s)) ?? states[0]; + } + const repo = moduleRef.get(EngagementWorkflowRepository); + const found = await repo.mostRecentStep(engagement.id, states); + return found ?? states[0] ?? EngagementStatus.InDevelopment; + }, +}); + +export const BackToActive = BackTo(Step.Active, Step.ActiveChangedPlan); diff --git a/src/components/engagement/workflow/transitions/context.ts b/src/components/engagement/workflow/transitions/context.ts new file mode 100644 index 0000000000..3a061025d8 --- /dev/null +++ b/src/components/engagement/workflow/transitions/context.ts @@ -0,0 +1,8 @@ +import { ModuleRef } from '@nestjs/core'; +import { Engagement, EngagementStatus } from '../../dto'; + +export interface EngagementWorkflowContext { + engagement: Engagement; + moduleRef: ModuleRef; + migrationPrevStates?: EngagementStatus[]; +} From 2e9ea2572c79f6ebfd28aa0a77f28054246d0f9b Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 8 Jul 2024 17:39:05 -0500 Subject: [PATCH 02/19] Add project step condition --- .../engagement/dto/engagement.dto.ts | 3 +- .../engagement/engagement.repository.ts | 6 ++ .../engagement-workflow.neo4j.repository.ts | 64 +------------------ .../engagement-workflow.repository.ts | 7 -- .../workflow/engagement-workflow.ts | 4 ++ .../workflow/transitions/project-step.ts | 24 +++++++ 6 files changed, 37 insertions(+), 71 deletions(-) create mode 100644 src/components/engagement/workflow/transitions/project-step.ts diff --git a/src/components/engagement/dto/engagement.dto.ts b/src/components/engagement/dto/engagement.dto.ts index 00ca49bdf5..31010d54df 100644 --- a/src/components/engagement/dto/engagement.dto.ts +++ b/src/components/engagement/dto/engagement.dto.ts @@ -69,7 +69,8 @@ class Engagement extends Interfaces { declare readonly __typename: 'LanguageEngagement' | 'InternshipEngagement'; - readonly project: LinkTo<'Project'> & Pick; + readonly project: LinkTo<'Project'> & + Pick, 'type' | 'step' | 'status'>; @Field(() => IProject) declare readonly parent: BaseNode; diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 9a92d5fb22..5d2a582054 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -158,6 +158,11 @@ export class EngagementRepository extends CommonRepository { relation('out', '', 'status', ACTIVE), node('status'), ]) + .match([ + node('project'), + relation('out', '', 'step', ACTIVE), + node('projectStep'), + ]) .return<{ dto: UnsecuredDto }>( merge('props', 'changedProps', { __typename: typenameForView( @@ -169,6 +174,7 @@ export class EngagementRepository extends CommonRepository { id: 'project.id', type: 'project.type', status: 'status.value', + step: 'projectStep.value', }, language: 'language.id', ceremony: 'ceremony.id', diff --git a/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts b/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts index 018b4517f5..7bd674aef2 100644 --- a/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts +++ b/src/components/engagement/workflow/engagement-workflow.neo4j.repository.ts @@ -1,13 +1,6 @@ import { Injectable } from '@nestjs/common'; import { inArray, node, Query, relation } from 'cypher-query-builder'; -import { - ID, - Order, - PublicOf, - ServerException, - Session, - UnsecuredDto, -} from '~/common'; +import { ID, Order, PublicOf, Session, UnsecuredDto } from '~/common'; import { DtoRepository } from '~/core/database'; import { ACTIVE, @@ -18,7 +11,6 @@ import { requestingUser, sorting, } from '~/core/database/query'; -import { ProjectStep } from '../../project/dto'; import { EngagementStatus, IEngagement } from '../dto'; import { ExecuteEngagementTransitionInput, @@ -138,58 +130,4 @@ export class EngagementWorkflowNeo4jRepository .first(); return result?.step ?? null; } - - async getCurrentProjectStep(engagementId: ID, changeset?: ID) { - const result = await this.db - .query() - .match([ - node('engagement', 'Engagement', { id: engagementId }), - relation('in', '', 'engagement'), // Removed active true due to changeset aware - node('project', 'Project'), - ]) - .raw('return project.id as projectId') - .asResult<{ projectId: ID }>() - .first(); - - if (!result?.projectId) { - throw new ServerException(`Could not find project`); - } - const projectId = result.projectId; - - let currentStep; - if (changeset) { - const result = await this.db - .query() - .match([ - node('project', 'Project', { id: projectId }), - relation('out', '', 'step', INACTIVE), - node('step', 'Property'), - relation('in', '', 'changeset', ACTIVE), - node('', 'Changeset', { id: changeset }), - ]) - .raw('return step.value as step') - .asResult<{ step: ProjectStep }>() - .first(); - currentStep = result?.step; - } - if (!currentStep) { - const result = await this.db - .query() - .match([ - node('project', 'Project', { id: projectId }), - relation('out', '', 'step', ACTIVE), - node('step', 'Property'), - ]) - .raw('return step.value as step') - .asResult<{ step: ProjectStep }>() - .first(); - currentStep = result?.step; - } - - if (!currentStep) { - throw new ServerException(`Could not find project's step`); - } - - return currentStep; - } } diff --git a/src/components/engagement/workflow/engagement-workflow.repository.ts b/src/components/engagement/workflow/engagement-workflow.repository.ts index 5b593e2190..2c97f2b8d3 100644 --- a/src/components/engagement/workflow/engagement-workflow.repository.ts +++ b/src/components/engagement/workflow/engagement-workflow.repository.ts @@ -69,11 +69,4 @@ export class EngagementWorkflowRepository extends RepoFor( `); return await this.db.run(query, { engagementId, steps }); } - - async getCurrentProjectStep(engagementId: ID) { - const engagement = e.cast(e.Engagement, e.uuid(engagementId)); - const project = e.cast(e.Project, engagement.project.id); - const query = e.select(project.step); - return await this.db.run(query); - } } diff --git a/src/components/engagement/workflow/engagement-workflow.ts b/src/components/engagement/workflow/engagement-workflow.ts index 6d8669a781..3f606f27dc 100644 --- a/src/components/engagement/workflow/engagement-workflow.ts +++ b/src/components/engagement/workflow/engagement-workflow.ts @@ -4,6 +4,7 @@ import { EngagementStatus as Status } from '../dto'; import { EngagementWorkflowEvent } from './dto'; import { BackTo, BackToActive } from './transitions/back'; import { EngagementWorkflowContext } from './transitions/context'; +import { ProjectStep } from './transitions/project-step'; // This also controls the order shown in the UI. // Therefore, these should generally flow down. @@ -20,18 +21,21 @@ export const EngagementWorkflow = defineWorkflow({ to: Status.Rejected, label: 'Reject', type: Type.Reject, + conditions: ProjectStep('Rejected'), }, 'End Development': { from: Status.InDevelopment, to: Status.DidNotDevelop, label: 'End Development', type: Type.Reject, + conditions: ProjectStep('DidNotDevelop'), }, 'Approve to Active': { from: Status.InDevelopment, to: Status.Active, label: 'Approve', type: Type.Approve, + conditions: ProjectStep('Active'), }, 'Discuss Change To Plan': { from: [Status.Active, Status.ActiveChangedPlan], diff --git a/src/components/engagement/workflow/transitions/project-step.ts b/src/components/engagement/workflow/transitions/project-step.ts new file mode 100644 index 0000000000..ee8ec688db --- /dev/null +++ b/src/components/engagement/workflow/transitions/project-step.ts @@ -0,0 +1,24 @@ +import { setOf } from '@seedcompany/common'; +import { ProjectStep as Step } from '../../../project/dto'; +import { TransitionCondition } from '../../../workflow/transitions/conditions'; +import { EngagementWorkflowContext } from './context'; + +type Condition = TransitionCondition; + +export const ProjectStep = (...steps: Step[]): Condition => { + const stepSet = setOf(steps); + const description = + 'Project needs to be ' + + [...stepSet].map((step) => Step.entry(step).label).join(' / '); + return { + description, + resolve({ engagement }) { + return { + status: steps.includes(engagement.project.step) + ? 'ENABLED' + : 'DISABLED', + disabledReason: description, + }; + }, + }; +}; From b05ace68a91e883317b64c81d0b68efdc39fb65d Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 8 Jul 2024 11:53:13 -0500 Subject: [PATCH 03/19] Polish transition names --- .../policies/by-role/controller.policy.ts | 12 ++++++------ .../policies/by-role/project-manager.policy.ts | 8 ++++---- .../engagement/workflow/engagement-workflow.ts | 18 +++++++++--------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/authorization/policies/by-role/controller.policy.ts b/src/components/authorization/policies/by-role/controller.policy.ts index b0568fd50b..c886d94894 100644 --- a/src/components/authorization/policies/by-role/controller.policy.ts +++ b/src/components/authorization/policies/by-role/controller.policy.ts @@ -4,12 +4,12 @@ import { Policy, Role } from '../util'; @Policy(Role.Controller, (r) => [ // keep multiline format r.EngagementWorkflowEvent.read.transitions( - 'Reject', - 'End Development', - 'Approve to Active', - 'Approve Change to Plan', - 'Will Not Change Plan', - 'Discussing Change to Plan -> Discussing Suspension', + 'Reject Proposal', + 'End Proposal', + 'Approve Proposal', + 'Approve Change To Plan', + 'End Change To Plan Discussion', + 'Discuss Suspension out of Change to Plan Discussion', ).execute, r.Organization.delete, r.Partner.delete, diff --git a/src/components/authorization/policies/by-role/project-manager.policy.ts b/src/components/authorization/policies/by-role/project-manager.policy.ts index 80005d0625..30ac7f6821 100644 --- a/src/components/authorization/policies/by-role/project-manager.policy.ts +++ b/src/components/authorization/policies/by-role/project-manager.policy.ts @@ -87,10 +87,10 @@ export const momentumProjectsTransitions = () => 'Discuss Suspension', 'Discuss Termination', 'Finalize Completion', - 'Approve Change to Plan', - 'Will Not Change Plan', - 'Discussing Change to Plan -> Discussing Suspension', - 'Will Not Suspend', + 'Approve Change To Plan', + 'End Change To Plan Discussion', + 'Discuss Suspension out of Change to Plan Discussion', + 'End Suspension Discussion', 'Approve Suspension', 'Approve Reactivation', 'Discuss Reactivation', diff --git a/src/components/engagement/workflow/engagement-workflow.ts b/src/components/engagement/workflow/engagement-workflow.ts index 3f606f27dc..488fc8385b 100644 --- a/src/components/engagement/workflow/engagement-workflow.ts +++ b/src/components/engagement/workflow/engagement-workflow.ts @@ -16,21 +16,21 @@ export const EngagementWorkflow = defineWorkflow({ event: EngagementWorkflowEvent, context: defineContext, })({ - Reject: { + 'Reject Proposal': { from: Status.InDevelopment, to: Status.Rejected, label: 'Reject', type: Type.Reject, conditions: ProjectStep('Rejected'), }, - 'End Development': { + 'End Proposal': { from: Status.InDevelopment, to: Status.DidNotDevelop, label: 'End Development', type: Type.Reject, conditions: ProjectStep('DidNotDevelop'), }, - 'Approve to Active': { + 'Approve Proposal': { from: Status.InDevelopment, to: Status.Active, label: 'Approve', @@ -46,7 +46,7 @@ export const EngagementWorkflow = defineWorkflow({ 'Discuss Suspension': { from: [Status.Active, Status.ActiveChangedPlan], to: Status.DiscussingSuspension, - label: 'Discuss Susupension', + label: 'Discuss Suspension', type: Type.Neutral, }, 'Discuss Termination': { @@ -67,22 +67,22 @@ export const EngagementWorkflow = defineWorkflow({ label: 'Finalize Completion', type: Type.Approve, }, - 'Approve Change to Plan': { + 'Approve Change To Plan': { from: Status.DiscussingChangeToPlan, to: Status.ActiveChangedPlan, label: 'Approve Change to Plan', type: Type.Approve, }, - 'Will Not Change Plan': { + 'End Change To Plan Discussion': { from: Status.DiscussingChangeToPlan, to: BackToActive, label: 'Will Not Change Plan', type: Type.Neutral, }, - 'Discussing Change to Plan -> Discussing Suspension': { + 'Discuss Suspension out of Change to Plan Discussion': { from: [Status.DiscussingChangeToPlan], to: Status.DiscussingSuspension, - label: 'Discuss Susupension', + label: 'Discuss Suspension', type: Type.Neutral, }, 'Approve Suspension': { @@ -91,7 +91,7 @@ export const EngagementWorkflow = defineWorkflow({ label: 'Approve Suspension', type: Type.Approve, }, - 'Will Not Suspend': { + 'End Suspension Discussion': { from: Status.DiscussingSuspension, to: BackToActive, label: 'Will Not Suspend', From c84ef4cb98cbacea3496d47dc9b2c882c428c782 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 9 Jul 2024 15:12:22 -0500 Subject: [PATCH 04/19] Fix permissions In general, just tried to follow suit of project workflow --- ...12-m1pqxkv.edgeql => 00012-m1vbhai.edgeql} | 4 +-- .../by-role/financial-analyst-lead.policy.ts | 1 + .../by-role/financial-analyst.policy.ts | 10 ++++-- .../by-role/project-manager.policy.ts | 36 ++++++++++--------- .../by-role/regional-director.policy.ts | 7 ++++ .../workflow/engagement-workflow.ts | 1 + 6 files changed, 38 insertions(+), 21 deletions(-) rename dbschema/migrations/{00012-m1pqxkv.edgeql => 00012-m1vbhai.edgeql} (57%) diff --git a/dbschema/migrations/00012-m1pqxkv.edgeql b/dbschema/migrations/00012-m1vbhai.edgeql similarity index 57% rename from dbschema/migrations/00012-m1pqxkv.edgeql rename to dbschema/migrations/00012-m1vbhai.edgeql index a18cc46c5c..e0312ba2aa 100644 --- a/dbschema/migrations/00012-m1pqxkv.edgeql +++ b/dbschema/migrations/00012-m1vbhai.edgeql @@ -1,4 +1,4 @@ -CREATE MIGRATION m1pqxkvfgki4526meuwugacot76zuerjw34bynv25qbjbga3t7ezta +CREATE MIGRATION m1vbhain4p2tmhlgcbbsj2bq7p4dcif4vpgrhef6afjryylrofzqma ONTO m17u4aufxga7wgmcebhiaapulc7xrqg34hcbmucddbvm4tw5c63kyq { CREATE TYPE Engagement::WorkflowEvent EXTENDING Project::ContextAware { @@ -8,7 +8,7 @@ CREATE MIGRATION m1pqxkvfgki4526meuwugacot76zuerjw34bynv25qbjbga3t7ezta SET readonly := true; }; CREATE ACCESS POLICY CanInsertGeneratedFromAppPoliciesForEngagementWorkflowEvent - ALLOW INSERT USING (((((default::Role.Administrator IN GLOBAL default::currentRoles) OR ((default::Role.Controller IN GLOBAL default::currentRoles) AND ((.transitionKey IN {'6eb17e2e-da0a-5277-9db0-e0f12396ff73', '539c7ca2-7c8d-57e1-a262-d385b4d88a6c', '6fa472fc-cf6a-5186-b2f1-46d2ed7fee54', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67'}) ?? false))) OR (EXISTS (({'FinancialAnalyst', 'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR (EXISTS (({'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'a535fba1-23c4-5c56-bb82-da6318aeda4d', '80b08b5d-93af-5f1b-b500-817c3624ad5b', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67', '30bb2a26-9b91-5fcd-8732-e305325eb1fe', 'd4dbcbb1-704b-5a93-961a-c302bba97866', 'b54cd0d5-942a-5e98-8a71-f5be87bc50b1', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'a0456858-07ec-59a2-9918-22ee106e2a20', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false)))); + ALLOW INSERT USING (((((((default::Role.Administrator IN GLOBAL default::currentRoles) OR ((default::Role.Controller IN GLOBAL default::currentRoles) AND ((.transitionKey IN {'bb30763a-b0fe-53f0-91fa-938357dafd23', '691d9824-78d2-59cf-9936-901ad4ef99b2', '4e1f2ab6-9be4-52a2-a68a-a3e3210bda55', '8c6f8a48-f5f5-5a15-80ac-d8521e972ecb', '4a4a07a3-d6fc-5466-aebf-b08e4034800a', '380b11ac-e303-5a7d-948a-acf6c66ce25e'}) ?? false))) OR (EXISTS (({'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR ((EXISTS (({'FinancialAnalyst', 'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND .isMember) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR ((EXISTS (({'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND .isMember) AND ((.transitionKey IN {'691d9824-78d2-59cf-9936-901ad4ef99b2', 'a535fba1-23c4-5c56-bb82-da6318aeda4d', '380b11ac-e303-5a7d-948a-acf6c66ce25e', '4a4a07a3-d6fc-5466-aebf-b08e4034800a', '80b08b5d-93af-5f1b-b500-817c3624ad5b', '735a1b6a-8811-5b65-beee-cbe6f67f431d', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR (EXISTS (({'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'691d9824-78d2-59cf-9936-901ad4ef99b2', 'a535fba1-23c4-5c56-bb82-da6318aeda4d', '380b11ac-e303-5a7d-948a-acf6c66ce25e', '4a4a07a3-d6fc-5466-aebf-b08e4034800a', '80b08b5d-93af-5f1b-b500-817c3624ad5b', '735a1b6a-8811-5b65-beee-cbe6f67f431d', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3', 'bb30763a-b0fe-53f0-91fa-938357dafd23', 'd4dbcbb1-704b-5a93-961a-c302bba97866', 'b54cd0d5-942a-5e98-8a71-f5be87bc50b1', 'a0456858-07ec-59a2-9918-22ee106e2a20'}) ?? false)))); CREATE ACCESS POLICY CanSelectUpdateReadGeneratedFromAppPoliciesForEngagementWorkflowEvent ALLOW SELECT, UPDATE READ USING (EXISTS (({'Administrator', 'Controller', 'FinancialAnalyst', 'LeadFinancialAnalyst', 'Leadership', 'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles))); CREATE ACCESS POLICY CanUpdateWriteGeneratedFromAppPoliciesForEngagementWorkflowEvent diff --git a/src/components/authorization/policies/by-role/financial-analyst-lead.policy.ts b/src/components/authorization/policies/by-role/financial-analyst-lead.policy.ts index 56d82cf89a..7ef7ffc10e 100644 --- a/src/components/authorization/policies/by-role/financial-analyst-lead.policy.ts +++ b/src/components/authorization/policies/by-role/financial-analyst-lead.policy.ts @@ -37,6 +37,7 @@ import * as FA from './financial-analyst.policy'; ).edit, ]), r.ProjectWorkflowEvent.transitions(FA.projectTransitions).execute, + r.EngagementWorkflowEvent.transitions(FA.engagementTransitions).execute, r.ProjectMember.edit.create.delete, ]) export class FinancialAnalystLeadPolicy {} diff --git a/src/components/authorization/policies/by-role/financial-analyst.policy.ts b/src/components/authorization/policies/by-role/financial-analyst.policy.ts index 5e78580275..ab44659b69 100644 --- a/src/components/authorization/policies/by-role/financial-analyst.policy.ts +++ b/src/components/authorization/policies/by-role/financial-analyst.policy.ts @@ -1,3 +1,4 @@ +import { EngagementWorkflow } from '../../../engagement/workflow/engagement-workflow'; import { ProjectWorkflow } from '../../../project/workflow/project-workflow'; import { inherit, @@ -16,6 +17,9 @@ export const projectTransitions = () => 'Complete', ); +export const engagementTransitions = () => + EngagementWorkflow.pickNames('Not Ready for Completion', 'Complete'); + // NOTE: There could be other permissions for this role from other policies @Policy( [Role.FinancialAnalyst, Role.LeadFinancialAnalyst, Role.Controller], @@ -31,9 +35,9 @@ export const projectTransitions = () => ]), r.LanguageEngagement.specifically((p) => p.paratextRegistryId.none), ), - r.EngagementWorkflowEvent.read.transitions( - 'Not Ready for Completion', - 'Complete', + r.EngagementWorkflowEvent.read.whenAll( + member, + r.EngagementWorkflowEvent.isTransitions(engagementTransitions), ).execute, r.FieldRegion.read, r.FieldZone.read, diff --git a/src/components/authorization/policies/by-role/project-manager.policy.ts b/src/components/authorization/policies/by-role/project-manager.policy.ts index 30ac7f6821..2e7af9ae4d 100644 --- a/src/components/authorization/policies/by-role/project-manager.policy.ts +++ b/src/components/authorization/policies/by-role/project-manager.policy.ts @@ -1,4 +1,5 @@ import { takeWhile } from 'lodash'; +import { EngagementWorkflow } from '../../../engagement/workflow/engagement-workflow'; import { ProjectStep } from '../../../project/dto'; import { ProjectWorkflow } from '../../../project/workflow/project-workflow'; import { @@ -57,6 +58,22 @@ export const momentumProjectsTransitions = () => 'Consultant Opposes Proposal', ); +export const engagementTransitions = () => + EngagementWorkflow.pickNames( + 'End Proposal', + 'Discuss Change To Plan', + 'Discuss Suspension out of Change to Plan Discussion', + 'End Change To Plan Discussion', + 'Discuss Suspension', + 'End Suspension Discussion', + 'Discuss Reactivation', + 'Discuss Termination', + 'End Termination Discussion', + 'Finalize Completion', + 'Not Ready for Completion', + 'Complete', + ); + // NOTE: There could be other permissions for this role from other policies @Policy( [Role.ProjectManager, Role.RegionalDirector, Role.FieldOperationsDirector], @@ -82,22 +99,9 @@ export const momentumProjectsTransitions = () => p.paratextRegistryId.when(member).read, ]), ), - r.EngagementWorkflowEvent.read.transitions( - 'Discuss Change To Plan', - 'Discuss Suspension', - 'Discuss Termination', - 'Finalize Completion', - 'Approve Change To Plan', - 'End Change To Plan Discussion', - 'Discuss Suspension out of Change to Plan Discussion', - 'End Suspension Discussion', - 'Approve Suspension', - 'Approve Reactivation', - 'Discuss Reactivation', - 'End Termination Discussion', - 'Approve Termination', - 'Not Ready for Completion', - 'Complete', + r.EngagementWorkflowEvent.read.whenAll( + member, + r.EngagementWorkflowEvent.isTransitions(engagementTransitions), ).execute, r.EthnologueLanguage.read, r.FieldRegion.read, diff --git a/src/components/authorization/policies/by-role/regional-director.policy.ts b/src/components/authorization/policies/by-role/regional-director.policy.ts index 87cb3fd280..efe90d33b0 100644 --- a/src/components/authorization/policies/by-role/regional-director.policy.ts +++ b/src/components/authorization/policies/by-role/regional-director.policy.ts @@ -34,5 +34,12 @@ import * as PM from './project-manager.policy'; 'Request Changes for Termination', 'End Termination Discussion By Approver', ).execute, + r.EngagementWorkflowEvent.transitions( + PM.engagementTransitions, + 'Reject Proposal', + 'Approve Suspension', + 'Approve Reactivation', + 'Approve Termination', + ).execute, ]) export class RegionalDirectorPolicy {} diff --git a/src/components/engagement/workflow/engagement-workflow.ts b/src/components/engagement/workflow/engagement-workflow.ts index 488fc8385b..2f51a6f7bf 100644 --- a/src/components/engagement/workflow/engagement-workflow.ts +++ b/src/components/engagement/workflow/engagement-workflow.ts @@ -115,6 +115,7 @@ export const EngagementWorkflow = defineWorkflow({ Status.Active, Status.ActiveChangedPlan, Status.DiscussingReactivation, + Status.DiscussingSuspension, Status.Suspended, ), label: 'Will Not Terminate', From 17a71fb9a4930bdbb912a559da5defd62957dc01 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 9 Jul 2024 15:28:20 -0500 Subject: [PATCH 05/19] Remove end/reject proposal condition on project state Seth agrees this doesn't make sense --- src/components/engagement/workflow/engagement-workflow.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/engagement/workflow/engagement-workflow.ts b/src/components/engagement/workflow/engagement-workflow.ts index 2f51a6f7bf..d2a003f08e 100644 --- a/src/components/engagement/workflow/engagement-workflow.ts +++ b/src/components/engagement/workflow/engagement-workflow.ts @@ -21,14 +21,12 @@ export const EngagementWorkflow = defineWorkflow({ to: Status.Rejected, label: 'Reject', type: Type.Reject, - conditions: ProjectStep('Rejected'), }, 'End Proposal': { from: Status.InDevelopment, to: Status.DidNotDevelop, label: 'End Development', type: Type.Reject, - conditions: ProjectStep('DidNotDevelop'), }, 'Approve Proposal': { from: Status.InDevelopment, From d5f1ff88cb634f8aaf4ae951fe2f12ee31cffc5e Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 9 Jul 2024 15:50:23 -0500 Subject: [PATCH 06/19] Work around needing engagement.project.step for migration --- ...ngagement-status-history-to-workflow-events.migration.ts | 4 ++-- .../engagement/workflow/transitions/project-step.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts index e6b2a45c04..8cff4002d8 100644 --- a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts +++ b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts @@ -2,7 +2,7 @@ import { ModuleRef } from '@nestjs/core'; import { node, relation } from 'cypher-query-builder'; import { chunk } from 'lodash'; import { DateTime } from 'luxon'; -import { Disabled, ID } from '~/common'; +import { ID } from '~/common'; import { BaseMigration, Migration } from '~/core/database'; import { ACTIVE, variable } from '~/core/database/query'; import { SystemAgentRepository } from '../../../user/system-agent.repository'; @@ -10,7 +10,7 @@ import { Engagement, EngagementStatus } from '../../dto'; import { EngagementWorkflowRepository } from '../engagement-workflow.repository'; import { EngagementWorkflowService } from '../engagement-workflow.service'; -@Disabled('Until Carson reviews')(Migration('2024-07-05T09:00:02')) +@Migration('2024-07-05T09:00:02') export class EngagementStatusHistoryToWorkflowEventsMigration extends BaseMigration { constructor( private readonly agents: SystemAgentRepository, diff --git a/src/components/engagement/workflow/transitions/project-step.ts b/src/components/engagement/workflow/transitions/project-step.ts index ee8ec688db..2a3d36db44 100644 --- a/src/components/engagement/workflow/transitions/project-step.ts +++ b/src/components/engagement/workflow/transitions/project-step.ts @@ -12,7 +12,11 @@ export const ProjectStep = (...steps: Step[]): Condition => { [...stepSet].map((step) => Step.entry(step).label).join(' / '); return { description, - resolve({ engagement }) { + resolve({ engagement, migrationPrevStates }) { + if (migrationPrevStates) { + // Skip for migration, since it is easier and not applicable for historic data in current use. + return { status: 'ENABLED' }; + } return { status: steps.includes(engagement.project.step) ? 'ENABLED' From cceaf887e9a0ffacfe52684efb4a3e4b9c80aeec Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Thu, 11 Jul 2024 14:37:23 -0500 Subject: [PATCH 07/19] Revised engagement seeding for new engagement workflow events --- .../seeds/011.language-engagements.edgeql | 56 ------- dbschema/seeds/011.language-engagements.ts | 117 +++++++++++++++ .../seeds/012.internship-engagements.edgeql | 61 -------- dbschema/seeds/012.internship-engagements.ts | 137 ++++++++++++++++++ 4 files changed, 254 insertions(+), 117 deletions(-) delete mode 100644 dbschema/seeds/011.language-engagements.edgeql create mode 100644 dbschema/seeds/011.language-engagements.ts delete mode 100644 dbschema/seeds/012.internship-engagements.edgeql create mode 100644 dbschema/seeds/012.internship-engagements.ts diff --git a/dbschema/seeds/011.language-engagements.edgeql b/dbschema/seeds/011.language-engagements.edgeql deleted file mode 100644 index f9c599b2e7..0000000000 --- a/dbschema/seeds/011.language-engagements.edgeql +++ /dev/null @@ -1,56 +0,0 @@ -with - engagementsJson := to_json('[ - { - "project": "Misty Mountains", - "language": "English", - "status": "InDevelopment", - "startDateOverride": "2020-04-01", - "endDateOverride": "2020-06-30" - }, - { - "project": "Arnor Lake", - "language": "Quenya", - "status": "FinalizingCompletion", - "startDateOverride": "2016-04-01", - "endDateOverride": "2017-06-30" - }, - { - "project": "Lothlorien", - "language": "Sindarin", - "status": "Active" - }, - { - "project": "Emyn Muil", - "language": "Khuzdul", - "status": "Active" - }, - { - "project": "South Downs", - "language": "Westron", - "status": "FinalizingCompletion", - "paratextRegistryId": "1234567890" - } - ]'), - engagements := ( - for engagement in json_array_unpack(engagementsJson) - union ( - with - language := assert_single((select Language filter .name = engagement['language'])), - project := (select TranslationProject filter .name = engagement['project']), - select ( - (select LanguageEngagement filter .language = language and .project = project) ?? - (insert LanguageEngagement { - project := project, - projectContext := project.projectContext, - status := engagement['status'], - startDateOverride := json_get(engagement, 'startDateOverride'), - endDateOverride := json_get(engagement, 'endDateOverride'), - language := language, - paratextRegistryId := json_get(engagement, 'paratextRegistryId') - }) - ) - ) - ), - new := (select engagements filter .createdAt = datetime_of_statement()) -select { `Added Language Engagements: Language -> Project` := new.language.name ++ ' -> ' ++ new.project.name } -filter count(new) > 0; diff --git a/dbschema/seeds/011.language-engagements.ts b/dbschema/seeds/011.language-engagements.ts new file mode 100644 index 0000000000..cad5e341e3 --- /dev/null +++ b/dbschema/seeds/011.language-engagements.ts @@ -0,0 +1,117 @@ +import type { SeedFn } from '~/core/edgedb/seeds.run'; +import { EngagementStatus } from '../../src/components/engagement/dto'; + +const engagementsInput: Input[] = [ + { + project: 'Misty Mountains', + language: 'English', + status: 'InDevelopment', + startDateOverride: '2020-04-01', + endDateOverride: '2020-06-30', + }, + { + project: 'Arnor Lake', + language: 'Quenya', + status: 'FinalizingCompletion', + startDateOverride: '2016-04-01', + endDateOverride: '2017-06-30', + }, + { + project: 'Lothlorien', + language: 'Sindarin', + status: 'Active', + }, + { + project: 'Emyn Muil', + language: 'Khuzdul', + status: 'Active', + }, + { + project: 'South Downs', + language: 'Westron', + status: 'FinalizingCompletion', + paratextRegistryId: '1234567890', + }, +]; + +interface Input { + project: string; + language: string; + status: string; + startDateOverride?: string; + endDateOverride?: string; + paratextRegistryId?: string; +} + +export default (async function ({ e, db, print }) { + const existingLanguageEngagements = await e + .select(e.LanguageEngagement, () => ({ + project: { + name: true, + }, + language: { + name: true, + }, + })) + .run(db); + const newEngagements = engagementsInput.filter( + (engagementSeed) => + !existingLanguageEngagements.some( + (engagementData) => + engagementData.project.name === engagementSeed.project && + engagementData.language.name === engagementSeed.language, + ), + ); + + if (newEngagements.length === 0) { + return; + } + + for (const { language, project, status, ...engagement } of newEngagements) { + const languageQ = e.assert_exists( + e.select(e.Language, (item) => ({ + filter_single: e.op(item.displayName, '=', language), + ...e.Language['*'], + })), + ); + + const translationProjectQ = e.assert_exists( + e.select(e.TranslationProject, (item) => ({ + filter_single: e.op(item.name, '=', project), + ...e.TranslationProject['*'], + })), + ); + + const insertQ = e.insert(e.LanguageEngagement, { + project: translationProjectQ, + projectContext: translationProjectQ.projectContext, + startDateOverride: engagement.startDateOverride + ? e.cal.local_date(engagement.startDateOverride) + : undefined, + endDateOverride: engagement.endDateOverride + ? e.cal.local_date(engagement.endDateOverride) + : undefined, + language: languageQ, + paratextRegistryId: engagement.paratextRegistryId, + }); + const query = e.select(insertQ, () => ({ id: true, projectContext: true })); + const inserted = await query.run(db); + + const engagementRef = e.cast(e.Engagement, e.uuid(inserted.id)); + + if (status !== 'InDevelopment') { + await e + .insert(e.Engagement.WorkflowEvent, { + engagement: engagementRef, + projectContext: engagementRef.projectContext, + to: status as EngagementStatus, + }) + .run(db); + } + } + print({ + 'Added LanguageEngagments': newEngagements.map( + (engagement) => engagement.project + ' - ' + engagement.language, + ), + }); +} satisfies SeedFn); diff --git a/dbschema/seeds/012.internship-engagements.edgeql b/dbschema/seeds/012.internship-engagements.edgeql deleted file mode 100644 index 270d7171b2..0000000000 --- a/dbschema/seeds/012.internship-engagements.edgeql +++ /dev/null @@ -1,61 +0,0 @@ -with - engagementsJson := to_json('[ - { - "project": "Arwen Evenstar Intern", - "intern": "Samwise", - "status": "Completed", - "mentor": "Gandalf", - "countryOfOrigin": "New Zealand", - "startDateOverride": "2019-04-01", - "endDateOverride": "2020-06-30" - }, - { - "project": "Glorfindel - Exegetical Facilitator", - "intern": "Frodo", - "status": "DiscussingChangeToPlan", - "mentor": "Bilbo", - "countryOfOrigin": "New Zealand", - "startDateOverride": "2023-01-01", - "endDateOverride": "2024-07-22" - }, - { - "project": "Cohort of the Ents", - "intern": "Meriadoc", - "status": "Active" - }, - { - "project": "Barliman Butterbur Intern", - "intern": "Peregrin", - "status": "Suspended" - }, - { - "project": "Eomer of Rohan Intern", - "intern": "Aragorn", - "status": "FinalizingCompletion", - "countryOfOrigin": "New Zealand" - } - ]'), - engagements := ( - for engagement in json_array_unpack(engagementsJson) - union ( - with - intern := assert_single((select User filter .realFirstName = engagement['intern'])), - project := (select InternshipProject filter .name = engagement['project']), - select ( - (select InternshipEngagement filter .intern = intern and .project = project) ?? - (insert InternshipEngagement { - project := project, - projectContext := project.projectContext, - intern := intern, - status := engagement['status'], - startDateOverride := json_get(engagement, 'startDateOverride'), - endDateOverride := json_get(engagement, 'endDateOverride'), - mentor := assert_single((select User filter .realFirstName = json_get(engagement, 'mentor'))), - countryOfOrigin := (select Location filter .name = json_get(engagement, 'countryOfOrigin')), - }) - ) - ) - ), - new := (select engagements filter .createdAt = datetime_of_statement()) -select { `Added Internship Engagements: Intern -> Project` := new.intern.realFirstName ++ ' ' ++ new.intern.realLastName ++ ' -> ' ++ new.project.name } -filter count(new) > 0; diff --git a/dbschema/seeds/012.internship-engagements.ts b/dbschema/seeds/012.internship-engagements.ts new file mode 100644 index 0000000000..1fddfbe73c --- /dev/null +++ b/dbschema/seeds/012.internship-engagements.ts @@ -0,0 +1,137 @@ +import type { SeedFn } from '~/core/edgedb/seeds.run'; +import { EngagementStatus } from '../../src/components/engagement/dto'; + +const engagementsInput: Input[] = [ + { + project: 'Arwen Evenstar Intern', + intern: 'Samwise', + status: 'Completed', + mentor: 'Gandalf', + countryOfOrigin: 'New Zealand', + startDateOverride: '2019-04-01', + endDateOverride: '2020-06-30', + }, + { + project: 'Glorfindel - Exegetical Facilitator', + intern: 'Frodo', + status: 'DiscussingChangeToPlan', + mentor: 'Bilbo', + countryOfOrigin: 'New Zealand', + startDateOverride: '2023-01-01', + endDateOverride: '2024-07-22', + }, + { + project: 'Cohort of the Ents', + intern: 'Meriadoc', + status: 'Active', + }, + { + project: 'Barliman Butterbur Intern', + intern: 'Peregrin', + status: 'Suspended', + }, + { + project: 'Eomer of Rohan Intern', + intern: 'Aragorn', + status: 'FinalizingCompletion', + countryOfOrigin: 'New Zealand', + }, +]; + +interface Input { + project: string; + intern: string; + status: string; + mentor?: string; + countryOfOrigin?: string; + startDateOverride?: string; + endDateOverride?: string; +} + +export default (async function ({ e, db, print }) { + const existingInternshipEngagements = await e + .select(e.InternshipEngagement, () => ({ + project: { + name: true, + }, + intern: { + realFirstName: true, + }, + })) + .run(db); + const newEngagements = engagementsInput.filter( + (engagementSeed) => + !existingInternshipEngagements.some( + (engagementData) => + engagementData.project.name === engagementSeed.project && + engagementData.intern.realFirstName === engagementSeed.intern, + ), + ); + + if (newEngagements.length === 0) { + return; + } + + for (const { intern, project, status, ...engagement } of newEngagements) { + const internQ = e.assert_exists( + e.select(e.User, (item) => ({ + filter_single: e.op(item.realFirstName, '=', intern), + ...e.User['*'], + })), + ); + + const intershipProjectQ = e.assert_exists( + e.select(e.InternshipProject, (item) => ({ + filter_single: e.op(item.name, '=', project), + ...e.InternshipProject['*'], + })), + ); + + const insertQ = e.insert(e.InternshipEngagement, { + project: intershipProjectQ, + projectContext: intershipProjectQ.projectContext, + intern: internQ, + startDateOverride: engagement.startDateOverride + ? e.cal.local_date(engagement.startDateOverride) + : undefined, + endDateOverride: engagement.endDateOverride + ? e.cal.local_date(engagement.endDateOverride) + : undefined, + mentor: engagement.mentor + ? e.assert_exists( + e.select(e.User, (user) => ({ + filter_single: e.op(user.realFirstName, '=', engagement.mentor!), + ...e.User['*'], + })), + ) + : undefined, + countryOfOrigin: engagement.countryOfOrigin + ? e.assert_exists( + e.select(e.Location, () => ({ + filter_single: { name: engagement.countryOfOrigin! }, + ...e.Location['*'], + })), + ) + : undefined, + }); + const query = e.select(insertQ, () => ({ id: true, projectContext: true })); + const inserted = await query.run(db); + + const engagementRef = e.cast(e.Engagement, e.uuid(inserted.id)); + + if (status !== 'InDevelopment') { + await e + .insert(e.Engagement.WorkflowEvent, { + engagement: engagementRef, + projectContext: engagementRef.projectContext, + to: status as EngagementStatus, + }) + .run(db); + } + } + print({ + 'Added InternshipEngagments': newEngagements.map( + (engagement) => engagement.project + ' - ' + engagement.intern, + ), + }); +} satisfies SeedFn); From e0accee89f384b267d6553c03d07115fb0f7889d Mon Sep 17 00:00:00 2001 From: Carson Full Date: Fri, 12 Jul 2024 17:32:11 -0500 Subject: [PATCH 08/19] Remove most of the engagement workflow tests / Replace with a couple late start & early end tests Mostly, we just want the engagement status to follow the project status. This is already being tested in engagement.e2e-spec.ts. So I removed all of these. I did add a couple where we think users will be directly interacting with the engagement workflow --- test/engagement-workflow.e2e-spec.ts | 215 ++++----------------- test/utility/engagement-workflow.tester.ts | 91 +++++++++ test/utility/workflow.tester.ts | 6 +- 3 files changed, 130 insertions(+), 182 deletions(-) create mode 100644 test/utility/engagement-workflow.tester.ts diff --git a/test/engagement-workflow.e2e-spec.ts b/test/engagement-workflow.e2e-spec.ts index f946d2be68..8537b644c9 100644 --- a/test/engagement-workflow.e2e-spec.ts +++ b/test/engagement-workflow.e2e-spec.ts @@ -1,212 +1,69 @@ import { Role } from '~/common'; import { EngagementStatus } from '../src/components/engagement/dto'; -import { ProjectStep, ProjectType } from '../src/components/project/dto'; import { - createFundingAccount, - createInternshipEngagement, createLanguageEngagement, - createLocation, createProject, - createRegion, createSession, createTestApp, - getCurrentEngagementStatus, registerUser, - runAsAdmin, TestApp, - updateProject, + TestUser, } from './utility'; -import { - changeLanguageEngagementStatus, - transitionEngagementToActive, -} from './utility/transition-engagement'; -import { - changeProjectStep, - stepsFromEarlyConversationToBeforeActive, -} from './utility/transition-project'; +import { EngagementWorkflowTester } from './utility/engagement-workflow.tester'; +import { RawLanguageEngagement, RawProject } from './utility/fragments'; +import { forceProjectTo } from './utility/transition-project'; describe('Engagement-Workflow e2e', () => { let app: TestApp; + let projectManager: TestUser; + let controller: TestUser; + let project: RawProject; + let engagement: RawLanguageEngagement; beforeAll(async () => { app = await createTestApp(); await createSession(app); - await registerUser(app, { - roles: [Role.ProjectManager, Role.Controller], + controller = await registerUser(app, { + roles: [Role.Controller], + }); + projectManager = await registerUser(app, { + roles: [Role.ProjectManager], + }); + + project = await createProject(app, {}); + engagement = await createLanguageEngagement(app, { + projectId: project.id, }); + await forceProjectTo(app, project.id, 'Active'); }); afterAll(async () => { await app.close(); }); - - it("should have engagement status 'InDevelopment' when add language or internship engagement", async () => { - // --- Translation Project with engagement - const transProject = await createProject(app, { - type: ProjectType.MomentumTranslation, - }); - - const langEngagement = await createLanguageEngagement(app, { - projectId: transProject.id, - }); - expect(langEngagement.status.value).toBe(EngagementStatus.InDevelopment); - - // --- Intern Project with engagement - const internProject = await createProject(app, { - type: ProjectType.Internship, - }); - const internEngagement = await createInternshipEngagement(app, { - projectId: internProject.id, - }); - expect(internEngagement.status.value).toBe(EngagementStatus.InDevelopment); + beforeEach(async () => { + await projectManager.login(); }); - describe('should test engagement status Active when Project is made Active', () => { - it('translation', async function () { - // --- Translation project - const transProject = await createProject(app, { - type: ProjectType.MomentumTranslation, - }); - const langEngagement = await createLanguageEngagement(app, { - projectId: transProject.id, - }); - await runAsAdmin(app, async () => { - const fundingAccount = await createFundingAccount(app); - const location = await createLocation(app, { - fundingAccountId: fundingAccount.id, - }); - const fieldRegion = await createRegion(app); - await updateProject(app, { - id: transProject.id, - primaryLocationId: location.id, - fieldRegionId: fieldRegion.id, - }); - for (const next of stepsFromEarlyConversationToBeforeActive) { - await changeProjectStep(app, transProject.id, next); - } - await changeProjectStep(app, transProject.id, ProjectStep.Active); - }); - const lEngagementStatus = await getCurrentEngagementStatus( - app, - langEngagement.id, - ); - expect(lEngagementStatus.status.value).toBe(EngagementStatus.Active); + it('Start Late', async () => { + const lateEng = await createLanguageEngagement(app, { + projectId: project.id, }); - it('internship', async function () { - // --- Internship project - const internProject = await createProject(app, { - type: ProjectType.Internship, - }); - const internEngagement = await createInternshipEngagement(app, { - projectId: internProject.id, - }); - await runAsAdmin(app, async () => { - const fundingAccount = await createFundingAccount(app); - const location = await createLocation(app, { - fundingAccountId: fundingAccount.id, - }); - const fieldRegion = await createRegion(app); + const eng = await EngagementWorkflowTester.for(app, lateEng.id); + expect(eng.state).toBe(EngagementStatus.InDevelopment); - await updateProject(app, { - id: internProject.id, - primaryLocationId: location.id, - fieldRegionId: fieldRegion.id, - }); - for (const next of stepsFromEarlyConversationToBeforeActive) { - await changeProjectStep(app, internProject.id, next); - } - await changeProjectStep(app, internProject.id, ProjectStep.Active); - }); - const lEngagementStatus = await getCurrentEngagementStatus( - app, - internEngagement.id, - ); - expect(lEngagementStatus.status.value).toBe(EngagementStatus.Active); - }); + await controller.login(); + await eng.executeByLabel('Approve'); + expect(eng.state).toBe(EngagementStatus.Active); }); - describe('Workflow', () => { - it('engagement completed', async function () { - // --- Engagement to Active - const transProject = await createProject(app, { - type: ProjectType.MomentumTranslation, - }); - const langEngagement = await createLanguageEngagement(app, { - projectId: transProject.id, - }); - await transitionEngagementToActive( - app, - transProject.id, - langEngagement.id, - ); - await runAsAdmin(app, async function () { - await changeProjectStep( - app, - transProject.id, - ProjectStep.DiscussingChangeToPlan, - ); - }); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.ActiveChangedPlan, - ); - await runAsAdmin(app, async function () { - await changeProjectStep(app, transProject.id, ProjectStep.Active); - }); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.FinalizingCompletion, - ); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.Completed, - ); - }); - - it('engagement terminated', async function () { - const transProject = await createProject(app, { - type: ProjectType.MomentumTranslation, - }); - const langEngagement = await createLanguageEngagement(app, { - projectId: transProject.id, - }); - await transitionEngagementToActive( - app, - transProject.id, - langEngagement.id, - ); + it('End Early', async () => { + const eng = await EngagementWorkflowTester.for(app, engagement.id); + expect(eng.state).toBe(EngagementStatus.Active); - await runAsAdmin(app, async function () { - await changeProjectStep( - app, - transProject.id, - ProjectStep.DiscussingChangeToPlan, - ); - }); + await eng.executeByLabel('Finalize Completion'); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.DiscussingSuspension, - ); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.Suspended, - ); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.DiscussingTermination, - ); - await changeLanguageEngagementStatus( - app, - langEngagement.id, - EngagementStatus.Terminated, - ); - }); + await controller.login(); + await eng.executeByState('Completed'); + expect(eng.state).toBe(EngagementStatus.Completed); }); }); diff --git a/test/utility/engagement-workflow.tester.ts b/test/utility/engagement-workflow.tester.ts new file mode 100644 index 0000000000..eca36de4a4 --- /dev/null +++ b/test/utility/engagement-workflow.tester.ts @@ -0,0 +1,91 @@ +import { ID } from '~/common'; +import { IEngagement } from '../../src/components/engagement/dto'; +import { + EngagementWorkflowTransition, + ExecuteEngagementTransitionInput, +} from '../../src/components/engagement/workflow/dto'; +import { EngagementWorkflow } from '../../src/components/engagement/workflow/engagement-workflow'; +import { TestApp } from './create-app'; +import { gql } from './gql-tag'; +import { Raw } from './raw.type'; +import { WorkflowTester } from './workflow.tester'; + +export class EngagementWorkflowTester extends WorkflowTester< + typeof EngagementWorkflow +> { + static async for(app: TestApp, id: ID) { + const { status: initial } = await EngagementWorkflowTester.getState( + app, + id, + ); + return new EngagementWorkflowTester(app, id, initial.value!); + } + + protected async fetchTransitions() { + const eng = await EngagementWorkflowTester.getState(this.app, this.id); + return eng.status.transitions; + } + + protected async doExecute(input: ExecuteEngagementTransitionInput) { + const result = await this.app.graphql.mutate( + gql` + mutation TransitionEngagement( + $input: ExecuteEngagementTransitionInput! + ) { + transitionEngagement(input: $input) { + status { + value + transitions { + key + label + to + type + disabled + disabledReason + } + } + } + } + `, + { input }, + ); + const res = await (result.transitionEngagement as ReturnType< + (typeof EngagementWorkflowTester)['getState'] + >); + return { + state: res.status.value!, + transitions: res.status.transitions, + }; + } + + static async getState(app: TestApp, id: ID) { + const result = await app.graphql.query( + gql` + query EngagementTransitions($engagement: ID!) { + engagement(id: $engagement) { + status { + value + transitions { + key + label + to + type + disabled + disabledReason + } + } + statusModifiedAt { + value + } + } + } + `, + { engagement: id }, + ); + return result.engagement as Raw< + Pick & { + status: { transitions: EngagementWorkflowTransition[] }; + } + >; + } +} diff --git a/test/utility/workflow.tester.ts b/test/utility/workflow.tester.ts index 56541076c2..999c7b2b76 100644 --- a/test/utility/workflow.tester.ts +++ b/test/utility/workflow.tester.ts @@ -83,14 +83,14 @@ export abstract class WorkflowTester< private cachedTransitions: { state: W['state']; actorId: string; - transitions: Transition[]; + transitions: readonly Transition[]; }; - protected abstract fetchTransitions(): Promise; + protected abstract fetchTransitions(): Promise; protected abstract doExecute( input: InstanceType>>, - ): Promise<{ state: W['state']; transitions: Transition[] }>; + ): Promise<{ state: W['state']; transitions: readonly Transition[] }>; } export class ProjectWorkflowTester extends WorkflowTester< From 7da0e5b5529c76b9f184c7b3c0e1175ef86ed6ab Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Tue, 18 Jun 2024 15:57:33 -0500 Subject: [PATCH 09/19] Engagement Workflow v2 --- dbschema/migrations/00012-m1pqxkv.edgeql | 68 +++++++++++++++++++ .../engagement/engagement-status.resolver.ts | 50 ++++++++++++++ .../workflow/engagement-workflow.service.ts | 8 +++ ...us-history-to-workflow-events.migration.ts | 8 +++ .../workflow/transitions/conditions.ts | 15 ++++ .../workflow/transitions/dynamic-step.ts | 31 +++++++++ .../workflow/transitions/notifiers.ts | 17 +++++ 7 files changed, 197 insertions(+) create mode 100644 dbschema/migrations/00012-m1pqxkv.edgeql create mode 100644 src/components/engagement/engagement-status.resolver.ts create mode 100644 src/components/engagement/workflow/transitions/conditions.ts create mode 100644 src/components/engagement/workflow/transitions/dynamic-step.ts create mode 100644 src/components/engagement/workflow/transitions/notifiers.ts diff --git a/dbschema/migrations/00012-m1pqxkv.edgeql b/dbschema/migrations/00012-m1pqxkv.edgeql new file mode 100644 index 0000000000..a18cc46c5c --- /dev/null +++ b/dbschema/migrations/00012-m1pqxkv.edgeql @@ -0,0 +1,68 @@ +CREATE MIGRATION m1pqxkvfgki4526meuwugacot76zuerjw34bynv25qbjbga3t7ezta + ONTO m17u4aufxga7wgmcebhiaapulc7xrqg34hcbmucddbvm4tw5c63kyq +{ + CREATE TYPE Engagement::WorkflowEvent EXTENDING Project::ContextAware { + CREATE ACCESS POLICY CanDeleteGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW DELETE USING ((default::Role.Administrator IN GLOBAL default::currentRoles)); + CREATE PROPERTY transitionKey: std::uuid { + SET readonly := true; + }; + CREATE ACCESS POLICY CanInsertGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW INSERT USING (((((default::Role.Administrator IN GLOBAL default::currentRoles) OR ((default::Role.Controller IN GLOBAL default::currentRoles) AND ((.transitionKey IN {'6eb17e2e-da0a-5277-9db0-e0f12396ff73', '539c7ca2-7c8d-57e1-a262-d385b4d88a6c', '6fa472fc-cf6a-5186-b2f1-46d2ed7fee54', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67'}) ?? false))) OR (EXISTS (({'FinancialAnalyst', 'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR (EXISTS (({'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'a535fba1-23c4-5c56-bb82-da6318aeda4d', '80b08b5d-93af-5f1b-b500-817c3624ad5b', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67', '30bb2a26-9b91-5fcd-8732-e305325eb1fe', 'd4dbcbb1-704b-5a93-961a-c302bba97866', 'b54cd0d5-942a-5e98-8a71-f5be87bc50b1', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'a0456858-07ec-59a2-9918-22ee106e2a20', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false)))); + CREATE ACCESS POLICY CanSelectUpdateReadGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW SELECT, UPDATE READ USING (EXISTS (({'Administrator', 'Controller', 'FinancialAnalyst', 'LeadFinancialAnalyst', 'Leadership', 'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles))); + CREATE ACCESS POLICY CanUpdateWriteGeneratedFromAppPoliciesForEngagementWorkflowEvent + ALLOW UPDATE WRITE ; + CREATE REQUIRED LINK engagement: default::Engagement { + ON TARGET DELETE DELETE SOURCE; + SET readonly := true; + }; + CREATE REQUIRED PROPERTY at: std::datetime { + SET default := (std::datetime_of_statement()); + SET readonly := true; + }; + CREATE REQUIRED PROPERTY to: Engagement::Status { + SET readonly := true; + }; + CREATE REQUIRED LINK who: default::Actor { + SET default := (GLOBAL default::currentActor); + SET readonly := true; + }; + CREATE PROPERTY notes: default::RichText { + SET readonly := true; + }; + }; + ALTER TYPE default::Engagement { + CREATE LINK workflowEvents := (.; +}; diff --git a/src/components/engagement/engagement-status.resolver.ts b/src/components/engagement/engagement-status.resolver.ts new file mode 100644 index 0000000000..1f6fc40a0c --- /dev/null +++ b/src/components/engagement/engagement-status.resolver.ts @@ -0,0 +1,50 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { stripIndent } from 'common-tags'; +import { AnonSession, ParentIdMiddlewareAdditions, Session } from '~/common'; +import { Loader, LoaderOf, ResourceLoader } from '~/core'; +import { EngagementStatusTransition, SecuredEngagementStatus } from './dto'; +import { EngagementLoader } from './engagement.loader'; +import { EngagementWorkflowService } from './workflow/engagement-workflow.service'; + +@Resolver(SecuredEngagementStatus) +export class EngagementStatusResolver { + constructor( + private readonly resources: ResourceLoader, + private readonly engagementWorkflowService: EngagementWorkflowService, + ) {} + + @ResolveField(() => [EngagementStatusTransition], { + description: 'The available statuses a engagement can be transitioned to.', + }) + async transitions( + @Parent() + status: SecuredEngagementStatus & ParentIdMiddlewareAdditions, + @Loader(EngagementLoader) engagements: LoaderOf, + @AnonSession() session: Session, + ): Promise { + if (!status.canRead || !status.canEdit || !status.value) { + return []; + } + const loaderKey = { + id: status.parentId, + view: { active: true }, + } as const; + const engagement = await engagements.load(loaderKey); + return await this.engagementWorkflowService.getAvailableTransitions( + engagement, + session, + ); + } + + @ResolveField(() => Boolean, { + description: stripIndent` + Is the current user allowed to bypass transitions entirely + and change the status to any other status? + `, + }) + async canBypassTransitions( + @AnonSession() session: Session, + ): Promise { + return await this.engagementWorkflowService.canBypassWorkflow(session); + } +} diff --git a/src/components/engagement/workflow/engagement-workflow.service.ts b/src/components/engagement/workflow/engagement-workflow.service.ts index 266c814a16..1ca48d9bfc 100644 --- a/src/components/engagement/workflow/engagement-workflow.service.ts +++ b/src/components/engagement/workflow/engagement-workflow.service.ts @@ -59,6 +59,14 @@ export class EngagementWorkflowService extends WorkflowService( ); } + async canBypassWorkflow(session: Session) { + return this.canBypass(session); + } + + canBypass(session: Session) { + return this.privileges.for(session, WorkflowEvent).can('create'); + } + async executeTransition( input: ExecuteEngagementTransitionInput, session: Session, diff --git a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts index 8cff4002d8..aa11b57e63 100644 --- a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts +++ b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts @@ -2,7 +2,11 @@ import { ModuleRef } from '@nestjs/core'; import { node, relation } from 'cypher-query-builder'; import { chunk } from 'lodash'; import { DateTime } from 'luxon'; +<<<<<<< HEAD import { ID } from '~/common'; +======= +import { Disabled, ID } from '~/common'; +>>>>>>> f308f17e6 (Engagement Workflow v2) import { BaseMigration, Migration } from '~/core/database'; import { ACTIVE, variable } from '~/core/database/query'; import { SystemAgentRepository } from '../../../user/system-agent.repository'; @@ -80,7 +84,11 @@ export class EngagementStatusHistoryToWorkflowEventsMigration extends BaseMigrat { engagement: fakeEngagement, moduleRef: this.moduleRef, +<<<<<<< HEAD migrationPrevStates: prev, +======= + migrationPrevSteps: prev, +>>>>>>> f308f17e6 (Engagement Workflow v2) }, engagement, // We don't know who did it, so we can't confirm this was an official diff --git a/src/components/engagement/workflow/transitions/conditions.ts b/src/components/engagement/workflow/transitions/conditions.ts new file mode 100644 index 0000000000..35324d6192 --- /dev/null +++ b/src/components/engagement/workflow/transitions/conditions.ts @@ -0,0 +1,15 @@ +import { TransitionCondition } from '../../../workflow/transitions/conditions'; +import { ResolveEngagementParams } from './dynamic-step'; + +type Condition = TransitionCondition; + +//delete this condition; created just to test engagement conditions +export const IsInternship: Condition = { + description: 'Internship', + resolve({ engagement }) { + return { + status: + engagement.__typename !== 'InternshipEngagement' ? 'ENABLED' : 'OMIT', + }; + }, +}; diff --git a/src/components/engagement/workflow/transitions/dynamic-step.ts b/src/components/engagement/workflow/transitions/dynamic-step.ts new file mode 100644 index 0000000000..5604ccf580 --- /dev/null +++ b/src/components/engagement/workflow/transitions/dynamic-step.ts @@ -0,0 +1,31 @@ +import { ModuleRef } from '@nestjs/core'; +import { DynamicState } from '../../../workflow/transitions/dynamic-state'; +import { + Engagement, + EngagementStatus, + EngagementStatus as Step, +} from '../../dto'; +import { EngagementWorkflowRepository } from '../engagement-workflow.repository'; + +export interface ResolveEngagementParams { + engagement: Engagement; + moduleRef: ModuleRef; + migrationPrevSteps?: EngagementStatus[]; +} + +export const BackTo = ( + ...steps: EngagementStatus[] +): DynamicState => ({ + description: 'Back', + relatedStates: steps, + async resolve({ engagement, moduleRef, migrationPrevSteps }) { + if (migrationPrevSteps) { + return migrationPrevSteps.find((s) => steps.includes(s)) ?? steps[0]; + } + const repo = moduleRef.get(EngagementWorkflowRepository); + const found = await repo.mostRecentStep(engagement.id, steps); + return found ?? steps[0] ?? EngagementStatus.InDevelopment; + }, +}); + +export const BackToActive = BackTo(Step.Active, Step.ActiveChangedPlan); diff --git a/src/components/engagement/workflow/transitions/notifiers.ts b/src/components/engagement/workflow/transitions/notifiers.ts new file mode 100644 index 0000000000..699dfd8e72 --- /dev/null +++ b/src/components/engagement/workflow/transitions/notifiers.ts @@ -0,0 +1,17 @@ +import { ConfigService } from '~/core'; +import { TransitionNotifier } from '../../../workflow/transitions/notifiers'; +import { ResolveEngagementParams } from './dynamic-step'; + +type Notifier = TransitionNotifier; + +//delete this notifier; created just to test engagement notifiers +export const EmailDistro = (email: string): Notifier => ({ + description: email, + resolve({ moduleRef }) { + const config = moduleRef.get(ConfigService, { strict: false }); + if (!config.email.notifyDistributionLists) { + return []; + } + return { email }; + }, +}); From 038cc2569ff0185816e13c7c7405e8756e78a9e3 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 8 Jul 2024 11:52:27 -0500 Subject: [PATCH 10/19] Rename step -> state --- ...tatus-history-to-workflow-events.migration.ts | 8 -------- .../workflow/transitions/dynamic-step.ts | 16 ++++++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts index aa11b57e63..8cff4002d8 100644 --- a/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts +++ b/src/components/engagement/workflow/migrations/engagement-status-history-to-workflow-events.migration.ts @@ -2,11 +2,7 @@ import { ModuleRef } from '@nestjs/core'; import { node, relation } from 'cypher-query-builder'; import { chunk } from 'lodash'; import { DateTime } from 'luxon'; -<<<<<<< HEAD import { ID } from '~/common'; -======= -import { Disabled, ID } from '~/common'; ->>>>>>> f308f17e6 (Engagement Workflow v2) import { BaseMigration, Migration } from '~/core/database'; import { ACTIVE, variable } from '~/core/database/query'; import { SystemAgentRepository } from '../../../user/system-agent.repository'; @@ -84,11 +80,7 @@ export class EngagementStatusHistoryToWorkflowEventsMigration extends BaseMigrat { engagement: fakeEngagement, moduleRef: this.moduleRef, -<<<<<<< HEAD migrationPrevStates: prev, -======= - migrationPrevSteps: prev, ->>>>>>> f308f17e6 (Engagement Workflow v2) }, engagement, // We don't know who did it, so we can't confirm this was an official diff --git a/src/components/engagement/workflow/transitions/dynamic-step.ts b/src/components/engagement/workflow/transitions/dynamic-step.ts index 5604ccf580..116371f63b 100644 --- a/src/components/engagement/workflow/transitions/dynamic-step.ts +++ b/src/components/engagement/workflow/transitions/dynamic-step.ts @@ -10,21 +10,21 @@ import { EngagementWorkflowRepository } from '../engagement-workflow.repository' export interface ResolveEngagementParams { engagement: Engagement; moduleRef: ModuleRef; - migrationPrevSteps?: EngagementStatus[]; + migrationPrevStates?: EngagementStatus[]; } export const BackTo = ( - ...steps: EngagementStatus[] + ...states: EngagementStatus[] ): DynamicState => ({ description: 'Back', - relatedStates: steps, - async resolve({ engagement, moduleRef, migrationPrevSteps }) { - if (migrationPrevSteps) { - return migrationPrevSteps.find((s) => steps.includes(s)) ?? steps[0]; + relatedStates: states, + async resolve({ engagement, moduleRef, migrationPrevStates }) { + if (migrationPrevStates) { + return migrationPrevStates.find((s) => states.includes(s)) ?? states[0]; } const repo = moduleRef.get(EngagementWorkflowRepository); - const found = await repo.mostRecentStep(engagement.id, steps); - return found ?? steps[0] ?? EngagementStatus.InDevelopment; + const found = await repo.mostRecentStep(engagement.id, states); + return found ?? states[0] ?? EngagementStatus.InDevelopment; }, }); From fb76d706ed2f5b555c6e4eda848d3e6616876832 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 8 Jul 2024 11:52:47 -0500 Subject: [PATCH 11/19] Drop unused code --- .../engagement/engagement-status.resolver.ts | 50 ------------------- .../workflow/engagement-workflow.service.ts | 4 -- .../workflow/transitions/conditions.ts | 15 ------ .../workflow/transitions/notifiers.ts | 17 ------- 4 files changed, 86 deletions(-) delete mode 100644 src/components/engagement/engagement-status.resolver.ts delete mode 100644 src/components/engagement/workflow/transitions/conditions.ts delete mode 100644 src/components/engagement/workflow/transitions/notifiers.ts diff --git a/src/components/engagement/engagement-status.resolver.ts b/src/components/engagement/engagement-status.resolver.ts deleted file mode 100644 index 1f6fc40a0c..0000000000 --- a/src/components/engagement/engagement-status.resolver.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { stripIndent } from 'common-tags'; -import { AnonSession, ParentIdMiddlewareAdditions, Session } from '~/common'; -import { Loader, LoaderOf, ResourceLoader } from '~/core'; -import { EngagementStatusTransition, SecuredEngagementStatus } from './dto'; -import { EngagementLoader } from './engagement.loader'; -import { EngagementWorkflowService } from './workflow/engagement-workflow.service'; - -@Resolver(SecuredEngagementStatus) -export class EngagementStatusResolver { - constructor( - private readonly resources: ResourceLoader, - private readonly engagementWorkflowService: EngagementWorkflowService, - ) {} - - @ResolveField(() => [EngagementStatusTransition], { - description: 'The available statuses a engagement can be transitioned to.', - }) - async transitions( - @Parent() - status: SecuredEngagementStatus & ParentIdMiddlewareAdditions, - @Loader(EngagementLoader) engagements: LoaderOf, - @AnonSession() session: Session, - ): Promise { - if (!status.canRead || !status.canEdit || !status.value) { - return []; - } - const loaderKey = { - id: status.parentId, - view: { active: true }, - } as const; - const engagement = await engagements.load(loaderKey); - return await this.engagementWorkflowService.getAvailableTransitions( - engagement, - session, - ); - } - - @ResolveField(() => Boolean, { - description: stripIndent` - Is the current user allowed to bypass transitions entirely - and change the status to any other status? - `, - }) - async canBypassTransitions( - @AnonSession() session: Session, - ): Promise { - return await this.engagementWorkflowService.canBypassWorkflow(session); - } -} diff --git a/src/components/engagement/workflow/engagement-workflow.service.ts b/src/components/engagement/workflow/engagement-workflow.service.ts index 1ca48d9bfc..ad27700ea4 100644 --- a/src/components/engagement/workflow/engagement-workflow.service.ts +++ b/src/components/engagement/workflow/engagement-workflow.service.ts @@ -59,10 +59,6 @@ export class EngagementWorkflowService extends WorkflowService( ); } - async canBypassWorkflow(session: Session) { - return this.canBypass(session); - } - canBypass(session: Session) { return this.privileges.for(session, WorkflowEvent).can('create'); } diff --git a/src/components/engagement/workflow/transitions/conditions.ts b/src/components/engagement/workflow/transitions/conditions.ts deleted file mode 100644 index 35324d6192..0000000000 --- a/src/components/engagement/workflow/transitions/conditions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TransitionCondition } from '../../../workflow/transitions/conditions'; -import { ResolveEngagementParams } from './dynamic-step'; - -type Condition = TransitionCondition; - -//delete this condition; created just to test engagement conditions -export const IsInternship: Condition = { - description: 'Internship', - resolve({ engagement }) { - return { - status: - engagement.__typename !== 'InternshipEngagement' ? 'ENABLED' : 'OMIT', - }; - }, -}; diff --git a/src/components/engagement/workflow/transitions/notifiers.ts b/src/components/engagement/workflow/transitions/notifiers.ts deleted file mode 100644 index 699dfd8e72..0000000000 --- a/src/components/engagement/workflow/transitions/notifiers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ConfigService } from '~/core'; -import { TransitionNotifier } from '../../../workflow/transitions/notifiers'; -import { ResolveEngagementParams } from './dynamic-step'; - -type Notifier = TransitionNotifier; - -//delete this notifier; created just to test engagement notifiers -export const EmailDistro = (email: string): Notifier => ({ - description: email, - resolve({ moduleRef }) { - const config = moduleRef.get(ConfigService, { strict: false }); - if (!config.email.notifyDistributionLists) { - return []; - } - return { email }; - }, -}); From 928cbfe15ba2c8f07ceee74def42bb861a53ad89 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 8 Jul 2024 17:35:14 -0500 Subject: [PATCH 12/19] Rename file --- .../workflow/transitions/dynamic-step.ts | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/components/engagement/workflow/transitions/dynamic-step.ts diff --git a/src/components/engagement/workflow/transitions/dynamic-step.ts b/src/components/engagement/workflow/transitions/dynamic-step.ts deleted file mode 100644 index 116371f63b..0000000000 --- a/src/components/engagement/workflow/transitions/dynamic-step.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ModuleRef } from '@nestjs/core'; -import { DynamicState } from '../../../workflow/transitions/dynamic-state'; -import { - Engagement, - EngagementStatus, - EngagementStatus as Step, -} from '../../dto'; -import { EngagementWorkflowRepository } from '../engagement-workflow.repository'; - -export interface ResolveEngagementParams { - engagement: Engagement; - moduleRef: ModuleRef; - migrationPrevStates?: EngagementStatus[]; -} - -export const BackTo = ( - ...states: EngagementStatus[] -): DynamicState => ({ - description: 'Back', - relatedStates: states, - async resolve({ engagement, moduleRef, migrationPrevStates }) { - if (migrationPrevStates) { - return migrationPrevStates.find((s) => states.includes(s)) ?? states[0]; - } - const repo = moduleRef.get(EngagementWorkflowRepository); - const found = await repo.mostRecentStep(engagement.id, states); - return found ?? states[0] ?? EngagementStatus.InDevelopment; - }, -}); - -export const BackToActive = BackTo(Step.Active, Step.ActiveChangedPlan); From 46232346e3dc5b1168abc386d11f863580c73694 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Wed, 10 Jul 2024 09:04:16 -0500 Subject: [PATCH 13/19] Drop another superfluous override --- .../engagement/workflow/engagement-workflow.service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/engagement/workflow/engagement-workflow.service.ts b/src/components/engagement/workflow/engagement-workflow.service.ts index ad27700ea4..266c814a16 100644 --- a/src/components/engagement/workflow/engagement-workflow.service.ts +++ b/src/components/engagement/workflow/engagement-workflow.service.ts @@ -59,10 +59,6 @@ export class EngagementWorkflowService extends WorkflowService( ); } - canBypass(session: Session) { - return this.privileges.for(session, WorkflowEvent).can('create'); - } - async executeTransition( input: ExecuteEngagementTransitionInput, session: Session, From 5c64b76896e4edd3f05f42b0e95c774a08f456b0 Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Mon, 15 Jul 2024 15:12:54 -0500 Subject: [PATCH 14/19] publish event for engagements --- .../engagement/engagement.service.ts | 12 ++++++--- .../events/engagement-updated.event.ts | 2 +- .../workflow/engagement-workflow.service.ts | 27 ++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/engagement/engagement.service.ts b/src/components/engagement/engagement.service.ts index 8a05556931..b3710cba2f 100644 --- a/src/components/engagement/engagement.service.ts +++ b/src/components/engagement/engagement.service.ts @@ -222,6 +222,12 @@ export class EngagementService { } // READ /////////////////////////////////////////////////////////// + async readOneUnsecured( + id: ID, + session: Session, + ): Promise> { + return await this.repo.readOne(id, session); + } @HandleIdLookup([LanguageEngagement, InternshipEngagement]) async readOne( @@ -294,7 +300,7 @@ export class EngagementService { view, )) as UnsecuredDto; - const event = new EngagementUpdatedEvent(updated, previous, input, session); + const event = new EngagementUpdatedEvent(updated, previous, session, input); await this.eventBus.publish(event); return (await this.secure(event.updated, session)) as LanguageEngagement; @@ -342,7 +348,7 @@ export class EngagementService { view, )) as UnsecuredDto; - const event = new EngagementUpdatedEvent(updated, previous, input, session); + const event = new EngagementUpdatedEvent(updated, previous, session, input); await this.eventBus.publish(event); return (await this.secure(event.updated, session)) as InternshipEngagement; @@ -350,7 +356,7 @@ export class EngagementService { async triggerUpdateEvent(id: ID, session: Session) { const object = await this.repo.readOne(id, session); - const event = new EngagementUpdatedEvent(object, object, { id }, session); + const event = new EngagementUpdatedEvent(object, object, session, { id }); await this.eventBus.publish(event); } diff --git a/src/components/engagement/events/engagement-updated.event.ts b/src/components/engagement/events/engagement-updated.event.ts index 84b21cc860..39f009763e 100644 --- a/src/components/engagement/events/engagement-updated.event.ts +++ b/src/components/engagement/events/engagement-updated.event.ts @@ -11,8 +11,8 @@ export class EngagementUpdatedEvent { constructor( public updated: UnsecuredDto, readonly previous: UnsecuredDto, - readonly input: UpdateLanguageEngagement | UpdateInternshipEngagement, readonly session: Session, + readonly input?: UpdateLanguageEngagement | UpdateInternshipEngagement, ) {} isLanguageEngagement(): this is EngagementUpdatedEvent & { diff --git a/src/components/engagement/workflow/engagement-workflow.service.ts b/src/components/engagement/workflow/engagement-workflow.service.ts index 266c814a16..86bfb8c3e8 100644 --- a/src/components/engagement/workflow/engagement-workflow.service.ts +++ b/src/components/engagement/workflow/engagement-workflow.service.ts @@ -1,12 +1,14 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { ID, Session, UnsecuredDto } from '~/common'; +import { IEventBus } from '~/core'; import { findTransition, WorkflowService, } from '../../workflow/workflow.service'; import { Engagement, EngagementStatus } from '../dto'; import { EngagementService } from '../engagement.service'; +import { EngagementUpdatedEvent } from '../events'; import { ExecuteEngagementTransitionInput, EngagementWorkflowEvent as WorkflowEvent, @@ -23,6 +25,7 @@ export class EngagementWorkflowService extends WorkflowService( private readonly engagements: EngagementService & {}, private readonly repo: EngagementWorkflowRepository, private readonly moduleRef: ModuleRef, + private readonly eventBus: IEventBus, ) { super(); } @@ -62,15 +65,22 @@ export class EngagementWorkflowService extends WorkflowService( async executeTransition( input: ExecuteEngagementTransitionInput, session: Session, + // eslint-disable-next-line @typescript-eslint/no-inferrable-types + isLegacy: boolean = false, ) { const { engagement: engagementId, notes } = input; - const previous = await this.engagements.readOne(engagementId, session); + const previous = await this.engagements.readOneUnsecured( + engagementId, + session, + ); + const object = await this.engagements.secure(previous, session); + //const previous = await this.engagements.readOne(engagementId, session); const next = this.getBypassIfValid(input, session) ?? findTransition( - await this.getAvailableTransitions(previous, session), + await this.getAvailableTransitions(object, session), input.transition, ); @@ -85,7 +95,16 @@ export class EngagementWorkflowService extends WorkflowService( session, ); - return await this.engagements.readOne(engagementId, session); + const updated = await this.engagements.readOneUnsecured( + engagementId, + session, + ); + if (!isLegacy) { + const event = new EngagementUpdatedEvent(updated, previous, session); + await this.eventBus.publish(event); + return await this.engagements.secure(event.updated, session); + } + return await this.engagements.secure(updated, session); } /** @deprecated */ @@ -100,13 +119,13 @@ export class EngagementWorkflowService extends WorkflowService( ); const transition = transitions.find((t) => t.to === step); - await this.executeTransition( { engagement: currentEngagement.id, ...(transition ? { transition: transition.key } : { bypassTo: step }), }, session, + true, ); } } From 0ae2d8a3de4dc3051b0bd38d3131e1b3f3728ef0 Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Tue, 16 Jul 2024 11:51:09 -0500 Subject: [PATCH 15/19] removed conflicting migration file --- dbschema/migrations/00012-m1pqxkv.edgeql | 68 ------------------------ 1 file changed, 68 deletions(-) delete mode 100644 dbschema/migrations/00012-m1pqxkv.edgeql diff --git a/dbschema/migrations/00012-m1pqxkv.edgeql b/dbschema/migrations/00012-m1pqxkv.edgeql deleted file mode 100644 index a18cc46c5c..0000000000 --- a/dbschema/migrations/00012-m1pqxkv.edgeql +++ /dev/null @@ -1,68 +0,0 @@ -CREATE MIGRATION m1pqxkvfgki4526meuwugacot76zuerjw34bynv25qbjbga3t7ezta - ONTO m17u4aufxga7wgmcebhiaapulc7xrqg34hcbmucddbvm4tw5c63kyq -{ - CREATE TYPE Engagement::WorkflowEvent EXTENDING Project::ContextAware { - CREATE ACCESS POLICY CanDeleteGeneratedFromAppPoliciesForEngagementWorkflowEvent - ALLOW DELETE USING ((default::Role.Administrator IN GLOBAL default::currentRoles)); - CREATE PROPERTY transitionKey: std::uuid { - SET readonly := true; - }; - CREATE ACCESS POLICY CanInsertGeneratedFromAppPoliciesForEngagementWorkflowEvent - ALLOW INSERT USING (((((default::Role.Administrator IN GLOBAL default::currentRoles) OR ((default::Role.Controller IN GLOBAL default::currentRoles) AND ((.transitionKey IN {'6eb17e2e-da0a-5277-9db0-e0f12396ff73', '539c7ca2-7c8d-57e1-a262-d385b4d88a6c', '6fa472fc-cf6a-5186-b2f1-46d2ed7fee54', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67'}) ?? false))) OR (EXISTS (({'FinancialAnalyst', 'LeadFinancialAnalyst', 'Controller'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false))) OR (EXISTS (({'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles)) AND ((.transitionKey IN {'a535fba1-23c4-5c56-bb82-da6318aeda4d', '80b08b5d-93af-5f1b-b500-817c3624ad5b', 'aed7b16f-5b8b-5b40-9a03-066ad842156e', 'e2f8c0ba-39ed-5d86-8270-d8a7ebce51ff', 'd50dc635-50aa-50a6-ae2d-5e32e43a5980', 'f95c8e46-55ae-5e05-bc71-2fce2d940b53', 'e6739a3d-ce68-5a07-8660-1ba46c6bed67', '30bb2a26-9b91-5fcd-8732-e305325eb1fe', 'd4dbcbb1-704b-5a93-961a-c302bba97866', 'b54cd0d5-942a-5e98-8a71-f5be87bc50b1', 'e14cbcc8-14ad-56a8-9f0d-06d3f670aa7a', 'ff0153a7-70dd-5249-92e4-20e252c3e202', 'a0456858-07ec-59a2-9918-22ee106e2a20', '5dcd3b86-39a5-513a-884b-3126eadb89d3', 'bc699b72-e9bd-5de3-9c1f-e18dd39d2dc3'}) ?? false)))); - CREATE ACCESS POLICY CanSelectUpdateReadGeneratedFromAppPoliciesForEngagementWorkflowEvent - ALLOW SELECT, UPDATE READ USING (EXISTS (({'Administrator', 'Controller', 'FinancialAnalyst', 'LeadFinancialAnalyst', 'Leadership', 'ProjectManager', 'RegionalDirector', 'FieldOperationsDirector'} INTERSECT GLOBAL default::currentRoles))); - CREATE ACCESS POLICY CanUpdateWriteGeneratedFromAppPoliciesForEngagementWorkflowEvent - ALLOW UPDATE WRITE ; - CREATE REQUIRED LINK engagement: default::Engagement { - ON TARGET DELETE DELETE SOURCE; - SET readonly := true; - }; - CREATE REQUIRED PROPERTY at: std::datetime { - SET default := (std::datetime_of_statement()); - SET readonly := true; - }; - CREATE REQUIRED PROPERTY to: Engagement::Status { - SET readonly := true; - }; - CREATE REQUIRED LINK who: default::Actor { - SET default := (GLOBAL default::currentActor); - SET readonly := true; - }; - CREATE PROPERTY notes: default::RichText { - SET readonly := true; - }; - }; - ALTER TYPE default::Engagement { - CREATE LINK workflowEvents := (.; -}; From 7c73772edfd611de591b4c0678d0916f99b22cfc Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Tue, 16 Jul 2024 17:59:24 -0500 Subject: [PATCH 16/19] add primary location to test project --- test/engagement-workflow.e2e-spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/engagement-workflow.e2e-spec.ts b/test/engagement-workflow.e2e-spec.ts index 8537b644c9..97778f8774 100644 --- a/test/engagement-workflow.e2e-spec.ts +++ b/test/engagement-workflow.e2e-spec.ts @@ -2,10 +2,12 @@ import { Role } from '~/common'; import { EngagementStatus } from '../src/components/engagement/dto'; import { createLanguageEngagement, + createLocation, createProject, createSession, createTestApp, registerUser, + runAsAdmin, TestApp, TestUser, } from './utility'; @@ -31,7 +33,14 @@ describe('Engagement-Workflow e2e', () => { roles: [Role.ProjectManager], }); - project = await createProject(app, {}); + const location = await runAsAdmin(app, async () => { + const location = await createLocation(app, {}); + return location; + }); + project = await createProject(app, { + primaryLocationId: location.id, + }); + engagement = await createLanguageEngagement(app, { projectId: project.id, }); From facba606f979d18712b6b287d7bc0babb7f06c16 Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Tue, 16 Jul 2024 18:55:48 -0500 Subject: [PATCH 17/19] added funding account to project in order to resolve missing departmentId --- test/engagement-workflow.e2e-spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/engagement-workflow.e2e-spec.ts b/test/engagement-workflow.e2e-spec.ts index 97778f8774..d983b1e7fb 100644 --- a/test/engagement-workflow.e2e-spec.ts +++ b/test/engagement-workflow.e2e-spec.ts @@ -1,6 +1,7 @@ import { Role } from '~/common'; import { EngagementStatus } from '../src/components/engagement/dto'; import { + createFundingAccount, createLanguageEngagement, createLocation, createProject, @@ -34,7 +35,10 @@ describe('Engagement-Workflow e2e', () => { }); const location = await runAsAdmin(app, async () => { - const location = await createLocation(app, {}); + const fundingAccount = await createFundingAccount(app); + const location = await createLocation(app, { + fundingAccountId: fundingAccount.id, + }); return location; }); project = await createProject(app, { From b82706197f0a865f9cf1be7ff26351e5c4de751d Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Wed, 17 Jul 2024 17:45:38 -0500 Subject: [PATCH 18/19] removed ProjectStep PendingTerminationApproval to Terminated from array --- test/engagement.e2e-spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/engagement.e2e-spec.ts b/test/engagement.e2e-spec.ts index 3bd68f496a..22d17d8c5a 100644 --- a/test/engagement.e2e-spec.ts +++ b/test/engagement.e2e-spec.ts @@ -1071,11 +1071,11 @@ describe('Engagement e2e', () => { ProjectStep.Rejected, EngagementStatus.Rejected, ], - [ - ProjectStep.PendingTerminationApproval, - ProjectStep.Terminated, - EngagementStatus.Terminated, - ], + // [ + // ProjectStep.PendingTerminationApproval, + // ProjectStep.Terminated, + // EngagementStatus.Terminated, + // ], // this only happens when an admin overrides to completed // this is prohibited if there are non terminal engagements [ From df796e0f74277b5738234e34863ff7e161d15072 Mon Sep 17 00:00:00 2001 From: Andre Turner Date: Fri, 19 Jul 2024 11:42:45 -0500 Subject: [PATCH 19/19] fix permissions error when creating engagement --- test/engagement-workflow.e2e-spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/engagement-workflow.e2e-spec.ts b/test/engagement-workflow.e2e-spec.ts index d983b1e7fb..0c1b41faab 100644 --- a/test/engagement-workflow.e2e-spec.ts +++ b/test/engagement-workflow.e2e-spec.ts @@ -58,8 +58,11 @@ describe('Engagement-Workflow e2e', () => { }); it('Start Late', async () => { - const lateEng = await createLanguageEngagement(app, { - projectId: project.id, + const lateEng = await runAsAdmin(app, async () => { + const lateEng = await createLanguageEngagement(app, { + projectId: project.id, + }); + return lateEng; }); const eng = await EngagementWorkflowTester.for(app, lateEng.id); expect(eng.state).toBe(EngagementStatus.InDevelopment);