diff --git a/apps/backoffice-v2/CHANGELOG.md b/apps/backoffice-v2/CHANGELOG.md index 05cd8d3179..2dbda13d9b 100644 --- a/apps/backoffice-v2/CHANGELOG.md +++ b/apps/backoffice-v2/CHANGELOG.md @@ -1,5 +1,23 @@ # @ballerine/backoffice-v2 +## 0.5.51 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + - @ballerine/workflow-browser-sdk@0.5.45 + - @ballerine/workflow-node-sdk@0.5.45 + +## 0.5.50 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.44 + - @ballerine/workflow-browser-sdk@0.5.44 + - @ballerine/workflow-node-sdk@0.5.44 + ## 0.5.49 ### Patch Changes diff --git a/apps/backoffice-v2/package.json b/apps/backoffice-v2/package.json index 2d9e4d79b0..2fa8d3b7bf 100644 --- a/apps/backoffice-v2/package.json +++ b/apps/backoffice-v2/package.json @@ -1,6 +1,6 @@ { "name": "@ballerine/backoffice-v2", - "version": "0.5.49", + "version": "0.5.51", "description": "Ballerine - Backoffice", "homepage": "https://github.com/ballerine-io/ballerine", "repository": { @@ -51,10 +51,10 @@ }, "dependencies": { "@ballerine/blocks": "0.1.27", - "@ballerine/common": "0.7.43", + "@ballerine/common": "0.7.45", "@ballerine/ui": "^0.3.29", - "@ballerine/workflow-browser-sdk": "0.5.43", - "@ballerine/workflow-node-sdk": "0.5.43", + "@ballerine/workflow-browser-sdk": "0.5.45", + "@ballerine/workflow-node-sdk": "0.5.45", "@fontsource/inter": "^4.5.15", "@formkit/auto-animate": "1.0.0-beta.5", "@hookform/resolvers": "^3.1.0", diff --git a/apps/backoffice-v2/src/common/enums.ts b/apps/backoffice-v2/src/common/enums.ts index 3d8e922ed9..3c3791b2a5 100644 --- a/apps/backoffice-v2/src/common/enums.ts +++ b/apps/backoffice-v2/src/common/enums.ts @@ -38,7 +38,6 @@ export const Action = { REJECT: 'reject', APPROVE: 'approve', REVISION: 'revision', - TASK_REVIEWED: 'TASK_REVIEWED', CASE_REVIEWED: 'CASE_REVIEWED', DISMISS: 'dismiss', FLAG: 'flag', diff --git a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation.tsx b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation.tsx index 6cb1d9aec0..e8f7304f5a 100644 --- a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation.tsx +++ b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation.tsx @@ -6,7 +6,7 @@ import { workflowsQueryKeys } from '../../../../workflows/query-keys'; import { Action } from '../../../../../common/enums'; import { useFilterId } from '../../../../../common/hooks/useFilterId/useFilterId'; -export const useApproveTaskByIdMutation = (workflowId: string, postUpdateEventName?: string) => { +export const useApproveTaskByIdMutation = (workflowId: string) => { const queryClient = useQueryClient(); const filterId = useFilterId(); const workflowById = workflowsQueryKeys.byId({ workflowId, filterId }); @@ -24,7 +24,6 @@ export const useApproveTaskByIdMutation = (workflowId: string, postUpdateEventNa documentId, body: { decision: Action.APPROVE, - postUpdateEventName, }, contextUpdateMethod, }), diff --git a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation.tsx b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation.tsx index 4cefc1005d..cc44b615b0 100644 --- a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation.tsx +++ b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation.tsx @@ -6,7 +6,7 @@ import { workflowsQueryKeys } from '../../../../workflows/query-keys'; import { Action } from '../../../../../common/enums'; import { useFilterId } from '../../../../../common/hooks/useFilterId/useFilterId'; -export const useRejectTaskByIdMutation = (workflowId: string, postUpdateEventName?: string) => { +export const useRejectTaskByIdMutation = (workflowId: string) => { const queryClient = useQueryClient(); const filterId = useFilterId(); const workflowById = workflowsQueryKeys.byId({ workflowId, filterId }); @@ -19,7 +19,6 @@ export const useRejectTaskByIdMutation = (workflowId: string, postUpdateEventNam body: { decision: Action.REJECT, reason, - postUpdateEventName, }, }), onMutate: async ({ documentId, reason }) => { diff --git a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation.tsx b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation.tsx index 35e67fed2d..1fba74bbe4 100644 --- a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation.tsx +++ b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation.tsx @@ -5,10 +5,7 @@ import { TWorkflowById, updateWorkflowDecision } from '../../../../workflows/fet import { workflowsQueryKeys } from '../../../../workflows/query-keys'; import { useFilterId } from '../../../../../common/hooks/useFilterId/useFilterId'; -export const useRemoveDecisionTaskByIdMutation = ( - workflowId: string, - postUpdateEventName?: string, -) => { +export const useRemoveDecisionTaskByIdMutation = (workflowId: string) => { const queryClient = useQueryClient(); const filterId = useFilterId(); const workflowById = workflowsQueryKeys.byId({ workflowId, filterId }); @@ -27,7 +24,6 @@ export const useRemoveDecisionTaskByIdMutation = ( body: { decision: null, reason: null, - postUpdateEventName, }, contextUpdateMethod, }), diff --git a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRevisionTaskByIdMutation/useRevisionTaskByIdMutation.tsx b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRevisionTaskByIdMutation/useRevisionTaskByIdMutation.tsx index daef5effd5..d1c9123c45 100644 --- a/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRevisionTaskByIdMutation/useRevisionTaskByIdMutation.tsx +++ b/apps/backoffice-v2/src/domains/entities/hooks/mutations/useRevisionTaskByIdMutation/useRevisionTaskByIdMutation.tsx @@ -6,7 +6,7 @@ import { workflowsQueryKeys } from '../../../../workflows/query-keys'; import { useFilterId } from '../../../../../common/hooks/useFilterId/useFilterId'; import { Action } from '../../../../../common/enums'; -export const useRevisionTaskByIdMutation = (postUpdateEventName?: string) => { +export const useRevisionTaskByIdMutation = () => { const queryClient = useQueryClient(); const filterId = useFilterId(); @@ -29,7 +29,6 @@ export const useRevisionTaskByIdMutation = (postUpdateEventName?: string) => { body: { decision: Action.REVISION, reason, - postUpdateEventName, }, }), onMutate: async ({ workflowId, documentId, reason }) => { diff --git a/apps/backoffice-v2/src/domains/workflows/fetchers.ts b/apps/backoffice-v2/src/domains/workflows/fetchers.ts index 0288710141..c3fa5e451f 100644 --- a/apps/backoffice-v2/src/domains/workflows/fetchers.ts +++ b/apps/backoffice-v2/src/domains/workflows/fetchers.ts @@ -286,7 +286,6 @@ export const updateWorkflowDecision = async ({ body: { decision: string | null; reason?: string; - postUpdateEventName?: string; }; contextUpdateMethod: 'base' | 'director'; }) => { diff --git a/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx b/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx index fb1285047e..abd0942dd9 100644 --- a/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx +++ b/apps/backoffice-v2/src/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic.tsx @@ -29,14 +29,6 @@ export interface IUseCallToActionLogicParams { }; } -export const getPostDecisionEventName = (workflow: TWorkflowById) => { - if ( - !workflow?.workflowDefinition?.config?.workflowLevelResolution && - workflow?.nextEvents?.includes(CommonWorkflowEvent.TASK_REVIEWED) - ) { - return CommonWorkflowEvent.TASK_REVIEWED; - } -}; export const useCallToActionLegacyLogic = ({ contextUpdateMethod = 'base', rejectionReasons, @@ -48,12 +40,10 @@ export const useCallToActionLegacyLogic = ({ isLoadingReuploadNeeded, dialog, }: IUseCallToActionLogicParams) => { - const postUpdateEventName = getPostDecisionEventName(workflow); - const { mutate: mutateApproveTaskById, isLoading: isLoadingApproveTaskById } = - useApproveTaskByIdMutation(workflow?.id, postUpdateEventName); + useApproveTaskByIdMutation(workflow?.id); const { mutate: mutateRejectTaskById, isLoading: isLoadingRejectTaskById } = - useRejectTaskByIdMutation(workflow?.id, postUpdateEventName); + useRejectTaskByIdMutation(workflow?.id); const isLoadingTaskDecisionById = isLoadingApproveTaskById || isLoadingRejectTaskById || isLoadingReuploadNeeded; diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDirectorsBlocks/useDirectorsBlocks.tsx b/apps/backoffice-v2/src/lib/blocks/hooks/useDirectorsBlocks/useDirectorsBlocks.tsx index 272b219db5..b34b01a9e2 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDirectorsBlocks/useDirectorsBlocks.tsx +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDirectorsBlocks/useDirectorsBlocks.tsx @@ -9,13 +9,11 @@ import { import { motionBadgeProps } from '../../motion-badge-props'; import { useApproveTaskByIdMutation } from '@/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation'; import { useRemoveDecisionTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation'; -import { getPostRemoveDecisionEventName } from '@/pages/Entity/get-post-remove-decision-event-name'; import { useCaseState } from '@/pages/Entity/components/Case/hooks/useCaseState/useCaseState'; import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery'; import { selectDirectorsDocuments } from '@/pages/Entity/selectors/selectDirectorsDocuments'; import { TWorkflowById } from '@/domains/workflows/fetchers'; import { useCaseDecision } from '@/pages/Entity/components/Case/hooks/useCaseDecision/useCaseDecision'; -import { getPostDecisionEventName } from '../../components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; import { createBlocksTyped } from '@/lib/blocks/create-blocks-typed/create-blocks-typed'; import { X } from 'lucide-react'; import { getRevisionReasonsForDocument } from '@/lib/blocks/components/DirectorsCallToAction/helpers'; @@ -44,10 +42,7 @@ export const useDirectorsBlocks = ({ reason?: string; }) => () => void; }) => { - const { mutate: removeDecisionById } = useRemoveDecisionTaskByIdMutation( - workflow?.id, - getPostRemoveDecisionEventName(workflow), - ); + const { mutate: removeDecisionById } = useRemoveDecisionTaskByIdMutation(workflow?.id); const { data: session } = useAuthenticatedUserQuery(); const caseState = useCaseState(session?.user, workflow); @@ -78,9 +73,8 @@ export const useDirectorsBlocks = ({ }); }, [documents, removeDecisionById]); - const postApproveEventName = getPostDecisionEventName(workflow); const { mutate: mutateApproveTaskById, isLoading: isLoadingApproveTaskById } = - useApproveTaskByIdMutation(workflow?.id, postApproveEventName); + useApproveTaskByIdMutation(workflow?.id); const onMutateApproveTaskById = useCallback( ({ taskId, diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx index 24e797ce91..7969e84239 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks.tsx @@ -12,7 +12,6 @@ import { FunctionComponent, useCallback, useMemo } from 'react'; import { selectWorkflowDocuments } from '@/pages/Entity/selectors/selectWorkflowDocuments'; import { useStorageFilesQuery } from '@/domains/storage/hooks/queries/useStorageFilesQuery/useStorageFilesQuery'; import { useApproveTaskByIdMutation } from '@/domains/entities/hooks/mutations/useApproveTaskByIdMutation/useApproveTaskByIdMutation'; -import { getPostRemoveDecisionEventName } from '@/pages/Entity/get-post-remove-decision-event-name'; import { useRemoveDecisionTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRemoveDecisionTaskByIdMutation/useRemoveDecisionTaskByIdMutation'; import { CommonWorkflowStates, StateTag } from '@ballerine/common'; import { X } from 'lucide-react'; @@ -20,7 +19,6 @@ import { valueOrNA } from '@/common/utils/value-or-na/value-or-na'; import { createBlocksTyped } from '@/lib/blocks/create-blocks-typed/create-blocks-typed'; import { ctw } from '@/common/utils/ctw/ctw'; import { getDocumentsSchemas } from '@/pages/Entity/utils/get-documents-schemas/get-documents-schemas'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; import { useDocumentPageImages } from '@/lib/blocks/hooks/useDocumentPageImages'; import { motionBadgeProps } from '@/lib/blocks/motion-badge-props'; import { useRejectTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRejectTaskByIdMutation/useRejectTaskByIdMutation'; @@ -70,7 +68,6 @@ export const useDocumentBlocks = ({ }) => { const issuerCountryCode = extractCountryCodeFromWorkflow(workflow); const documentsSchemas = getDocumentsSchemas(issuerCountryCode, workflow); - const postDecisionEventName = getPostDecisionEventName(workflow); const documents = useMemo(() => selectWorkflowDocuments(workflow), [workflow]); const documentPages = useMemo( () => documents.flatMap(({ pages }) => pages?.map(({ ballerineFileId }) => ballerineFileId)), @@ -80,11 +77,8 @@ export const useDocumentBlocks = ({ const documentPagesResults = useDocumentPageImages(documents, storageFilesQueryResult); const { mutate: mutateApproveTaskById, isLoading: isLoadingApproveTaskById } = - useApproveTaskByIdMutation(workflow?.id, postDecisionEventName); - const { isLoading: isLoadingRejectTaskById } = useRejectTaskByIdMutation( - workflow?.id, - postDecisionEventName, - ); + useApproveTaskByIdMutation(workflow?.id); + const { isLoading: isLoadingRejectTaskById } = useRejectTaskByIdMutation(workflow?.id); const onMutateApproveTaskById = useCallback( ({ taskId, @@ -97,11 +91,7 @@ export const useDocumentBlocks = ({ mutateApproveTaskById({ documentId: taskId, contextUpdateMethod }), [mutateApproveTaskById], ); - const postRemoveDecisionEventName = getPostRemoveDecisionEventName(workflow); - const { mutate: onMutateRemoveDecisionById } = useRemoveDecisionTaskByIdMutation( - workflow?.id, - postRemoveDecisionEventName, - ); + const { mutate: onMutateRemoveDecisionById } = useRemoveDecisionTaskByIdMutation(workflow?.id); return ( documents?.flatMap( diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-approve/check-can-approve.ts b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-approve/check-can-approve.ts index 80350c7a47..12a25a239d 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-approve/check-can-approve.ts +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-approve/check-can-approve.ts @@ -1,7 +1,6 @@ import { useCaseState } from '@/pages/Entity/components/Case/hooks/useCaseState/useCaseState'; import { TWorkflowById } from '@/domains/workflows/fetchers'; -import { CommonWorkflowEvent, DefaultContextSchema } from '@ballerine/common'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; +import { CommonWorkflowEvent, CommonWorkflowStates, DefaultContextSchema } from '@ballerine/common'; import { checkCanMakeDecision } from '@/lib/blocks/hooks/useDocumentBlocks/utils/check-can-make-decision/check-can-make-decision'; export const checkCanApprove = ({ @@ -17,7 +16,8 @@ export const checkCanApprove = ({ decision: DefaultContextSchema['documents'][number]['decision']; isLoadingApprove: boolean; }) => { - const hasTaskReviewedEvent = !!getPostDecisionEventName(workflow); + const isStateManualReview = workflow.state === CommonWorkflowStates.MANUAL_REVIEW; + const hasApproveEvent = workflow?.nextEvents?.includes(CommonWorkflowEvent.APPROVE); const canMakeDecision = checkCanMakeDecision({ caseState, @@ -25,5 +25,5 @@ export const checkCanApprove = ({ decision, }); - return !isLoadingApprove && canMakeDecision && (hasTaskReviewedEvent || hasApproveEvent); + return !isLoadingApprove && canMakeDecision && (isStateManualReview || hasApproveEvent); }; diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-reject/check-can-reject.ts b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-reject/check-can-reject.ts index 08ca9defd7..b144366eca 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-reject/check-can-reject.ts +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-reject/check-can-reject.ts @@ -1,7 +1,6 @@ import { useCaseState } from '@/pages/Entity/components/Case/hooks/useCaseState/useCaseState'; import { TWorkflowById } from '@/domains/workflows/fetchers'; -import { CommonWorkflowEvent, DefaultContextSchema } from '@ballerine/common'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; +import { CommonWorkflowEvent, CommonWorkflowStates, DefaultContextSchema } from '@ballerine/common'; import { checkCanMakeDecision } from '@/lib/blocks/hooks/useDocumentBlocks/utils/check-can-make-decision/check-can-make-decision'; export const checkCanReject = ({ @@ -17,7 +16,8 @@ export const checkCanReject = ({ decision: DefaultContextSchema['documents'][number]['decision']; isLoadingReject: boolean; }) => { - const hasTaskReviewedEvent = !!getPostDecisionEventName(workflow); + const isStateManualReview = workflow.state === CommonWorkflowStates.MANUAL_REVIEW; + const hasRejectEvent = workflow?.nextEvents?.includes(CommonWorkflowEvent.REJECT); const canMakeDecision = checkCanMakeDecision({ caseState, @@ -25,5 +25,5 @@ export const checkCanReject = ({ decision, }); - return !isLoadingReject && canMakeDecision && (hasTaskReviewedEvent || hasRejectEvent); + return !isLoadingReject && canMakeDecision && (isStateManualReview || hasRejectEvent); }; diff --git a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-revision/check-can-revision.ts b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-revision/check-can-revision.ts index 60811da840..fb807a015c 100644 --- a/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-revision/check-can-revision.ts +++ b/apps/backoffice-v2/src/lib/blocks/hooks/useDocumentBlocks/utils/check-can-revision/check-can-revision.ts @@ -1,7 +1,6 @@ import { useCaseState } from '@/pages/Entity/components/Case/hooks/useCaseState/useCaseState'; import { TWorkflowById } from '@/domains/workflows/fetchers'; -import { CommonWorkflowEvent, DefaultContextSchema } from '@ballerine/common'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; +import { CommonWorkflowEvent, CommonWorkflowStates, DefaultContextSchema } from '@ballerine/common'; import { checkCanMakeDecision } from '@/lib/blocks/hooks/useDocumentBlocks/utils/check-can-make-decision/check-can-make-decision'; export const checkCanRevision = ({ @@ -17,7 +16,8 @@ export const checkCanRevision = ({ decision: DefaultContextSchema['documents'][number]['decision']; isLoadingRevision: boolean; }) => { - const hasTaskReviewedEvent = !!getPostDecisionEventName(workflow); + const isStateManualReview = workflow.state === CommonWorkflowStates.MANUAL_REVIEW; + const hasRevisionEvent = workflow?.nextEvents?.includes(CommonWorkflowEvent.REVISION); const canMakeDecision = checkCanMakeDecision({ caseState, @@ -25,5 +25,5 @@ export const checkCanRevision = ({ decision, }); - return !isLoadingRevision && canMakeDecision && (hasTaskReviewedEvent || hasRevisionEvent); + return !isLoadingRevision && canMakeDecision && (isStateManualReview || hasRevisionEvent); }; diff --git a/apps/backoffice-v2/src/lib/blocks/variants/DefaultBlocks/hooks/useDefaultBlocksLogic/useDefaultBlocksLogic.tsx b/apps/backoffice-v2/src/lib/blocks/variants/DefaultBlocks/hooks/useDefaultBlocksLogic/useDefaultBlocksLogic.tsx index 0068e883b6..a69457ac6b 100644 --- a/apps/backoffice-v2/src/lib/blocks/variants/DefaultBlocks/hooks/useDefaultBlocksLogic/useDefaultBlocksLogic.tsx +++ b/apps/backoffice-v2/src/lib/blocks/variants/DefaultBlocks/hooks/useDefaultBlocksLogic/useDefaultBlocksLogic.tsx @@ -16,7 +16,6 @@ import { Button } from '@/common/components/atoms/Button/Button'; import { ctw } from '@/common/utils/ctw/ctw'; import { Send } from 'lucide-react'; import { useAssociatedCompaniesInformationBlock } from '@/lib/blocks/hooks/useAssociatedCompaniesInformationBlock/useAssociatedCompaniesInformationBlock'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; import { useDocumentPageImages } from '@/lib/blocks/hooks/useDocumentPageImages'; import { useRegistryInfoBlock } from '@/lib/blocks/hooks/useRegistryInfoBlock/useRegistryInfoBlock'; import { useKybRegistryInfoBlock } from '@/lib/blocks/hooks/useKybRegistryInfoBlock/useKybRegistryInfoBlock'; @@ -63,9 +62,8 @@ export const useDefaultBlocksLogic = () => { const isWorkflowLevelResolution = workflow?.workflowDefinition?.config?.workflowLevelResolution ?? workflow?.context?.entity?.type === 'business'; - const postDecisionEventName = getPostDecisionEventName(workflow); const { mutate: mutateRevisionTaskById, isLoading: isLoadingReuploadNeeded } = - useRevisionTaskByIdMutation(postDecisionEventName); + useRevisionTaskByIdMutation(); const onReuploadNeeded = useCallback( ({ workflowId, diff --git a/apps/backoffice-v2/src/lib/blocks/variants/KybExampleBlocks/hooks/useKybExampleBlocksLogic/useKybExampleBlocksLogic.tsx b/apps/backoffice-v2/src/lib/blocks/variants/KybExampleBlocks/hooks/useKybExampleBlocksLogic/useKybExampleBlocksLogic.tsx index 85b0850a11..e8a65a5c17 100644 --- a/apps/backoffice-v2/src/lib/blocks/variants/KybExampleBlocks/hooks/useKybExampleBlocksLogic/useKybExampleBlocksLogic.tsx +++ b/apps/backoffice-v2/src/lib/blocks/variants/KybExampleBlocks/hooks/useKybExampleBlocksLogic/useKybExampleBlocksLogic.tsx @@ -16,7 +16,6 @@ import { ctw } from '@/common/utils/ctw/ctw'; import { ExternalLink, Send } from 'lucide-react'; import { useDocumentPageImages } from '@/lib/blocks/hooks/useDocumentPageImages'; import { associatedCompanyAdapter } from '@/lib/blocks/hooks/useAssosciatedCompaniesBlock/associated-company-adapter'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; import { useEntityInfoBlock } from '@/lib/blocks/hooks/useEntityInfoBlock/useEntityInfoBlock'; import { useDocumentBlocks } from '@/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks'; import { useMainRepresentativeBlock } from '@/lib/blocks/hooks/useMainRepresentativeBlock/useMainRepresentativeBlock'; @@ -79,9 +78,8 @@ export const useKybExampleBlocksLogic = () => { }, [mutateEvent], ); - const postDecisionEventName = getPostDecisionEventName(workflow); const { mutate: mutateRevisionTaskById, isLoading: isLoadingReuploadNeeded } = - useRevisionTaskByIdMutation(postDecisionEventName); + useRevisionTaskByIdMutation(); const onReuploadNeeded = useCallback( ({ workflowId, diff --git a/apps/backoffice-v2/src/lib/blocks/variants/ManualReviewBlocks/hooks/useManualReviewBlocksLogic/useManualReviewBlocksLogic.tsx b/apps/backoffice-v2/src/lib/blocks/variants/ManualReviewBlocks/hooks/useManualReviewBlocksLogic/useManualReviewBlocksLogic.tsx index 5a0d0d262d..ddb575c4f5 100644 --- a/apps/backoffice-v2/src/lib/blocks/variants/ManualReviewBlocks/hooks/useManualReviewBlocksLogic/useManualReviewBlocksLogic.tsx +++ b/apps/backoffice-v2/src/lib/blocks/variants/ManualReviewBlocks/hooks/useManualReviewBlocksLogic/useManualReviewBlocksLogic.tsx @@ -7,7 +7,6 @@ import { useCaseState } from '@/pages/Entity/components/Case/hooks/useCaseState/ import { useRevisionTaskByIdMutation } from '@/domains/entities/hooks/mutations/useRevisionTaskByIdMutation/useRevisionTaskByIdMutation'; import { useCallback, useMemo } from 'react'; import toast from 'react-hot-toast'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; import { useEntityInfoBlock } from '@/lib/blocks/hooks/useEntityInfoBlock/useEntityInfoBlock'; import { useDocumentBlocks } from '@/lib/blocks/hooks/useDocumentBlocks/useDocumentBlocks'; @@ -30,9 +29,8 @@ export const useManualReviewBlocksLogic = () => { openCorporate: _openCorporate, ...entityDataAdditionalInfo } = workflow?.context?.entity?.data?.additionalInfo ?? {}; - const postDecisionEventName = getPostDecisionEventName(workflow); const { mutate: mutateRevisionTaskById, isLoading: isLoadingReuploadNeeded } = - useRevisionTaskByIdMutation(postDecisionEventName); + useRevisionTaskByIdMutation(); const onReuploadNeeded = useCallback( ({ workflowId, diff --git a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/interfaces.ts b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/interfaces.ts index 650c880912..3843c9bc15 100644 --- a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/interfaces.ts +++ b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/interfaces.ts @@ -1,5 +1,6 @@ export interface IPendingEvent { workflowId: string; + workflowState: string; documentId: string; eventName: string; token: string; diff --git a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/usePendingRevisionEvents.tsx b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/usePendingRevisionEvents.tsx index 9feb31679d..cbd53b7f21 100644 --- a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/usePendingRevisionEvents.tsx +++ b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/usePendingRevisionEvents.tsx @@ -1,7 +1,7 @@ import { TWorkflowById } from '@/domains/workflows/fetchers'; import { useCallback, useMemo } from 'react'; import { calculateAllWorkflowPendingEvents } from '@/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-pending-workflow-events'; -import { CommonWorkflowEvent } from '@ballerine/common'; +import { CommonWorkflowEvent, CommonWorkflowStates } from '@ballerine/common'; import { checkIsKybExampleVariant } from '@/lib/blocks/variants/variant-checkers'; import { useRevisionCaseMutation } from '@/domains/workflows/hooks/mutations/useRevisionCaseMutation/useRevisionCaseMutation'; import { IPendingEvent } from './interfaces'; @@ -18,7 +18,7 @@ const composeUniqueWorkflowEvents = ( const isPendingEventIsRevision = (pendingWorkflowEvent: IPendingEvent) => pendingWorkflowEvent?.eventName === CommonWorkflowEvent.REVISION || - pendingWorkflowEvent?.eventName === CommonWorkflowEvent.TASK_REVIEWED; + pendingWorkflowEvent?.workflowState === CommonWorkflowStates.MANUAL_REVIEW; export const usePendingRevisionEvents = ( mutateRevisionCase: ReturnType['mutate'], diff --git a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-pending-workflow-events.ts b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-pending-workflow-events.ts index 36779468f4..028bf7dbd7 100644 --- a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-pending-workflow-events.ts +++ b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-pending-workflow-events.ts @@ -23,6 +23,7 @@ export const calculatePendingWorkflowEvents = (workflow: TWorkflowById): Array { return { workflowId: workflow.id, + workflowState: workflow.state, documentId: document?.id as string, eventName: calculateWorkflowRevisionableEvent(workflow, document?.decision?.status), token: workflow?.context?.metadata?.token, @@ -31,9 +32,7 @@ export const calculatePendingWorkflowEvents = (workflow: TWorkflowById): Array => !!a && !!a.eventName); }; -export const calculateAllWorkflowPendingEvents = ( - workflow: TWorkflowById, -): Array => { +export const calculateAllWorkflowPendingEvents = (workflow: TWorkflowById): IPendingEvent[] => { return [ ...calculatePendingWorkflowEvents(workflow), ...(workflow.childWorkflows?.flatMap(childWorkflow => diff --git a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-workflow-revisionable-event.tsx b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-workflow-revisionable-event.tsx index 2f79df395f..4bc779d819 100644 --- a/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-workflow-revisionable-event.tsx +++ b/apps/backoffice-v2/src/pages/Entity/components/Case/hooks/usePendingRevisionEvents/utils/calculate-workflow-revisionable-event.tsx @@ -1,14 +1,13 @@ import { TWorkflowById } from '@/domains/workflows/fetchers'; -import { getPostDecisionEventName } from '@/lib/blocks/components/CallToActionLegacy/hooks/useCallToActionLegacyLogic/useCallToActionLegacyLogic'; -import { CommonWorkflowEvent } from '@ballerine/common'; +import { CommonWorkflowEvent, CommonWorkflowStates } from '@ballerine/common'; export const calculateWorkflowRevisionableEvent = ( workflow: TWorkflowById, documentStatus: string, ) => { - const postDecisionEvent = getPostDecisionEventName(workflow); + const isStateManualReview = workflow.state === CommonWorkflowStates.MANUAL_REVIEW; - if (postDecisionEvent) return postDecisionEvent; + if (isStateManualReview) return CommonWorkflowEvent.REVISION; if (documentStatus === CommonWorkflowEvent.REVISION) { return CommonWorkflowEvent.REVISION; diff --git a/apps/backoffice-v2/src/pages/Entity/get-post-remove-decision-event-name.ts b/apps/backoffice-v2/src/pages/Entity/get-post-remove-decision-event-name.ts deleted file mode 100644 index 8ae9365d19..0000000000 --- a/apps/backoffice-v2/src/pages/Entity/get-post-remove-decision-event-name.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TWorkflowById } from '../../domains/workflows/fetchers'; -import { CommonWorkflowEvent } from '@ballerine/common'; - -export function getPostRemoveDecisionEventName(workflow: TWorkflowById): string | undefined { - if ( - !workflow?.workflowDefinition?.config?.workflowLevelResolution && - workflow?.nextEvents?.includes(CommonWorkflowEvent.RETURN_TO_REVIEW) - ) { - return CommonWorkflowEvent.RETURN_TO_REVIEW; - } -} diff --git a/apps/kyb-app/CHANGELOG.md b/apps/kyb-app/CHANGELOG.md index 92b9459d5a..4fde34d954 100644 --- a/apps/kyb-app/CHANGELOG.md +++ b/apps/kyb-app/CHANGELOG.md @@ -1,5 +1,21 @@ # kyb-app +## 0.1.46 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + - @ballerine/workflow-browser-sdk@0.5.45 + +## 0.1.45 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.44 + - @ballerine/workflow-browser-sdk@0.5.44 + ## 0.1.44 ### Patch Changes diff --git a/apps/kyb-app/package.json b/apps/kyb-app/package.json index daa471286d..862edac57a 100644 --- a/apps/kyb-app/package.json +++ b/apps/kyb-app/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/kyb-app", "private": true, - "version": "0.1.44", + "version": "0.1.46", "type": "module", "scripts": { "dev": "vite", @@ -15,9 +15,9 @@ }, "dependencies": { "@ballerine/blocks": "0.1.27", - "@ballerine/common": "^0.7.43", + "@ballerine/common": "^0.7.45", "@ballerine/ui": "0.3.29", - "@ballerine/workflow-browser-sdk": "0.5.43", + "@ballerine/workflow-browser-sdk": "0.5.45", "@lukemorales/query-key-factory": "^1.0.3", "@radix-ui/react-icons": "^1.3.0", "@rjsf/core": "^5.9.0", diff --git a/examples/headless-example/CHANGELOG.md b/examples/headless-example/CHANGELOG.md index 7dccbdd687..0c09bbf54f 100644 --- a/examples/headless-example/CHANGELOG.md +++ b/examples/headless-example/CHANGELOG.md @@ -1,5 +1,21 @@ # @ballerine/headless-example +## 0.1.45 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + - @ballerine/workflow-browser-sdk@0.5.45 + +## 0.1.44 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.44 + - @ballerine/workflow-browser-sdk@0.5.44 + ## 0.1.43 ### Patch Changes diff --git a/examples/headless-example/package.json b/examples/headless-example/package.json index 7768969d43..5a7f621448 100644 --- a/examples/headless-example/package.json +++ b/examples/headless-example/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/headless-example", "private": true, - "version": "0.1.43", + "version": "0.1.45", "type": "module", "scripts": { "spellcheck": "cspell \"*\"", @@ -34,8 +34,8 @@ "vite": "^4.1.0" }, "dependencies": { - "@ballerine/common": "0.7.43", - "@ballerine/workflow-browser-sdk": "0.5.43", + "@ballerine/common": "0.7.45", + "@ballerine/workflow-browser-sdk": "0.5.45", "@felte/reporter-svelte": "^1.1.5", "@felte/validator-zod": "^1.0.13", "@fontsource/inter": "^4.5.15", diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 6505fd5a9d..ceb7ce8fb0 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -1,5 +1,17 @@ # @ballerine/common +## 0.7.45 + +### Patch Changes + +- Updated common enums + +## 0.7.44 + +### Patch Changes + +- Fixes for concurrency fixes + ## 0.7.43 ### Patch Changes diff --git a/packages/common/package.json b/packages/common/package.json index 405f3a388a..03d2a2ed9c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -2,7 +2,7 @@ "private": false, "name": "@ballerine/common", "author": "Ballerine ", - "version": "0.7.43", + "version": "0.7.45", "description": "common", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", diff --git a/packages/common/src/consts/index.ts b/packages/common/src/consts/index.ts index 8e97e2b822..7f5f415186 100644 --- a/packages/common/src/consts/index.ts +++ b/packages/common/src/consts/index.ts @@ -8,8 +8,8 @@ export const StateTag = { COLLECTION_FLOW: 'collection_flow', FAILURE: 'failure', DATA_ENRICHMENT: 'data_enrichment', - DISMISSED: 'dismissed', FLAGGED: 'flagged', + DISMISSED: 'dismissed', } as const; export const StateTags = [ @@ -21,10 +21,11 @@ export const StateTags = [ StateTag.COLLECTION_FLOW, StateTag.RESOLVED, StateTag.FAILURE, + StateTag.FLAGGED, + StateTag.DISMISSED, ] as const; export const CommonWorkflowEvent = { - TASK_REVIEWED: 'TASK_REVIEWED', CASE_REVIEWED: 'CASE_REVIEWED', RETURN_TO_REVIEW: 'RETURN_TO_REVIEW', RESUBMITTED: 'RESUBMITTED', @@ -32,8 +33,8 @@ export const CommonWorkflowEvent = { APPROVE: 'approve', REVISION: 'revision', RESOLVE: 'resolve', - DISMISS: 'dismiss', FLAG: 'flag', + DISMISS: 'dismiss', } as const; export const CommonWorkflowStates = { @@ -42,8 +43,8 @@ export const CommonWorkflowStates = { APPROVED: 'approved', RESOLVED: 'resolved', REVISION: 'revision', - DISMISSED: 'dismissed', FLAGGED: 'flagged', + DISMISSED: 'dismissed', } as const; export type TStateTag = (typeof StateTags)[number]; diff --git a/packages/workflow-core/CHANGELOG.md b/packages/workflow-core/CHANGELOG.md index 4982b173f2..ea7a2a707d 100644 --- a/packages/workflow-core/CHANGELOG.md +++ b/packages/workflow-core/CHANGELOG.md @@ -1,5 +1,20 @@ # @ballerine/workflow-core +## 0.5.45 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + +## 0.5.44 + +### Patch Changes + +- Fixes for concurrency fixes +- Updated dependencies + - @ballerine/common@0.7.44 + ## 0.5.43 ### Patch Changes diff --git a/packages/workflow-core/package.json b/packages/workflow-core/package.json index 83ac8e4433..34317b274d 100644 --- a/packages/workflow-core/package.json +++ b/packages/workflow-core/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-core", "author": "Ballerine ", - "version": "0.5.43", + "version": "0.5.45", "description": "workflow-core", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -31,7 +31,7 @@ "node": ">=12" }, "dependencies": { - "@ballerine/common": "0.7.43", + "@ballerine/common": "0.7.45", "ajv": "^8.12.0", "i18n-iso-countries": "^7.6.0", "jmespath": "^0.16.0", diff --git a/packages/workflow-core/src/index.ts b/packages/workflow-core/src/index.ts index 64b769ee1f..6794079137 100644 --- a/packages/workflow-core/src/index.ts +++ b/packages/workflow-core/src/index.ts @@ -16,6 +16,8 @@ export type { ChildToParentCallback, SerializableValidatableTransformer, THelperFormatingLogic, + BuiltInEvent, + ArrayMergeOption, } from './lib'; export { createWorkflow, @@ -26,4 +28,6 @@ export { JsonSchemaValidator, HelpersTransformer, validateDefinitionLogic, + BUILT_IN_EVENT, + ARRAY_MERGE_OPTION, } from './lib'; diff --git a/packages/workflow-core/src/lib/built-in-event.ts b/packages/workflow-core/src/lib/built-in-event.ts new file mode 100644 index 0000000000..f2fc695a3c --- /dev/null +++ b/packages/workflow-core/src/lib/built-in-event.ts @@ -0,0 +1,6 @@ +export const BUILT_IN_EVENT = { + UPDATE_CONTEXT: 'UPDATE_CONTEXT', + DEEP_MERGE_CONTEXT: 'DEEP_MERGE_CONTEXT', +} as const; + +export type BuiltInEvent = (typeof BUILT_IN_EVENT)[keyof typeof BUILT_IN_EVENT]; diff --git a/packages/workflow-core/src/lib/index.ts b/packages/workflow-core/src/lib/index.ts index 3b82845fa7..4d2bf3bcca 100644 --- a/packages/workflow-core/src/lib/index.ts +++ b/packages/workflow-core/src/lib/index.ts @@ -45,3 +45,7 @@ export { } from './utils'; export { HttpError } from './errors'; export { createWorkflow } from './create-workflow'; +export { BUILT_IN_EVENT } from './built-in-event'; +export type { BuiltInEvent } from './built-in-event'; +export { ARRAY_MERGE_OPTION } from './utils/deep-merge-with-options'; +export type { ArrayMergeOption } from './utils/deep-merge-with-options'; diff --git a/packages/workflow-core/src/lib/plugins/common-plugin/child-workflow-plugin.ts b/packages/workflow-core/src/lib/plugins/common-plugin/child-workflow-plugin.ts index 83cd13eedb..80814db21b 100644 --- a/packages/workflow-core/src/lib/plugins/common-plugin/child-workflow-plugin.ts +++ b/packages/workflow-core/src/lib/plugins/common-plugin/child-workflow-plugin.ts @@ -27,19 +27,21 @@ export class ChildWorkflowPlugin { async invoke(context: TContext) { const childWorkflowContext = await this.transformData(this.transformers || [], context); - void this.action({ - parentWorkflowRuntimeId: this.parentWorkflowRuntimeId, - definitionId: this.definitionId, - initOptions: { - context: childWorkflowContext, - event: this.initEvent, - config: { - language: this.parentWorkflowRuntimeConfig.language, + try { + await this.action({ + parentWorkflowRuntimeId: this.parentWorkflowRuntimeId, + definitionId: this.definitionId, + initOptions: { + context: childWorkflowContext, + event: this.initEvent, + config: { + language: this.parentWorkflowRuntimeConfig.language, + }, }, - }, - }); - - return Promise.resolve(); + }); + } finally { + return Promise.resolve(); + } } async transformData(transformers: Transformers, record: AnyRecord) { diff --git a/packages/workflow-core/src/lib/plugins/common-plugin/iterative-plugin.ts b/packages/workflow-core/src/lib/plugins/common-plugin/iterative-plugin.ts index 0e378c211f..a326760828 100644 --- a/packages/workflow-core/src/lib/plugins/common-plugin/iterative-plugin.ts +++ b/packages/workflow-core/src/lib/plugins/common-plugin/iterative-plugin.ts @@ -21,13 +21,10 @@ export class IterativePlugin { console.log(`Constructed IterativePlugin with params: ${JSON.stringify(pluginParams)}`); } - async invoke(context: TContext, config: unknown) { + async invoke(context: TContext) { console.log('invoke() method called'); - const iterationParams = await this.transformData(this.iterateOn, { - ...context, - workflowRuntimeConfig: config, - }); + const iterationParams = await this.transformData(this.iterateOn, context); if (!Array.isArray(iterationParams)) { console.error('Iterative plugin could not find iterate on param'); diff --git a/packages/workflow-core/src/lib/plugins/common-plugin/transformer-plugin.ts b/packages/workflow-core/src/lib/plugins/common-plugin/transformer-plugin.ts index 72cc422190..a705cf8709 100644 --- a/packages/workflow-core/src/lib/plugins/common-plugin/transformer-plugin.ts +++ b/packages/workflow-core/src/lib/plugins/common-plugin/transformer-plugin.ts @@ -27,7 +27,7 @@ export class TransformerPlugin implements ISerializableMappingPluginParams { this.stateNames = params.stateNames; } - async invoke(context: TContext, config: unknown) { + async invoke(context: TContext) { console.log('invoke() method called'); for (const transformParams of this.transformers) { @@ -36,7 +36,7 @@ export class TransformerPlugin implements ISerializableMappingPluginParams { transformParams.mapping, ); - transformer.transform({ ...context, workflowRuntimeConfig: config }); + transformer.transform(context); } console.log('Transform performed successfully.'); diff --git a/packages/workflow-core/src/lib/plugins/external-plugin/api-plugin.ts b/packages/workflow-core/src/lib/plugins/external-plugin/api-plugin.ts index f479222f4f..b5adb79426 100644 --- a/packages/workflow-core/src/lib/plugins/external-plugin/api-plugin.ts +++ b/packages/workflow-core/src/lib/plugins/external-plugin/api-plugin.ts @@ -36,12 +36,9 @@ export class ApiPlugin { this.displayName = pluginParams.displayName; } - async invoke(context: TContext, config: unknown) { + async invoke(context: TContext) { try { - const requestPayload = await this.transformData(this.request.transformers, { - ...context, - workflowRuntimeConfig: config, - }); + const requestPayload = await this.transformData(this.request.transformers, context); const { isValidRequest, errorMessage } = await this.validateContent( this.request.schemaValidator, diff --git a/packages/workflow-core/src/lib/types.ts b/packages/workflow-core/src/lib/types.ts index 6f854d9904..afc100a055 100644 --- a/packages/workflow-core/src/lib/types.ts +++ b/packages/workflow-core/src/lib/types.ts @@ -60,11 +60,6 @@ export interface WorkflowContext { lockKey?: string; } -export interface IUpdateContextEvent { - type: string; - payload: Record; -} - export interface WorkflowOptions { runtimeId: string; definitionType: 'statechart-json' | 'bpmn-json'; diff --git a/packages/workflow-core/src/lib/utils/deep-merge-with-options.ts b/packages/workflow-core/src/lib/utils/deep-merge-with-options.ts new file mode 100644 index 0000000000..1861400777 --- /dev/null +++ b/packages/workflow-core/src/lib/utils/deep-merge-with-options.ts @@ -0,0 +1,139 @@ +import { isObject } from '@ballerine/common'; + +type UnknownRecord = Record; + +const mergeObjects = (obj1: UnknownRecord, obj2: UnknownRecord) => { + const result = { ...obj1 }; + + for (let key in obj2) { + if (isObject(obj2[key]) && !Array.isArray(obj2[key]) && key in result) { + result[key] = mergeObjects(result[key] as UnknownRecord, obj2[key] as UnknownRecord); + } else { + result[key] = obj2[key]; + } + } + + return result; +}; + +type ArrayOfObjectsWithId = (UnknownRecord & { id: unknown })[]; + +const mergeArraysById = (arr1: ArrayOfObjectsWithId, arr2: ArrayOfObjectsWithId) => { + const combined = [...arr1, ...arr2]; + const ids = Array.from(new Set(combined.map(item => item.id))); + + return ids.map(id => { + const sameIdItems = combined.filter(item => item.id === id); + return sameIdItems.reduce(mergeObjects, {}); + }); +}; + +const mergeArraysByIndex = (arr1: unknown[], arr2: unknown[]) => { + const maxLength = Math.max(arr1.length, arr2.length); + const result = new Array(maxLength); + + for (let i = 0; i < maxLength; i++) { + if (i < arr1.length && i < arr2.length) { + // Change here: Check if the elements are basic values or JSON objects + if (typeof arr1[i] !== 'object' && typeof arr2[i] !== 'object') { + result[i] = arr2[i]; + } else { + result[i] = mergeObjects(arr1[i] as UnknownRecord, arr2[i] as UnknownRecord); + } + } else { + result[i] = arr1[i] || arr2[i]; + } + } + + return result; +}; + +export const ARRAY_MERGE_OPTION = { + BY_ID: 'by_id', + BY_INDEX: 'by_index', + CONCAT: 'concat', + REPLACE: 'replace', +} as const; + +export type ArrayMergeOption = (typeof ARRAY_MERGE_OPTION)[keyof typeof ARRAY_MERGE_OPTION]; + +export const deepMergeWithOptions = ( + val1: UnknownRecord | unknown[], + val2: UnknownRecord | unknown[], + arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT, +) => { + // Handle array inputs + if (Array.isArray(val1) && Array.isArray(val2)) { + if (arrayMergeOption === ARRAY_MERGE_OPTION.REPLACE) { + return val2; + } else { + switch (arrayMergeOption) { + case ARRAY_MERGE_OPTION.BY_ID: { + // Merge by_id could not be performed on primite or falsy (null, undefined) values. + // Checking of some of value within array doesnt include id or falsy and forcing merge by_index strategy + if ( + val1.some((val: unknown) => !isObject(val) || !Boolean(val?.id)) || + val2.some((val: unknown) => !isObject(val) || !Boolean(val?.id)) + ) { + return mergeArraysByIndex(val1, val2); + } + return mergeArraysById(val1 as ArrayOfObjectsWithId, val2 as ArrayOfObjectsWithId); + } + case ARRAY_MERGE_OPTION.BY_INDEX: + return mergeArraysByIndex(val1, val2); + case ARRAY_MERGE_OPTION.CONCAT: + default: + return [...val1, ...val2]; + } + } + } + // Handle object inputs + else if (isObject(val1) && isObject(val2)) { + const result = { ...val1 }; + + for (const key in val2) { + const val1Child = val1[key]; + const val2Child = val2[key]; + if (Array.isArray(val1Child) && Array.isArray(val2Child)) { + if (arrayMergeOption === ARRAY_MERGE_OPTION.REPLACE) { + result[key] = val2[key]; + } else { + switch (arrayMergeOption) { + case ARRAY_MERGE_OPTION.BY_ID: { + // Merging arrays of primitives using by_index strategy + if ( + val1Child.some((val: unknown) => !isObject(val) || !Boolean(val?.id)) || + val2Child.some((val: unknown) => !isObject(val) || !Boolean(val?.id)) + ) { + result[key] = mergeArraysByIndex(val1Child, val2Child); + break; + } + result[key] = mergeArraysById(val1Child || [], val2Child); + break; + } + case ARRAY_MERGE_OPTION.BY_INDEX: + result[key] = mergeArraysByIndex(val1Child || [], val2Child); + break; + case ARRAY_MERGE_OPTION.CONCAT: + default: + result[key] = [...(val1Child || []), ...val2Child]; + } + } + } else if (isObject(val2Child) && !Array.isArray(val2Child) && key in result) { + result[key] = deepMergeWithOptions( + result[key] as UnknownRecord | unknown[], + val2Child, + arrayMergeOption, + ); + } else { + result[key] = val2[key]; + } + } + + return result; + } + // For all other types of inputs, return the second value + else { + return val2; + } +}; diff --git a/packages/workflow-core/src/lib/utils/index.ts b/packages/workflow-core/src/lib/utils/index.ts index f8f55ce236..a3b71c42a2 100644 --- a/packages/workflow-core/src/lib/utils/index.ts +++ b/packages/workflow-core/src/lib/utils/index.ts @@ -5,3 +5,5 @@ export { JmespathTransformer } from './context-transformers'; export { HelpersTransformer } from './context-transformers'; export { JsonSchemaValidator } from './context-validator'; export { validateDefinitionLogic } from './definition-validator'; +export { deepMergeWithOptions, ARRAY_MERGE_OPTION } from './deep-merge-with-options'; +export type { ArrayMergeOption } from './deep-merge-with-options'; diff --git a/packages/workflow-core/src/lib/workflow-runner.ts b/packages/workflow-core/src/lib/workflow-runner.ts index 2be40fb23a..088e083bd7 100644 --- a/packages/workflow-core/src/lib/workflow-runner.ts +++ b/packages/workflow-core/src/lib/workflow-runner.ts @@ -6,7 +6,6 @@ import { assign, createMachine, interpret } from 'xstate'; import { HttpError } from './errors'; import type { ChildPluginCallbackOutput, - IUpdateContextEvent, ObjectValues, WorkflowEvent, WorkflowEventWithoutState, @@ -40,7 +39,7 @@ import { ISerializableCommonPluginParams, IterativePluginParams, } from './plugins/common-plugin/types'; -import { TContext } from './utils'; +import { ArrayMergeOption, TContext } from './utils'; import { IterativePlugin } from './plugins/common-plugin/iterative-plugin'; import { ChildWorkflowPlugin } from './plugins/common-plugin/child-workflow-plugin'; import { search } from 'jmespath'; @@ -51,6 +50,8 @@ import { TransformerPlugin, TransformerPluginParams, } from './plugins/common-plugin/transformer-plugin'; +import { deepMergeWithOptions } from './utils'; +import { BUILT_IN_EVENT } from './index'; export interface ChildCallabackable { invokeChildWorkflowAction?: (childParams: ChildPluginCallbackOutput) => Promise; @@ -125,7 +126,6 @@ export class WorkflowRunner { ...(workflowContext && Object.keys(workflowContext.machineContext ?? {})?.length ? workflowContext.machineContext : definition.context ?? {}), - workflowRuntimeId: runtimeId, }; // use initial state or provided state @@ -240,7 +240,12 @@ export class WorkflowRunner { stateNames: iterarivePluginParams.stateNames, //@ts-ignore iterateOn: this.fetchTransformers(iterarivePluginParams.iterateOn), - action: (context: TContext) => actionPlugin!.invoke(context, this.#__config), + action: (context: TContext) => + actionPlugin!.invoke({ + ...context, + workflowRuntimeConfig: this.#__config, + workflowRuntimeId: this.#__runtimeId, + }), successAction: iterarivePluginParams.successAction, errorAction: iterarivePluginParams.errorAction, }; @@ -435,24 +440,41 @@ export class WorkflowRunner { }, }; - const updateContext = assign, IUpdateContextEvent>( - (context, event) => { - context = { - ...context, - ...event.payload, - }; + const updateContext = assign( + ( + context, + event: { + payload: { + context: Record; + }; + }, + ) => event.payload.context, + ); - return context; - }, + const deepMergeContext = assign( + ( + context, + { + payload, + }: { + payload: { + arrayMergeOption: ArrayMergeOption; + newContext: Record; + }; + }, + ) => deepMergeWithOptions(context, payload.newContext, payload.arrayMergeOption), ); return createMachine( { predictableActionArguments: true, on: { - UPDATE_CONTEXT: { + [BUILT_IN_EVENT.UPDATE_CONTEXT]: { actions: updateContext, }, + [BUILT_IN_EVENT.DEEP_MERGE_CONTEXT]: { + actions: deepMergeContext, + }, }, ...definition, }, @@ -582,7 +604,11 @@ export class WorkflowRunner { private async __invokeCommonPlugin(commonPlugin: CommonPlugin) { // @ts-expect-error - multiple types of plugins return different responses - const { callbackAction, error } = await commonPlugin.invoke?.(this.#__context, this.#__config); + const { callbackAction, error } = await commonPlugin.invoke?.({ + ...this.#__context, + workflowRuntimeConfig: this.#__config, + workflowRuntimeId: this.#__runtimeId, + }); if (!!error) { this.#__context.pluginsOutput = { @@ -598,10 +624,11 @@ export class WorkflowRunner { private async __invokeApiPlugin(apiPlugin: HttpPlugin) { // @ts-expect-error - multiple types of plugins return different responses - const { callbackAction, responseBody, error } = await apiPlugin.invoke?.( - this.#__context, - this.#__config, - ); + const { callbackAction, responseBody, error } = await apiPlugin.invoke?.({ + ...this.#__context, + workflowRuntimeConfig: this.#__config, + workflowRuntimeId: this.#__runtimeId, + }); if (error) { console.error('Error invoking plugin: ', apiPlugin.name, this.#__context, error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecdcea3cb3..68f57fd16d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,16 +64,16 @@ importers: specifier: 0.1.27 version: link:../../packages/blocks '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../../packages/common '@ballerine/ui': specifier: ^0.3.29 version: link:../../packages/ui '@ballerine/workflow-browser-sdk': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../sdks/workflow-browser-sdk '@ballerine/workflow-node-sdk': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../sdks/workflow-node-sdk '@fontsource/inter': specifier: ^4.5.15 @@ -377,13 +377,13 @@ importers: specifier: 0.1.27 version: link:../../packages/blocks '@ballerine/common': - specifier: ^0.7.43 + specifier: ^0.7.45 version: link:../../packages/common '@ballerine/ui': specifier: 0.3.29 version: link:../../packages/ui '@ballerine/workflow-browser-sdk': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../sdks/workflow-browser-sdk '@lukemorales/query-key-factory': specifier: ^1.0.3 @@ -805,10 +805,10 @@ importers: examples/headless-example: dependencies: '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../../packages/common '@ballerine/workflow-browser-sdk': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../sdks/workflow-browser-sdk '@felte/reporter-svelte': specifier: ^1.1.5 @@ -1660,7 +1660,7 @@ importers: packages/workflow-core: dependencies: '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../common ajv: specifier: ^8.12.0 @@ -1823,7 +1823,7 @@ importers: sdks/web-ui-sdk: dependencies: '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../../packages/common '@zerodevx/svelte-toast': specifier: ^0.8.0 @@ -1950,10 +1950,10 @@ importers: sdks/workflow-browser-sdk: dependencies: '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../../packages/common '@ballerine/workflow-core': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../packages/workflow-core xstate: specifier: ^4.37.0 @@ -2092,7 +2092,7 @@ importers: sdks/workflow-node-sdk: dependencies: '@ballerine/workflow-core': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../packages/workflow-core json-logic-js: specifier: ^2.0.2 @@ -2334,13 +2334,13 @@ importers: specifier: 3.347.1 version: 3.347.1 '@ballerine/common': - specifier: 0.7.43 + specifier: 0.7.45 version: link:../../packages/common '@ballerine/workflow-core': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../packages/workflow-core '@ballerine/workflow-node-sdk': - specifier: 0.5.43 + specifier: 0.5.45 version: link:../../sdks/workflow-node-sdk '@faker-js/faker': specifier: ^7.6.0 @@ -2653,7 +2653,7 @@ importers: specifier: ^4.0.0 version: 4.0.0(astro@3.3.3)(tailwindcss@3.3.5)(ts-node@10.9.1) '@ballerine/common': - specifier: ^0.7.43 + specifier: ^0.7.45 version: link:../../packages/common astro: specifier: 3.3.3 @@ -32685,5 +32685,4 @@ packages: file:services/workflows-service/plugins/verify-repository-project-scoped: resolution: {directory: services/workflows-service/plugins/verify-repository-project-scoped, type: directory} name: eslint-plugin-ballerine - version: 1.0.0 dev: true diff --git a/sdks/web-ui-sdk/CHANGELOG.md b/sdks/web-ui-sdk/CHANGELOG.md index 69d2af533f..e85dc860d5 100644 --- a/sdks/web-ui-sdk/CHANGELOG.md +++ b/sdks/web-ui-sdk/CHANGELOG.md @@ -1,5 +1,19 @@ # web-ui-sdk +## 1.4.43 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + +## 1.4.42 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.44 + ## 1.4.41 ### Patch Changes diff --git a/sdks/web-ui-sdk/package.json b/sdks/web-ui-sdk/package.json index 7af4633a96..8d49584d37 100644 --- a/sdks/web-ui-sdk/package.json +++ b/sdks/web-ui-sdk/package.json @@ -21,7 +21,7 @@ "types": "dist/index.d.ts", "name": "@ballerine/web-ui-sdk", "private": false, - "version": "1.4.41", + "version": "1.4.43", "type": "module", "files": [ "dist" @@ -96,7 +96,7 @@ "vitest": "^0.24.5" }, "dependencies": { - "@ballerine/common": "0.7.43", + "@ballerine/common": "0.7.45", "@zerodevx/svelte-toast": "^0.8.0", "compressorjs": "^1.1.1", "deepmerge": "^4.3.0", diff --git a/sdks/workflow-browser-sdk/CHANGELOG.md b/sdks/workflow-browser-sdk/CHANGELOG.md index 855ac6ef94..6cc725e960 100644 --- a/sdks/workflow-browser-sdk/CHANGELOG.md +++ b/sdks/workflow-browser-sdk/CHANGELOG.md @@ -1,5 +1,21 @@ # @ballerine/workflow-browser-sdk +## 0.5.45 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + - @ballerine/workflow-core@0.5.45 + +## 0.5.44 + +### Patch Changes + +- Updated dependencies + - @ballerine/workflow-core@0.5.44 + - @ballerine/common@0.7.44 + ## 0.5.43 ### Patch Changes diff --git a/sdks/workflow-browser-sdk/package.json b/sdks/workflow-browser-sdk/package.json index 2341f11ae4..bd1d9cab10 100644 --- a/sdks/workflow-browser-sdk/package.json +++ b/sdks/workflow-browser-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-browser-sdk", "author": "Ballerine ", - "version": "0.5.43", + "version": "0.5.45", "description": "workflow-browser-sdk", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -33,8 +33,8 @@ "node": ">=12" }, "dependencies": { - "@ballerine/common": "0.7.43", - "@ballerine/workflow-core": "0.5.43", + "@ballerine/common": "0.7.45", + "@ballerine/workflow-core": "0.5.45", "xstate": "^4.37.0" }, "devDependencies": { diff --git a/sdks/workflow-node-sdk/CHANGELOG.md b/sdks/workflow-node-sdk/CHANGELOG.md index 28a125242c..8ed55b83e3 100644 --- a/sdks/workflow-node-sdk/CHANGELOG.md +++ b/sdks/workflow-node-sdk/CHANGELOG.md @@ -1,5 +1,18 @@ # @ballerine/workflow-node-sdk +## 0.5.45 + +### Patch Changes + +- @ballerine/workflow-core@0.5.45 + +## 0.5.44 + +### Patch Changes + +- Updated dependencies + - @ballerine/workflow-core@0.5.44 + ## 0.5.43 ### Patch Changes diff --git a/sdks/workflow-node-sdk/package.json b/sdks/workflow-node-sdk/package.json index 52946f89f6..3fa209ab68 100644 --- a/sdks/workflow-node-sdk/package.json +++ b/sdks/workflow-node-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflow-node-sdk", "author": "Ballerine ", - "version": "0.5.43", + "version": "0.5.45", "description": "workflow-node-sdk", "module": "./dist/esm/index.js", "main": "./dist/cjs/index.js", @@ -28,7 +28,7 @@ "node": ">=12" }, "dependencies": { - "@ballerine/workflow-core": "0.5.43", + "@ballerine/workflow-core": "0.5.45", "json-logic-js": "^2.0.2", "xstate": "^4.36.0" }, diff --git a/services/workflows-service/CHANGELOG.md b/services/workflows-service/CHANGELOG.md index 93f96411f0..9c7e8fde3d 100644 --- a/services/workflows-service/CHANGELOG.md +++ b/services/workflows-service/CHANGELOG.md @@ -1,5 +1,23 @@ # @ballerine/workflows-service +## 0.5.45 + +### Patch Changes + +- Updated dependencies + - @ballerine/common@0.7.45 + - @ballerine/workflow-core@0.5.45 + - @ballerine/workflow-node-sdk@0.5.45 + +## 0.5.44 + +### Patch Changes + +- Updated dependencies + - @ballerine/workflow-core@0.5.44 + - @ballerine/common@0.7.44 + - @ballerine/workflow-node-sdk@0.5.44 + ## 0.5.43 ### Patch Changes diff --git a/services/workflows-service/package.json b/services/workflows-service/package.json index 6b574dd4a1..812b996dac 100644 --- a/services/workflows-service/package.json +++ b/services/workflows-service/package.json @@ -1,7 +1,7 @@ { "name": "@ballerine/workflows-service", "private": false, - "version": "0.5.43", + "version": "0.5.45", "description": "workflow-service", "scripts": { "spellcheck": "cspell \"*\"", @@ -42,9 +42,9 @@ "@aws-sdk/client-s3": "3.347.1", "@aws-sdk/lib-storage": "3.347.1", "@aws-sdk/s3-request-presigner": "3.347.1", - "@ballerine/common": "0.7.43", - "@ballerine/workflow-core": "0.5.43", - "@ballerine/workflow-node-sdk": "0.5.43", + "@ballerine/common": "0.7.45", + "@ballerine/workflow-core": "0.5.45", + "@ballerine/workflow-node-sdk": "0.5.45", "@faker-js/faker": "^7.6.0", "@nestjs/axios": "^2.0.0", "@nestjs/common": "^9.3.12", diff --git a/services/workflows-service/scripts/seed.ts b/services/workflows-service/scripts/seed.ts index d9defaeef8..3079497804 100644 --- a/services/workflows-service/scripts/seed.ts +++ b/services/workflows-service/scripts/seed.ts @@ -18,7 +18,7 @@ import { generateKycSessionDefinition } from './workflows/kyc-email-process-exam import { env } from '../src/env'; import { generateKybKycWorkflowDefinition } from './workflows/kyb-kyc-workflow-definition'; import { generateBaseTaskLevelStates } from './workflows/generate-base-task-level-states'; -import { generateBaseCaseLevelStates } from './workflows/generate-base-case-level-states'; +import { generateBaseCaseLevelStatesAutoTransitionOnRevision } from './workflows/generate-base-case-level-states'; import type { InputJsonValue } from '../src/types'; import { generateWebsiteMonitoringExample } from './workflows/website-monitoring-workflow'; import { generateCollectionKybWorkflow } from './workflows/generate-collection-kyb-workflow'; @@ -549,7 +549,7 @@ async function seed(bcryptSalt: string | number) { // KYB Manual Review (workflowLevelResolution true) await client.workflowDefinition.create({ data: { - ...baseReviewDefinition(generateBaseCaseLevelStates()), + ...baseReviewDefinition(generateBaseCaseLevelStatesAutoTransitionOnRevision()), id: kybManualMachineId, config: { workflowLevelResolution: true, @@ -871,7 +871,7 @@ async function seed(bcryptSalt: string | number) { context: { documents: [], }, - states: generateBaseCaseLevelStates(), + states: generateBaseCaseLevelStatesAutoTransitionOnRevision(), }, }, }); diff --git a/services/workflows-service/scripts/workflows/generate-base-case-level-states.ts b/services/workflows-service/scripts/workflows/generate-base-case-level-states.ts index c16ae90bd6..5f68c75aa2 100644 --- a/services/workflows-service/scripts/workflows/generate-base-case-level-states.ts +++ b/services/workflows-service/scripts/workflows/generate-base-case-level-states.ts @@ -3,31 +3,58 @@ import { CommonWorkflowEvent, CommonWorkflowStates, StateTag } from '@ballerine/ export const generateBaseCaseLevelStates = ( defaultState: string = CommonWorkflowStates.MANUAL_REVIEW, defaultResubmitEvent: string = CommonWorkflowEvent.RETURN_TO_REVIEW, -) => ({ - [defaultState]: { - tags: [StateTag.MANUAL_REVIEW], - on: { - [CommonWorkflowEvent.REJECT]: { target: CommonWorkflowStates.REJECTED }, - [CommonWorkflowEvent.APPROVE]: { target: CommonWorkflowStates.APPROVED }, - [CommonWorkflowEvent.REVISION]: { target: CommonWorkflowStates.REVISION }, +) => + ({ + [defaultState]: { + tags: [StateTag.MANUAL_REVIEW], + on: { + [CommonWorkflowEvent.REJECT]: { target: CommonWorkflowStates.REJECTED }, + [CommonWorkflowEvent.APPROVE]: { target: CommonWorkflowStates.APPROVED }, + [CommonWorkflowEvent.REVISION]: { target: CommonWorkflowStates.REVISION }, + }, }, - }, - rejected: { - tags: [StateTag.REJECTED], - type: 'final', - }, - approved: { - tags: [StateTag.APPROVED], - type: 'final', - }, - resolved: { - tags: [StateTag.RESOLVED], - type: 'final', - }, - revision: { - tags: [StateTag.REVISION], - on: { - [defaultResubmitEvent]: defaultState, + [CommonWorkflowStates.REJECTED]: { + tags: [StateTag.REJECTED], + type: 'final', }, - }, -}); + [CommonWorkflowStates.APPROVED]: { + tags: [StateTag.APPROVED], + type: 'final', + }, + [CommonWorkflowStates.RESOLVED]: { + tags: [StateTag.RESOLVED], + type: 'final', + }, + [CommonWorkflowStates.REVISION]: { + tags: [StateTag.REVISION], + on: { + [defaultResubmitEvent]: defaultState, + }, + }, + } as const); + +export const generateBaseCaseLevelStatesAutoTransitionOnRevision = ( + defaultState: string = CommonWorkflowStates.MANUAL_REVIEW, + defaultResubmitEvent: string = CommonWorkflowEvent.RETURN_TO_REVIEW, +) => { + const definition = generateBaseCaseLevelStates(defaultState, defaultResubmitEvent); + + return { + ...definition, + [CommonWorkflowStates.REVISION]: { + ...definition[CommonWorkflowStates.REVISION], + on: { + ...definition[CommonWorkflowStates.REVISION].on, + }, + always: { + target: defaultState, + cond: { + type: 'jmespath', + options: { + rule: 'length(documents[?decision.status]) < length(documents)', + }, + }, + }, + }, + }; +}; diff --git a/services/workflows-service/scripts/workflows/generate-base-task-level-states.ts b/services/workflows-service/scripts/workflows/generate-base-task-level-states.ts index bf19048fba..43a52b9d61 100644 --- a/services/workflows-service/scripts/workflows/generate-base-task-level-states.ts +++ b/services/workflows-service/scripts/workflows/generate-base-task-level-states.ts @@ -1,59 +1,63 @@ import { CommonWorkflowEvent, CommonWorkflowStates, StateTag } from '@ballerine/common'; -export const generateBaseTaskLevelStates = ( - defaultState: string = CommonWorkflowStates.MANUAL_REVIEW, - defaultResubmitEvent: string = CommonWorkflowEvent.RETURN_TO_REVIEW, -) => ({ - [defaultState]: { +export const generateBaseTaskLevelStates = () => ({ + [CommonWorkflowStates.MANUAL_REVIEW]: { tags: [StateTag.MANUAL_REVIEW], - on: { - TASK_REVIEWED: [ - { - target: CommonWorkflowStates.APPROVED, - cond: { - type: 'jmespath', - options: { - rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'approved']) == length(documents)", - }, + always: [ + { + target: CommonWorkflowStates.APPROVED, + cond: { + type: 'jmespath', + options: { + rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'approved']) == length(documents)", }, }, - { - target: CommonWorkflowStates.REJECTED, - cond: { - type: 'jmespath', - options: { - rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'rejected']) > `0`", - }, + }, + { + target: CommonWorkflowStates.REJECTED, + cond: { + type: 'jmespath', + options: { + rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'rejected']) > `0`", }, }, - { - target: CommonWorkflowStates.REVISION, - cond: { - type: 'jmespath', - options: { - rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'revision']) > `0`", - }, + }, + { + target: CommonWorkflowStates.REVISION, + cond: { + type: 'jmespath', + options: { + rule: "length(documents[?decision.status]) == length(documents) && length(documents) > `0` && length(documents[?decision.status == 'revision']) > `0`", }, }, - ], - }, + }, + ], }, - rejected: { + [CommonWorkflowStates.REJECTED]: { tags: [StateTag.REJECTED], type: 'final', }, - approved: { + [CommonWorkflowStates.APPROVED]: { tags: [StateTag.APPROVED], type: 'final', }, - resolved: { + [CommonWorkflowStates.RESOLVED]: { tags: [StateTag.RESOLVED], type: 'final', }, - revision: { + [CommonWorkflowStates.REVISION]: { tags: [StateTag.REVISION], on: { - [defaultResubmitEvent]: defaultState, + [CommonWorkflowEvent.RETURN_TO_REVIEW]: CommonWorkflowStates.MANUAL_REVIEW, + }, + always: { + target: CommonWorkflowStates.MANUAL_REVIEW, + cond: { + type: 'jmespath', + options: { + rule: 'length(documents[?decision.status]) < length(documents)', + }, + }, }, }, }); diff --git a/services/workflows-service/src/auth/workflow-token/workflow-token.repository.ts b/services/workflows-service/src/auth/workflow-token/workflow-token.repository.ts index 76f07dc5dc..7e72d2df56 100644 --- a/services/workflows-service/src/auth/workflow-token/workflow-token.repository.ts +++ b/services/workflows-service/src/auth/workflow-token/workflow-token.repository.ts @@ -1,6 +1,6 @@ import { Prisma } from '@prisma/client'; import { Injectable } from '@nestjs/common'; -import type { TProjectId } from '@/types'; +import type { PrismaTransaction, TProjectId } from '@/types'; import { PrismaService } from '@/prisma/prisma.service'; @Injectable() @@ -13,8 +13,9 @@ export class WorkflowTokenRepository { Prisma.WorkflowRuntimeDataTokenUncheckedCreateInput, 'workflowRuntimeDataId' | 'endUserId' | 'expiresAt' >, + transaction: PrismaTransaction | PrismaService = this.prisma, ) { - return await this.prisma.workflowRuntimeDataToken.create({ + return await transaction.workflowRuntimeDataToken.create({ data: { ...data, projectId, diff --git a/services/workflows-service/src/auth/workflow-token/workflow-token.service.ts b/services/workflows-service/src/auth/workflow-token/workflow-token.service.ts index 747a0bbc86..497eb0d225 100644 --- a/services/workflows-service/src/auth/workflow-token/workflow-token.service.ts +++ b/services/workflows-service/src/auth/workflow-token/workflow-token.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { WorkflowTokenRepository } from '@/auth/workflow-token/workflow-token.repository'; -import type { TProjectId } from '@/types'; +import type { PrismaTransaction, TProjectId } from '@/types'; @Injectable() export class WorkflowTokenService { @@ -9,8 +9,9 @@ export class WorkflowTokenService { async create( projectId: TProjectId, data: Parameters[1], + transaction?: PrismaTransaction, ) { - return await this.workflowTokenRepository.create(projectId, data); + return await this.workflowTokenRepository.create(projectId, data, transaction); } async findByToken(token: string) { diff --git a/services/workflows-service/src/business/business.repository.ts b/services/workflows-service/src/business/business.repository.ts index 80239de298..b9494ee58e 100644 --- a/services/workflows-service/src/business/business.repository.ts +++ b/services/workflows-service/src/business/business.repository.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { BusinessModel } from './business.model'; import { ProjectScopeService } from '@/project/project-scope.service'; import type { TProjectIds } from '@/types'; +import { PrismaTransaction } from '@/types'; @Injectable() export class BusinessRepository { @@ -74,8 +75,9 @@ export class BusinessRepository { async updateById>( id: string, args: Prisma.SelectSubset>, + transaction: PrismaClient | PrismaTransaction = this.prisma, ): Promise { - return await this.prisma.business.update({ + return await transaction.business.update({ where: { id }, ...args, }); diff --git a/services/workflows-service/src/collection-flow/collection-flow.service.ts b/services/workflows-service/src/collection-flow/collection-flow.service.ts index cf45cb270b..8d7e43f8f3 100644 --- a/services/workflows-service/src/collection-flow/collection-flow.service.ts +++ b/services/workflows-service/src/collection-flow/collection-flow.service.ts @@ -22,6 +22,7 @@ import { plainToClass } from 'class-transformer'; import { randomUUID } from 'crypto'; import keyBy from 'lodash/keyBy'; import get from 'lodash/get'; +import { BUILT_IN_EVENT } from '@ballerine/workflow-core'; @Injectable() export class CollectionFlowService { @@ -189,37 +190,6 @@ export class CollectionFlowService { return workflowData ? workflowData : null; } - async updateWorkflowRuntimeData(payload: UpdateFlowDto, tokenScope: ITokenScope) { - const workflowRuntime = await this.workflowService.getWorkflowRuntimeDataById( - tokenScope.workflowRuntimeDataId, - {}, - [tokenScope.projectId] as TProjectIds, - ); - - if (payload.data.endUser) { - await this.endUserService.updateById(tokenScope.endUserId, { - data: { ...payload.data.endUser, projectId: tokenScope.projectId }, - }); - } - - if (payload.data.ballerineEntityId && payload.data.business) { - await this.businessService.updateById(payload.data.ballerineEntityId, { - data: { ...payload.data.business, projectId: tokenScope.projectId }, - }); - } - - const { state, ...resetContext } = payload.data.context as Record; - - return await this.workflowService.createOrUpdateWorkflowRuntime({ - workflowDefinitionId: workflowRuntime.workflowDefinitionId, - context: resetContext as DefaultContextSchema, - config: workflowRuntime.config, - parentWorkflowId: undefined, - projectIds: [tokenScope.projectId], - currentProjectId: tokenScope.projectId, - }); - } - async updateWorkflowRuntimeLanguage(language: string, tokenScope: ITokenScope) { const workflowRuntime = await this.workflowService.getWorkflowRuntimeDataById( tokenScope.workflowRuntimeDataId, @@ -245,21 +215,19 @@ export class CollectionFlowService { }); } - return await this.workflowService.syncContextById( - tokenScope.workflowRuntimeDataId, - payload.data.context as DefaultContextSchema, + return await this.workflowService.event( + { + id: tokenScope.workflowRuntimeDataId, + name: BUILT_IN_EVENT.UPDATE_CONTEXT, + payload: { + context: payload.data.context, + }, + }, + [tokenScope.projectId], tokenScope.projectId, ); } - async resubmitFlow(flowId: string, projectIds: TProjectIds, currentProjectId: TProjectId) { - await this.workflowService.event( - { id: flowId, name: 'RESUBMITTED' }, - projectIds, - currentProjectId, - ); - } - async uploadNewFile(projectId: string, workflowRuntimeDataId: string, file: Express.Multer.File) { // upload file into a customer folder const customer = await this.customerService.getByProjectId(projectId); diff --git a/services/workflows-service/src/collection-flow/controllers/collection-flow.controller.ts b/services/workflows-service/src/collection-flow/controllers/collection-flow.controller.ts index f59b686075..210cf94c23 100644 --- a/services/workflows-service/src/collection-flow/controllers/collection-flow.controller.ts +++ b/services/workflows-service/src/collection-flow/controllers/collection-flow.controller.ts @@ -15,6 +15,7 @@ import { WorkflowService } from '@/workflow/workflow.service'; import { FinishFlowDto } from '@/collection-flow/dto/finish-flow.dto'; import { GetFlowConfigurationInputDto } from '@/collection-flow/dto/get-flow-configuration-input.dto'; import { UpdateContextInputDto } from '@/collection-flow/dto/update-context-input.dto'; +import { BUILT_IN_EVENT, ARRAY_MERGE_OPTION } from '@ballerine/workflow-core'; @Public() @UseTokenAuthGuard() @@ -104,11 +105,6 @@ export class ColectionFlowController { ); } - @common.Put('') - async updateFlow(@common.Body() payload: UpdateFlowDto, @TokenScope() tokenScope: ITokenScope) { - return await this.service.updateWorkflowRuntimeData(payload, tokenScope); - } - @common.Put('/language') async updateFlowLanguage( @common.Body() { language }: UpdateFlowLanguageDto, @@ -127,9 +123,18 @@ export class ColectionFlowController { @common.Body() { context }: UpdateContextInputDto, @TokenScope() tokenScope: ITokenScope, ) { - return await this.workflowService.updateContextById(tokenScope.workflowRuntimeDataId, context, [ + return await this.workflowService.event( + { + id: tokenScope.workflowRuntimeDataId, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext: context, + arrayMergeOption: ARRAY_MERGE_OPTION.BY_ID, + }, + }, + [tokenScope.projectId], tokenScope.projectId, - ]); + ); } @common.Post('/send-event') @@ -146,8 +151,8 @@ export class ColectionFlowController { @common.Post('resubmit') async resubmitFlow(@TokenScope() tokenScope: ITokenScope) { - return this.service.resubmitFlow( - tokenScope.workflowRuntimeDataId, + await this.workflowService.event( + { id: tokenScope.workflowRuntimeDataId, name: 'RESUBMITTED' }, [tokenScope.projectId], tokenScope.projectId, ); diff --git a/services/workflows-service/src/end-user/end-user.controller.external.intg.test.ts b/services/workflows-service/src/end-user/end-user.controller.external.intg.test.ts index a22d9184a1..d492adff37 100644 --- a/services/workflows-service/src/end-user/end-user.controller.external.intg.test.ts +++ b/services/workflows-service/src/end-user/end-user.controller.external.intg.test.ts @@ -91,7 +91,7 @@ describe('#EndUserControllerExternal', () => { customer = await createCustomer( await app.get(PrismaService), - 'someRandomId', + faker.datatype.uuid(), 'secret3', '', '', diff --git a/services/workflows-service/src/end-user/end-user.repository.ts b/services/workflows-service/src/end-user/end-user.repository.ts index 61d3a72f22..06bcecb7b8 100644 --- a/services/workflows-service/src/end-user/end-user.repository.ts +++ b/services/workflows-service/src/end-user/end-user.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { EndUserModel } from './end-user.model'; -import type { TProjectIds } from '@/types'; +import type { PrismaTransaction, TProjectIds } from '@/types'; import { ProjectScopeService } from '@/project/project-scope.service'; @Injectable() @@ -72,8 +72,9 @@ export class EndUserRepository { async updateById>( id: string, args: Prisma.SelectSubset>, + transaction: PrismaClient | PrismaTransaction = this.prisma, ): Promise { - return await this.prisma.endUser.update({ + return await transaction.endUser.update({ where: { id }, ...args, }); diff --git a/services/workflows-service/src/metrics/repository/metrics.repository.ts b/services/workflows-service/src/metrics/repository/metrics.repository.ts index 8904a74f1b..79e9a3ae8b 100644 --- a/services/workflows-service/src/metrics/repository/metrics.repository.ts +++ b/services/workflows-service/src/metrics/repository/metrics.repository.ts @@ -5,8 +5,7 @@ import { Injectable } from '@nestjs/common'; import { plainToClass } from 'class-transformer'; import { IAggregateUsersWithCasesCount } from '@/metrics/repository/types/aggregate-users-with-cases-count'; import { WorkflowRuntimeStatusCaseCountModel } from '@/metrics/repository/models/workflow-runtime-status-case-count.model'; -import { aggregateWorkflowRuntimeStatisticQuery } from './sql/aggregate-workflow-runtime-statistic.sql'; -import { aggregateWorkflowRuntimeStatusCaseCountQuery } from './sql/aggregate-workflow-runtime-status-case-count.sql'; +import { buildAggregateWorkflowRuntimeStatusCaseCountQuery } from './sql/build-aggregate-workflow-runtime-status-case-count.sql'; import { IAggregateWorkflowRuntimeStatusCaseCount } from '@/metrics/repository/types/aggregate-workflow-runtime-status-case-count'; import { GetRuntimeStatusCaseCountParams } from '@/metrics/repository/types/get-runtime-status-case-count.params'; import { GetUserApprovalRateParams } from '@/metrics/repository/types/get-user-approval-rate.params'; @@ -14,7 +13,7 @@ import { ApprovalRateModel } from '@/metrics/repository/models/approval-rate.mod import { IAggregateApprovalRate } from '@/metrics/repository/types/aggregate-approval-rate'; import { GetUserAverageResolutionTimeParams } from '@/metrics/repository/types/get-user-average-resolution-time.params'; import { AverageResolutionTimeModel } from '@/metrics/repository/models/average-resolution-time.model'; -import { aggregateAverageResolutionTimeQuery } from './sql/aggregate-average-resolution-time.sql'; +import { buildAggregateAverageResolutionTimeQuery } from './sql/build-aggregate-average-resolution-time.sql'; import { IAggregateAverageResolutionTime } from '@/metrics/repository/types/aggregate-average-resolution-time'; import { GetUserAverageAssignmentTimeParams } from '@/metrics/repository/types/get-user-average-assignment-time.params'; import { IAggregateAverageAssignmentTime } from '@/metrics/repository/types/aggregate-average-assignment-time'; @@ -24,22 +23,23 @@ import { IAggregateAverageReviewTime } from '@/metrics/repository/types/aggregat import { ListUserCasesResolvedDailyParams } from '@/metrics/repository/types/list-user-cases-resolved-daily.params'; import { CasesResolvedInDay } from '@/metrics/repository/models/cases-resolved-daily.model'; import { IAggregateCasesResolvedDaily } from '@/metrics/repository/types/aggregate-cases-resolved-daily'; -import { aggregateDailyCasesResolvedQuery } from '@/metrics/repository/sql/aggregate-daily-cases-resolved.sql'; +import { buildAggregateDailyCasesResolvedQuery } from '@/metrics/repository/sql/build-aggregate-daily-cases-resolved.sql'; import { MetricsUserModel } from '@/metrics/repository/models/metrics-user.model'; import { ISelectActiveUser } from '@/metrics/repository/types/select-active-user'; -import { selectActiveUsersQuery } from '@/metrics/repository/sql/select-active-users.sql'; +import { buildSelectActiveUsersQuery } from '@/metrics/repository/sql/build-select-active-users.sql'; import { FindUsersAssignedCasesStatisticParams } from '@/metrics/repository/types/find-users-assigned-cases-statistic.params'; import { UserAssignedCasesStatisticModel } from '@/metrics/repository/models/user-assigned-cases-statistic.model'; -import { aggregateUsersAssignedCasesStatisticQuery } from '@/metrics/repository/sql/aggregate-users-assigned-cases-statistic.sql'; +import { buildAggregateUsersAssignedCasesStatisticQuery } from '@/metrics/repository/sql/build-aggregate-users-assigned-cases-statistic.sql'; import { FindUsersResolvedCasesStatisticParams } from '@/metrics/repository/types/find-users-resolved-cases-statistic.params'; import { UserResolvedCasesStatisticModel } from '@/metrics/repository/models/user-resolved-cases-statistic.model'; import { IAggregateUserResolvedCasesStatistic } from '@/metrics/repository/types/aggregate-user-resolved-cases-statistic'; -import { aggregateUsersResolvedCasesStatisticQuery } from '@/metrics/repository/sql/aggregate-users-resolved-cases-statistic.sql'; -import { aggregateApprovalRateQuery } from '@/metrics/repository/sql/aggregate-approval-rate.sql'; -import { aggregateAverageAssignmentTimeQuery } from '@/metrics/repository/sql/aggregate-average-assignment-time.sql'; +import { buildAggregateUsersResolvedCasesStatisticQuery } from '@/metrics/repository/sql/build-aggregate-users-resolved-cases-statistic.sql'; +import { buildAggregateApprovalRateQuery } from '@/metrics/repository/sql/build-aggregate-approval-rate.sql'; +import { buildAggregateAverageAssignmentTimeQuery } from '@/metrics/repository/sql/build-aggregate-average-assignment-time.sql'; import { AverageAssignmentTimeModel } from '@/metrics/repository/models/average-assignment-time.model'; -import { aggregateAverageReviewTimeQuery } from '@/metrics/repository/sql/aggregate-average-review-time.sql'; +import { buildAggregateAverageReviewTimeQuery } from '@/metrics/repository/sql/build-aggregate-average-review-time.sql'; import type { TProjectIds } from '@/types'; +import { buildAggregateWorkflowRuntimeStatisticQuery } from '@/metrics/repository/sql/build-aggregate-workflow-runtime-statistic.sql'; @Injectable() export class MetricsRepository { @@ -49,12 +49,8 @@ export class MetricsRepository { params: GetRuntimeStatusCaseCountParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe< - IAggregateWorkflowRuntimeStatusCaseCount[] - >( - aggregateWorkflowRuntimeStatusCaseCountQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateWorkflowRuntimeStatusCaseCountQuery(params.fromDate, projectIds), ); return plainToClass( @@ -64,9 +60,8 @@ export class MetricsRepository { } async findRuntimeStatistic(projectIds: TProjectIds): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateWorkflowRuntimeStatisticQuery, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateWorkflowRuntimeStatisticQuery(projectIds), ); return results.map(result => @@ -84,10 +79,8 @@ export class MetricsRepository { params: FindUsersAssignedCasesStatisticParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateUsersAssignedCasesStatisticQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateUsersAssignedCasesStatisticQuery(params.fromDate, projectIds), ); return results.map(result => plainToClass(UserAssignedCasesStatisticModel, result)); @@ -97,12 +90,8 @@ export class MetricsRepository { params: FindUsersResolvedCasesStatisticParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe< - IAggregateUserResolvedCasesStatistic[] - >( - aggregateUsersResolvedCasesStatisticQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateUsersResolvedCasesStatisticQuery(params.fromDate, projectIds), ); return results.map(result => @@ -120,10 +109,8 @@ export class MetricsRepository { params: GetUserApprovalRateParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateApprovalRateQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateApprovalRateQuery(params.fromDate, projectIds), ); return results.length @@ -135,10 +122,8 @@ export class MetricsRepository { params: GetUserAverageResolutionTimeParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateAverageResolutionTimeQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateAverageResolutionTimeQuery(params.fromDate, projectIds), ); return results.length @@ -150,10 +135,8 @@ export class MetricsRepository { params: GetUserAverageAssignmentTimeParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateAverageAssignmentTimeQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateAverageAssignmentTimeQuery(params.fromDate, projectIds), ); return results.length @@ -165,10 +148,8 @@ export class MetricsRepository { params: GetUserAverageReviewTimeParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateAverageReviewTimeQuery, - params.fromDate, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateAverageReviewTimeQuery(params.fromDate, projectIds), ); return results.length @@ -180,11 +161,8 @@ export class MetricsRepository { params: ListUserCasesResolvedDailyParams, projectIds: TProjectIds, ): Promise { - const results = await this.prismaService.$queryRawUnsafe( - aggregateDailyCasesResolvedQuery, - params.fromDate, - params.userId, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildAggregateDailyCasesResolvedQuery(params.fromDate, params.userId, projectIds), ); if (!results.length) return []; @@ -198,9 +176,8 @@ export class MetricsRepository { } async listUsers(projectIds: TProjectIds): Promise { - const results = await this.prismaService.$queryRawUnsafe( - selectActiveUsersQuery, - projectIds ? projectIds.join(',') : 'null', + const results = await this.prismaService.$queryRaw( + buildSelectActiveUsersQuery(projectIds), ); return results.map(result => plainToClass(MetricsUserModel, result)); diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-average-assignment-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/aggregate-average-assignment-time.sql.ts deleted file mode 100644 index e660f24efa..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-average-assignment-time.sql.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const aggregateAverageAssignmentTimeQuery = ` -SELECT AVG(time)::varchar as average_time -FROM ( - SELECT - COALESCE( - EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."assignedAt" - "WorkflowRuntimeData"."createdAt")) * 1000, - 0 - ) AS time - FROM - "WorkflowRuntimeData" - WHERE - "WorkflowRuntimeData"."createdAt" IS NOT NULL - AND - "WorkflowRuntimeData"."assignedAt" >= $1 - AND - "WorkflowRuntimeData"."projectId" in ($2) -) AS T -`; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-average-resolution-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/aggregate-average-resolution-time.sql.ts deleted file mode 100644 index 0f7b30750f..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-average-resolution-time.sql.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const aggregateAverageResolutionTimeQuery = ` -SELECT AVG(time)::varchar as average_time -FROM ( - SELECT - COALESCE( - EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."resolvedAt" - "WorkflowRuntimeData"."createdAt")) * 1000, - 0 - ) AS time - FROM - "WorkflowRuntimeData" - WHERE - "WorkflowRuntimeData"."createdAt" IS NOT NULL - AND - "WorkflowRuntimeData"."resolvedAt" >= $1 - AND - "WorkflowRuntimeData"."projectId" in ($2) -) AS T - `; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-average-review-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/aggregate-average-review-time.sql.ts deleted file mode 100644 index 0fd545cfe0..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-average-review-time.sql.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const aggregateAverageReviewTimeQuery = ` -SELECT AVG(time)::varchar as average_time -FROM ( - SELECT - COALESCE( - EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."resolvedAt" - "WorkflowRuntimeData"."assignedAt")) * 1000, - 0 - ) AS time - FROM - "WorkflowRuntimeData" - WHERE - "WorkflowRuntimeData"."assignedAt" IS NOT NULL - AND - "WorkflowRuntimeData"."resolvedAt" >= $1 - AND - "WorkflowRuntimeData"."projectId" in ($2) -) AS T -`; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-daily-cases-resolved.sql.ts b/services/workflows-service/src/metrics/repository/sql/aggregate-daily-cases-resolved.sql.ts deleted file mode 100644 index b76819d267..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-daily-cases-resolved.sql.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const aggregateDailyCasesResolvedQuery = ` -select - date, - sum(( - SELECT COUNT(*) - FROM "WorkflowRuntimeData" - WHERE "assigneeId" = coalesce($2, "WorkflowRuntimeData"."assigneeId") - AND date_trunc('day', "resolvedAt"::timestamp) = date_trunc('day', date::timestamp) - AND "projectId" in ($3) - ))::int AS cases -from -generate_series($1::timestamp, now()::timestamp, interval '1 day') as date -group by date -order by date asc -`; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-users-resolved-cases-statistic.sql.ts b/services/workflows-service/src/metrics/repository/sql/aggregate-users-resolved-cases-statistic.sql.ts deleted file mode 100644 index a9d62da2bc..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-users-resolved-cases-statistic.sql.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const aggregateUsersResolvedCasesStatisticQuery = ` -select - id, - "firstName", - "lastName", - "casesCount"::int, - email -from - "User" -inner join ( - select - "WorkflowRuntimeData"."assigneeId", - count(*) as "casesCount" - from - "WorkflowRuntimeData" - where "resolvedAt" >= $1 - AND "projectId" in ($2) - group by "assigneeId" -) as agent_workflow_runtime on -agent_workflow_runtime."assigneeId" = "id" -order by "casesCount" desc -`; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-approval-rate.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-approval-rate.sql.ts similarity index 50% rename from services/workflows-service/src/metrics/repository/sql/aggregate-approval-rate.sql.ts rename to services/workflows-service/src/metrics/repository/sql/build-aggregate-approval-rate.sql.ts index 549de336e5..0059b9c1d9 100644 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-approval-rate.sql.ts +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-approval-rate.sql.ts @@ -1,4 +1,10 @@ -export const aggregateApprovalRateQuery = ` +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateApprovalRateQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` SELECT (CASE WHEN counts."resolvedCount" > 0 AND counts."approvedCount" > 0 @@ -9,12 +15,12 @@ FROM ( SELECT (SELECT COUNT(*) FROM "WorkflowRuntimeData" - WHERE "resolvedAt" >= $1 - AND "projectId" in ($2)) AS "resolvedCount", + WHERE "resolvedAt" >= ${fromDate} + AND "projectId" in (${projectIds?.join(',')})) AS "resolvedCount", (SELECT COUNT(*) FROM "WorkflowRuntimeData" WHERE context -> 'documents' @> '[{"decision": {"status": "approved"}}]' - AND "resolvedAt" >= $1 - AND "projectId" in ($2)) AS "approvedCount" + AND "resolvedAt" >= ${fromDate} + AND "projectId" in (${projectIds?.join(',')})) AS "approvedCount" ) AS counts; `; diff --git a/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-assignment-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-assignment-time.sql.ts new file mode 100644 index 0000000000..4884740ac6 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-assignment-time.sql.ts @@ -0,0 +1,24 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateAverageAssignmentTimeQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` +SELECT AVG(time)::varchar as average_time +FROM ( + SELECT + COALESCE( + EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."assignedAt" - "WorkflowRuntimeData"."createdAt")) * 1000, + 0 + ) AS time + FROM + "WorkflowRuntimeData" + WHERE + "WorkflowRuntimeData"."createdAt" IS NOT NULL + AND + "WorkflowRuntimeData"."assignedAt" >= ${fromDate} + AND + "WorkflowRuntimeData"."projectId" in (${projectIds?.join(',')}) +) AS T +`; diff --git a/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-resolution-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-resolution-time.sql.ts new file mode 100644 index 0000000000..866b317fb8 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-resolution-time.sql.ts @@ -0,0 +1,24 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateAverageResolutionTimeQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` +SELECT AVG(time)::varchar as average_time +FROM ( + SELECT + COALESCE( + EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."resolvedAt" - "WorkflowRuntimeData"."createdAt")) * 1000, + 0 + ) AS time + FROM + "WorkflowRuntimeData" + WHERE + "WorkflowRuntimeData"."createdAt" IS NOT NULL + AND + "WorkflowRuntimeData"."resolvedAt" >= ${fromDate} + AND + "WorkflowRuntimeData"."projectId" in (${projectIds?.join(',')}) +) AS T + `; diff --git a/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-review-time.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-review-time.sql.ts new file mode 100644 index 0000000000..d1a8fd02b8 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-average-review-time.sql.ts @@ -0,0 +1,24 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateAverageReviewTimeQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` +SELECT AVG(time)::varchar as average_time +FROM ( + SELECT + COALESCE( + EXTRACT(EPOCH FROM ("WorkflowRuntimeData"."resolvedAt" - "WorkflowRuntimeData"."assignedAt")) * 1000, + 0 + ) AS time + FROM + "WorkflowRuntimeData" + WHERE + "WorkflowRuntimeData"."assignedAt" IS NOT NULL + AND + "WorkflowRuntimeData"."resolvedAt" >= ${fromDate} + AND + "WorkflowRuntimeData"."projectId" in (${projectIds?.join(',')}) +) AS T +`; diff --git a/services/workflows-service/src/metrics/repository/sql/build-aggregate-daily-cases-resolved.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-daily-cases-resolved.sql.ts new file mode 100644 index 0000000000..254ef4a480 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-daily-cases-resolved.sql.ts @@ -0,0 +1,22 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateDailyCasesResolvedQuery = ( + fromDate: Date = new Date(), + userId: string, + projectIds: TProjectIds, +) => Prisma.sql` +select + date, + sum(( + SELECT COUNT(*) + FROM "WorkflowRuntimeData" + WHERE "assigneeId" = coalesce(${userId}, "WorkflowRuntimeData"."assigneeId") + AND date_trunc('day', "resolvedAt"::timestamp) = date_trunc('day', date::timestamp) + AND "projectId" in (${projectIds?.join(',')}) + ))::int AS cases +from +generate_series(${fromDate}::timestamp, now()::timestamp, interval '1 day') as date +group by date +order by date asc +`; diff --git a/services/workflows-service/src/metrics/repository/sql/build-aggregate-users-assigned-cases-statistic.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-users-assigned-cases-statistic.sql.ts new file mode 100644 index 0000000000..86c049b3d5 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-users-assigned-cases-statistic.sql.ts @@ -0,0 +1,28 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateUsersAssignedCasesStatisticQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` +select + id, + "firstName", + "lastName", + "casesCount"::int, + email +from + "User" +inner join ( + select + "WorkflowRuntimeData"."assigneeId", + count(*) as "casesCount" + from + "WorkflowRuntimeData" + where "assignedAt" >= coalesce(${fromDate}, '1900-01-01'::timestamp) + AND "projectId" in (${projectIds?.join(',')}) + group by "assigneeId" +) as agent_workflow_runtime on +agent_workflow_runtime."assigneeId" = "id" +order by "casesCount" desc +`; diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-users-assigned-cases-statistic.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-users-resolved-cases-statistic.sql.ts similarity index 52% rename from services/workflows-service/src/metrics/repository/sql/aggregate-users-assigned-cases-statistic.sql.ts rename to services/workflows-service/src/metrics/repository/sql/build-aggregate-users-resolved-cases-statistic.sql.ts index e6e53cbb8d..3b88dca93a 100644 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-users-assigned-cases-statistic.sql.ts +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-users-resolved-cases-statistic.sql.ts @@ -1,4 +1,10 @@ -export const aggregateUsersAssignedCasesStatisticQuery = ` +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateUsersResolvedCasesStatisticQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` select id, "firstName", @@ -13,8 +19,8 @@ inner join ( count(*) as "casesCount" from "WorkflowRuntimeData" - where "assignedAt" >= coalesce($1, '1900-01-01'::timestamp) - AND "projectId" in ($2) + where "resolvedAt" >= ${fromDate} + AND "projectId" in (${projectIds?.join(',')}) group by "assigneeId" ) as agent_workflow_runtime on agent_workflow_runtime."assigneeId" = "id" diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-statistic.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-statistic.sql.ts similarity index 79% rename from services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-statistic.sql.ts rename to services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-statistic.sql.ts index 025933887e..0b0e32b136 100644 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-statistic.sql.ts +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-statistic.sql.ts @@ -1,4 +1,7 @@ -export const aggregateWorkflowRuntimeStatisticQuery = ` +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateWorkflowRuntimeStatisticQuery = (projectIds: TProjectIds) => Prisma.sql` select "workflowDefinitionId", "name" as "workflowDefinitionName", @@ -29,7 +32,7 @@ from from "WorkflowRuntimeData" where - "projectId" in ($1) + "projectId" in (${projectIds?.join(',')}) group by "workflowDefinitionId", "status" diff --git a/services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-status-case-count.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-status-case-count.sql.ts similarity index 64% rename from services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-status-case-count.sql.ts rename to services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-status-case-count.sql.ts index d961fc7a97..782cee14ac 100644 --- a/services/workflows-service/src/metrics/repository/sql/aggregate-workflow-runtime-status-case-count.sql.ts +++ b/services/workflows-service/src/metrics/repository/sql/build-aggregate-workflow-runtime-status-case-count.sql.ts @@ -1,4 +1,10 @@ -export const aggregateWorkflowRuntimeStatusCaseCountQuery = ` +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildAggregateWorkflowRuntimeStatusCaseCountQuery = ( + fromDate: Date = new Date(), + projectIds: TProjectIds, +) => Prisma.sql` select sum( case @@ -25,8 +31,8 @@ from count("status") as status_count from "WorkflowRuntimeData" - where "createdAt" >= coalesce($1, '1900-01-01'::timestamp) - AND "projectId" in ($2) + where "createdAt" >= coalesce(${fromDate}, '1900-01-01'::timestamp) + AND "projectId" in (${projectIds?.join(',')}) group by "status" ) as workflow_runtime_data`; diff --git a/services/workflows-service/src/metrics/repository/sql/build-select-active-users.sql.ts b/services/workflows-service/src/metrics/repository/sql/build-select-active-users.sql.ts new file mode 100644 index 0000000000..51add3d2a7 --- /dev/null +++ b/services/workflows-service/src/metrics/repository/sql/build-select-active-users.sql.ts @@ -0,0 +1,10 @@ +import { TProjectIds } from '@/types'; +import { Prisma } from '@prisma/client'; + +export const buildSelectActiveUsersQuery = (projectIds: TProjectIds) => Prisma.sql` +select +id, "firstName", "lastName", "lastActiveAt" +from "User" +inner join "UserToProject" on "UserToProject"."userId" = "User".id +where "UserToProject"."projectId" in (${projectIds?.join(',')}) +order by "lastActiveAt" desc nulls last`; diff --git a/services/workflows-service/src/metrics/repository/sql/select-active-users.sql.ts b/services/workflows-service/src/metrics/repository/sql/select-active-users.sql.ts deleted file mode 100644 index 3b946c8a14..0000000000 --- a/services/workflows-service/src/metrics/repository/sql/select-active-users.sql.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const selectActiveUsersQuery = ` -select -id, "firstName", "lastName", "lastActiveAt" -from "User" -inner join "UserToProject" on "UserToProject"."userId" = "User".id -where "UserToProject"."projectId" in ($1) -order by "lastActiveAt" desc nulls last`; diff --git a/services/workflows-service/src/prisma/prisma.util.ts b/services/workflows-service/src/prisma/prisma.util.ts index 22b681adcb..a2e49344ac 100644 --- a/services/workflows-service/src/prisma/prisma.util.ts +++ b/services/workflows-service/src/prisma/prisma.util.ts @@ -1,4 +1,6 @@ import { Prisma } from '@prisma/client'; +import { PrismaTransaction } from '@/types'; +import { PrismaService } from '@/prisma/prisma.service'; export const PRISMA_RECORD_NOT_FOUND_ERROR = 'P2025'; @@ -30,3 +32,34 @@ export const transformStringFieldUpdateInput = async < } return input; }; + +type PrismaTransactionOptions = { + maxWait?: number; + timeout?: number; + isolationLevel?: Prisma.TransactionIsolationLevel; +}; +/** + * If transaction is not provided, a new transaction will be created and used for the callback. + * This function is a curried function that takes a callback function that will be executed with the transaction. + * @param transaction + * @param prismaService + * @param options + */ +export const beginTransactionIfNotExistCurry = ({ + transaction, + prismaService, + options, +}: { + transaction?: PrismaTransaction; + prismaService: PrismaService; + options?: PrismaTransactionOptions; +}) => { + return (callback: (transaction: PrismaTransaction) => Promise): Promise => { + return transaction ? callback(transaction) : prismaService.$transaction(callback, options); + }; +}; + +export const defaultPrismaTransactionOptions: PrismaTransactionOptions = { + maxWait: 60_000, + timeout: 60_000, +}; diff --git a/services/workflows-service/src/types.ts b/services/workflows-service/src/types.ts index 7961c307ff..2bfe58118b 100644 --- a/services/workflows-service/src/types.ts +++ b/services/workflows-service/src/types.ts @@ -1,5 +1,6 @@ import type { JsonValue } from 'type-fest'; -import { Customer, Project, User, UserToProject } from '@prisma/client'; +import { Customer, PrismaClient, Project, User, UserToProject } from '@prisma/client'; +import * as runtime from '@prisma/client/runtime/library'; export type InputJsonValue = Omit; @@ -26,3 +27,5 @@ export type AuthenticatedEntity = { export type AuthenticatedEntityWithProjects = AuthenticatedEntity & { projectIds: TProjectIds }; export type ObjectValues> = TObject[keyof TObject]; + +export type PrismaTransaction = Omit; diff --git a/services/workflows-service/src/workflow-defintion/workflow-definition.repository.ts b/services/workflows-service/src/workflow-defintion/workflow-definition.repository.ts index ed70a2dd11..8979398f41 100644 --- a/services/workflows-service/src/workflow-defintion/workflow-definition.repository.ts +++ b/services/workflows-service/src/workflow-defintion/workflow-definition.repository.ts @@ -2,8 +2,9 @@ import { PrismaService } from '@/prisma/prisma.service'; import { ProjectScopeService } from '@/project/project-scope.service'; import type { TProjectIds } from '@/types'; import { Injectable } from '@nestjs/common'; -import { Prisma, WorkflowDefinition } from '@prisma/client'; +import { Prisma, PrismaClient, WorkflowDefinition } from '@prisma/client'; import { validateDefinitionLogic } from '@ballerine/workflow-core'; +import { PrismaTransaction } from '@/types'; @Injectable() export class WorkflowDefinitionRepository { @@ -55,6 +56,7 @@ export class WorkflowDefinitionRepository { id: string, args: Prisma.SelectSubset>, projectIds: TProjectIds, + transaction: PrismaTransaction | PrismaClient = this.prisma, ): Promise { const queryArgs = args as Prisma.WorkflowDefinitionFindFirstOrThrowArgs; @@ -71,7 +73,7 @@ export class WorkflowDefinitionRepository { }, ], }; - return await this.prisma.workflowDefinition.findFirstOrThrow(queryArgs); + return await transaction.workflowDefinition.findFirstOrThrow(queryArgs); } async findTemplateByIdUnscoped< diff --git a/services/workflows-service/src/workflow-defintion/workflow-definition.service.intg.test.ts b/services/workflows-service/src/workflow-defintion/workflow-definition.service.intg.test.ts index 816d5541c3..9290becccc 100644 --- a/services/workflows-service/src/workflow-defintion/workflow-definition.service.intg.test.ts +++ b/services/workflows-service/src/workflow-defintion/workflow-definition.service.intg.test.ts @@ -4,7 +4,6 @@ import { WorkflowDefinitionRepository } from '@/workflow-defintion/workflow-defi import { FilterService } from '@/filter/filter.service'; import { CustomerService } from '@/customer/customer.service'; import { PrismaService } from '@/prisma/prisma.service'; -import { buildWorkflowDefinition } from '@/workflow/workflow.controller.internal.unit.test'; import { createCustomer } from '@/test/helpers/create-customer'; import { createProject } from '@/test/helpers/create-project'; import { Project } from '@prisma/client'; @@ -15,6 +14,36 @@ import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import { WinstonLogger } from '@/common/utils/winston-logger/winston-logger'; import { ClsService } from 'nestjs-cls'; +const buildWorkflowDefinition = (sequenceNum: number, projectId?: string) => { + return { + id: sequenceNum.toString(), + name: `name ${sequenceNum}`, + version: sequenceNum, + definition: { + initial: 'initial', + states: { + initial: { + on: { + COMPLETE: 'completed', + }, + }, + completed: { + type: 'final', + }, + }, + }, + definitionType: `definitionType ${sequenceNum}`, + createdAt: new Date(), + updatedAt: new Date(), + contextSchema: { + type: 'json-schema', + schema: {}, + }, + projectId: projectId, + isPublic: false, + }; +}; + describe('WorkflowDefinitionService', () => { let workflowDefinitionService: WorkflowDefinitionService; let workflowDefinitionRepository: WorkflowDefinitionRepository; diff --git a/services/workflows-service/src/workflow/dtos/document-decision-update-input.ts b/services/workflows-service/src/workflow/dtos/document-decision-update-input.ts index 968a1f693f..880d6f9282 100644 --- a/services/workflows-service/src/workflow/dtos/document-decision-update-input.ts +++ b/services/workflows-service/src/workflow/dtos/document-decision-update-input.ts @@ -18,8 +18,4 @@ export class DocumentDecisionUpdateInput { @IsOptional() @IsString() reason?: string; - - @IsOptional() - @IsString() - postUpdateEventName?: string; } diff --git a/services/workflows-service/src/workflow/dtos/workflow-definition-update-input.ts b/services/workflows-service/src/workflow/dtos/workflow-definition-update-input.ts index 02304b4f5b..be8510fb30 100644 --- a/services/workflows-service/src/workflow/dtos/workflow-definition-update-input.ts +++ b/services/workflows-service/src/workflow/dtos/workflow-definition-update-input.ts @@ -51,8 +51,4 @@ export class WorkflowDefinitionUpdateInput { @IsString() @IsOptional() assigneeId?: string; - - @IsString() - @IsOptional() - postUpdateEventName?: string; } diff --git a/services/workflows-service/src/workflow/dtos/workflow-event-input.ts b/services/workflows-service/src/workflow/dtos/workflow-event-input.ts index 82662166e8..de30c34db1 100644 --- a/services/workflows-service/src/workflow/dtos/workflow-event-input.ts +++ b/services/workflows-service/src/workflow/dtos/workflow-event-input.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; +import { IsObject, IsOptional, IsString } from 'class-validator'; export class WorkflowEventInput { @ApiProperty({ @@ -8,4 +8,12 @@ export class WorkflowEventInput { }) @IsString() name!: string; + + @ApiProperty({ + required: false, + type: Object, + }) + @IsObject() + @IsOptional() + payload?: Record; } diff --git a/services/workflows-service/src/workflow/hook-callback-handler.service.ts b/services/workflows-service/src/workflow/hook-callback-handler.service.ts index 457eb3518d..a6ca592094 100644 --- a/services/workflows-service/src/workflow/hook-callback-handler.service.ts +++ b/services/workflows-service/src/workflow/hook-callback-handler.service.ts @@ -59,17 +59,7 @@ export class HookCallbackHandlerService { ); } - set(workflowRuntime.context, resultDestinationPath, data); - - await this.workflowService.updateWorkflowRuntimeData( - workflowRuntime.id, - { - context: workflowRuntime.context, - }, - currentProjectId, - ); - - return data; + return set({}, resultDestinationPath, data); } async mapCallbackDataToIndividual( data: AnyRecord, @@ -78,7 +68,7 @@ export class HookCallbackHandlerService { currentProjectId: TProjectId, ) { const attributePath = resultDestinationPath.split('.'); - const context = workflowRuntime.context; + const context = JSON.parse(JSON.stringify(workflowRuntime.context)); const kycDocument = data.document as AnyRecord; const entity = this.formatEntityData(data); const issuer = this.formatIssuerData(kycDocument); @@ -109,11 +99,8 @@ export class HookCallbackHandlerService { this.setNestedProperty(context, attributePath, result); context.documents = persistedDocuments; - await this.workflowService.updateWorkflowRuntimeData( - workflowRuntime.id, - { context: context }, - currentProjectId, - ); + + return context; } private formatDocuments( diff --git a/services/workflows-service/src/workflow/types/params.ts b/services/workflows-service/src/workflow/types/params.ts index b52fe2c8cd..ce16e7ad97 100644 --- a/services/workflows-service/src/workflow/types/params.ts +++ b/services/workflows-service/src/workflow/types/params.ts @@ -4,9 +4,3 @@ export interface FindLastActiveFlowParams { workflowDefinitionId: string; businessId: string; } - -export interface GetLastActiveFlowParams { - email: string; - workflowRuntimeDefinitionId: string; - projectIds: TProjectIds; -} diff --git a/services/workflows-service/src/workflow/workflow-runtime-data.repository.intg.test.ts b/services/workflows-service/src/workflow/workflow-runtime-data.repository.intg.test.ts index 3ae14bffd4..d49a5d2a1b 100644 --- a/services/workflows-service/src/workflow/workflow-runtime-data.repository.intg.test.ts +++ b/services/workflows-service/src/workflow/workflow-runtime-data.repository.intg.test.ts @@ -9,10 +9,7 @@ import { FileService } from '@/providers/file/file.service'; import { StorageService } from '@/storage/storage.service'; import { WorkflowEventEmitterService } from '@/workflow/workflow-event-emitter.service'; import { BusinessRepository } from '@/business/business.repository'; -import { - ArrayMergeOption, - WorkflowRuntimeDataRepository, -} from '@/workflow/workflow-runtime-data.repository'; +import { WorkflowRuntimeDataRepository } from '@/workflow/workflow-runtime-data.repository'; import { WorkflowService } from '@/workflow/workflow.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { PrismaService } from '@/prisma/prisma.service'; @@ -32,14 +29,13 @@ import { WorkflowTokenRepository } from '@/auth/workflow-token/workflow-token.re import { WorkflowDefinitionRepository } from '@/workflow-defintion/workflow-definition.repository'; import { UiDefinitionService } from '@/ui-definition/ui-definition.service'; import { UiDefinitionRepository } from '@/ui-definition/ui-definition.repository'; +import { faker } from '@faker-js/faker'; describe('#Workflow Runtime Repository Integration Tests', () => { let workflowRuntimeRepository: WorkflowRuntimeDataRepository; let userRepository: UserRepository; let workflowDefinitionRepository: WorkflowDefinitionRepository; let project: Project; - // beforeEach(cleanupDatabase); - // afterEach(tearDownDatabase); beforeAll(async () => { await cleanupDatabase(); @@ -94,7 +90,7 @@ describe('#Workflow Runtime Repository Integration Tests', () => { const customer = await createCustomer( prismaService, - '1', + faker.datatype.uuid(), 'secret', '', '', @@ -120,893 +116,6 @@ describe('#Workflow Runtime Repository Integration Tests', () => { await tearDownDatabase(); }); - describe('Workflow Runtime Data Repository: Jsonb Merge', () => { - it('updateById: Merge context with nested entities - will preserve "replacment" behaviour for merging arrays', async () => { - // Set up initial data - - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - entity: { - id: '1', - name: 'TestEntity', - }, - documents: ['file1', 'file2'], - }, - projectId: project.id, - }, - }); - - // Update the context with a new object - const updatedContext = { - entity: { - id: '2', - name: 'UpdatedEntity', - }, - documents: ['file3'], - }; - - const res = await workflowRuntimeRepository.updateById(createRes.id, { - data: { - context: updatedContext, - projectId: project.id, - }, - }); - - // The expected result should be the merged version of initial and updated context - expect(res.context).toMatchObject({ - entity: { - id: '2', - name: 'UpdatedEntity', - }, - documents: ['file3'], - }); - }); - - it('updateById: Merge context with nested entities - will preserve "replacment" behaviour for merging objects', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - entity: { - id: '1', - }, - }, - projectId: project.id, - }, - }); - - const res = await workflowRuntimeRepository.updateById(createRes.id, { - data: { - context: { - entity: { - id: '2', - }, - documents: [], - }, - projectId: project.id, - }, - }); - - expect(res).toMatchObject({ - endUserId: null, - businessId: null, - assigneeId: null, - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { entity: { id: '2' }, documents: [] }, - config: null, - state: null, - status: 'active', - createdBy: 'SYSTEM', - resolvedAt: null, - assignedAt: null, - }); - }); - - it('should merge the existing and new context data when updateContextById is called', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, - projectId: project.id, - }, - }); - const newContext = { - key2: 'new-value', - key3: 'value3', - documents: [ - { id: '1', name: 'doc2' }, - { id: '2', name: 'doc3' }, - ], - }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById( - createRes.id, - newContext, - arrayMergeOption, - [project.id], - ); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ - project.id, - ]); - - const expectedContext = { - key1: 'value1', - key2: 'new-value', - key3: 'value3', - documents: [ - { id: '1', name: 'doc2' }, - { id: '2', name: 'doc3' }, - ], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should not change existing context when the new context is empty', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, - projectId: project.id, - }, - }); - const newContext = {}; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById( - createRes.id, - newContext, - arrayMergeOption, - [project.id], - ); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ - project.id, - ]); - - const expectedContext = { - key1: 'value1', - key2: 'value2', - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should add new key from the new context to the existing context', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, - projectId: project.id, - }, - }); - const newContext = { key3: 'value3' }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById( - createRes.id, - newContext, - arrayMergeOption, - [project.id], - ); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ - project.id, - ]); - - const expectedContext = { - key1: 'value1', - key2: 'value2', - key3: 'value3', - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - - it('should update the value of an existing key when the new context has a different value for that key', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, - projectId: project.id, - }, - }); - const newContext = { key2: 'new-value2' }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById( - createRes.id, - newContext, - arrayMergeOption, - [project.id], - ); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ - project.id, - ]); - - const expectedContext = { - key1: 'value1', - key2: 'new-value2', - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - }); - it('should merge nested objects in the context', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey1: 'nestedValue1' }, - documents: [{ id: '1', name: 'doc1' }], - }, - projectId: project.id, - }, - }); - const newContext = { key2: { nestedKey2: 'nestedValue2' } }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: { nestedKey1: 'nestedValue1', nestedKey2: 'nestedValue2' }, - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should update values in nested objects in the context', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey1: 'nestedValue1' }, - documents: [{ id: '1', name: 'doc1' }], - }, - projectId: project.id, - }, - }); - const newContext = { key2: { nestedKey1: 'new-nestedValue1' } }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: { nestedKey1: 'new-nestedValue1' }, - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should add a new element to an array in the context', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', key2: ['element1'], documents: [{ id: '1', name: 'doc1' }] }, - projectId: project.id, - }, - }); - const newContext = { key2: ['element2'] }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: ['element1', 'element2'], - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should replace an element from an array in the context when the new context have it on the same index', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: ['element1', 'element2'], - documents: [{ id: '1', name: 'doc1' }], - }, - projectId: project.id, - }, - }); - const newContext = { key2: ['element3'] }; - - const arrayMergeOption: ArrayMergeOption = 'by_index'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: ['element3', 'element2'], - documents: [{ id: '1', name: 'doc1' }], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should throw an error when trying to update context of a non-existing ID', async () => { - const newContext = { key1: 'value1' }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await expect( - workflowRuntimeRepository.updateContextById('non-existing-id', newContext, arrayMergeOption, [ - project.id, - ]), - ).rejects.toThrow(); - }); - - it('should be able to handle large context objects', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: 'value1', largeKey: new Array(1000).fill('value').join('') }, - projectId: project.id, - }, - }); - const newContext = { key2: 'value2' }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: 'value2', - largeKey: new Array(1000).fill('value').join(''), - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should concatenate array in a nested object when array_merge_option is "concat"', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey: ['value2'] }, - }, - projectId: project.id, - }, - }); - - const newContext = { - key2: { nestedKey: ['value3'] }, - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: { nestedKey: ['value2', 'value3'] }, - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should concatenate array of objects in a nested object when array_merge_option is "concat"', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey: [{ id: '1', value: 'value2' }] }, - }, - projectId: project.id, - }, - }); - - const newContext = { - key2: { nestedKey: [{ id: '2', value: 'value3' }] }, - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: { - nestedKey: [ - { id: '1', value: 'value2' }, - { id: '2', value: 'value3' }, - ], - }, - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should concatenate deeply nested arrays when array_merge_option is "concat"', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey: { anotherNestedKey: ['value2'] } }, - }, - projectId: project.id, - }, - }); - - const newContext = { - key2: { nestedKey: { anotherNestedKey: ['value3'] } }, - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: 'value1', - key2: { nestedKey: { anotherNestedKey: ['value2', 'value3'] } }, - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should correctly merge context data with high nesting level', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: { key2: { key3: { key4: { key5: 'value1' } } } }, - }, - projectId: project.id, - }, - }); - - const newContext = { - key1: { key2: { key3: { key4: { key5: 'value2', key6: 'value3' } } } }, - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: { key2: { key3: { key4: { key5: 'value2', key6: 'value3' } } } }, - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should correctly merge context data with mixed data types', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { - key1: 'value1', - key2: { nestedKey1: 'value2', nestedKey2: ['value3'] }, - }, - projectId: project.id, - }, - }); - - const newContext = { - key1: { nestedKey1: 'new-value1' }, - key2: { nestedKey1: 'new-value2', nestedKey3: 'value4' }, - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: { nestedKey1: 'new-value1' }, - key2: { nestedKey1: 'new-value2', nestedKey2: ['value3'], nestedKey3: 'value4' }, - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should correctly merge deeply nested arrays with the by_id strategy', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: [{ id: '1', data: 'data1' }], key2: [{ id: '1', data: 'data1' }] }, - projectId: project.id, - }, - }); - const newContext = { key1: [{ id: '1', data: 'data2' }], key2: [{ id: '2', data: 'data2' }] }; - - const arrayMergeOption: ArrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: [{ id: '1', data: 'data2' }], - key2: [ - { id: '1', data: 'data1' }, - { id: '2', data: 'data2' }, - ], - }; - expect(updatedContext).toEqual(expectedContext); - }); - - it('should correctly merge deeply nested arrays with the by_index strategy', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: ['element1', 'element2'], key2: ['element1', 'element2'] }, - projectId: project.id, - }, - }); - const newContext = { key1: ['element3'], key2: ['element3', 'element4'] }; - - const arrayMergeOption: ArrayMergeOption = 'by_index'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: ['element3', 'element2'], - key2: ['element3', 'element4'], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should correctly merge deeply nested arrays with the concat strategy', async () => { - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: { key1: ['element1', 'element2'], key2: ['element1', 'element2'] }, - projectId: project.id, - }, - }); - const newContext = { key1: ['element3'], key2: ['element3', 'element4'] }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - - const expectedContext = { - key1: ['element1', 'element2', 'element3'], - key2: ['element1', 'element2', 'element3', 'element4'], - }; - expect(updatedContext).toEqual(expectedContext); - }); - it('should merge the nested entity data when updateContextById is called', async () => { - const initialContext = { - entity: { - type: 'individual', - data: { - name: 'John Doe', - age: 30, - additionalInfo: { - hobbies: ['running', 'reading'], - }, - }, - id: '123', - }, - documents: [], - }; - - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: initialContext, - projectId: project.id, - }, - }); - const newContext = { - entity: { - data: { - age: 35, - additionalInfo: { - hobbies: ['cycling'], - occupation: 'engineer', - }, - }, - }, - }; - - const arrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const expectedContext = { - entity: { - type: 'individual', - data: { - name: 'John Doe', - age: 35, - additionalInfo: { - hobbies: ['running', 'reading', 'cycling'], - occupation: 'engineer', - }, - }, - id: '123', - }, - documents: [], - }; - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - expect(updatedContext).toEqual(expectedContext); - }); - it('should merge the documents array by id when updateContextById is called', async () => { - const initialContext = { - entity: { - type: 'individual', - id: '123', - }, - documents: [ - { - id: 'doc1', - category: 'category1', - // other properties... - }, - ], - }; - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: initialContext, - projectId: project.id, - }, - }); - const newContext = { - documents: [ - { - id: 'doc1', - category: 'category2', - // other properties... - }, - ], - }; - - const arrayMergeOption = 'by_id'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const expectedContext = { - entity: { - type: 'individual', - id: '123', - }, - documents: [ - { - id: 'doc1', - category: 'category2', - // other properties... - }, - ], - }; - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - expect(updatedContext).toEqual(expectedContext); - }); - it('should merge nested arrays in additionalInfo when updateContextById is called', async () => { - const initialContext = { - entity: { - type: 'individual', - data: { - additionalInfo: { - hobbies: ['running', 'reading'], - }, - }, - id: '123', - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['photo', 'signature'], - }, - }, - // other properties... - }, - ], - }; - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: initialContext, - projectId: project.id, - }, - }); - const newContext = { - entity: { - data: { - additionalInfo: { - hobbies: ['cycling'], - }, - }, - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['address'], - }, - }, - // other properties... - }, - ], - }; - - const arrayMergeOption: ArrayMergeOption = 'concat'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const expectedContext = { - entity: { - type: 'individual', - data: { - additionalInfo: { - hobbies: ['running', 'reading', 'cycling'], - }, - }, - id: '123', - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['photo', 'signature'], - }, - }, - }, - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['address'], - }, - }, - }, - ], - }; - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - expect(updatedContext).toEqual(expectedContext); - }); - it('should merge nested arrays in additionalInfo when updateContextById is called', async () => { - const initialContext = { - entity: { - type: 'individual', - data: { - additionalInfo: { - hobbies: ['running', 'reading'], - }, - }, - id: '123', - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['photo', 'signature'], - }, - }, - // other properties... - }, - ], - }; - const createRes = await workflowRuntimeRepository.create({ - data: { - workflowDefinitionId: 'test-definition', - workflowDefinitionVersion: 1, - context: initialContext, - projectId: project.id, - }, - }); - const newContext = { - entity: { - data: { - additionalInfo: { - hobbies: ['cycling'], - }, - }, - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['address'], - }, - }, - // other properties... - }, - ], - }; - - const arrayMergeOption: ArrayMergeOption = 'replace'; - await workflowRuntimeRepository.updateContextById(createRes.id, newContext, arrayMergeOption, [ - project.id, - ]); - - const expectedContext = { - entity: { - type: 'individual', - data: { - additionalInfo: { - hobbies: ['cycling'], - }, - }, - id: '123', - }, - documents: [ - { - id: 'doc1', - issuer: { - additionalInfo: { - requirements: ['address'], - }, - }, - // other properties... - }, - ], - }; - - const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [project.id]); - expect(updatedContext).toEqual(expectedContext); - }); - describe('updateById', () => { describe('when updating workflow but not its context', () => { it('should not result in empty context', async () => { diff --git a/services/workflows-service/src/workflow/workflow-runtime-data.repository.ts b/services/workflows-service/src/workflow/workflow-runtime-data.repository.ts index 0f04f697b3..1c3b3ab199 100644 --- a/services/workflows-service/src/workflow/workflow-runtime-data.repository.ts +++ b/services/workflows-service/src/workflow/workflow-runtime-data.repository.ts @@ -1,16 +1,24 @@ import { PrismaService } from '@/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; -import { Prisma, WorkflowRuntimeData, WorkflowRuntimeDataStatus } from '@prisma/client'; +import { + Prisma, + PrismaClient, + WorkflowRuntimeData, + WorkflowRuntimeDataStatus, +} from '@prisma/client'; import { TEntityType } from '@/workflow/types'; import { merge } from 'lodash'; import { assignIdToDocuments } from '@/workflow/assign-id-to-documents'; -import { FindLastActiveFlowParams } from '@/workflow/types/params'; import { ProjectScopeService } from '@/project/project-scope.service'; -import { SortOrder } from '@/common/query-filters/sort-order'; -import type { TProjectIds } from '@/types'; +import type { PrismaTransaction, TProjectIds } from '@/types'; import { toPrismaOrderBy } from '@/workflow/utils/toPrismaOrderBy'; +import { ARRAY_MERGE_OPTION, ArrayMergeOption } from '@ballerine/workflow-core'; -export type ArrayMergeOption = 'by_id' | 'by_index' | 'concat' | 'replace'; +/** + * Columns that are related to the state of the workflow runtime data. + * These columns should be excluded from regular update operations. + */ +type StateRelatedColumns = 'state' | 'status' | 'context' | 'tags'; @Injectable() export class WorkflowRuntimeDataRepository { @@ -22,8 +30,9 @@ export class WorkflowRuntimeDataRepository { async create( args: Prisma.SelectSubset, + transaction: PrismaTransaction | PrismaClient = this.prisma, ): Promise { - return await this.prisma.workflowRuntimeData.create({ + return await transaction.workflowRuntimeData.create({ ...args, data: { ...args.data, @@ -47,29 +56,93 @@ export class WorkflowRuntimeDataRepository { async findOne( args: Prisma.SelectSubset, projectIds: TProjectIds, + transaction: PrismaTransaction | PrismaClient = this.prisma, ): Promise { - return await this.prisma.workflowRuntimeData.findFirst( + return await transaction.workflowRuntimeData.findFirst( this.scopeService.scopeFindOne(args, projectIds), ); } + async findByIdAndLock>( + id: string, + args: Prisma.SelectSubset>, + projectIds: TProjectIds, + transaction: PrismaTransaction, + ): Promise { + await this.lockWorkflowHierarchyForUpdate(Prisma.sql`"id" = ${id}`, projectIds, transaction); + + return await transaction.workflowRuntimeData.findFirstOrThrow( + this.scopeService.scopeFindOne(merge(args, { where: { id } }), projectIds), + ); + } + async findById>( id: string, args: Prisma.SelectSubset>, projectIds: TProjectIds, + transaction: PrismaTransaction | PrismaClient = this.prisma, ): Promise { - return await this.prisma.workflowRuntimeData.findFirstOrThrow( + return await transaction.workflowRuntimeData.findFirstOrThrow( this.scopeService.scopeFindOne(merge(args, { where: { id } }), projectIds), ); } - async findByIdUnscoped(id: string): Promise { - return await this.prisma.workflowRuntimeData.findFirstOrThrow({ where: { id } }); + /** + * Locks the workflow hierarchy (including specified workflow runtime data, its parents, and children) to prevent concurrent modifications. + * This function uses a recursive CTE to identify and lock all relevant rows within the transaction context, ensuring data integrity during updates. + * + * @param {Prisma.Sql} where - SQL condition to identify the anchor workflow runtime data records. + * @param projectIds + * @param transaction + * @private + */ + private async lockWorkflowHierarchyForUpdate( + where: Prisma.Sql, + projectIds: TProjectIds, + transaction: PrismaTransaction, + ): Promise { + await transaction.$executeRaw`WITH RECURSIVE "Hierarchy" AS ( + -- Anchor member: Select the target row along with a path tracking + SELECT w1."id", ARRAY[w1."id"] AS path + FROM "WorkflowRuntimeData" w1 + WHERE ${where} + ${ + Array.isArray(projectIds) + ? Prisma.sql`AND "projectId" in (${Prisma.join(projectIds)})` + : Prisma.sql`` + } + + UNION ALL + + -- Recursive member: Select parents and children, avoiding cycles by checking the path + SELECT w2."id", "Hierarchy".path || w2."id" + FROM "WorkflowRuntimeData" w2 + JOIN "Hierarchy" ON w2."parent_runtime_data_id" = "Hierarchy"."id" OR "Hierarchy"."id" = w2."id" + WHERE NOT w2."id" = ANY("Hierarchy".path) -- Prevent revisiting nodes + ) + SELECT w.* + FROM "WorkflowRuntimeData" w + INNER JOIN "Hierarchy" ir ON w."id" = ir."id" + FOR UPDATE`; } - async updateById>( + async findByIdAndLockUnscoped({ + id, + transaction = this.prisma, + }: { + id: string; + transaction: PrismaTransaction | PrismaClient; + }): Promise { + await this.lockWorkflowHierarchyForUpdate(Prisma.sql`"id" = ${id}`, null, transaction); + + return await transaction.workflowRuntimeData.findFirstOrThrow({ where: { id } }); + } + + async updateById( id: string, - args: Prisma.SelectSubset>, + args: { + data: Omit; + }, ): Promise { return await this.prisma.workflowRuntimeData.update({ where: { id }, @@ -77,35 +150,26 @@ export class WorkflowRuntimeDataRepository { }); } - async updateRuntimeConfigById( + async updateStateById( id: string, - newConfig: any, - arrayMergeOption: ArrayMergeOption = 'by_id', - projectIds: TProjectIds, + { data }: { data: Prisma.WorkflowRuntimeDataUncheckedUpdateInput }, + transaction: PrismaTransaction, ): Promise { - const stringifiedConfig = JSON.stringify(newConfig); - const affectedRows = await this.prisma - .$executeRaw`UPDATE "WorkflowRuntimeData" SET "config" = jsonb_deep_merge_with_options("config", ${stringifiedConfig}::jsonb, ${arrayMergeOption}) WHERE "id" = ${id} AND "projectId" in (${projectIds?.join( - ',', - )})`; - - // Retrieve and return the updated record - if (affectedRows === 0) { - throw new Error(`No workflowRuntimeData found with the id "${id}"`); - } - - return this.findById(id, {}, projectIds); + return await transaction.workflowRuntimeData.update({ + where: { id }, + data: data, + }); } - async updateContextById( + async updateRuntimeConfigById( id: string, - newContext: any, - arrayMergeOption: ArrayMergeOption = 'by_id', + newConfig: any, + arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID, projectIds: TProjectIds, ): Promise { - const stringifiedContext = JSON.stringify(newContext); + const stringifiedConfig = JSON.stringify(newConfig); const affectedRows = await this.prisma - .$executeRaw`UPDATE "WorkflowRuntimeData" SET "context" = jsonb_deep_merge_with_options("context", ${stringifiedContext}::jsonb, ${arrayMergeOption}) WHERE "id" = ${id} AND "projectId" in (${projectIds?.join( + .$executeRaw`UPDATE "WorkflowRuntimeData" SET "config" = jsonb_deep_merge_with_options("config", ${stringifiedConfig}::jsonb, ${arrayMergeOption}) WHERE "id" = ${id} AND "projectId" in (${projectIds?.join( ',', )})`; @@ -133,7 +197,7 @@ export class WorkflowRuntimeDataRepository { ); } - async findActiveWorkflowByEntity( + async findActiveWorkflowByEntityAndLock( { entityId, entityType, @@ -144,7 +208,24 @@ export class WorkflowRuntimeDataRepository { workflowDefinitionId: string; }, projectIds: TProjectIds, + transaction: PrismaTransaction, ) { + let query: Prisma.Sql; + + switch (entityType) { + case 'endUser': + query = Prisma.sql`"status" != 'completed' AND "endUserId" = ${entityId}`; + break; + case 'business': + query = Prisma.sql`"status" != 'completed' AND "businessId" = ${entityId}`; + break; + default: + entityType satisfies never; + throw new Error(`Unsupported entity type: ${entityType}`); + } + + await this.lockWorkflowHierarchyForUpdate(query, null, transaction); + return await this.findOne( { where: { @@ -158,21 +239,7 @@ export class WorkflowRuntimeDataRepository { }, }, projectIds, - ); - } - - async getEntityTypeAndId(workflowRuntimeDataId: string, projectIds: TProjectIds) { - return await this.findOne( - { - where: { - id: workflowRuntimeDataId, - }, - select: { - businessId: true, - endUserId: true, - }, - }, - projectIds, + transaction, ); } @@ -278,25 +345,4 @@ export class WorkflowRuntimeDataRepository { return (await this.prisma.$queryRaw(sql)) as WorkflowRuntimeData[]; } - - async findLastActive( - { workflowDefinitionId, businessId }: FindLastActiveFlowParams, - projectIds: TProjectIds, - ): Promise { - const query = this.projectScopeService.scopeFindOne( - { - orderBy: { - createdAt: 'desc' as SortOrder, - }, - where: { - // status: 'active' as WorkflowRuntimeDataStatus, - businessId, - workflowDefinitionId, - }, - }, - projectIds, - ); - - return await this.findOne(query, projectIds); - } } diff --git a/services/workflows-service/src/workflow/workflow.controller.external.ts b/services/workflows-service/src/workflow/workflow.controller.external.ts index 46c96f5d6f..6b685508f5 100644 --- a/services/workflows-service/src/workflow/workflow.controller.external.ts +++ b/services/workflows-service/src/workflow/workflow.controller.external.ts @@ -1,6 +1,6 @@ import { UserData } from '@/user/user-data.decorator'; import { UserInfo } from '@/user/user-info'; -import { isRecordNotFoundError } from '@/prisma/prisma.util'; +import { defaultPrismaTransactionOptions, isRecordNotFoundError } from '@/prisma/prisma.util'; import * as common from '@nestjs/common'; import { NotFoundException, Query, Res } from '@nestjs/common'; import * as swagger from '@nestjs/swagger'; @@ -33,6 +33,8 @@ import { Public } from '@/common/decorators/public.decorator'; import { WorkflowDefinitionService } from '@/workflow-defintion/workflow-definition.service'; import { CreateCollectionFlowUrlDto } from '@/workflow/dtos/create-collection-flow-url'; import { env } from '@/env'; +import { PrismaService } from '@/prisma/prisma.service'; +import { BUILT_IN_EVENT, ARRAY_MERGE_OPTION } from '@ballerine/workflow-core'; @swagger.ApiBearerAuth() @swagger.ApiTags('external/workflows') @@ -45,6 +47,7 @@ export class WorkflowControllerExternal { protected readonly rolesBuilder: nestAccessControl.RolesBuilder, private readonly workflowTokenService: WorkflowTokenService, private readonly workflowDefinitionService: WorkflowDefinitionService, + private readonly prismaService: PrismaService, ) {} // GET /workflows @@ -131,29 +134,6 @@ export class WorkflowControllerExternal { } } - // POST /intent - @common.Post('/intent') - @swagger.ApiOkResponse() - @common.HttpCode(200) - @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) - @UseCustomerAuthGuard() - async intent( - @common.Body() { intentName, entityId }: IntentDto, - @ProjectIds() projectIds: TProjectIds, - @CurrentProject() currentProjectId: TProjectId, - ) { - const entityType = intentName === 'kycSignup' ? 'endUser' : 'business'; - - // @TODO: Rename to intent or getRunnableWorkflowDataByIntent? - return await this.service.resolveIntent( - intentName, - entityId, - entityType, - projectIds, - currentProjectId, - ); - } - @common.Post('/run') @swagger.ApiOkResponse() @UseCustomerAuthGuard() @@ -318,24 +298,45 @@ export class WorkflowControllerExternal { @common.Body() hookResponse: any, ): Promise { try { - const workflowRuntime = await this.service.getWorkflowRuntimeDataByIdUnscoped(params.id); - await this.normalizeService.handleHookResponse({ - workflowRuntime: workflowRuntime, - data: hookResponse, - resultDestinationPath: query.resultDestination || 'hookResponse', - processName: query.processName, - projectIds: [workflowRuntime.projectId], - currentProjectId: workflowRuntime.projectId, - }); - - await this.service.event( - { + await this.prismaService.$transaction(async transaction => { + const workflowRuntime = await this.service.getWorkflowRuntimeDataByIdAndLockUnscoped({ id: params.id, - name: params.event, - }, - [workflowRuntime.projectId], - workflowRuntime.projectId, - ); + transaction, + }); + + const context = await this.normalizeService.handleHookResponse({ + workflowRuntime: workflowRuntime, + data: hookResponse, + resultDestinationPath: query.resultDestination || 'hookResponse', + processName: query.processName, + projectIds: [workflowRuntime.projectId], + currentProjectId: workflowRuntime.projectId, + }); + + await this.service.event( + { + id: params.id, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext: context, + arrayMergeOption: ARRAY_MERGE_OPTION.REPLACE, + }, + }, + [workflowRuntime.projectId], + workflowRuntime.projectId, + transaction, + ); + + await this.service.event( + { + id: params.id, + name: params.event, + }, + [workflowRuntime.projectId], + workflowRuntime.projectId, + transaction, + ); + }, defaultPrismaTransactionOptions); } catch (error) { if (isRecordNotFoundError(error)) { throw new errors.NotFoundException(`No resource was found for ${JSON.stringify(params)}`); diff --git a/services/workflows-service/src/workflow/workflow.controller.external.unit.test.ts b/services/workflows-service/src/workflow/workflow.controller.external.unit.test.ts index 6a55b4974a..ef8c97e803 100644 --- a/services/workflows-service/src/workflow/workflow.controller.external.unit.test.ts +++ b/services/workflows-service/src/workflow/workflow.controller.external.unit.test.ts @@ -14,6 +14,8 @@ import { HookCallbackHandlerService } from '@/workflow/hook-callback-handler.ser import { EndUserService } from '@/end-user/end-user.service'; import { WorkflowTokenService } from '@/auth/workflow-token/workflow-token.service'; import { WorkflowDefinitionService } from '@/workflow-defintion/workflow-definition.service'; +import { PrismaService } from '@/prisma/prisma.service'; +import { WinstonLogger } from '@/common/utils/winston-logger/winston-logger'; const acGuard = { canActivate: () => { @@ -70,6 +72,14 @@ describe('Workflow (external)', () => { provide: WorkflowTokenService, useValue: {} as WorkflowTokenService, }, + { + provide: 'LOGGER', + useClass: WinstonLogger, + }, + { + provide: PrismaService, + useValue: {} as PrismaService, + }, ], controllers: [WorkflowControllerExternal], imports: [ACLModule], diff --git a/services/workflows-service/src/workflow/workflow.controller.internal.intg.test.ts b/services/workflows-service/src/workflow/workflow.controller.internal.intg.test.ts index efd051bf77..7d1bc773e3 100644 --- a/services/workflows-service/src/workflow/workflow.controller.internal.intg.test.ts +++ b/services/workflows-service/src/workflow/workflow.controller.internal.intg.test.ts @@ -19,7 +19,7 @@ import { PrismaService } from '@/prisma/prisma.service'; import { EntityRepository } from '@/common/entity/entity.repository'; import { ProjectScopeService } from '@/project/project-scope.service'; import { createCustomer } from '@/test/helpers/create-customer'; -import { PrismaClient, Project, User } from '@prisma/client'; +import { Business, PrismaClient, Project, User } from '@prisma/client'; import { createProject } from '@/test/helpers/create-project'; import { UserService } from '@/user/user.service'; import { SalesforceService } from '@/salesforce/salesforce.service'; @@ -38,6 +38,7 @@ describe('/api/v1/internal/workflows #api #integration', () => { let app: INestApplication; let workflowService: WorkflowService; let project: Project; + let business: Business; let assignee: User; const db = new PrismaClient(); @@ -101,6 +102,11 @@ describe('/api/v1/internal/workflows #api #integration', () => { [PrismaModule], [userAuthOverrideMiddleware], ); + const businessRepository = (await fetchServiceFromModule( + BusinessRepository, + servicesProviders, + [PrismaModule], + )) as unknown as BusinessRepository; const customer = await createCustomer( await app.get(PrismaService), @@ -111,6 +117,16 @@ describe('/api/v1/internal/workflows #api #integration', () => { 'webhook-shared-secret', ); project = await createProject(await app.get(PrismaService), customer, '4'); + business = await businessRepository.create({ + data: { + companyName: 'Test Company', + project: { + connect: { + id: project.id, + }, + }, + }, + }); }); describe('PATCH /:id/decision/:documentId', () => { @@ -121,7 +137,10 @@ describe('/api/v1/internal/workflows #api #integration', () => { data: { name: 'test', definitionType: 'statechart-json', - definition: {}, + definition: { + initial: 'idle', + states: { idle: {} }, + }, isPublic: true, }, } satisfies Parameters<(typeof db)['workflowDefinition']['create']>[0]; @@ -150,6 +169,7 @@ describe('/api/v1/internal/workflows #api #integration', () => { ], }, projectId: project.id, + businessId: business.id, }, } satisfies Parameters<(typeof db)['workflowRuntimeData']['create']>[0]; const createUserPayload = { diff --git a/services/workflows-service/src/workflow/workflow.controller.internal.ts b/services/workflows-service/src/workflow/workflow.controller.internal.ts index 7c833e4ee9..02229e9d66 100644 --- a/services/workflows-service/src/workflow/workflow.controller.internal.ts +++ b/services/workflows-service/src/workflow/workflow.controller.internal.ts @@ -269,7 +269,6 @@ export class WorkflowControllerInternal { }, projectIds, currentProjectId, - data.postUpdateEventName, ); } catch (error) { if (isRecordNotFoundError(error)) { diff --git a/services/workflows-service/src/workflow/workflow.controller.internal.unit.test.ts b/services/workflows-service/src/workflow/workflow.controller.internal.unit.test.ts deleted file mode 100644 index e711497f78..0000000000 --- a/services/workflows-service/src/workflow/workflow.controller.internal.unit.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { BaseFakeRepository } from '../../../../test-utils/src/base-fake-repository'; -import { WorkflowControllerInternal } from './workflow.controller.internal'; -import { WorkflowService } from './workflow.service'; -import { WorkflowDefinitionModel } from './workflow-definition.model'; -import { EndUserModel } from '@/end-user/end-user.model'; -import { BusinessModel } from '@/business/business.model'; -import { AppLoggerService } from '@/common/app-logger/app-logger.service'; -import { commonTestingModules } from '@/test/helpers/nest-app-helper'; -import { Test, TestingModule } from '@nestjs/testing'; - -class FakeWorkflowRuntimeDataRepo extends BaseFakeRepository { - constructor() { - super(Object); - } -} - -class FakeWorkflowDefinitionRepo extends BaseFakeRepository { - constructor() { - super(WorkflowDefinitionModel); - } -} - -class FakeBusinessRepo extends BaseFakeRepository { - constructor() { - super(BusinessModel); - } -} - -class FakeEndUserRepo extends BaseFakeRepository { - constructor() { - super(EndUserModel); - } -} - -class FakeEntityRepo extends BaseFakeRepository { - constructor() { - super(Object); - } -} - -class FakeUiDefinitionService extends BaseFakeRepository { - constructor() { - super(Object); - } -} - -export const buildWorkflowDefinition = (sequenceNum: number, projectId?: string) => { - return { - id: sequenceNum.toString(), - name: `name ${sequenceNum}`, - version: sequenceNum, - definition: { - initial: 'initial', - states: { - initial: { - on: { - COMPLETE: 'completed', - }, - }, - completed: { - type: 'final', - }, - }, - }, - definitionType: `definitionType ${sequenceNum}`, - createdAt: new Date(), - updatedAt: new Date(), - contextSchema: { - type: 'json-schema', - schema: {}, - }, - projectId: projectId, - isPublic: false, - }; -}; - -describe('WorkflowControllerInternal', () => { - let controller; - let workflowRuntimeDataRepo; - let businessRepo; - let endUserRepo; - let entityRepo; - let eventEmitterSpy; - let customerService; - let scopeService; - let userService; - let salesforceService; - let workflowTokenService; - let uiDefinitionService; - const numbUserInfo = Symbol(); - let testingModule: TestingModule; - - beforeAll(async () => { - testingModule = await Test.createTestingModule({ - imports: commonTestingModules, - }).compile(); - }); - - beforeEach(() => { - const workflowDefinitionRepo = new FakeWorkflowDefinitionRepo(); - workflowRuntimeDataRepo = new FakeWorkflowRuntimeDataRepo(); - businessRepo = new FakeBusinessRepo(); - endUserRepo = new FakeEndUserRepo(); - entityRepo = new FakeEntityRepo(); - scopeService = new FakeEntityRepo(); - customerService = new FakeEntityRepo(); - userService = new FakeEntityRepo(); - salesforceService = new FakeEntityRepo(); - workflowTokenService = new FakeEntityRepo(); - uiDefinitionService = new FakeUiDefinitionService(); - - eventEmitterSpy = { - emitted: [], - - emit(status, data) { - this.emitted.push({ status, data }); - }, - }; - const service = new WorkflowService( - workflowDefinitionRepo as any, - workflowRuntimeDataRepo, - endUserRepo, - {} as any, - businessRepo, - entityRepo, - customerService, - {} as any, - eventEmitterSpy, - testingModule.get(AppLoggerService), - scopeService, - userService, - salesforceService, - workflowTokenService, - uiDefinitionService, - ); - const filterService = {} as any; - const rolesBuilder = {} as any; - - controller = new WorkflowControllerInternal(service, filterService, rolesBuilder, scopeService); - }); - - describe('.event', () => { - describe('reaching to a state of type "final"', () => { - it('updates runtime data status to "completed"', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - numb: 'context', - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - await controller.createWorkflowDefinition(buildWorkflowDefinition(2)); - await controller.event({ id: '2' }, { name: 'COMPLETE' }); - - const runtimeData = await workflowRuntimeDataRepo.findById('2'); - - expect(runtimeData.state).toEqual('completed'); - expect(runtimeData.status).toEqual('completed'); - }); - - it.skip('emits an event', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - numb: 'context', - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - await controller.createWorkflowDefinition(numbUserInfo, buildWorkflowDefinition(2)); - await controller.event({ id: '2' }, { name: 'COMPLETE' }); - - expect(eventEmitterSpy.emitted).toEqual([ - { - status: 'workflow.completed', - data: { - runtimeData: initialRuntimeData, - state: 'completed', - context: { - numb: 'context', - }, - }, - }, - ]); - }); - }); - }); -}); diff --git a/services/workflows-service/src/workflow/workflow.service.intg.test.ts b/services/workflows-service/src/workflow/workflow.service.intg.test.ts new file mode 100644 index 0000000000..6c7e2e5525 --- /dev/null +++ b/services/workflows-service/src/workflow/workflow.service.intg.test.ts @@ -0,0 +1,1175 @@ +import { cleanupDatabase, tearDownDatabase } from '@/test/helpers/database-helper'; +import { fetchServiceFromModule } from '@/test/helpers/nest-app-helper'; +import { PrismaModule } from 'nestjs-prisma'; +import { EndUserRepository } from '@/end-user/end-user.repository'; +import { FilterService } from '@/filter/filter.service'; +import { FilterRepository } from '@/filter/filter.repository'; +import { FileRepository } from '@/storage/storage.repository'; +import { FileService } from '@/providers/file/file.service'; +import { StorageService } from '@/storage/storage.service'; +import { WorkflowEventEmitterService } from '@/workflow/workflow-event-emitter.service'; +import { BusinessRepository } from '@/business/business.repository'; +import { WorkflowRuntimeDataRepository } from '@/workflow/workflow-runtime-data.repository'; +import { WorkflowService } from '@/workflow/workflow.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PrismaService } from '@/prisma/prisma.service'; +import { EntityRepository } from '@/common/entity/entity.repository'; +import { ProjectScopeService } from '@/project/project-scope.service'; +import { EndUserService } from '@/end-user/end-user.service'; +import { Business, Project, WorkflowDefinition } from '@prisma/client'; +import { createCustomer } from '@/test/helpers/create-customer'; +import { createProject } from '@/test/helpers/create-project'; +import { UserService } from '@/user/user.service'; +import { SalesforceService } from '@/salesforce/salesforce.service'; +import { SalesforceIntegrationRepository } from '@/salesforce/salesforce-integration.repository'; +import { UserRepository } from '@/user/user.repository'; +import { PasswordService } from '@/auth/password/password.service'; +import { WorkflowTokenService } from '@/auth/workflow-token/workflow-token.service'; +import { WorkflowTokenRepository } from '@/auth/workflow-token/workflow-token.repository'; +import { WorkflowDefinitionRepository } from '@/workflow-defintion/workflow-definition.repository'; +import { UiDefinitionService } from '@/ui-definition/ui-definition.service'; +import { UiDefinitionRepository } from '@/ui-definition/ui-definition.repository'; +import { faker } from '@faker-js/faker'; +import { BUILT_IN_EVENT, ARRAY_MERGE_OPTION, ArrayMergeOption } from '@ballerine/workflow-core'; + +describe('WorkflowService', () => { + let workflowRuntimeRepository: WorkflowRuntimeDataRepository; + let workflowDefinitionRepository: WorkflowDefinitionRepository; + let project: Project; + let workflowRuntimeService: WorkflowService; + let workflowDefinition: WorkflowDefinition; + let business: Business; + + beforeAll(async () => { + await cleanupDatabase(); + + const servicesProviders = [ + EndUserRepository, + EndUserService, + FilterService, + FilterRepository, + ProjectScopeService, + FileRepository, + FileService, + StorageService, + WorkflowEventEmitterService, + BusinessRepository, + WorkflowDefinitionRepository, + WorkflowService, + EventEmitter2, + PrismaService, + EntityRepository, + UserService, + UserRepository, + SalesforceService, + SalesforceIntegrationRepository, + PasswordService, + WorkflowTokenService, + WorkflowTokenRepository, + WorkflowRuntimeDataRepository, + UiDefinitionService, + UiDefinitionRepository, + ]; + + workflowRuntimeService = (await fetchServiceFromModule(WorkflowService, servicesProviders, [ + PrismaModule, + ])) as unknown as WorkflowService; + + workflowRuntimeRepository = (await fetchServiceFromModule( + WorkflowRuntimeDataRepository, + servicesProviders, + [PrismaModule], + )) as unknown as WorkflowRuntimeDataRepository; + + workflowDefinitionRepository = (await fetchServiceFromModule( + WorkflowDefinitionRepository, + servicesProviders, + [PrismaModule], + )) as unknown as WorkflowDefinitionRepository; + + const businessRepository = (await fetchServiceFromModule( + BusinessRepository, + servicesProviders, + [PrismaModule], + )) as unknown as BusinessRepository; + + const prismaService = (await fetchServiceFromModule(PrismaService, servicesProviders, [ + PrismaModule, + ])) as unknown as PrismaService; + + const customer = await createCustomer( + prismaService, + faker.datatype.uuid(), + 'secret', + '', + '', + 'webhook-shared-secret', + ); + project = await createProject(prismaService, customer, '5'); + + workflowDefinition = await workflowDefinitionRepository.create({ + data: { + id: faker.datatype.uuid(), + name: 'test', + version: 1, + definitionType: 'statechart-json', + definition: { + id: 'Manual Review', + initial: 'pending', + states: { + pending: { + on: { + APPROVE: 'approved', + REJECT: 'rejected', + }, + }, + approved: {}, + rejected: {}, + }, + }, + projectId: project.id, + }, + }); + + business = await businessRepository.create({ + data: { + companyName: 'Test Company', + project: { + connect: { + id: project.id, + }, + }, + }, + }); + }); + + afterAll(async () => { + await cleanupDatabase(); + await tearDownDatabase(); + }); + + describe('event', () => { + describe(BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, () => { + it('should merge the existing and new context data when event is called', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + key2: 'new-value', + key3: 'value3', + documents: [ + { id: '1', name: 'doc2' }, + { id: '2', name: 'doc3' }, + ], + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: 'new-value', + key3: 'value3', + documents: [ + { id: '1', name: 'doc2' }, + { id: '2', name: 'doc3' }, + ], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should not change existing context when the new context is empty', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = {}; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: 'value2', + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should add new key from the new context to the existing context', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key3: 'value3' }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: 'value2', + key3: 'value3', + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should update the value of an existing key when the new context has a different value for that key', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', key2: 'value2', documents: [{ id: '1', name: 'doc1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: 'new-value2' }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: 'new-value2', + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should merge nested objects in the context', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey1: 'nestedValue1' }, + documents: [{ id: '1', name: 'doc1' }], + }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: { nestedKey2: 'nestedValue2' } }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: { nestedKey1: 'nestedValue1', nestedKey2: 'nestedValue2' }, + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should update values in nested objects in the context', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey1: 'nestedValue1' }, + documents: [{ id: '1', name: 'doc1' }], + }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: { nestedKey1: 'new-nestedValue1' } }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: { nestedKey1: 'new-nestedValue1' }, + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should add a new element to an array in the context', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', key2: ['element1'], documents: [{ id: '1', name: 'doc1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: ['element2'] }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: ['element1', 'element2'], + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should replace an element from an array in the context when the new context have it on the same index', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: ['element1', 'element2'], + documents: [{ id: '1', name: 'doc1' }], + }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: ['element3'] }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_INDEX; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: ['element3', 'element2'], + documents: [{ id: '1', name: 'doc1' }], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should be able to handle large context objects', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: 'value1', largeKey: new Array(1000).fill('value').join('') }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key2: 'value2' }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: 'value2', + largeKey: new Array(1000).fill('value').join(''), + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should concatenate array in a nested object when array_merge_option is "concat"', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey: ['value2'] }, + }, + projectId: project.id, + businessId: business.id, + }, + }); + + const newContext = { + key2: { nestedKey: ['value3'] }, + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: { nestedKey: ['value2', 'value3'] }, + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should concatenate array of objects in a nested object when array_merge_option is "concat"', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey: [{ id: '1', value: 'value2' }] }, + }, + projectId: project.id, + businessId: business.id, + }, + }); + + const newContext = { + key2: { nestedKey: [{ id: '2', value: 'value3' }] }, + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: { + nestedKey: [ + { id: '1', value: 'value2' }, + { id: '2', value: 'value3' }, + ], + }, + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should concatenate deeply nested arrays when array_merge_option is "concat"', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey: { anotherNestedKey: ['value2'] } }, + }, + projectId: project.id, + businessId: business.id, + }, + }); + + const newContext = { + key2: { nestedKey: { anotherNestedKey: ['value3'] } }, + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: 'value1', + key2: { nestedKey: { anotherNestedKey: ['value2', 'value3'] } }, + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should correctly merge context data with high nesting level', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: { key2: { key3: { key4: { key5: 'value1' } } } }, + }, + projectId: project.id, + businessId: business.id, + }, + }); + + const newContext = { + key1: { key2: { key3: { key4: { key5: 'value2', key6: 'value3' } } } }, + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: { key2: { key3: { key4: { key5: 'value2', key6: 'value3' } } } }, + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should correctly merge context data with mixed data types', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { + key1: 'value1', + key2: { nestedKey1: 'value2', nestedKey2: ['value3'] }, + }, + projectId: project.id, + businessId: business.id, + }, + }); + + const newContext = { + key1: { nestedKey1: 'new-value1' }, + key2: { nestedKey1: 'new-value2', nestedKey3: 'value4' }, + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: { nestedKey1: 'new-value1' }, + key2: { nestedKey1: 'new-value2', nestedKey2: ['value3'], nestedKey3: 'value4' }, + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should correctly merge deeply nested arrays with the by_id strategy', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: [{ id: '1', data: 'data1' }], key2: [{ id: '1', data: 'data1' }] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + key1: [{ id: '1', data: 'data2' }], + key2: [{ id: '2', data: 'data2' }], + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: [{ id: '1', data: 'data2' }], + key2: [ + { id: '1', data: 'data1' }, + { id: '2', data: 'data2' }, + ], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should correctly merge deeply nested arrays with the by_index strategy', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: ['element1', 'element2'], key2: ['element1', 'element2'] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key1: ['element3'], key2: ['element3', 'element4'] }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.BY_INDEX; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: ['element3', 'element2'], + key2: ['element3', 'element4'], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should correctly merge deeply nested arrays with the concat strategy', async () => { + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: { key1: ['element1', 'element2'], key2: ['element1', 'element2'] }, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { key1: ['element3'], key2: ['element3', 'element4'] }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + + const expectedContext = { + key1: ['element1', 'element2', 'element3'], + key2: ['element1', 'element2', 'element3', 'element4'], + }; + expect(updatedContext).toEqual(expectedContext); + }); + it('should merge the nested entity data when event is called', async () => { + const initialContext = { + entity: { + type: 'individual', + data: { + name: 'John Doe', + age: 30, + additionalInfo: { + hobbies: ['running', 'reading'], + }, + }, + id: '123', + }, + documents: [], + }; + + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: initialContext, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + entity: { + data: { + age: 35, + additionalInfo: { + hobbies: ['cycling'], + occupation: 'engineer', + }, + }, + }, + }; + + const arrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const expectedContext = { + entity: { + type: 'individual', + data: { + name: 'John Doe', + age: 35, + additionalInfo: { + hobbies: ['running', 'reading', 'cycling'], + occupation: 'engineer', + }, + }, + id: '123', + }, + documents: [], + }; + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + expect(updatedContext).toEqual(expectedContext); + }); + it('should merge the documents array by id when event is called', async () => { + const initialContext = { + entity: { + type: 'individual', + id: '123', + }, + documents: [ + { + id: 'doc1', + category: 'category1', + // other properties... + }, + ], + }; + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: initialContext, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + documents: [ + { + id: 'doc1', + category: 'category2', + // other properties... + }, + ], + }; + + const arrayMergeOption = ARRAY_MERGE_OPTION.BY_ID; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const expectedContext = { + entity: { + type: 'individual', + id: '123', + }, + documents: [ + { + id: 'doc1', + category: 'category2', + // other properties... + }, + ], + }; + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + expect(updatedContext).toEqual(expectedContext); + }); + it('should merge nested arrays in additionalInfo when event is called - concat', async () => { + const initialContext = { + entity: { + type: 'individual', + data: { + additionalInfo: { + hobbies: ['running', 'reading'], + }, + }, + id: '123', + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['photo', 'signature'], + }, + }, + // other properties... + }, + ], + }; + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: initialContext, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + entity: { + data: { + additionalInfo: { + hobbies: ['cycling'], + }, + }, + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['address'], + }, + }, + // other properties... + }, + ], + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.CONCAT; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const expectedContext = { + entity: { + type: 'individual', + data: { + additionalInfo: { + hobbies: ['running', 'reading', 'cycling'], + }, + }, + id: '123', + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['photo', 'signature'], + }, + }, + }, + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['address'], + }, + }, + }, + ], + }; + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + expect(updatedContext).toEqual(expectedContext); + }); + it('should merge nested arrays in additionalInfo when event is called - replace', async () => { + const initialContext = { + entity: { + type: 'individual', + data: { + additionalInfo: { + hobbies: ['running', 'reading'], + }, + }, + id: '123', + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['photo', 'signature'], + }, + }, + // other properties... + }, + ], + }; + const createRes = await workflowRuntimeRepository.create({ + data: { + workflowDefinitionId: workflowDefinition.id, + workflowDefinitionVersion: 1, + context: initialContext, + projectId: project.id, + businessId: business.id, + }, + }); + const newContext = { + entity: { + data: { + additionalInfo: { + hobbies: ['cycling'], + }, + }, + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['address'], + }, + }, + // other properties... + }, + ], + }; + + const arrayMergeOption: ArrayMergeOption = ARRAY_MERGE_OPTION.REPLACE; + await workflowRuntimeService.event( + { + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + id: createRes.id, + payload: { + newContext, + arrayMergeOption, + }, + }, + [project.id], + project.id, + ); + + const expectedContext = { + entity: { + type: 'individual', + data: { + additionalInfo: { + hobbies: ['cycling'], + }, + }, + id: '123', + }, + documents: [ + { + id: 'doc1', + issuer: { + additionalInfo: { + requirements: ['address'], + }, + }, + // other properties... + }, + ], + }; + + const updatedContext = await workflowRuntimeRepository.findContext(createRes.id, [ + project.id, + ]); + expect(updatedContext).toEqual(expectedContext); + }); + }); + }); +}); diff --git a/services/workflows-service/src/workflow/workflow.service.ts b/services/workflows-service/src/workflow/workflow.service.ts index 633c683c57..6e08debead 100644 --- a/services/workflows-service/src/workflow/workflow.service.ts +++ b/services/workflows-service/src/workflow/workflow.service.ts @@ -12,12 +12,17 @@ import { EndUserService } from '@/end-user/end-user.service'; import { ProjectScopeService } from '@/project/project-scope.service'; import { FileService } from '@/providers/file/file.service'; import { SalesforceService } from '@/salesforce/salesforce.service'; -import type { InputJsonValue, IObjectWithId, TProjectId, TProjectIds } from '@/types'; +import type { + InputJsonValue, + IObjectWithId, + PrismaTransaction, + TProjectId, + TProjectIds, +} from '@/types'; import { UserService } from '@/user/user.service'; import { assignIdToDocuments } from '@/workflow/assign-id-to-documents'; import { WorkflowAssigneeId } from '@/workflow/dtos/workflow-assignee-id'; import { WorkflowDefinitionCloneDto } from '@/workflow/dtos/workflow-definition-clone'; -import { GetLastActiveFlowParams } from '@/workflow/types/params'; import { toPrismaOrderBy } from '@/workflow/utils/toPrismaOrderBy'; import { toPrismaWhere } from '@/workflow/utils/toPrismaWhere'; import { @@ -31,6 +36,8 @@ import { isErrorWithMessage, } from '@ballerine/common'; import { + ARRAY_MERGE_OPTION, + BUILT_IN_EVENT, ChildPluginCallbackOutput, ChildToParentCallback, ChildWorkflowCallback, @@ -52,6 +59,7 @@ import { Business, EndUser, Prisma, + PrismaClient, UiDefinitionContext, WorkflowDefinition, WorkflowRuntimeData, @@ -73,15 +81,17 @@ import { import { addPropertiesSchemaToDocument } from './utils/add-properties-schema-to-document'; import { WorkflowDefinitionRepository } from '@/workflow-defintion/workflow-definition.repository'; import { WorkflowEventEmitterService } from './workflow-event-emitter.service'; -import { - ArrayMergeOption, - WorkflowRuntimeDataRepository, -} from './workflow-runtime-data.repository'; +import { WorkflowRuntimeDataRepository } from './workflow-runtime-data.repository'; import mime from 'mime'; import { env } from '@/env'; import { ValidationError } from '@/errors'; import { UiDefinitionService } from '@/ui-definition/ui-definition.service'; import { ajv } from '@/common/ajv/ajv.validator'; +import { PrismaService } from '@/prisma/prisma.service'; +import { + beginTransactionIfNotExistCurry, + defaultPrismaTransactionOptions, +} from '@/prisma/prisma.util'; type TEntityId = string; @@ -134,6 +144,7 @@ export class WorkflowService { private readonly salesforceService: SalesforceService, private readonly workflowTokenService: WorkflowTokenService, private readonly uiDefinitionService: UiDefinitionService, + private readonly prismaService: PrismaService, ) {} async createWorkflowDefinition(data: WorkflowDefinitionCreateDto, projectId: TProjectId) { @@ -193,22 +204,17 @@ export class WorkflowService { return await this.workflowRuntimeDataRepository.findById(id, args, projectIds); } - async getWorkflowRuntimeDataByIdUnscoped(workflowRuntimeDataId: string) { - return await this.workflowRuntimeDataRepository.findByIdUnscoped(workflowRuntimeDataId); - } - - async getWorkflowRuntimeWithChildrenDataById( - id: string, - args: Parameters[1], - projectIds: TProjectIds, - ) { - return await this.workflowRuntimeDataRepository.findById( + async getWorkflowRuntimeDataByIdAndLockUnscoped({ + id, + transaction, + }: { + id: string; + transaction: PrismaTransaction | PrismaClient; + }) { + return await this.workflowRuntimeDataRepository.findByIdAndLockUnscoped({ id, - { - ...args, - }, - projectIds, - ); + transaction, + }); } async getWorkflowByIdWithRelations( @@ -308,62 +314,60 @@ export class WorkflowService { childPluginConfig: ChildPluginCallbackOutput, projectIds: TProjectIds, currentProjectId: TProjectId, + transaction: PrismaTransaction, ) { const childWorkflow = ( - await this.createOrUpdateWorkflowRuntime({ - workflowDefinitionId: childPluginConfig.definitionId, - context: childPluginConfig.initOptions.context as unknown as DefaultContextSchema, - config: childPluginConfig.initOptions.config as unknown as AnyRecord, - parentWorkflowId: childPluginConfig.parentWorkflowRuntimeId, - projectIds, - currentProjectId, - }) + await this.createOrUpdateWorkflowRuntime( + { + workflowDefinitionId: childPluginConfig.definitionId, + context: childPluginConfig.initOptions.context as unknown as DefaultContextSchema, + config: childPluginConfig.initOptions.config as unknown as AnyRecord, + parentWorkflowId: childPluginConfig.parentWorkflowRuntimeId, + projectIds, + currentProjectId, + }, + transaction, + ) )[0]; - const parentWorkflowRuntime = await this.getWorkflowRuntimeDataById( - childPluginConfig.parentWorkflowRuntimeId, - {}, - projectIds, - ); - if (childWorkflow) { - const contextToPersist = { - [childWorkflow.workflowRuntimeData.id]: { - entityId: childWorkflow.workflowRuntimeData.context.entity.id, - status: childWorkflow.workflowRuntimeData.status || 'active', - state: childWorkflow.workflowRuntimeData.state, + const newContext = { + childWorkflows: { + [childWorkflow.workflowRuntimeData.id]: { + [childWorkflow.workflowRuntimeData.id]: { + entityId: childWorkflow.workflowRuntimeData.context.entity.id, + status: childWorkflow.workflowRuntimeData.status || 'active', + state: childWorkflow.workflowRuntimeData.state, + }, + }, }, }; - const parentContext = this.composeContextWithChildResponse( - parentWorkflowRuntime.context, - childWorkflow.workflowDefinition.id, - contextToPersist, - ); - await this.updateWorkflowRuntimeData( - parentWorkflowRuntime.id, - { context: parentContext }, + await this.event( + { + id: childPluginConfig.parentWorkflowRuntimeId, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext, + arrayMergeOption: ARRAY_MERGE_OPTION.REPLACE, + }, + }, + projectIds, currentProjectId, + transaction, ); } return childWorkflow; } - async getWorkflowRuntimeDataByCorrelationId( - id: string, - args: Parameters[1], - projectIds: TProjectIds, - ) { - return await this.workflowRuntimeDataRepository.findById(id, args, projectIds); - } - async getWorkflowDefinitionById( id: string, args: Parameters[1], projectIds: TProjectIds, + transaction?: PrismaTransaction, ) { - return await this.workflowDefinitionRepository.findById(id, args, projectIds); + return await this.workflowDefinitionRepository.findById(id, args, projectIds, transaction); } async listActiveWorkflowsRuntimeStates(projectIds: TProjectIds) { @@ -660,73 +664,78 @@ export class WorkflowService { reason?: string; projectId: TProjectId; }) { - const runtimeData = await this.workflowRuntimeDataRepository.findById( - id, - {}, - projectId ? [projectId] : null, - ); - // `name` is always `approve` and not `approved` etc. - const Status = { - approve: 'approved', - reject: 'rejected', - revision: 'revision', - } as const; - const status = Status[name as keyof typeof Status]; - const decision = (() => { - if (status === 'approved') { - return { - revisionReason: null, - rejectionReason: null, - }; - } + return await this.prismaService.$transaction(async transaction => { + const runtimeData = await this.workflowRuntimeDataRepository.findByIdAndLock( + id, + {}, + [projectId], + transaction, + ); + // `name` is always `approve` and not `approved` etc. + const Status = { + approve: 'approved', + reject: 'rejected', + revision: 'revision', + } as const; + const status = Status[name as keyof typeof Status]; + const decision = (() => { + if (status === 'approved') { + return { + revisionReason: null, + rejectionReason: null, + }; + } - if (status === 'rejected') { - return { - revisionReason: null, - rejectionReason: reason, - }; - } + if (status === 'rejected') { + return { + revisionReason: null, + rejectionReason: reason, + }; + } - if (status === 'revision') { - return { - revisionReason: reason, - rejectionReason: null, - }; - } + if (status === 'revision') { + return { + revisionReason: reason, + rejectionReason: null, + }; + } - throw new BadRequestException(`Invalid decision status: ${status}`); - })(); - const documentsWithDecision = runtimeData?.context?.documents?.map( - (document: DefaultContextSchema['documents'][number]) => ({ - ...document, - decision: { - ...document?.decision, - ...decision, - status, - }, - }), - ); - const updatedWorkflow = await this.updateWorkflowRuntimeData( - id, - { - context: { - ...runtimeData?.context, - documents: documentsWithDecision, + throw new BadRequestException(`Invalid decision status: ${status}`); + })(); + const documentsWithDecision = runtimeData?.context?.documents?.map( + (document: DefaultContextSchema['documents'][number]) => ({ + ...document, + decision: { + ...document?.decision, + ...decision, + status, + }, + }), + ); + const updatedWorkflow = await this.updateWorkflowRuntimeData( + id, + { + context: { + ...runtimeData.context, + documents: documentsWithDecision, + }, }, - }, - projectId, - ); + projectId, + transaction, + ); - await this.event( - { - id, - name, - }, - projectId ? [projectId] : null, - projectId, - ); + await this.event( + { + id, + name, + }, + projectId ? [projectId] : null, + projectId, + transaction, + ); - return updatedWorkflow; + return updatedWorkflow; + }, defaultPrismaTransactionOptions); } async updateDocumentDecisionById( @@ -745,108 +754,110 @@ export class WorkflowService { }, projectIds: TProjectIds, currentProjectId: TProjectId, - postUpdateEventName?: string, ) { - const workflow = await this.workflowRuntimeDataRepository.findById(workflowId, {}, projectIds); - const workflowDefinition = await this.workflowDefinitionRepository.findById( - workflow?.workflowDefinitionId, - {}, - projectIds, - ); - // `name` is always `approve` and not `approved` etc. - const Status = { - approve: 'approved', - reject: 'rejected', - revision: 'revision', - revised: 'revised', - } as const; - const status = decision.status ? Status[decision.status] : null; - const newDecision = (() => { - if (!status || status === 'approved') { - return { - revisionReason: null, - rejectionReason: null, - }; - } + return await this.prismaService.$transaction(async transaction => { + const workflow = await this.workflowRuntimeDataRepository.findByIdAndLock( + workflowId, + {}, + projectIds, + transaction, + ); + const workflowDefinition = await this.workflowDefinitionRepository.findById( + workflow?.workflowDefinitionId, + {}, + projectIds, + transaction, + ); + // `name` is always `approve` and not `approved` etc. + const Status = { + approve: 'approved', + reject: 'rejected', + revision: 'revision', + revised: 'revised', + } as const; + const status = decision.status ? Status[decision.status] : null; + const newDecision = (() => { + if (!status || status === 'approved') { + return { + revisionReason: null, + rejectionReason: null, + }; + } - if (status === 'rejected') { - return { - revisionReason: null, - rejectionReason: decision?.reason, - }; - } + if (status === 'rejected') { + return { + revisionReason: null, + rejectionReason: decision?.reason, + }; + } - if (['revision', 'revised'].includes(status)) { - return { - revisionReason: decision?.reason, - rejectionReason: null, - }; - } + if (['revision', 'revised'].includes(status)) { + return { + revisionReason: decision?.reason, + rejectionReason: null, + }; + } - throw new BadRequestException(`Invalid decision status: ${status}`); - })(); + throw new BadRequestException(`Invalid decision status: ${status}`); + })(); - const documents = this.getDocuments(workflow.context, documentsUpdateContextMethod); - let document = documents.find((document: any) => document.id === documentId); - const updatedStatus = - (documentId === document.id ? status : document?.decision?.status) ?? undefined; + const documents = this.getDocuments(workflow.context, documentsUpdateContextMethod); + let document = documents.find((document: any) => document.id === documentId); + const updatedStatus = + (documentId === document.id ? status : document?.decision?.status) ?? undefined; - const updatedContext = this.updateDocumentInContext( - workflow.context, - { - ...document, - decision: { - ...document?.decision, - status: updatedStatus, + const updatedContext = this.updateDocumentInContext( + workflow.context, + { + ...document, + decision: { + ...document?.decision, + status: updatedStatus, + }, + type: + document?.type === 'unknown' && updatedStatus === 'approved' + ? undefined + : document?.type, }, - type: - document?.type === 'unknown' && updatedStatus === 'approved' ? undefined : document?.type, - }, - documentsUpdateContextMethod, - ); - - document = this.getDocuments(updatedContext, documentsUpdateContextMethod)?.find( - (document: any) => document.id === documentId, - ); - - this.__validateWorkflowDefinitionContext(workflowDefinition, updatedContext); + documentsUpdateContextMethod, + ); - const documentWithDecision = { - ...document, - id: document.id, - decision: { - ...newDecision, - status, - }, - }; - const validateDocumentSchema = status === 'approved'; + document = this.getDocuments(updatedContext, documentsUpdateContextMethod)?.find( + (document: any) => document.id === documentId, + ); - const updatedWorkflow = await this.updateDocumentById( - { - workflowId, - documentId, - validateDocumentSchema, - documentsUpdateContextMethod: documentsUpdateContextMethod, - }, - documentWithDecision as unknown as DefaultContextSchema['documents'][number], - projectIds![0]!, - ); + this.__validateWorkflowDefinitionContext(workflowDefinition, updatedContext); - logDocumentWithoutId({ - line: 'updateDocumentDecisionById 770', - logger: this.logger, - workflowRuntimeData: updatedWorkflow, - }); + const documentWithDecision = { + ...document, + id: document.id, + decision: { + ...newDecision, + status, + }, + }; + const validateDocumentSchema = status === 'approved'; - if (postUpdateEventName) { - return await this.event( - { id: workflowId, name: postUpdateEventName }, - projectIds, - currentProjectId, + const updatedWorkflow = await this.updateDocumentById( + { + workflowId, + documentId, + validateDocumentSchema, + documentsUpdateContextMethod: documentsUpdateContextMethod, + }, + documentWithDecision as unknown as DefaultContextSchema['documents'][number], + projectIds![0]!, + transaction, ); - } - return updatedWorkflow; + logDocumentWithoutId({ + line: 'updateDocumentDecisionById 770', + logger: this.logger, + workflowRuntimeData: updatedWorkflow, + }); + + return updatedWorkflow; + }, defaultPrismaTransactionOptions); } async updateDocumentById( @@ -863,97 +874,122 @@ export class WorkflowService { }, data: DefaultContextSchema['documents'][number] & { propertiesSchema?: object }, projectId: TProjectId, + transaction?: PrismaTransaction, ) { - const runtimeData = await this.workflowRuntimeDataRepository.findById(workflowId, {}, [ - projectId, - ]); - const workflowDef = await this.workflowDefinitionRepository.findById( - runtimeData.workflowDefinitionId, - {}, - [projectId], - ); - const documentToUpdate = runtimeData?.context?.documents?.find( - (document: DefaultContextSchema['documents'][number]) => document.id === documentId, - ); + const beginTransactionIfNotExist = beginTransactionIfNotExistCurry({ + transaction, + prismaService: this.prismaService, + options: defaultPrismaTransactionOptions, + }); - const document = { - ...data, - id: documentId, - }; + return await beginTransactionIfNotExist(async transaction => { + const runtimeData = await this.workflowRuntimeDataRepository.findByIdAndLock( + workflowId, + {}, + [projectId], + transaction, + ); + const workflowDef = await this.workflowDefinitionRepository.findById( + runtimeData.workflowDefinitionId, + {}, + [projectId], + transaction, + ); + const documentToUpdate = runtimeData?.context?.documents?.find( + (document: DefaultContextSchema['documents'][number]) => document.id === documentId, + ); + + const document = { + ...data, + id: documentId, + }; - const documentSchema = addPropertiesSchemaToDocument(document, workflowDef.documentsSchema); - const propertiesSchema = documentSchema?.propertiesSchema ?? {}; + const documentSchema = addPropertiesSchemaToDocument(document, workflowDef.documentsSchema); + const propertiesSchema = documentSchema?.propertiesSchema ?? {}; - if (Object.keys(propertiesSchema)?.length && validateDocumentSchema) { - const propertiesSchemaForValidation = propertiesSchema; + if (Object.keys(propertiesSchema)?.length && validateDocumentSchema) { + const propertiesSchemaForValidation = propertiesSchema; - const validatePropertiesSchema = ajv.compile(propertiesSchemaForValidation); + const validatePropertiesSchema = ajv.compile(propertiesSchemaForValidation); - const isValidPropertiesSchema = validatePropertiesSchema(documentSchema?.properties); + const isValidPropertiesSchema = validatePropertiesSchema(documentSchema?.properties); - if (!isValidPropertiesSchema && document.type === documentToUpdate.type) { - throw ValidationError.fromAjvError(validatePropertiesSchema.errors!); + if (!isValidPropertiesSchema && document.type === documentToUpdate.type) { + throw ValidationError.fromAjvError(validatePropertiesSchema.errors!); + } } - } - const updatedWorkflow = await this.updateContextById( - workflowId, - this.updateDocumentInContext( - runtimeData.context, - documentSchema, - documentsUpdateContextMethod, - ), - [projectId], - documentsUpdateContextMethod === 'director' ? 'by_index' : 'by_id', - ); + let updatedWorkflow = await this.event( + { + id: workflowId, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext: this.updateDocumentInContext( + runtimeData.context, + documentSchema, + documentsUpdateContextMethod, + ), + arrayMergeOption: + documentsUpdateContextMethod === 'director' + ? ARRAY_MERGE_OPTION.BY_INDEX + : ARRAY_MERGE_OPTION.BY_ID, + }, + }, + [projectId], + projectId, + transaction, + ); - const updatedDocuments = this.getDocuments( - updatedWorkflow.context, - documentsUpdateContextMethod, - ); + const updatedDocuments = this.getDocuments( + updatedWorkflow.context, + documentsUpdateContextMethod, + ); - logDocumentWithoutId({ - line: 'updateDocumentDecisionById 844', - logger: this.logger, - workflowRuntimeData: updatedWorkflow, - }); + logDocumentWithoutId({ + line: 'updateDocumentDecisionById 844', + logger: this.logger, + workflowRuntimeData: updatedWorkflow, + }); - this.__validateWorkflowDefinitionContext(workflowDef, updatedWorkflow.context); - const correlationId = await this.getCorrelationIdFromWorkflow(updatedWorkflow, [projectId]); + this.__validateWorkflowDefinitionContext(workflowDef, updatedWorkflow.context); + const correlationId = await this.getCorrelationIdFromWorkflow(updatedWorkflow, [projectId]); + + if ( + ['active'].includes(updatedWorkflow.status) && + workflowDef.config?.completedWhenTasksResolved + ) { + const allDocumentsResolved = + updatedDocuments?.length && + updatedDocuments?.every((document: DefaultContextSchema['documents'][number]) => { + return ['approved', 'rejected', 'revision'].includes( + document?.decision?.status as string, + ); + }); - if ( - ['active'].includes(updatedWorkflow.status) && - workflowDef.config?.completedWhenTasksResolved - ) { - const allDocumentsResolved = - updatedDocuments?.length && - updatedDocuments?.every((document: DefaultContextSchema['documents'][number]) => { - return ['approved', 'rejected', 'revision'].includes( - document?.decision?.status as string, + if (allDocumentsResolved) { + updatedWorkflow = await this.workflowRuntimeDataRepository.updateStateById( + workflowId, + { + data: { + status: allDocumentsResolved ? 'completed' : updatedWorkflow.status, + resolvedAt: new Date().toISOString(), + }, + }, + transaction, ); - }); - - if (allDocumentsResolved) { - updatedWorkflow.status = allDocumentsResolved ? 'completed' : updatedWorkflow.status; - await this.workflowRuntimeDataRepository.updateById(workflowId, { - data: { - status: updatedWorkflow.status, - resolvedAt: new Date().toISOString(), - projectId, - }, - }); - this.workflowEventEmitter.emit('workflow.completed', { - runtimeData: updatedWorkflow, - state: updatedWorkflow.state, - //@ts-expect-error - entityId: updatedWorkflow.businessId || updatedWorkflow.endUserId, - correlationId, - }); + this.workflowEventEmitter.emit('workflow.completed', { + runtimeData: updatedWorkflow, + state: updatedWorkflow.state, + //@ts-expect-error + entityId: updatedWorkflow.businessId || updatedWorkflow.endUserId, + correlationId, + }); + } } - } - return updatedWorkflow; + return updatedWorkflow; + }); } private updateDocumentInContext( @@ -1020,219 +1056,127 @@ export class WorkflowService { return (context?.entity?.data?.additionalInfo?.directors as any[]) || []; } - async updateContextById( - id: string, - context: WorkflowRuntimeData['context'], - projectIds: TProjectIds, - mergeBy: ArrayMergeOption = 'by_id', - ) { - const runtimeData = await this.workflowRuntimeDataRepository.findById(id, {}, projectIds); - const correlationId = await this.getCorrelationIdFromWorkflow(runtimeData, projectIds); - const updatedRuntimeData = await this.workflowRuntimeDataRepository.updateContextById( - id, - context, - mergeBy, - projectIds, - ); - - this.workflowEventEmitter.emit('workflow.context.changed', { - oldRuntimeData: runtimeData, - updatedRuntimeData: updatedRuntimeData, - state: updatedRuntimeData.state as string, - entityId: (updatedRuntimeData.businessId || updatedRuntimeData.endUserId) as string, - correlationId: correlationId, - }); - - return updatedRuntimeData; - } - - async syncContextById( - id: string, - context: WorkflowRuntimeData['context'], - projectId: TProjectId, - ) { - return this.workflowRuntimeDataRepository.updateById(id, { data: { context, projectId } }); - } - async updateWorkflowRuntimeData( workflowRuntimeId: string, data: WorkflowDefinitionUpdateInput, projectId: TProjectId, + transaction?: PrismaTransaction, ): Promise { - const projectIds: TProjectIds = projectId ? [projectId] : null; - - const runtimeData = await this.workflowRuntimeDataRepository.findById( - workflowRuntimeId, - {}, - projectIds, - ); - const workflowDef = await this.workflowDefinitionRepository.findById( - runtimeData.workflowDefinitionId, - {}, - projectIds, - ); - - const correlationId: string = await this.getCorrelationIdFromWorkflow(runtimeData, projectIds); - - let contextHasChanged, mergedContext; - if (data.context) { - data.context.documents = assignIdToDocuments(data.context.documents); - contextHasChanged = !isEqual(data.context, runtimeData.context); - mergedContext = merge({}, runtimeData.context, data.context); - - this.__validateWorkflowDefinitionContext(workflowDef, { - ...mergedContext, - documents: mergedContext?.documents?.map( - (document: DefaultContextSchema['documents'][number]) => ({ - ...document, - decision: { - ...document?.decision, - status: document?.decision?.status === null ? undefined : document?.decision?.status, - }, - type: - document?.type === 'unknown' && document?.decision?.status === 'approved' - ? undefined - : document?.type, - }), - ), - }); + const beginTransactionIfNotExist = beginTransactionIfNotExistCurry({ + transaction, + prismaService: this.prismaService, + options: defaultPrismaTransactionOptions, + }); - // @ts-ignore - data?.context?.documents?.forEach(({ propertiesSchema, ...document }) => { - if (document?.decision?.status !== 'approve') return; + return await beginTransactionIfNotExist(async transaction => { + const projectIds: TProjectIds = projectId ? [projectId] : null; - if (!Object.keys(propertiesSchema ?? {})?.length) return; + const runtimeData = await this.workflowRuntimeDataRepository.findByIdAndLock( + workflowRuntimeId, + {}, + projectIds, + transaction, + ); + const workflowDef = await this.workflowDefinitionRepository.findById( + runtimeData.workflowDefinitionId, + {}, + projectIds, + ); - const validatePropertiesSchema = ajv.compile(propertiesSchema ?? {}); // we shouldn't rely on schema from the client, add to tech debt - const isValidPropertiesSchema = validatePropertiesSchema(document?.properties); + const correlationId: string = await this.getCorrelationIdFromWorkflow( + runtimeData, + projectIds, + ); - if (!isValidPropertiesSchema) { - throw ValidationError.fromAjvError(validatePropertiesSchema.errors!); - } - }); - data.context = mergedContext; - } + let contextHasChanged; + if (data.context) { + data.context.documents = assignIdToDocuments(data.context.documents); + contextHasChanged = !isEqual(data.context, runtimeData.context); + + this.__validateWorkflowDefinitionContext(workflowDef, { + ...data.context, + documents: data.context?.documents?.map( + (document: DefaultContextSchema['documents'][number]) => ({ + ...document, + decision: { + ...document?.decision, + status: + document?.decision?.status === null ? undefined : document?.decision?.status, + }, + type: + document?.type === 'unknown' && document?.decision?.status === 'approved' + ? undefined + : document?.type, + }), + ), + }); - this.logger.log('Workflow state transition', { - id: workflowRuntimeId, - from: runtimeData.state, - to: data.state, - }); + // @ts-ignore + data?.context?.documents?.forEach(({ propertiesSchema, ...document }) => { + if (document?.decision?.status !== 'approve') return; - // in case current state is a final state, we want to create another machine, of type manual review. - // assign runtime to user, copy the context. - const currentState = data.state; + if (!Object.keys(propertiesSchema ?? {})?.length) return; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: Use snapshot.done instead - const isFinal = workflowDef.definition?.states?.[currentState]?.type === 'final'; - const isResolved = isFinal || data.status === WorkflowRuntimeDataStatus.completed; + const validatePropertiesSchema = ajv.compile(propertiesSchema ?? {}); // we shouldn't rely on schema from the client, add to tech debt + const isValidPropertiesSchema = validatePropertiesSchema(document?.properties); - const documentToRevise = data.context?.documents?.find( - ({ decision }: { decision: DefaultContextSchema['documents'][number]['decision'] }) => - decision?.status === 'revision', - ); - let updatedResult; + if (!isValidPropertiesSchema) { + throw ValidationError.fromAjvError(validatePropertiesSchema.errors!); + } + }); + } - if (documentToRevise && !workflowDef.reviewMachineId) { - const parentMachine = await this.workflowRuntimeDataRepository.findById( - runtimeData?.context?.parentMachine?.id, - { - include: { - workflowDefinition: { - select: { - definition: true, - }, - }, - }, - }, - projectIds, - ); + this.logger.log('Workflow state transition', { + id: workflowRuntimeId, + from: runtimeData.state, + to: data.state, + }); - // Updates the collect documents workflow with the manual review workflow's decision. - await this.workflowRuntimeDataRepository.updateById(parentMachine?.id, { - data: { - status: 'active', - //@ts-expect-error - state: parentMachine?.workflowDefinition?.definition?.initial as string, - context: { - ...parentMachine?.context, - documents: parentMachine?.context?.documents?.map((document: any) => { - if (document.id !== documentToRevise.id) return document; + // in case current state is a final state, we want to create another machine, of type manual review. + // assign runtime to user, copy the context. + const currentState = data.state; - return { - ...document, - decision: documentToRevise.decision, - }; - }), - }, - projectId, - }, - }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // TODO: Use snapshot.done instead + const isFinal = workflowDef.definition?.states?.[currentState]?.type === 'final'; + const isResolved = isFinal || data.status === WorkflowRuntimeDataStatus.completed; - updatedResult = await this.workflowRuntimeDataRepository.updateById(workflowRuntimeId, { - data: { - ...data, - context: { - ...data.context, - parentMachine: { - id: parentMachine?.id, - status: 'active', - }, + const updatedResult = await this.workflowRuntimeDataRepository.updateStateById( + runtimeData.id, + { + data: { + ...data, + resolvedAt: isResolved ? new Date().toISOString() : null, }, - resolvedAt: isResolved ? new Date().toISOString() : null, - projectId, }, - }); - } else { - updatedResult = await this.workflowRuntimeDataRepository.updateById(workflowRuntimeId, { - data: { - ...data, - resolvedAt: isResolved ? new Date().toISOString() : null, - projectId, - }, - }); - } - - if (isResolved) { - this.logger.log('Workflow resolved', { id: workflowRuntimeId }); - - this.workflowEventEmitter.emit('workflow.completed', { - runtimeData: updatedResult, - state: currentState ?? updatedResult.state, - // @ts-ignore - error from Prisma types fix - entityId: updatedResult.businessId || updatedResult.endUserId, - correlationId, - }); - } + transaction, + ); - if (contextHasChanged) { - this.workflowEventEmitter.emit('workflow.context.changed', { - oldRuntimeData: runtimeData, - updatedRuntimeData: updatedResult, - state: currentState as string, - entityId: (runtimeData.businessId || runtimeData.endUserId) as string, - correlationId: correlationId, - }); - } + if (isResolved) { + this.logger.log('Workflow resolved', { id: workflowRuntimeId }); - // TODO: Move to a separate method - if (data.state && isFinal && workflowDef.reviewMachineId) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await this.handleRuntimeFinalState(runtimeData, data.context, workflowDef); - } + this.workflowEventEmitter.emit('workflow.completed', { + runtimeData: updatedResult, + state: currentState ?? updatedResult.state, + // @ts-ignore - error from Prisma types fix + entityId: updatedResult.businessId || updatedResult.endUserId, + correlationId, + }); + } - if (data.postUpdateEventName) { - return await this.event( - { name: data.postUpdateEventName, id: workflowRuntimeId }, - projectIds, - projectId, - ); - } + if (contextHasChanged) { + this.workflowEventEmitter.emit('workflow.context.changed', { + oldRuntimeData: runtimeData, + updatedRuntimeData: updatedResult, + state: currentState as string, + entityId: (runtimeData.businessId || runtimeData.endUserId) as string, + correlationId: correlationId, + }); + } - return updatedResult; + return updatedResult; + }); } async updateWorkflowRuntimeLanguage( @@ -1245,7 +1189,7 @@ export class WorkflowService { return await this.workflowRuntimeDataRepository.updateRuntimeConfigById( workflowRuntimeId, { language }, - 'by_index', + ARRAY_MERGE_OPTION.BY_INDEX, projectIds, ); } @@ -1328,164 +1272,6 @@ export class WorkflowService { return await this.workflowDefinitionRepository.deleteById(id, args, projectIds); } - async handleRuntimeFinalState( - runtime: WorkflowRuntimeData, - context: Record, - workflow: WorkflowDefinition, - projectIds: TProjectIds, - currentProjectId: TProjectId, - ) { - // discuss error handling - if (!workflow.reviewMachineId) { - return; - } - const endUserId = runtime.endUserId; - const businessId = runtime.businessId; - endUserId && - (await this.endUserRepository.updateById(endUserId, { - data: { - approvalState: ApprovalState.PROCESSING, - }, - })); - businessId && - (await this.businessRepository.updateById(businessId, { - data: { - approvalState: ApprovalState.PROCESSING, - }, - })); - - const entityId = endUserId || businessId; - - this.logger.log(`Entity state updated to ${ApprovalState.PROCESSING}`, { - entityType: endUserId ? 'endUser' : 'business', - entityId, - }); - - // will throw exception if review machine def is missing - await this.workflowDefinitionRepository.findById(workflow.reviewMachineId, {}, projectIds); - - const entitySearch: { businessId?: string; endUserId?: string } = {}; - - if (businessId) { - entitySearch.businessId = runtime.businessId as string; - } else { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - entitySearch.endUserId = runtime.endUserId as string; - } - - const manualReviewWorkflow = await this.workflowRuntimeDataRepository.findOne( - { - where: { - ...entitySearch, - context: { - path: ['parentMachine', 'id'], - equals: runtime.id, - }, - }, - }, - projectIds, - ); - - if (!manualReviewWorkflow) { - await this.workflowRuntimeDataRepository.create({ - data: { - ...entitySearch, - workflowDefinitionVersion: workflow.version, - workflowDefinitionId: workflow.reviewMachineId, - context: { - ...context, - parentMachine: { - id: runtime.id, - status: 'completed', - }, - }, - status: 'active', - projectId: currentProjectId, - }, - }); - } else { - if (manualReviewWorkflow.state === 'revision') { - await this.event( - { - name: 'review', - id: manualReviewWorkflow.id, - }, - projectIds, - currentProjectId, - ); - } - - await this.workflowRuntimeDataRepository.updateById(manualReviewWorkflow.id, { - data: { - context: { - ...manualReviewWorkflow.context, - parentMachine: { - id: runtime.id, - status: 'completed', - }, - }, - projectId: currentProjectId, - }, - }); - } - - await this.updateWorkflowRuntimeData( - runtime.id, - { - status: 'completed', - }, - currentProjectId, - ); - } - - async resolveIntent( - intent: string, - entityId: string, - entityType: TEntityType, - projectIds: TProjectIds, - currentProjectId: TProjectId, - ) { - const workflowDefinitionResolver = policies[intent as keyof typeof policies]; - const entity = await (async () => { - if (entityType === 'business') - return await this.businessRepository.findById(entityId, {}, projectIds); - if (entityType === 'endUser') - return await this.endUserRepository.findById(entityId, {}, projectIds); - - throw new BadRequestException(`Invalid entity type ${entityType}`); - })(); - const isBusinessEntity = (entity: EndUser | Business): entity is Business => - entityType === 'business'; - - // TODO: implement logic for multiple workflows - const { workflowDefinitionId } = workflowDefinitionResolver()[0]; - const context: DefaultContextSchema = { - entity: { - ballerineEntityId: entityId, - type: entityType, - // @ts-ignore - data: { - ...(isBusinessEntity(entity) - ? { - companyName: entity?.companyName, - registrationNumber: entity?.registrationNumber, - } - : { - firstName: entity?.firstName, - lastName: entity?.lastName, - }), - }, - }, - documents: [], - }; - return this.createOrUpdateWorkflowRuntime({ - workflowDefinitionId, - context, - projectIds, - currentProjectId, - }); - } - omitTypeFromDocumentsPages(documents: DefaultContextSchema['documents']) { return documents?.map(document => ({ ...document, @@ -1493,302 +1279,319 @@ export class WorkflowService { })); } - async createOrUpdateWorkflowRuntime({ - workflowDefinitionId, - context, - config, - parentWorkflowId, - projectIds, - currentProjectId, - ...salesforceData - }: { - workflowDefinitionId: string; - context: DefaultContextSchema; - config?: WorkflowConfig; - parentWorkflowId?: string; - projectIds: TProjectIds; - currentProjectId: TProjectId; - // eslint-disable-next-line @typescript-eslint/ban-types - } & ({ salesforceObjectName: string; salesforceRecordId: string } | {})) { - const workflowDefinition = await this.workflowDefinitionRepository.findById( + async createOrUpdateWorkflowRuntime( + { workflowDefinitionId, - {}, - projectIds, - ); - - config = merge(workflowDefinition.config, config); - let validatedConfig: WorkflowConfig; - const result = ConfigSchema.safeParse(config); - - if (!result.success) { - throw ValidationError.fromZodError(result.error); - } - - const customer = await this.customerService.getByProjectId(projectIds![0]!); - // @ts-ignore - context.customerName = customer.displayName; - this.__validateWorkflowDefinitionContext(workflowDefinition, context); - const entityId = await this.__findOrPersistEntityInformation( context, + config, + parentWorkflowId, projectIds, currentProjectId, - ); - const entityType = context.entity.type === 'business' ? 'business' : 'endUser'; - const existingWorkflowRuntimeData = - await this.workflowRuntimeDataRepository.findActiveWorkflowByEntity( - { - entityId, - entityType, - workflowDefinitionId: workflowDefinition.id, - }, + ...salesforceData + }: { + workflowDefinitionId: string; + context: DefaultContextSchema; + config?: WorkflowConfig; + parentWorkflowId?: string; + projectIds: TProjectIds; + currentProjectId: TProjectId; + // eslint-disable-next-line @typescript-eslint/ban-types + } & ({ salesforceObjectName: string; salesforceRecordId: string } | {}), + transaction?: PrismaTransaction, + ) { + const beginTransactionIfNotExist = beginTransactionIfNotExistCurry({ + transaction, + prismaService: this.prismaService, + options: defaultPrismaTransactionOptions, + }); + + return await beginTransactionIfNotExist(async transaction => { + const workflowDefinition = await this.workflowDefinitionRepository.findById( + workflowDefinitionId, + {}, projectIds, ); - let contextToInsert = structuredClone(context); - - // @ts-ignore - contextToInsert.entity.ballerineEntityId ||= entityId; + config = merge(workflowDefinition.config, config); + let validatedConfig: WorkflowConfig; + const result = ConfigSchema.safeParse(config); - const entityConnect = { - [`${entityType}Id`]: entityId, - }; + if (!result.success) { + throw ValidationError.fromZodError(result.error); + } - let workflowRuntimeData: WorkflowRuntimeData, newWorkflowCreated: boolean; + const customer = await this.customerService.getByProjectId(projectIds![0]!); + // @ts-ignore + context.customerName = customer.displayName; + this.__validateWorkflowDefinitionContext(workflowDefinition, context); + const entityId = await this.__findOrPersistEntityInformation( + context, + projectIds, + currentProjectId, + ); + const entityType = context.entity.type === 'business' ? 'business' : 'endUser'; + const existingWorkflowRuntimeData = + await this.workflowRuntimeDataRepository.findActiveWorkflowByEntityAndLock( + { + entityId, + entityType, + workflowDefinitionId: workflowDefinition.id, + }, + projectIds, + transaction, + ); - const mergedConfig: WorkflowConfig = merge( - workflowDefinition.config, - validatedConfig || {}, - ) as InputJsonValue; + let contextToInsert = structuredClone(context); - const entities: Array<{ - id: string; - type: 'individual' | 'business'; - tags?: Array<'mainRepresentative' | 'UBO'>; - }> = []; + // @ts-ignore + contextToInsert.entity.ballerineEntityId ||= entityId; - // Creating new workflow - if (!existingWorkflowRuntimeData || mergedConfig?.allowMultipleActiveWorkflows) { - const contextWithoutDocumentPageType = { - ...contextToInsert, - documents: this.omitTypeFromDocumentsPages(contextToInsert.documents), + const entityConnect = { + [`${entityType}Id`]: entityId, }; - const documentsWithPersistedImages = await this.copyDocumentsPagesFilesAndCreate( - contextWithoutDocumentPageType?.documents, - entityId, - currentProjectId, - customer.name, - ); - let uiDefinition; + let workflowRuntimeData: WorkflowRuntimeData, newWorkflowCreated: boolean; - try { - uiDefinition = await this.uiDefinitionService.getByWorkflowDefinitionId( - workflowDefinitionId, - UiDefinitionContext.collection_flow, - projectIds, - {}, + const mergedConfig: WorkflowConfig = merge( + workflowDefinition.config, + validatedConfig || {}, + ) as InputJsonValue; + + const entities: Array<{ + id: string; + type: 'individual' | 'business'; + tags?: Array<'mainRepresentative' | 'UBO'>; + }> = []; + + // Creating new workflow + if (!existingWorkflowRuntimeData || mergedConfig?.allowMultipleActiveWorkflows) { + const contextWithoutDocumentPageType = { + ...contextToInsert, + documents: this.omitTypeFromDocumentsPages(contextToInsert.documents), + }; + + const documentsWithPersistedImages = await this.copyDocumentsPagesFilesAndCreate( + contextWithoutDocumentPageType?.documents, + entityId, + currentProjectId, + customer.name, ); - } catch (err) { - if (isErrorWithMessage(err)) { - this.logger.error(err.message); + let uiDefinition; + + try { + uiDefinition = await this.uiDefinitionService.getByWorkflowDefinitionId( + workflowDefinitionId, + UiDefinitionContext.collection_flow, + projectIds, + {}, + ); + } catch (err) { + if (isErrorWithMessage(err)) { + this.logger.error(err.message); + } } - } - const uiSchema = (uiDefinition as Record)?.uiSchema; + const uiSchema = (uiDefinition as Record)?.uiSchema; + + const createFlowConfig = (uiSchema: Record) => { + return { + stepsProgress: ( + uiSchema?.elements as Array<{ + type: string; + number: number; + stateName: string; + }> + )?.reduce((acc, curr) => { + if (curr?.type !== 'page') { + return acc; + } + + acc[curr?.stateName] = { + number: curr?.number, + isCompleted: false, + }; - const createFlowConfig = (uiSchema: Record) => { - return { - stepsProgress: ( - uiSchema?.elements as Array<{ - type: string; - number: number; - stateName: string; - }> - )?.reduce((acc, curr) => { - if (curr?.type !== 'page') { return acc; - } + }, {} as { [key: string]: { number: number; isCompleted: boolean } }), + }; + }; - acc[curr?.stateName] = { - number: curr?.number, - isCompleted: false, - }; + workflowRuntimeData = await this.workflowRuntimeDataRepository.create( + { + data: { + ...entityConnect, + workflowDefinitionVersion: workflowDefinition.version, + context: { + ...contextToInsert, + documents: documentsWithPersistedImages, + flowConfig: (contextToInsert as any)?.flowConfig ?? createFlowConfig(uiSchema), + } as InputJsonValue, + config: mergedConfig as InputJsonValue, + // @ts-expect-error - error from Prisma types fix + state: workflowDefinition.definition.initial as string, + status: 'active', + workflowDefinitionId: workflowDefinition.id, + ...(parentWorkflowId && + ({ + parentRuntimeDataId: parentWorkflowId, + } satisfies Omit< + Prisma.WorkflowRuntimeDataCreateArgs['data'], + 'context' | 'workflowDefinitionVersion' + >)), + ...('salesforceObjectName' in salesforceData && salesforceData), + projectId: currentProjectId, + }, + }, + transaction, + ); - return acc; - }, {} as { [key: string]: { number: number; isCompleted: boolean } }), - }; - }; + logDocumentWithoutId({ + line: 'createOrUpdateWorkflow 1476', + logger: this.logger, + workflowRuntimeData, + }); - workflowRuntimeData = await this.workflowRuntimeDataRepository.create({ - data: { - ...entityConnect, - workflowDefinitionVersion: workflowDefinition.version, - context: { - ...contextToInsert, - documents: documentsWithPersistedImages, - flowConfig: (contextToInsert as any)?.flowConfig ?? createFlowConfig(uiSchema), - } as InputJsonValue, - config: mergedConfig as InputJsonValue, - // @ts-expect-error - error from Prisma types fix - state: workflowDefinition.definition.initial as string, - status: 'active', - workflowDefinitionId: workflowDefinition.id, - ...(parentWorkflowId && - ({ - parentRuntimeDataId: parentWorkflowId, - } satisfies Omit< - Prisma.WorkflowRuntimeDataCreateArgs['data'], - 'context' | 'workflowDefinitionVersion' - >)), - ...('salesforceObjectName' in salesforceData && salesforceData), - projectId: currentProjectId, - }, - }); + let endUserId: string; + + if (mergedConfig.createCollectionFlowToken) { + if (entityType === 'endUser') { + endUserId = entityId; + entities.push({ type: 'individual', id: entityId }); + } else { + endUserId = await this.__generateEndUserWithBusiness({ + entityType, + workflowRuntimeData, + entityData: + workflowRuntimeData.context.entity?.data?.additionalInfo?.mainRepresentative, + currentProjectId, + entityId, + }); - logDocumentWithoutId({ - line: 'createOrUpdateWorkflow 1476', - logger: this.logger, - workflowRuntimeData, - }); + entities.push({ + type: 'individual', + id: endUserId, + }); - let endUserId: string; + entities.push({ type: 'business', id: entityId }); + } - if (mergedConfig.createCollectionFlowToken) { - if (entityType === 'endUser') { - endUserId = entityId; - entities.push({ type: 'individual', id: entityId }); - } else { - endUserId = await this.__generateEndUserWithBusiness({ - entityType, - workflowRuntimeData, - entityData: - workflowRuntimeData.context.entity?.data?.additionalInfo?.mainRepresentative, + const nowPlus30Days = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const workflowToken = await this.workflowTokenService.create( currentProjectId, - entityId, - }); + { + workflowRuntimeDataId: workflowRuntimeData.id, + endUserId: endUserId, + expiresAt: nowPlus30Days, + }, + transaction, + ); - entities.push({ - type: 'individual', - id: endUserId, - }); + workflowRuntimeData = await this.workflowRuntimeDataRepository.updateStateById( + workflowRuntimeData.id, + { + data: { + context: { + ...workflowRuntimeData.context, + metadata: { + customerNormalizedName: customer.name, + customerName: customer.displayName, + token: workflowToken.token, + collectionFlowUrl: env.COLLECTION_FLOW_URL, + webUiSDKUrl: env.WEB_UI_SDK_URL, + }, + }, + projectId: currentProjectId, + }, + }, + transaction, + ); + } - entities.push({ type: 'business', id: entityId }); + if (mergedConfig?.initialEvent) { + workflowRuntimeData = await this.event( + { + id: workflowRuntimeData.id, + name: mergedConfig?.initialEvent, + }, + projectIds, + currentProjectId, + transaction, + ); } - const nowPlus30Days = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - const workflowToken = await this.workflowTokenService.create(currentProjectId, { - workflowRuntimeDataId: workflowRuntimeData.id, - endUserId: endUserId, - expiresAt: nowPlus30Days, - }); + if ('salesforceObjectName' in salesforceData && salesforceData.salesforceObjectName) { + await this.updateSalesforceRecord({ + workflowRuntimeData, + data: { + KYB_Started_At__c: workflowRuntimeData.createdAt, + KYB_Status__c: 'In Progress', + KYB_Assigned_Agent__c: '', + }, + }); + } - workflowRuntimeData = await this.workflowRuntimeDataRepository.updateById( - workflowRuntimeData.id, + newWorkflowCreated = true; + } else { + // Updating existing workflow + this.logger.log('existing documents', existingWorkflowRuntimeData.context.documents); + this.logger.log('documents', contextToInsert.documents); + // contextToInsert.documents = updateDocuments( + // existingWorkflowRuntimeData.context.documents, + // context.documents, + // ); + const documentsWithPersistedImages = await this.copyDocumentsPagesFilesAndCreate( + contextToInsert?.documents, + entityId, + currentProjectId, + customer.name, + ); + + contextToInsert = { + ...contextToInsert, + documents: documentsWithPersistedImages as DefaultContextSchema['documents'], + }; + + workflowRuntimeData = await this.workflowRuntimeDataRepository.updateStateById( + existingWorkflowRuntimeData.id, { data: { - context: { - ...workflowRuntimeData.context, - metadata: { - customerNormalizedName: customer.name, - customerName: customer.displayName, - token: workflowToken.token, - collectionFlowUrl: env.COLLECTION_FLOW_URL, - webUiSDKUrl: env.WEB_UI_SDK_URL, - }, - } as InputJsonValue, + ...entityConnect, + context: contextToInsert, + config: merge( + existingWorkflowRuntimeData.config, + validatedConfig || {}, + ) as InputJsonValue, projectId: currentProjectId, }, }, + transaction, ); - } - - mergedConfig?.initialEvent && - (await this.event( - { - id: workflowRuntimeData.id, - name: mergedConfig?.initialEvent, - }, - projectIds, - currentProjectId, - )); - workflowRuntimeData = await this.workflowRuntimeDataRepository.findById( - workflowRuntimeData.id, - {}, - projectIds, - ); - - if ('salesforceObjectName' in salesforceData && salesforceData.salesforceObjectName) { - await this.updateSalesforceRecord({ + logDocumentWithoutId({ + line: 'createOrUpdateWorkflow 1584', + logger: this.logger, workflowRuntimeData, - data: { - KYB_Started_At__c: workflowRuntimeData.createdAt, - KYB_Status__c: 'In Progress', - KYB_Assigned_Agent__c: '', - }, }); + + newWorkflowCreated = false; } - newWorkflowCreated = true; - } else { - // Updating existing workflow - this.logger.log('existing documents', existingWorkflowRuntimeData.context.documents); - this.logger.log('documents', contextToInsert.documents); - // contextToInsert.documents = updateDocuments( - // existingWorkflowRuntimeData.context.documents, - // context.documents, - // ); - const documentsWithPersistedImages = await this.copyDocumentsPagesFilesAndCreate( - contextToInsert?.documents, + this.logger.log(existingWorkflowRuntimeData ? 'Workflow updated' : 'Workflow created', { + workflowRuntimeDataId: workflowRuntimeData.id, entityId, - currentProjectId, - customer.name, - ); - - contextToInsert = { - ...contextToInsert, - documents: documentsWithPersistedImages as DefaultContextSchema['documents'], - }; + entityType, + newWorkflowCreated, + }); - workflowRuntimeData = await this.workflowRuntimeDataRepository.updateById( - existingWorkflowRuntimeData.id, + return [ { - data: { - ...entityConnect, - context: contextToInsert as InputJsonValue, - config: merge( - existingWorkflowRuntimeData.config, - validatedConfig || {}, - ) as InputJsonValue, - projectId: currentProjectId, - }, + workflowDefinition, + workflowRuntimeData, + ballerineEntityId: entityId, + entities, }, - ); - - logDocumentWithoutId({ - line: 'createOrUpdateWorkflow 1584', - logger: this.logger, - workflowRuntimeData, - }); - - newWorkflowCreated = false; - } - - this.logger.log(existingWorkflowRuntimeData ? 'Workflow updated' : 'Workflow created', { - workflowRuntimeDataId: workflowRuntimeData.id, - entityId, - entityType, - newWorkflowCreated, + ] as const; }); - - return [ - { - workflowDefinition, - workflowRuntimeData, - ballerineEntityId: entityId, - entities, - }, - ] as const; } private async __generateEndUserWithBusiness({ @@ -1904,10 +1707,16 @@ export class WorkflowService { context: DefaultContextSchema, projectId: TProjectId, ) { + const data = context.entity.data as Record; + const { id } = await this.endUserRepository.create({ data: { correlationId: entity.id, - ...(context.entity.data as object), + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + nationalId: data.nationalId, + additionalInfo: data.additionalInfo, project: { connect: { id: projectId } }, } as Prisma.EndUserCreateInput, }); @@ -1986,44 +1795,57 @@ export class WorkflowService { } async event( - { name: type, id }: WorkflowEventInput & IObjectWithId, + { name: type, id, payload }: WorkflowEventInput & IObjectWithId, projectIds: TProjectIds, currentProjectId: TProjectId, + transaction?: PrismaTransaction, ) { - this.logger.log('Workflow event received', { id, type }); - const workflowRuntimeData = await this.workflowRuntimeDataRepository.findById( - id, - {}, - projectIds, - ); - const workflowDefinition = await this.workflowDefinitionRepository.findById( - workflowRuntimeData.workflowDefinitionId, - {} as any, - projectIds, - ); + const beginTransactionIfNotExist = beginTransactionIfNotExistCurry({ + transaction, + prismaService: this.prismaService, + options: defaultPrismaTransactionOptions, + }); - const service = createWorkflow({ - runtimeId: workflowRuntimeData.id, - // @ts-expect-error - error from Prisma types fix - definition: workflowDefinition.definition, - // @ts-expect-error - error from Prisma types fix - definitionType: workflowDefinition.definitionType, - config: workflowRuntimeData.config, - workflowContext: { - machineContext: workflowRuntimeData.context, - state: workflowRuntimeData.state, - }, - // @ts-expect-error - error from Prisma types fix - extensions: workflowDefinition.extensions, - invokeChildWorkflowAction: async (childPluginConfiguration: ChildPluginCallbackOutput) => { - const runnableChildWorkflow = await this.persistChildEvent( - childPluginConfiguration, - projectIds, - currentProjectId, - ); + return await beginTransactionIfNotExist(async transaction => { + this.logger.log('Workflow event received', { id, type }); + const workflowRuntimeData = await this.workflowRuntimeDataRepository.findByIdAndLock( + id, + {}, + projectIds, + transaction, + ); + const workflowDefinition = await this.workflowDefinitionRepository.findById( + workflowRuntimeData.workflowDefinitionId, + {}, + projectIds, + transaction, + ); + + const service = createWorkflow({ + runtimeId: workflowRuntimeData.id, + // @ts-expect-error - error from Prisma types fix + definition: workflowDefinition.definition, + // @ts-expect-error - error from Prisma types fix + definitionType: workflowDefinition.definitionType, + config: workflowRuntimeData.config, + workflowContext: { + machineContext: workflowRuntimeData.context, + state: workflowRuntimeData.state, + }, + // @ts-expect-error - error from Prisma types fix + extensions: workflowDefinition.extensions, + invokeChildWorkflowAction: async (childPluginConfiguration: ChildPluginCallbackOutput) => { + const runnableChildWorkflow = await this.persistChildEvent( + childPluginConfiguration, + projectIds, + currentProjectId, + transaction, + ); + + if (!runnableChildWorkflow || !childPluginConfiguration.initOptions.event) { + return; + } - if (runnableChildWorkflow && childPluginConfiguration.initOptions.event) { - // TODO: Review the issue if return child workflow id for parent and not "send event" await this.event( { id: runnableChildWorkflow.workflowRuntimeData.id, @@ -2031,95 +1853,110 @@ export class WorkflowService { }, projectIds, currentProjectId, + transaction, ); - } - }, - }); + }, + }); - if (!service.getSnapshot().nextEvents.includes(type)) { - throw new BadRequestException( - `Event ${type} does not exist for workflow ${workflowDefinition.id}'s state: ${workflowRuntimeData.state}`, - ); - } + if (!service.getSnapshot().nextEvents.includes(type)) { + throw new BadRequestException( + `Event ${type} does not exist for workflow ${workflowDefinition.id}'s state: ${workflowRuntimeData.state}`, + ); + } - await service.sendEvent({ - type, - }); + await service.sendEvent({ + type, + ...(payload ? { payload } : {}), + }); - const snapshot = service.getSnapshot(); - const currentState = snapshot.value; - const context = snapshot.machine.context; - // TODO: Refactor to use snapshot.done instead - const isFinal = snapshot.machine.states[currentState].type === 'final'; - const entityType = aliasIndividualAsEndUser(context?.entity?.type); - const entityId = workflowRuntimeData[`${entityType}Id`]; - - this.logger.log('Workflow state transition', { - id: id, - from: workflowRuntimeData.state, - to: currentState, - }); - const updatedRuntimeData = await this.updateWorkflowRuntimeData( - workflowRuntimeData.id, - { - context, - state: currentState, - tags: Array.from(snapshot.tags) as unknown as WorkflowDefinitionUpdateInput['tags'], - status: isFinal ? 'completed' : workflowRuntimeData.status, - }, - currentProjectId, - ); + const snapshot = service.getSnapshot(); + const currentState = snapshot.value; + const context = snapshot.machine.context; + // TODO: Refactor to use snapshot.done instead + const isFinal = snapshot.machine.states[currentState].type === 'final'; + const entityType = aliasIndividualAsEndUser(context?.entity?.type); + const entityId = workflowRuntimeData[`${entityType}Id`]; + + this.logger.log('Workflow state transition', { + id: id, + from: workflowRuntimeData.state, + to: currentState, + }); - if (workflowRuntimeData.parentRuntimeDataId) { - await this.persistChildWorkflowToParent( - workflowRuntimeData, - workflowDefinition, - isFinal, - projectIds, + const updatedRuntimeData = await this.updateWorkflowRuntimeData( + workflowRuntimeData.id, + { + context, + state: currentState, + tags: Array.from(snapshot.tags) as unknown as WorkflowDefinitionUpdateInput['tags'], + status: isFinal ? 'completed' : workflowRuntimeData.status, + }, currentProjectId, - currentState, + transaction, ); - } - this.workflowEventEmitter.emit('workflow.state.changed', { - //@ts-expect-error - entityId, - state: updatedRuntimeData.state, - correlationId: updatedRuntimeData.context.ballerineEntityId, - runtimeData: updatedRuntimeData, - }); + if (workflowRuntimeData.parentRuntimeDataId) { + await this.persistChildWorkflowToParent( + workflowRuntimeData, + workflowDefinition, + isFinal, + projectIds, + currentProjectId, + transaction, + currentState, + ); + } - if (!isFinal || (currentState !== 'approved' && currentState !== 'rejected')) { - return updatedRuntimeData; - } + if (currentState !== workflowRuntimeData.state) { + this.workflowEventEmitter.emit('workflow.state.changed', { + //@ts-expect-error + entityId, + state: updatedRuntimeData.state, + correlationId: updatedRuntimeData.context.ballerineEntityId, + runtimeData: updatedRuntimeData, + }); + } - const approvalState = ApprovalState[currentState.toUpperCase() as keyof typeof ApprovalState]; + if (!isFinal || (currentState !== 'approved' && currentState !== 'rejected')) { + return updatedRuntimeData; + } - if (!entityType) { - throw new BadRequestException(`entity.type is required`); - } + const approvalState = ApprovalState[currentState.toUpperCase() as keyof typeof ApprovalState]; - if (!entityId) { - throw new BadRequestException(`entity.${entityType}Id is required`); - } + if (!entityType) { + throw new BadRequestException(`entity.type is required`); + } - if (entityType === 'endUser') { - await this.entityRepository[entityType].updateById(entityId, { - data: { - approvalState, - }, - }); - } + if (!entityId) { + throw new BadRequestException(`entity.${entityType}Id is required`); + } - if (entityType === 'business') { - await this.entityRepository[entityType].updateById(entityId, { - data: { - approvalState, - }, - }); - } + if (entityType === 'endUser') { + await this.entityRepository[entityType].updateById( + entityId, + { + data: { + approvalState, + }, + }, + transaction, + ); + } + + if (entityType === 'business') { + await this.entityRepository[entityType].updateById( + entityId, + { + data: { + approvalState, + }, + }, + transaction, + ); + } - return updatedRuntimeData; + return updatedRuntimeData; + }); } async persistChildWorkflowToParent( @@ -2128,13 +1965,15 @@ export class WorkflowService { isFinal: boolean, projectIds: TProjectIds, currentProjectId: TProjectId, + transaction: PrismaTransaction, childRuntimeState?: string, ) { - const parentWorkflowRuntime = await this.getWorkflowRuntimeWithChildrenDataById( + let parentWorkflowRuntime = await this.workflowRuntimeDataRepository.findByIdAndLock( // @ts-expect-error - error from Prisma types fix workflowRuntimeData.parentRuntimeDataId, { include: { childWorkflowsRuntimeData: true } }, projectIds, + transaction, ); const parentWorkflowDefinition = await this.getWorkflowDefinitionById( @@ -2175,15 +2014,25 @@ export class WorkflowService { workflowDefinition, ); - await this.updateWorkflowRuntimeData( - parentWorkflowRuntime.id, - { context: parentContext }, + parentWorkflowRuntime = await this.event( + { + id: parentWorkflowRuntime.id, + name: BUILT_IN_EVENT.DEEP_MERGE_CONTEXT, + payload: { + newContext: parentContext, + arrayMergeOption: ARRAY_MERGE_OPTION.BY_ID, + }, + }, + projectIds, currentProjectId, + transaction, ); if ( childWorkflowCallback.deliverEvent && - parentWorkflowRuntime.status !== WorkflowRuntimeDataStatus.completed + parentWorkflowRuntime.status !== WorkflowRuntimeDataStatus.completed && + childRuntimeState && + childWorkflowCallback.persistenceStates?.includes(childRuntimeState) ) { try { await this.event( @@ -2193,6 +2042,7 @@ export class WorkflowService { }, projectIds, currentProjectId, + transaction, ); } catch (ex) { console.warn( @@ -2272,35 +2122,6 @@ export class WorkflowService { return this.workflowRuntimeDataRepository.findContext(id, projectIds); } - async getLastActiveFlow({ - email, - workflowRuntimeDefinitionId, - projectIds, - }: GetLastActiveFlowParams): Promise { - const endUser = await this.endUserService.getByEmail(email, projectIds); - - if (!endUser || !endUser?.businesses?.length) return null; - - const query = { - endUserId: endUser.id, - ...{ - workflowDefinitionId: workflowRuntimeDefinitionId, - businessId: endUser.businesses.at(-1)!.id, - }, - projectIds, - }; - - this.logger.log(`Getting last active workflow`, query); - - const workflowData = await this.workflowRuntimeDataRepository.findLastActive(query, projectIds); - - this.logger.log('Last active workflow: ', { - workflowId: workflowData ? workflowData.id : null, - }); - - return workflowData ? workflowData : null; - } - async copyDocumentsPagesFilesAndCreate( documents: TDocumentsWithoutPageType, entityId: string, diff --git a/services/workflows-service/src/workflow/workflow.service.unit.test.ts b/services/workflows-service/src/workflow/workflow.service.unit.test.ts index 95ba474c37..50398331f9 100644 --- a/services/workflows-service/src/workflow/workflow.service.unit.test.ts +++ b/services/workflows-service/src/workflow/workflow.service.unit.test.ts @@ -193,6 +193,7 @@ describe('WorkflowService', () => { salesforceService, workflowTokenService, uiDefinitionService, + {} as any, ); }); @@ -224,171 +225,4 @@ describe('WorkflowService', () => { expect(definitions[0]).not.toHaveProperty('updatedAt'); }); }); - - describe('.event', () => { - describe('reaching to a state of type "final"', () => { - it('updates runtime data status to "completed"', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - numb: 'context', - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - await service.createWorkflowDefinition(buildWorkflowDeifintion(2)); - await service.event({ id: '2', name: 'COMPLETE' }); - - const runtimeData = await workflowRuntimeDataRepo.findById('2'); - - expect(runtimeData.state).toEqual('completed'); - expect(runtimeData.status).toEqual('completed'); - }); - }); - }); - - describe('.updateWorkflowRuntimeData', () => { - it('sends a webbhook only for changed documents', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - documents: [buildDocument('willBeRemoved', 'pending'), buildDocument('a', 'pending')], - }, - config: { - subscriptions: [ - { - type: 'webhook', - url: 'https://example.com', - events: ['workflow.context.document.changed'], - }, - ], - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - const newContext = { - documents: [buildDocument('a', 'approved'), buildDocument('added', 'pending')], - }; - - await service.createWorkflowDefinition(buildWorkflowDeifintion(2)); - await service.updateWorkflowRuntimeData('2', { context: newContext }); - - expect(fakeHttpService.requests).toEqual([ - { - url: configService.get('WEBHOOK_URL'), - data: { - id: expect.stringMatching(/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/), - eventName: 'workflow.context.document.changed', - workflowDefinitionId: '2', - workflowCreatedAt: undefined, - workflowResolvedAt: null, - ballerineEntityId: undefined, - correlationId: '', - apiVersion: packageJson.version, - timestamp: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), - workflowRuntimeId: '2', - environment: 'test', - data: { - ...newContext, - }, - }, - }, - ]); - }); - - it('sends a webbhook regardless regardless of case identifier case', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - documents: [buildDocument('a', 'pending')], - }, - config: { - subscriptions: [ - { - type: 'webhook', - url: 'https://example.com', - events: ['workflow.context.document.changed'], - }, - ], - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - const newContext = { - documents: [buildDocument('A', 'approved')], - }; - - await service.createWorkflowDefinition(buildWorkflowDeifintion(2)); - await service.updateWorkflowRuntimeData('2', { context: newContext }); - - expect(fakeHttpService.requests).toEqual([ - { - url: configService.get('WEBHOOK_URL'), - data: { - id: expect.stringMatching(/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/), - eventName: 'workflow.context.document.changed', - ballerineEntityId: undefined, - workflowResolvedAt: null, - workflowCreatedAt: undefined, - correlationId: '', - apiVersion: packageJson.version, - timestamp: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), - workflowRuntimeId: '2', - workflowDefinitionId: '2', - environment: 'test', - data: { - ...newContext, - }, - }, - }, - ]); - }); - it('does not send a webhook if no documents have changed', async () => { - const initialRuntimeData = { - id: '2', - workflowDefinitionId: '2', - context: { - documents: [ - buildDocument('willBeRemoved', 'pending'), - buildDocument('a', 'pending'), - buildDocument('b', 'pending'), - ], - }, - config: { - subscriptions: [ - { - type: 'webhook', - url: 'https://example.com', - events: ['workflow.context.document.changed'], - }, - ], - }, - }; - await workflowRuntimeDataRepo.create({ - data: initialRuntimeData, - }); - - await service.createWorkflowDefinition(buildWorkflowDeifintion(2)); - await service.updateWorkflowRuntimeData('2', { - context: { - documents: [ - buildDocument('added', 'pending'), - buildDocument('a', 'pending'), - buildDocument('b', 'pending'), - ], - }, - }); - - expect(fakeHttpService.requests).toEqual([]); - }); - }); }); diff --git a/websites/docs/package.json b/websites/docs/package.json index e23ba6092f..2099532e2b 100644 --- a/websites/docs/package.json +++ b/websites/docs/package.json @@ -17,7 +17,7 @@ "dependencies": { "@astrojs/starlight": "0.11.1", "@astrojs/tailwind": "^4.0.0", - "@ballerine/common": "^0.7.43", + "@ballerine/common": "^0.7.45", "astro": "3.3.3", "sharp": "^0.32.4", "shiki": "^0.14.3"