diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn new file mode 100644 index 0000000000..6ad8c1281a --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/bpmn/case-notification-process.bpmn @@ -0,0 +1,46 @@ + + + + + Flow_1 + + + + + Flow_1 + Flow_2 + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json new file mode 100644 index 0000000000..0c2c46a95a --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/building-block-definition-main-process-definition.json @@ -0,0 +1,3 @@ +{ + "processDefinitionKey": "case-notification-process" +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json new file mode 100644 index 0000000000..c807adeca2 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/building-block/definition/case-notification.building-block-definition.json @@ -0,0 +1,7 @@ +{ + "key": "case-notification", + "name": "Case notification", + "description": "Started by the 'case-notification-requested' message start event. Demonstrates caseCorrelationService.sendStartMessageToCase: this building block has no start form and cannot be started from the case Start menu.", + "versionTag": "1.0.0", + "final": false +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json new file mode 100644 index 0000000000..099fcd632c --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/document/definition/case-notification.schema.document-definition.json @@ -0,0 +1,16 @@ +{ + "$id": "case-notification.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Case notification", + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject copied from the case by the input mapping of the case definition link" + }, + "handledBy": { + "type": "string", + "description": "Who acknowledged the notification" + } + } +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json new file mode 100644 index 0000000000..ffde4c1401 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/form/case-notification-acknowledge.form.json @@ -0,0 +1,41 @@ +{ + "display": "form", + "components": [ + { + "label": "HTML", + "content": "This building block was started by a message. The subject below came from the case through the input mapping of the case definition link.", + "refreshOnChange": false, + "key": "html", + "type": "htmlelement", + "input": false, + "tableView": false + }, + { + "label": "Subject", + "disabled": true, + "tableView": true, + "key": "subject", + "type": "textfield", + "input": true + }, + { + "label": "Handled by", + "placeholder": "Who picked this up?", + "tableView": true, + "validate": { + "required": true + }, + "key": "handledBy", + "type": "textfield", + "input": true + }, + { + "type": "button", + "label": "Acknowledge", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ] +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json new file mode 100644 index 0000000000..19b6437b0b --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-notification/1-0-0/process-link/case-notification-process.process-link.json @@ -0,0 +1,8 @@ +[ + { + "activityId": "AcknowledgeNotificationTask", + "activityType": "bpmn:UserTask:create", + "processLinkType": "form", + "formDefinitionName": "case-notification-acknowledge" + } +] diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn new file mode 100644 index 0000000000..a0d2fe8f68 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/bpmn/case-review-process.bpmn @@ -0,0 +1,61 @@ + + + + + Flow_1 + + + + Flow_1 + Flow_2 + + + + + Flow_2 + Flow_3 + + + + Flow_3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json new file mode 100644 index 0000000000..929aa6bf68 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/building-block-definition-main-process-definition.json @@ -0,0 +1,3 @@ +{ + "processDefinitionKey": "case-review-process" +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json new file mode 100644 index 0000000000..8a13eb3ca4 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/building-block/definition/case-review.building-block-definition.json @@ -0,0 +1,7 @@ +{ + "key": "case-review", + "name": "Case review", + "description": "Waits for the 'review-approved' message. Demonstrates that a message sent to the case with caseCorrelationService.sendCatchEventMessageToCase reaches a running building block.", + "versionTag": "1.0.0", + "final": false +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json new file mode 100644 index 0000000000..fde2ec2cbe --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/document/definition/case-review.schema.document-definition.json @@ -0,0 +1,20 @@ +{ + "$id": "case-review.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Case review", + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject copied from the case by the input mapping" + }, + "reviewer": { + "type": "string", + "description": "Reviewer entered on the start form" + }, + "approvedBy": { + "type": "string", + "description": "Filled from the variable that came along with the 'review-approved' message" + } + } +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json new file mode 100644 index 0000000000..8165e77d1e --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/form/start-form-case-review.form.json @@ -0,0 +1,33 @@ +{ + "display": "form", + "components": [ + { + "label": "HTML", + "content": "This building block waits for the review-approved message. Start it, then run the Send review-approved message action on the case to let it continue.", + "refreshOnChange": false, + "key": "html", + "type": "htmlelement", + "input": false, + "tableView": false + }, + { + "label": "Reviewer", + "placeholder": "Who is going to review this?", + "tableView": true, + "validate": { + "required": true + }, + "key": "reviewer", + "type": "textfield", + "input": true + }, + { + "type": "button", + "label": "Start review", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ] +} diff --git a/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json new file mode 100644 index 0000000000..13a6cbd3a5 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/building-block/case-review/1-0-0/process-link/case-review-process.process-link.json @@ -0,0 +1,8 @@ +[ + { + "activityId": "StartEvent", + "activityType": "bpmn:StartEvent:start", + "processLinkType": "form", + "formDefinitionName": "start-form-case-review" + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn new file mode 100644 index 0000000000..25064e3e79 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/case-messaging.bpmn @@ -0,0 +1,29 @@ + + + + + Flow_1 + + + + Flow_1 + + + + + + + + + + + + + + + + + + + + diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn new file mode 100644 index 0000000000..9e6ef242c7 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/request-case-notification.bpmn @@ -0,0 +1,42 @@ + + + + + Flow_1 + + + + Flow_1 + Flow_2 + + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn new file mode 100644 index 0000000000..04ba781028 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/bpmn/send-review-approved.bpmn @@ -0,0 +1,42 @@ + + + + + Flow_1 + + + + Flow_1 + Flow_2 + + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json new file mode 100644 index 0000000000..082ad9f67b --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/building-block-link/case-messaging.case-building-block-links.json @@ -0,0 +1,34 @@ +[ + { + "buildingBlockDefinitionKey": "case-review", + "buildingBlockDefinitionVersionTag": "1.0.0", + "inputMappings": [ + { + "source": "doc:/subject", + "target": "doc:/subject" + } + ], + "outputMappings": [ + { + "source": "doc:/approvedBy", + "target": "doc:/reviewedBy" + } + ] + }, + { + "buildingBlockDefinitionKey": "case-notification", + "buildingBlockDefinitionVersionTag": "1.0.0", + "inputMappings": [ + { + "source": "doc:/subject", + "target": "doc:/subject" + } + ], + "outputMappings": [ + { + "source": "doc:/handledBy", + "target": "doc:/lastNotification" + } + ] + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json new file mode 100644 index 0000000000..ac5099083b --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/definition/case-messaging.case-definition.json @@ -0,0 +1,8 @@ +{ + "key": "case-messaging", + "name": "Case messaging", + "versionTag": "1.0.0", + "final": false, + "canHaveAssignee": true, + "autoAssignTasks": false +} diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json new file mode 100644 index 0000000000..1e9ef16121 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/case/tab/case-messaging.case-tab.json @@ -0,0 +1,15 @@ +[ + { + "name": "Summary", + "key": "summary", + "type": "standard", + "contentKey": "summary" + }, + { + "name": "Progress", + "key": "progress", + "type": "standard", + "contentKey": "progress", + "showTasks": true + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json new file mode 100644 index 0000000000..e23cb4d55f --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/document/definition/case-messaging.schema.document-definition.json @@ -0,0 +1,21 @@ +{ + "$id": "case-messaging.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Case messaging", + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject of the case, passed on to the building blocks" + }, + "reviewedBy": { + "type": "string", + "description": "Written back by the Case review building block after it received the message" + }, + "lastNotification": { + "type": "string", + "description": "Written back by the Case notification building block that was started by a message" + } + }, + "required": ["subject"] +} diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json new file mode 100644 index 0000000000..1dce1956c8 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-case-messaging.form.json @@ -0,0 +1,33 @@ +{ + "display": "form", + "components": [ + { + "label": "HTML", + "content": "Demo case for case-scoped messaging. After starting: use StartCase review to get a building block waiting for a message, then StartSend review-approved message to let it continue. StartRequest case notification starts a building block that has no start form at all.", + "refreshOnChange": false, + "key": "html", + "type": "htmlelement", + "input": false, + "tableView": false + }, + { + "label": "Subject", + "placeholder": "What is this case about?", + "tableView": true, + "validate": { + "required": true + }, + "key": "subject", + "type": "textfield", + "input": true + }, + { + "type": "button", + "label": "Start", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ] +} diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json new file mode 100644 index 0000000000..13c659e118 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-request-case-notification.form.json @@ -0,0 +1,22 @@ +{ + "display": "form", + "components": [ + { + "label": "HTML", + "content": "Sends the case-notification-requested message to this case. The Case notification building block is linked to this case definition and its main process starts with a message start event, so a new instance is created — even though it has no start form and is not in this Start menu.", + "refreshOnChange": false, + "key": "html", + "type": "htmlelement", + "input": false, + "tableView": false + }, + { + "type": "button", + "label": "Request notification", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ] +} diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json new file mode 100644 index 0000000000..9b785f8a36 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/form/start-form-send-review-approved.form.json @@ -0,0 +1,22 @@ +{ + "display": "form", + "components": [ + { + "label": "HTML", + "content": "Sends the review-approved message to every running process of this case. A Case review building block that is waiting for it continues, writes the approvedBy variable that travelled with the message into its own document, and syncs that value back to reviewedBy on this case.", + "refreshOnChange": false, + "key": "html", + "type": "htmlelement", + "input": false, + "tableView": false + }, + { + "type": "button", + "label": "Send message", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ] +} diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json new file mode 100644 index 0000000000..405e98fb7b --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-document-link/case-messaging.process-document-link.json @@ -0,0 +1,17 @@ +[ + { + "processDefinitionKey": "case-messaging", + "canInitializeDocument": true, + "startableByUser": true + }, + { + "processDefinitionKey": "send-review-approved", + "canInitializeDocument": false, + "startableByUser": true + }, + { + "processDefinitionKey": "request-case-notification", + "canInitializeDocument": false, + "startableByUser": true + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json new file mode 100644 index 0000000000..1eabcfb6cc --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/case-messaging.process-link.json @@ -0,0 +1,8 @@ +[ + { + "activityId": "start-event", + "activityType": "bpmn:StartEvent:start", + "processLinkType": "form", + "formDefinitionName": "start-form-case-messaging" + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json new file mode 100644 index 0000000000..df3ea76822 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/request-case-notification.process-link.json @@ -0,0 +1,8 @@ +[ + { + "activityId": "start-event", + "activityType": "bpmn:StartEvent:start", + "processLinkType": "form", + "formDefinitionName": "start-form-request-case-notification" + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json new file mode 100644 index 0000000000..0f9b26d585 --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/process-link/send-review-approved.process-link.json @@ -0,0 +1,8 @@ +[ + { + "activityId": "start-event", + "activityType": "bpmn:StartEvent:start", + "processLinkType": "form", + "formDefinitionName": "start-form-send-review-approved" + } +] diff --git a/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json new file mode 100644 index 0000000000..816f962c2e --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/case/case-messaging/1-0-0/startable-item/case-messaging.startable-items.json @@ -0,0 +1,15 @@ +[ + { + "type": "BUILDING_BLOCK", + "key": "case-review", + "versionTag": "1.0.0" + }, + { + "type": "PROCESS", + "key": "send-review-approved" + }, + { + "type": "PROCESS", + "key": "request-case-notification" + } +] diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt index cb71c9afe0..577949820c 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt @@ -52,6 +52,8 @@ import com.ritense.buildingblock.service.BuildingBlockDefinitionImporter import com.ritense.buildingblock.service.BuildingBlockDefinitionMainProcessDefinitionImporter import com.ritense.buildingblock.service.BuildingBlockDefinitionProcessDefinitionService import com.ritense.buildingblock.service.BuildingBlockDocumentDefinitionService +import com.ritense.buildingblock.service.BuildingBlockCaseCorrelationBusinessKeyProvider +import com.ritense.buildingblock.service.BuildingBlockCaseCorrelationStartTargetProvider import com.ritense.buildingblock.service.BuildingBlockFieldService import com.ritense.buildingblock.service.BuildingBlockFormDefinitionExporter import com.ritense.buildingblock.service.BuildingBlockFormDefinitionImporter @@ -100,6 +102,8 @@ import com.ritense.importer.ValtimoImportService import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver import com.ritense.plugin.service.PluginService import com.ritense.processdocument.service.BuildingBlockProcessLookup +import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider +import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider import com.ritense.processdocument.service.ProcessDocumentAssociationService import com.ritense.processlink.exporter.BuildingBlockProcessLinkToBuildingBlockMapper import com.ritense.processlink.mapper.ProcessLinkMapper @@ -371,6 +375,30 @@ class BuildingBlockAutoConfiguration { return BuildingBlockProcessLookupImpl(buildingBlockInstanceRepository) } + @Bean + @ConditionalOnMissingBean(CaseCorrelationBusinessKeyProvider::class) + fun buildingBlockCaseCorrelationBusinessKeyProvider( + buildingBlockInstanceRepository: BuildingBlockInstanceRepository, + ): CaseCorrelationBusinessKeyProvider { + return BuildingBlockCaseCorrelationBusinessKeyProvider(buildingBlockInstanceRepository) + } + + @Bean + @ConditionalOnMissingBean(CaseCorrelationStartTargetProvider::class) + fun buildingBlockCaseCorrelationStartTargetProvider( + documentService: DocumentService, + caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository, + repositoryService: RepositoryService, + ): CaseCorrelationStartTargetProvider { + return BuildingBlockCaseCorrelationStartTargetProvider( + documentService, + caseDefinitionBuildingBlockLinkRepository, + processDefinitionBuildingBlockDefinitionRepository, + repositoryService, + ) + } + @Bean @ConditionalOnMissingBean(BuildingBlockInstanceResource::class) fun buildingBlockInstanceResource( diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt new file mode 100644 index 0000000000..1337ec1451 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository +import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider +import java.util.UUID + +/** + * Makes the building-block instances of a case reachable for case-scoped message correlation. + * A building-block process instance runs under its own document id as business key, so the case + * business key alone never reaches it. + */ +class BuildingBlockCaseCorrelationBusinessKeyProvider( + private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository, +) : CaseCorrelationBusinessKeyProvider { + + override fun getBusinessKeysForCase(caseDocumentId: UUID): List { + return buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId) + .map { it.documentId.toString() } + } +} diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt new file mode 100644 index 0000000000..0ce39267d2 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProvider.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository +import com.ritense.document.service.DocumentService +import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider +import java.util.UUID +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.model.bpmn.instance.EventDefinition +import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition +import org.operaton.bpm.model.bpmn.instance.StartEvent + +/** + * Makes the building blocks linked to a case definition startable by message. Only the main process + * of a link is considered, and always in the version the link pins — engine-level start correlation + * on a process definition key would pick the latest deployed version instead, which could start a + * building block in a version the case does not use. + */ +class BuildingBlockCaseCorrelationStartTargetProvider( + private val documentService: DocumentService, + private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + private val processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository, + private val repositoryService: RepositoryService, +) : CaseCorrelationStartTargetProvider { + + override fun getStartTargets(caseDocumentId: UUID, message: String): List { + val caseDefinitionId = runWithoutAuthorization { + documentService.get(caseDocumentId.toString()) + }.definitionId().caseDefinitionId() + + return caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId) + .mapNotNull { link -> + processDefinitionBuildingBlockDefinitionRepository + .findByIdBuildingBlockDefinitionIdAndMain(link.buildingBlockDefinitionId, true) + ?.id?.processDefinitionId?.id + } + .filter { hasMessageStartEvent(it, message) } + } + + private fun hasMessageStartEvent(processDefinitionId: String, message: String): Boolean { + val bpmnModel = repositoryService.getBpmnModelInstance(processDefinitionId) ?: return false + return bpmnModel.getModelElementsByType(StartEvent::class.java).any { startEvent -> + startEvent.getChildElementsByType(EventDefinition::class.java) + .filterIsInstance() + .any { it.message?.name == message } + } + } +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt new file mode 100644 index 0000000000..86bdf2b2e0 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationBusinessKeyProviderTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.buildingblock.domain.instance.BuildingBlockInstance +import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.UUID + +class BuildingBlockCaseCorrelationBusinessKeyProviderTest { + + private lateinit var buildingBlockInstanceRepository: BuildingBlockInstanceRepository + private lateinit var provider: BuildingBlockCaseCorrelationBusinessKeyProvider + + private val caseDocumentId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp() { + buildingBlockInstanceRepository = mock() + provider = BuildingBlockCaseCorrelationBusinessKeyProvider(buildingBlockInstanceRepository) + } + + @Test + fun `should return the document id of every building block instance of the case`() { + val documentIdOne = UUID.randomUUID() + val documentIdTwo = UUID.randomUUID() + val instances = listOf(instanceWithDocumentId(documentIdOne), instanceWithDocumentId(documentIdTwo)) + whenever(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).thenReturn(instances) + + val businessKeys = provider.getBusinessKeysForCase(caseDocumentId) + + assertThat(businessKeys).containsExactly(documentIdOne.toString(), documentIdTwo.toString()) + } + + @Test + fun `should return no business keys when the case has no building block instances`() { + whenever(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).thenReturn(emptyList()) + + assertThat(provider.getBusinessKeysForCase(caseDocumentId)).isEmpty() + } + + private fun instanceWithDocumentId(documentId: UUID): BuildingBlockInstance { + val instance = mock() + whenever(instance.documentId).thenReturn(documentId) + return instance + } +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt new file mode 100644 index 0000000000..961c792d6d --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockCaseCorrelationStartTargetProviderTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository +import com.ritense.document.domain.Document +import com.ritense.document.service.DocumentService +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.model.bpmn.Bpmn +import org.operaton.bpm.model.bpmn.BpmnModelInstance +import org.operaton.bpm.model.bpmn.instance.Message +import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition +import org.operaton.bpm.model.bpmn.instance.StartEvent +import java.util.UUID + +class BuildingBlockCaseCorrelationStartTargetProviderTest { + + private lateinit var documentService: DocumentService + private lateinit var caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository + private lateinit var processDefinitionBuildingBlockDefinitionRepository: + ProcessDefinitionBuildingBlockDefinitionRepository + private lateinit var repositoryService: RepositoryService + private lateinit var provider: BuildingBlockCaseCorrelationStartTargetProvider + + private val caseDocumentId: UUID = UUID.randomUUID() + private val caseDefinitionId: CaseDefinitionId = CaseDefinitionId.of("bb-case", "1.0.0") + private val buildingBlockDefinitionId: BuildingBlockDefinitionId = + BuildingBlockDefinitionId.of("notify", "1.0.0") + private val processDefinitionId = "notify-process:1:${UUID.randomUUID()}" + + @BeforeEach + fun setUp() { + documentService = mock() + caseDefinitionBuildingBlockLinkRepository = mock() + processDefinitionBuildingBlockDefinitionRepository = mock() + repositoryService = mock() + + provider = BuildingBlockCaseCorrelationStartTargetProvider( + documentService, + caseDefinitionBuildingBlockLinkRepository, + processDefinitionBuildingBlockDefinitionRepository, + repositoryService, + ) + + val document = mock(defaultAnswer = RETURNS_DEEP_STUBS) + whenever(document.definitionId().caseDefinitionId()).thenReturn(caseDefinitionId) + whenever(documentService.get(caseDocumentId.toString())).thenReturn(document) + } + + @Test + fun `should return the pinned main process definition when it declares the message start event`() { + linkBuildingBlock() + mockMainProcessDefinition(processDefinitionId) + mockBpmnModel(processDefinitionId, messageStartEventName = MESSAGE) + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).containsExactly(processDefinitionId) + } + + @Test + fun `should not return a main process that declares a different message start event`() { + linkBuildingBlock() + mockMainProcessDefinition(processDefinitionId) + mockBpmnModel(processDefinitionId, messageStartEventName = "some-other-message") + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty() + } + + @Test + fun `should not return a main process without a message start event`() { + linkBuildingBlock() + mockMainProcessDefinition(processDefinitionId) + mockBpmnModel(processDefinitionId, messageStartEventName = null) + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty() + } + + @Test + fun `should skip building blocks without a main process definition`() { + linkBuildingBlock() + whenever( + processDefinitionBuildingBlockDefinitionRepository + .findByIdBuildingBlockDefinitionIdAndMain(eq(buildingBlockDefinitionId), eq(true)) + ).thenReturn(null) + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty() + } + + @Test + fun `should return no targets when the case definition has no building block links`() { + whenever(caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId)) + .thenReturn(emptyList()) + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty() + } + + @Test + fun `should skip a main process whose bpmn model cannot be read`() { + linkBuildingBlock() + mockMainProcessDefinition(processDefinitionId) + whenever(repositoryService.getBpmnModelInstance(processDefinitionId)).thenReturn(null) + + assertThat(provider.getStartTargets(caseDocumentId, MESSAGE)).isEmpty() + } + + private fun linkBuildingBlock() { + val link = CaseDefinitionBuildingBlockLink( + caseDefinitionId = caseDefinitionId, + buildingBlockDefinitionId = buildingBlockDefinitionId, + inputMappings = emptyList(), + outputMappings = emptyList(), + ) + whenever(caseDefinitionBuildingBlockLinkRepository.findAllByCaseDefinitionId(caseDefinitionId)) + .thenReturn(listOf(link)) + } + + private fun mockMainProcessDefinition(processDefinitionId: String) { + val mainProcessDefinition = ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of(processDefinitionId), + buildingBlockDefinitionId = buildingBlockDefinitionId, + ), + main = true, + ) + whenever( + processDefinitionBuildingBlockDefinitionRepository + .findByIdBuildingBlockDefinitionIdAndMain(eq(buildingBlockDefinitionId), eq(true)) + ).thenReturn(mainProcessDefinition) + } + + private fun mockBpmnModel(processDefinitionId: String, messageStartEventName: String?) { + whenever(repositoryService.getBpmnModelInstance(processDefinitionId)) + .thenReturn(bpmnModel(messageStartEventName)) + } + + private fun bpmnModel(messageStartEventName: String?): BpmnModelInstance { + val model = Bpmn.createExecutableProcess("notify-process") + .startEvent("start") + .endEvent() + .done() + if (messageStartEventName != null) { + val startEvent = model.getModelElementById("start") + val message = model.newInstance(Message::class.java) + message.name = messageStartEventName + model.definitions.addChildElement(message) + val messageEventDefinition = model.newInstance(MessageEventDefinition::class.java) + messageEventDefinition.message = message + startEvent.addChildElement(messageEventDefinition) + } + return model + } + + private companion object { + private const val MESSAGE = "case-notification" + } +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt new file mode 100644 index 0000000000..7cb4ee1581 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockMessageStartIT.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.buildingblock.BaseIntegrationTest +import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId +import com.ritense.buildingblock.processlink.domain.BuildingBlockInputMapping +import com.ritense.buildingblock.processlink.domain.BuildingBlockOutputMapping +import com.ritense.buildingblock.processlink.domain.BuildingBlockSyncTiming +import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.document.service.DocumentService +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processdocument.service.CaseCorrelationService +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.operaton.bpm.engine.RepositoryService +import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Integration test for starting building blocks by message. A case pins a specific building-block + * version, so [CaseCorrelationService] starts the main process definition the link points at rather + * than letting the engine pick the latest deployed version of the process definition key. + */ +@Transactional +class BuildingBlockMessageStartIT @Autowired constructor( + private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository, + private val caseCorrelationService: CaseCorrelationService, + private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + private val documentService: DocumentService, + private val objectMapper: ObjectMapper, + private val processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository, + private val repositoryService: RepositoryService, + private val runtimeService: RuntimeService, +) : BaseIntegrationTest() { + + @Test + fun `should start a linked building block through its message start event`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument() + + val processInstances = caseCorrelationService.sendStartMessageToCase( + START_MESSAGE, + caseDocumentId.toString(), + mapOf("messagePayload" to "from-case") + ) + + assertThat(processInstances).hasSize(1) + val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single() + assertThat(instance.caseDocumentId).isEqualTo(caseDocumentId) + assertThat(instance.documentId).isNotEqualTo(caseDocumentId) + assertThat(instance.definition.id).isEqualTo(notifyDefinitionId(NOTIFY_VERSION)) + + // The building-block bootstrap rewrote the business key from the case to the BB document. + val runningInstance = runtimeService.createProcessInstanceQuery() + .processInstanceId(instance.processInstanceId!!) + .singleResult() + assertThat(runningInstance.businessKey).isEqualTo(instance.documentId.toString()) + assertThat(runtimeService.getVariable(instance.processInstanceId!!, "messagePayload")) + .isEqualTo("from-case") + } + + @Test + fun `should apply the input mappings of the case definition link to the new building block document`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument(firstName = "Jan") + + caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString()) + + val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single() + val buildingBlockDocument = runWithoutAuthorization { + documentService.get(instance.documentId.toString()) + } as JsonSchemaDocument + assertThat(buildingBlockDocument.content().asJson().get("notificationText").asText()).isEqualTo("Jan") + } + + @Test + fun `should sync the output mappings back to the case when the started building block ends`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument(firstName = "Jan") + + caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString()) + + val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single() + runWithoutAuthorization { + runtimeService.correlateMessage(READY_MESSAGE, instance.documentId.toString()) + } + + val caseDocument = runWithoutAuthorization { + documentService.get(caseDocumentId.toString()) + } as JsonSchemaDocument + assertThat(caseDocument.content().asJson().get("resultFromBb").asText()).isEqualTo("Jan") + } + + @Test + fun `should start the building block version the case link pins, not the latest deployed one`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument() + val pinnedProcessDefinitionId = notifyProcessDefinitionId(NOTIFY_VERSION) + val latestProcessDefinitionId = notifyProcessDefinitionId(NOTIFY_NEWER_VERSION) + assertThat(pinnedProcessDefinitionId).isNotEqualTo(latestProcessDefinitionId) + + val processInstances = caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString()) + + assertThat(processInstances).hasSize(1) + assertThat(processInstances.single().processDefinitionId).isEqualTo(pinnedProcessDefinitionId) + } + + @Test + fun `should not start anything for a message no linked building block declares`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument() + + val processInstances = caseCorrelationService.sendStartMessageToCase( + "message-nobody-declares", + caseDocumentId.toString() + ) + + assertThat(processInstances).isEmpty() + assertThat(buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId)).isEmpty() + assertThat(runningInstanceCount(NOTIFY_PROCESS_KEY)).isZero() + } + + @Test + fun `should not start a linked building block whose main process has no message start event`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + linkBezwaarBuildingBlock() + val caseDocumentId = createCaseDocument() + + caseCorrelationService.sendStartMessageToCase(START_MESSAGE, caseDocumentId.toString()) + + val instances = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId) + assertThat(instances).hasSize(1) + assertThat(instances.single().definition.id.key).isEqualTo(NOTIFY_KEY) + assertThat(runningInstanceCount(BEZWAAR_PROCESS_KEY)).isZero() + } + + @Test + fun `should start a building block from a case process through the bpmn expression`() { + linkNotifyBuildingBlock(NOTIFY_VERSION) + val caseDocumentId = createCaseDocument() + + runWithoutAuthorization { + runtimeService.startProcessInstanceByKey( + SENDER_PROCESS_KEY, + caseDocumentId.toString(), + emptyMap() + ) + } + + val instance = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).single() + assertThat(instance.definition.id).isEqualTo(notifyDefinitionId(NOTIFY_VERSION)) + } + + private fun linkNotifyBuildingBlock(versionTag: String) { + ensureMainProcessLink(notifyDefinitionId(versionTag), NOTIFY_PROCESS_KEY) + caseDefinitionBuildingBlockLinkRepository.save( + CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION), + buildingBlockDefinitionId = notifyDefinitionId(versionTag), + inputMappings = listOf( + BuildingBlockInputMapping(source = "doc:/contact/firstName", target = "/notificationText") + ), + outputMappings = listOf( + BuildingBlockOutputMapping( + source = "doc:/notificationText", + target = "doc:/resultFromBb", + syncTiming = BuildingBlockSyncTiming.END + ) + ), + ) + ) + } + + private fun linkBezwaarBuildingBlock() { + val bezwaarDefinitionId = BuildingBlockDefinitionId.of(BEZWAAR_KEY, BEZWAAR_VERSION) + ensureMainProcessLink(bezwaarDefinitionId, BEZWAAR_PROCESS_KEY) + caseDefinitionBuildingBlockLinkRepository.save( + CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION), + buildingBlockDefinitionId = bezwaarDefinitionId, + inputMappings = emptyList(), + outputMappings = emptyList(), + ) + ) + } + + /** The notify link maps `contact.firstName` into the BB document, which requires a string. */ + private fun createCaseDocument(firstName: String = "Jan"): UUID { + val content = objectMapper.createObjectNode() + content.putObject("contact").put("firstName", firstName) + return runWithoutAuthorization { + documentService.createDocument( + NewDocumentRequest( + CASE_DEFINITION_KEY, + CASE_DEFINITION_KEY, + CASE_DEFINITION_VERSION, + content as ObjectNode + ) + ).resultingDocument() + .orElseThrow { IllegalStateException("Case document not created") } + .id() + .getId() + } + } + + private fun notifyDefinitionId(versionTag: String) = BuildingBlockDefinitionId.of(NOTIFY_KEY, versionTag) + + /** + * Recreates the main-process link of a deployed building block when it is missing. Other + * (non-transactional) integration tests in this module clear + * `process_definition_building_block_definition`, so the deployed state cannot be relied upon. + */ + private fun ensureMainProcessLink( + buildingBlockDefinitionId: BuildingBlockDefinitionId, + processDefinitionKey: String + ) { + if ( + processDefinitionBuildingBlockDefinitionRepository + .findByIdBuildingBlockDefinitionIdAndMain(buildingBlockDefinitionId, true) != null + ) { + return + } + processDefinitionBuildingBlockDefinitionRepository.save( + ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of( + processDefinitionId(buildingBlockDefinitionId, processDefinitionKey) + ), + buildingBlockDefinitionId = buildingBlockDefinitionId, + ), + main = true, + ) + ) + } + + private fun notifyProcessDefinitionId(versionTag: String): String = + processDefinitionId(notifyDefinitionId(versionTag), NOTIFY_PROCESS_KEY) + + private fun processDefinitionId( + buildingBlockDefinitionId: BuildingBlockDefinitionId, + processDefinitionKey: String + ): String { + val versionTag = "BB:${buildingBlockDefinitionId.key}:${buildingBlockDefinitionId.versionTag}" + return repositoryService.createProcessDefinitionQuery() + .processDefinitionKey(processDefinitionKey) + .versionTag(versionTag) + .orderByProcessDefinitionVersion() + .desc() + .list() + .firstOrNull() + ?.id + ?: throw IllegalStateException("Process '$processDefinitionKey' ($versionTag) not deployed") + } + + private fun runningInstanceCount(processDefinitionKey: String): Int { + return runtimeService.createProcessInstanceQuery() + .processDefinitionKey(processDefinitionKey) + .list() + .size + } + + companion object { + private const val CASE_DEFINITION_KEY = "bb-case" + private const val CASE_DEFINITION_VERSION = "1.0.0" + private const val NOTIFY_KEY = "notify" + private const val NOTIFY_VERSION = "1.0.0" + private const val NOTIFY_NEWER_VERSION = "1.1.0" + private const val NOTIFY_PROCESS_KEY = "notify-process" + private const val BEZWAAR_KEY = "bezwaar" + private const val BEZWAAR_VERSION = "1.0.0" + private const val BEZWAAR_PROCESS_KEY = "building-block-process" + private const val SENDER_PROCESS_KEY = "case-start-message-sender" + private const val START_MESSAGE = "case-notification" + private const val READY_MESSAGE = "test-ready" + } +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt new file mode 100644 index 0000000000..309529b6cc --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/CaseCorrelationBuildingBlockIT.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.buildingblock.BaseIntegrationTest +import com.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink +import com.ritense.buildingblock.domain.instance.BuildingBlockInstance +import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.document.service.DocumentService +import com.ritense.processdocument.repository.ProcessDocumentInstanceRepository +import com.ritense.processdocument.service.CaseCorrelationService +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.operaton.bpm.engine.HistoryService +import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Integration test for case-scoped message correlation towards building blocks. A building-block + * process instance runs under its own document id as business key, so the case business key alone + * never reaches it — [CaseCorrelationService] fans the message out over the case and all of its + * building-block instances. + */ +@Transactional +class CaseCorrelationBuildingBlockIT @Autowired constructor( + private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository, + private val caseCorrelationService: CaseCorrelationService, + private val caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + private val documentService: DocumentService, + private val objectMapper: ObjectMapper, + private val historyService: HistoryService, + private val processDocumentInstanceRepository: ProcessDocumentInstanceRepository, + private val runtimeService: RuntimeService, +) : BaseIntegrationTest() { + + @Test + fun `should deliver a message to the running building block of a case`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instance = startAdHocBuildingBlock(caseDocumentId) + + val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString()) + + assertThat(results).hasSize(1) + assertThat(isRunning(instance.processInstanceId!!)).isFalse() + } + + @Test + fun `should deliver a message to every building block instance of a case`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instanceOne = startAdHocBuildingBlock(caseDocumentId) + val instanceTwo = startAdHocBuildingBlock(caseDocumentId) + assertThat(instanceOne.documentId).isNotEqualTo(instanceTwo.documentId) + + val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString()) + + assertThat(results).hasSize(2) + assertThat(isRunning(instanceOne.processInstanceId!!)).isFalse() + assertThat(isRunning(instanceTwo.processInstanceId!!)).isFalse() + } + + @Test + fun `should not deliver a message to building blocks of another case`() { + linkBuildingBlockToCaseDefinition() + val targetCaseDocumentId = createCaseDocument() + val otherCaseDocumentId = createCaseDocument() + val targetInstance = startAdHocBuildingBlock(targetCaseDocumentId) + val otherInstance = startAdHocBuildingBlock(otherCaseDocumentId) + + val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, targetCaseDocumentId.toString()) + + assertThat(results).hasSize(1) + assertThat(isRunning(targetInstance.processInstanceId!!)).isFalse() + assertThat(isRunning(otherInstance.processInstanceId!!)).isTrue() + } + + @Test + fun `should accept a building block document id and resolve it to the owning case`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instance = startAdHocBuildingBlock(caseDocumentId) + + val results = caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, instance.documentId.toString()) + + assertThat(results).hasSize(1) + assertThat(isRunning(instance.processInstanceId!!)).isFalse() + } + + @Test + fun `should set variables on the receiving building block process`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instance = startAdHocBuildingBlock(caseDocumentId) + val processInstanceId = instance.processInstanceId!! + + caseCorrelationService.sendCatchEventMessageToCase( + TEST_MESSAGE, + caseDocumentId.toString(), + mapOf("messagePayload" to "from-case") + ) + + val variable = historyService.createHistoricVariableInstanceQuery() + .processInstanceId(processInstanceId) + .variableName("messagePayload") + .singleResult() + assertThat(variable?.value).isEqualTo("from-case") + } + + @Test + fun `should not associate a building block process with the case document`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instance = startAdHocBuildingBlock(caseDocumentId) + + caseCorrelationService.sendCatchEventMessageToCase(TEST_MESSAGE, caseDocumentId.toString()) + + val caseAssociations = processDocumentInstanceRepository + .findAllByProcessDocumentInstanceIdDocumentId(JsonSchemaDocumentId.existingId(caseDocumentId)) + assertThat(caseAssociations.map { it.processDocumentInstanceId().processInstanceId().toString() }) + .doesNotContain(instance.processInstanceId) + } + + @Test + fun `should return an empty result when nothing in the case is subscribed`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + startAdHocBuildingBlock(caseDocumentId) + + val results = caseCorrelationService.sendCatchEventMessageToCase("no-one-listens-to-this", caseDocumentId.toString()) + + assertThat(results).isEmpty() + } + + @Test + fun `should deliver a message thrown from a case process through the bpmn expression`() { + linkBuildingBlockToCaseDefinition() + val caseDocumentId = createCaseDocument() + val instance = startAdHocBuildingBlock(caseDocumentId) + + runWithoutAuthorization { + runtimeService.startProcessInstanceByKey( + SENDER_PROCESS_KEY, + caseDocumentId.toString(), + emptyMap() + ) + } + + assertThat(isRunning(instance.processInstanceId!!)).isFalse() + } + + private fun linkBuildingBlockToCaseDefinition() { + caseDefinitionBuildingBlockLinkRepository.save( + CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION), + buildingBlockDefinitionId = BuildingBlockDefinitionId.of(BUILDING_BLOCK_KEY, BUILDING_BLOCK_VERSION), + inputMappings = emptyList(), + outputMappings = emptyList(), + ) + ) + } + + private fun createCaseDocument(): UUID { + return runWithoutAuthorization { + documentService.createDocument( + NewDocumentRequest( + CASE_DEFINITION_KEY, + CASE_DEFINITION_KEY, + CASE_DEFINITION_VERSION, + objectMapper.createObjectNode() + ) + ).resultingDocument() + .orElseThrow { IllegalStateException("Case document not created") } + .id() + .getId() + } + } + + /** Starts the building block as a case action and returns its freshly created instance. */ + private fun startAdHocBuildingBlock(caseDocumentId: UUID): BuildingBlockInstance { + val known = buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId).map { it.id } + runWithoutAuthorization { + runtimeService.startProcessInstanceByKey( + BUILDING_BLOCK_PROCESS_KEY, + caseDocumentId.toString(), + emptyMap() + ) + } + return buildingBlockInstanceRepository.findAllByCaseDocumentId(caseDocumentId) + .single { it.id !in known } + } + + private fun isRunning(processInstanceId: String): Boolean { + return runtimeService.createProcessInstanceQuery() + .processInstanceId(processInstanceId) + .singleResult() != null + } + + companion object { + private const val BUILDING_BLOCK_KEY = "bezwaar" + private const val BUILDING_BLOCK_VERSION = "1.0.0" + private const val CASE_DEFINITION_KEY = "bb-case" + private const val CASE_DEFINITION_VERSION = "1.0.0" + private const val BUILDING_BLOCK_PROCESS_KEY = "building-block-process" + private const val SENDER_PROCESS_KEY = "case-message-sender" + private const val TEST_MESSAGE = "test-ready" + } +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn new file mode 100644 index 0000000000..61ee0bf85e --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/bpmn/notify-process.bpmn @@ -0,0 +1,48 @@ + + + + + Flow_1 + + + + + Flow_1 + Flow_2 + + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json new file mode 100644 index 0000000000..52806b9755 --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/building-block-definition-main-process-definition.json @@ -0,0 +1,3 @@ +{ + "processDefinitionKey": "notify-process" +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json new file mode 100644 index 0000000000..6bb756191b --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/building-block/definition/notify.building-block-definition.json @@ -0,0 +1,6 @@ +{ + "key": "notify", + "name": "Notify", + "versionTag": "1.0.0", + "final": true +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json new file mode 100644 index 0000000000..51d256a23e --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-0-0/document/definition/notify.schema.document-definition.json @@ -0,0 +1,12 @@ +{ + "$id": "notify.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Notify", + "type": "object", + "properties": { + "notificationText": { + "type": "string", + "description": "Text of the notification" + } + } +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn new file mode 100644 index 0000000000..994d1e821e --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/bpmn/notify-process.bpmn @@ -0,0 +1,48 @@ + + + + + Flow_1 + + + + + Flow_1 + Flow_2 + + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json new file mode 100644 index 0000000000..52806b9755 --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/building-block-definition-main-process-definition.json @@ -0,0 +1,3 @@ +{ + "processDefinitionKey": "notify-process" +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json new file mode 100644 index 0000000000..24026c523e --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/building-block/definition/notify.building-block-definition.json @@ -0,0 +1,6 @@ +{ + "key": "notify", + "name": "Notify", + "versionTag": "1.1.0", + "final": true +} diff --git a/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json new file mode 100644 index 0000000000..51d256a23e --- /dev/null +++ b/backend/building-block/src/test/resources/config/building-block/notify/1-1-0/document/definition/notify.schema.document-definition.json @@ -0,0 +1,12 @@ +{ + "$id": "notify.schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Notify", + "type": "object", + "properties": { + "notificationText": { + "type": "string", + "description": "Text of the notification" + } + } +} diff --git a/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn new file mode 100644 index 0000000000..dacaf9c533 --- /dev/null +++ b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-message-sender.bpmn @@ -0,0 +1,42 @@ + + + + + Flow_1 + + + Flow_1 + Flow_2 + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn new file mode 100644 index 0000000000..0bc03f9fb7 --- /dev/null +++ b/backend/building-block/src/test/resources/config/case/bb-case/1-0-0/bpmn/case-start-message-sender.bpmn @@ -0,0 +1,42 @@ + + + + + Flow_1 + + + Flow_1 + Flow_2 + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt index bba134424f..3bc01cde6f 100644 --- a/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/autoconfigure/ProcessDocumentsAutoConfiguration.kt @@ -43,6 +43,10 @@ import com.ritense.processdocument.repository.OperatonProcessDefinitionCaseDefin import com.ritense.processdocument.repository.ProcessDefinitionCaseDefinitionRepository import com.ritense.processdocument.repository.ProcessDocumentInstanceRepository import com.ritense.processdocument.repository.TaskQuickSearchRepository +import com.ritense.processdocument.service.CaseCorrelationBusinessKeyProvider +import com.ritense.processdocument.service.CaseCorrelationService +import com.ritense.processdocument.service.CaseCorrelationServiceImpl +import com.ritense.processdocument.service.CaseCorrelationStartTargetProvider import com.ritense.processdocument.service.CaseDefinitionProcessLinkService import com.ritense.processdocument.service.CaseTaskListSearchService import com.ritense.processdocument.service.CorrelationService @@ -164,6 +168,25 @@ class ProcessDocumentsAutoConfiguration { ) } + @ProcessBean + @Bean + @ConditionalOnMissingBean(CaseCorrelationService::class) + fun caseCorrelationService( + runtimeService: RuntimeService, + correlationService: CorrelationService, + caseDocumentResolver: CaseDocumentResolver, + caseCorrelationBusinessKeyProviders: List, + caseCorrelationStartTargetProviders: List, + ): CaseCorrelationService { + return CaseCorrelationServiceImpl( + runtimeService = runtimeService, + correlationService = correlationService, + caseDocumentResolver = caseDocumentResolver, + businessKeyProviders = caseCorrelationBusinessKeyProviders, + startTargetProviders = caseCorrelationStartTargetProviders, + ) + } + @ProcessBean @Bean("processService") @ConditionalOnMissingBean(ProcessDocumentsService::class) diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt new file mode 100644 index 0000000000..ba154347cb --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationBusinessKeyProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import java.util.UUID + +/** + * Extension point that contributes business keys of additional process instances belonging to a + * case, e.g. building-block instances that run under their own document id. Used by + * [CaseCorrelationService] to fan a message out over every process instance of a case. + * + * Implemented by the building-block module when present; absent when an app does not include it. + */ +fun interface CaseCorrelationBusinessKeyProvider { + + /** + * Returns the business keys of the process instances this provider knows about for the given + * case, excluding the case's own processes (those already use the case document id). + */ + fun getBusinessKeysForCase(caseDocumentId: UUID): List +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt new file mode 100644 index 0000000000..3820b6d0e8 --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationService.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.operaton.bpm.engine.runtime.MessageCorrelationResult +import org.operaton.bpm.engine.runtime.ProcessInstance + +/** + * Sends BPMN messages to every process instance that belongs to a case: the case's own processes + * and every building-block instance of that case. + * + * Where [CorrelationService] correlates on a single business key, this service resolves the case a + * message is meant for and fans the message out over all business keys of that case. This makes + * building blocks — which run under their own document id as business key — reachable from case + * processes and vice versa. + * + * Available in BPMN as `caseCorrelationService`, e.g. + * `${caseCorrelationService.sendCatchEventMessageToCase("income-verified", execution)}`. + */ +interface CaseCorrelationService { + + /** + * Sends [message] to all subscribed process instances of the case the [execution] belongs to. + * + * @throws IllegalStateException when the current case cannot be determined from the execution. + */ + fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution + ): List + + /** + * Sends [message] to all subscribed process instances of the case the [execution] belongs to, + * with [variables] as alternating name/value pairs. + */ + fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution, + vararg variables: Any? + ): List + + /** + * Sends [message] to all subscribed process instances of the case the [execution] belongs to, + * with [variables] set on the receiving process instances. + */ + fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution, + variables: Map? + ): List + + /** + * Sends [message] to all subscribed process instances of the case identified by + * [caseDocumentId], for example a related case. A building-block document id is accepted too + * and is normalized to the case it belongs to. + * + * @throws IllegalArgumentException when [caseDocumentId] is not a valid document id. + */ + fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String + ): List + + /** + * Sends [message] to all subscribed process instances of the case identified by + * [caseDocumentId], with [variables] as alternating name/value pairs. + */ + fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String, + vararg variables: Any? + ): List + + /** + * Sends [message] to all subscribed process instances of the case identified by + * [caseDocumentId], with [variables] set on the receiving process instances. + */ + fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String, + variables: Map? + ): List + + /** + * Starts a new instance of every building block that is linked to the case the [execution] + * belongs to and whose main process declares a message start event named [message]. The + * building block is started in the version the case link pins, not the latest deployed one. + * + * The returned process instances carry the case business key as it was at start time; the + * building-block bootstrap rewrites it to the new building-block document id within the same + * transaction. + * + * @throws IllegalStateException when the current case cannot be determined from the execution. + */ + fun sendStartMessageToCase( + message: String, + execution: DelegateExecution + ): List + + /** + * Starts the matching building blocks of the case the [execution] belongs to, with [variables] + * as alternating name/value pairs. + */ + fun sendStartMessageToCase( + message: String, + execution: DelegateExecution, + vararg variables: Any? + ): List + + /** + * Starts the matching building blocks of the case the [execution] belongs to, with [variables] + * set on the started process instances. + */ + fun sendStartMessageToCase( + message: String, + execution: DelegateExecution, + variables: Map? + ): List + + /** + * Starts the matching building blocks of the case identified by [caseDocumentId], for example a + * related case. A building-block document id is accepted too and is normalized to the case it + * belongs to. + * + * @throws IllegalArgumentException when [caseDocumentId] is not a valid document id. + */ + fun sendStartMessageToCase( + message: String, + caseDocumentId: String + ): List + + /** + * Starts the matching building blocks of the case identified by [caseDocumentId], with + * [variables] as alternating name/value pairs. + */ + fun sendStartMessageToCase( + message: String, + caseDocumentId: String, + vararg variables: Any? + ): List + + /** + * Starts the matching building blocks of the case identified by [caseDocumentId], with + * [variables] set on the started process instances. + */ + fun sendStartMessageToCase( + message: String, + caseDocumentId: String, + variables: Map? + ): List +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt new file mode 100644 index 0000000000..5167693823 --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImpl.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.processdocument.helper.GetJsonSchemaDocumentHelper.getJsonSchemaDocumentIdOrNull +import com.ritense.valtimo.contract.document.CaseDocumentResolver +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.UUID +import org.operaton.bpm.engine.RuntimeService +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.operaton.bpm.engine.runtime.MessageCorrelationResult +import org.operaton.bpm.engine.runtime.ProcessInstance + +class CaseCorrelationServiceImpl( + private val runtimeService: RuntimeService, + private val correlationService: CorrelationService, + private val caseDocumentResolver: CaseDocumentResolver, + private val businessKeyProviders: List, + private val startTargetProviders: List, +) : CaseCorrelationService { + + override fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution + ): List { + return sendCatchEventMessageToCase(message, execution, null as Map?) + } + + override fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution, + vararg variables: Any? + ): List { + return sendCatchEventMessageToCase(message, execution, toVariableMap(*variables)) + } + + override fun sendCatchEventMessageToCase( + message: String, + execution: DelegateExecution, + variables: Map? + ): List { + return sendCatchEventMessageToCase(message, resolveCaseDocumentId(execution), variables) + } + + override fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String + ): List { + return sendCatchEventMessageToCase(message, caseDocumentId, null as Map?) + } + + override fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String, + vararg variables: Any? + ): List { + return sendCatchEventMessageToCase(message, caseDocumentId, toVariableMap(*variables)) + } + + override fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: String, + variables: Map? + ): List { + return sendCatchEventMessageToCase(message, resolveCaseDocumentId(caseDocumentId), variables) + } + + private fun sendCatchEventMessageToCase( + message: String, + caseDocumentId: UUID, + variables: Map? + ): List { + val caseBusinessKey = caseDocumentId.toString() + + // Delivery to the case's own processes keeps the behaviour of CorrelationService, including + // the ProcessDocumentInstance association. + val caseResults = correlationService.sendCatchEventMessageToAll(message, caseBusinessKey, variables) + + // Building blocks run under their own document id as business key. They get the message too, + // but deliberately without a case association: their processes belong to the BB document. + val additionalResults = businessKeyProviders + .flatMap { it.getBusinessKeysForCase(caseDocumentId) } + .distinct() + .filterNot { it == caseBusinessKey } + .flatMap { correlateAll(message, it, variables) } + + val results = caseResults + additionalResults + if (results.isEmpty()) { + logger.warn { + "No process instance of case '$caseBusinessKey' was subscribed to message '$message'." + } + } + return results + } + + override fun sendStartMessageToCase( + message: String, + execution: DelegateExecution + ): List { + return sendStartMessageToCase(message, execution, null as Map?) + } + + override fun sendStartMessageToCase( + message: String, + execution: DelegateExecution, + vararg variables: Any? + ): List { + return sendStartMessageToCase(message, execution, toVariableMap(*variables)) + } + + override fun sendStartMessageToCase( + message: String, + execution: DelegateExecution, + variables: Map? + ): List { + return sendStartMessageToCase(message, resolveCaseDocumentId(execution), variables) + } + + override fun sendStartMessageToCase( + message: String, + caseDocumentId: String + ): List { + return sendStartMessageToCase(message, caseDocumentId, null as Map?) + } + + override fun sendStartMessageToCase( + message: String, + caseDocumentId: String, + vararg variables: Any? + ): List { + return sendStartMessageToCase(message, caseDocumentId, toVariableMap(*variables)) + } + + override fun sendStartMessageToCase( + message: String, + caseDocumentId: String, + variables: Map? + ): List { + return sendStartMessageToCase(message, resolveCaseDocumentId(caseDocumentId), variables) + } + + private fun sendStartMessageToCase( + message: String, + caseDocumentId: UUID, + variables: Map? + ): List { + val processInstances = startTargetProviders + .flatMap { it.getStartTargets(caseDocumentId, message) } + .distinct() + .map { correlateStartMessage(message, caseDocumentId.toString(), it, variables) } + + if (processInstances.isEmpty()) { + logger.warn { + "No process definition linked to case '$caseDocumentId' declares a start event " + + "for message '$message'." + } + } + return processInstances + } + + private fun resolveCaseDocumentId(execution: DelegateExecution): UUID { + val documentId = execution.getJsonSchemaDocumentIdOrNull() + ?: throw IllegalStateException( + "Cannot determine the current case for process instance '${execution.processInstanceId}': " + + "the business key is not a document id. " + + "Use the overload that accepts an explicit case document id." + ) + return resolveCaseDocumentId(documentId) + } + + private fun resolveCaseDocumentId(caseDocumentId: String): UUID { + val documentId = try { + UUID.fromString(caseDocumentId) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Cannot send a message to case '$caseDocumentId': the value is not a valid document id.", e + ) + } + return resolveCaseDocumentId(documentId) + } + + /** + * Resolving the owning case is an internal step of message delivery, so it runs in system + * context — like the document lookups in [CorrelationServiceImpl]. Message correlation itself + * is not permission-checked either, and expressions are also evaluated from async jobs and + * timers, which carry no user context. + */ + private fun resolveCaseDocumentId(documentId: UUID): UUID { + return runWithoutAuthorization { caseDocumentResolver.resolveCaseDocumentId(documentId) } + } + + private fun correlateAll( + message: String, + businessKey: String, + variables: Map? + ): List { + val builder = runtimeService.createMessageCorrelation(message) + builder.processInstanceBusinessKey(businessKey) + variables?.run { builder.setVariables(variables) } + return builder.correlateAllWithResult() + } + + private fun correlateStartMessage( + message: String, + businessKey: String, + processDefinitionId: String, + variables: Map? + ): ProcessInstance { + val builder = runtimeService.createMessageCorrelation(message) + builder.processDefinitionId(processDefinitionId) + builder.processInstanceBusinessKey(businessKey) + variables?.run { builder.setVariables(variables) } + return builder.correlateStartMessage() + } + + private fun toVariableMap(vararg variables: Any?): Map? { + return if (variables.isNotEmpty()) { + (0 until variables.size / 2).associate { i -> variables[i * 2] as String to variables[i * 2 + 1] } + } else { + null + } + } + + private companion object { + private val logger = KotlinLogging.logger {} + } +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt new file mode 100644 index 0000000000..10cf710818 --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/CaseCorrelationStartTargetProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import java.util.UUID + +/** + * Extension point that contributes process definitions which may be started by message for a case, + * e.g. the main processes of the building blocks linked to the case definition. + * + * Targets are returned as concrete process definition ids rather than keys: a case pins a specific + * building-block version, and engine-level start correlation would always pick the latest deployed + * version of a key. + * + * Implemented by the building-block module when present; absent when an app does not include it. + */ +fun interface CaseCorrelationStartTargetProvider { + + /** + * Returns the ids of the process definitions that declare a message start event named [message] + * and may be started for the given case. + */ + fun getStartTargets(caseDocumentId: UUID, message: String): List +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt new file mode 100644 index 0000000000..a4ddde8599 --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceImplTest.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import com.ritense.valtimo.contract.document.CaseDocumentResolutionException +import com.ritense.valtimo.contract.document.CaseDocumentResolver +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.RuntimeService +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.operaton.bpm.engine.runtime.MessageCorrelationBuilder +import org.operaton.bpm.engine.runtime.MessageCorrelationResult +import org.operaton.bpm.engine.runtime.ProcessInstance +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +class CaseCorrelationServiceImplTest { + + lateinit var runtimeService: RuntimeService + lateinit var correlationService: CorrelationService + lateinit var caseDocumentResolver: CaseDocumentResolver + lateinit var builder: MessageCorrelationBuilder + + val messageName = "test-message" + val caseDocumentId: UUID = UUID.randomUUID() + val buildingBlockDocumentId: UUID = UUID.randomUUID() + val processInstanceId: String = UUID.randomUUID().toString() + + @BeforeEach + fun setUp() { + runtimeService = mock(defaultAnswer = RETURNS_DEEP_STUBS) + correlationService = mock() + caseDocumentResolver = mock() + + builder = mock() + whenever(runtimeService.createMessageCorrelation(any())).thenReturn(builder) + whenever(builder.processInstanceBusinessKey(any())).thenReturn(builder) + whenever(builder.setVariables(any())).thenReturn(builder) + whenever(builder.correlateAllWithResult()).thenReturn(emptyList()) + } + + // --- Case resolution from an execution --- + + @Test + fun `should deliver to case and building blocks when execution runs on the case itself`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + val caseResult = mock() + whenever( + correlationService.sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + ).thenReturn(listOf(caseResult)) + val buildingBlockResult = mock() + whenever(builder.correlateAllWithResult()).thenReturn(listOf(buildingBlockResult)) + + val results = service.sendCatchEventMessageToCase(messageName, execution(caseDocumentId.toString())) + + assertEquals(listOf(caseResult, buildingBlockResult), results) + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + verify(builder).processInstanceBusinessKey(buildingBlockDocumentId.toString()) + verify(builder).correlateAllWithResult() + } + + @Test + fun `should resolve owning case when execution runs on a building block document`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(buildingBlockDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + + service.sendCatchEventMessageToCase(messageName, execution(buildingBlockDocumentId.toString())) + + verify(caseDocumentResolver).resolveCaseDocumentId(buildingBlockDocumentId) + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + verify(builder).processInstanceBusinessKey(buildingBlockDocumentId.toString()) + } + + @Test + fun `should throw descriptive exception when the execution has no document business key`() { + val service = caseCorrelationService() + + val exception = assertFailsWith { + service.sendCatchEventMessageToCase(messageName, execution("not-a-uuid")) + } + + assertTrue(exception.message!!.contains(processInstanceId)) + assertTrue(exception.message!!.contains("explicit case document id")) + } + + @Test + fun `should propagate case resolution failures`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(buildingBlockDocumentId)) + .thenThrow(CaseDocumentResolutionException("No resolver available")) + val service = caseCorrelationService() + + assertFailsWith { + service.sendCatchEventMessageToCase(messageName, execution(buildingBlockDocumentId.toString())) + } + } + + // --- Case resolution from an explicit document id --- + + @Test + fun `should throw descriptive exception when the explicit case document id is malformed`() { + val service = caseCorrelationService() + + val exception = assertFailsWith { + service.sendCatchEventMessageToCase(messageName, "not-a-uuid") + } + + assertTrue(exception.message!!.contains("not-a-uuid")) + verify(correlationService, never()).sendCatchEventMessageToAll(any(), any(), anyOrNull>()) + } + + @Test + fun `should normalize an explicit building block document id to its case`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(buildingBlockDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService() + + service.sendCatchEventMessageToCase(messageName, buildingBlockDocumentId.toString()) + + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + } + + // --- Fan-out over providers --- + + @Test + fun `should de-duplicate business keys contributed by multiple providers`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService( + providerReturning(buildingBlockDocumentId.toString()), + providerReturning(buildingBlockDocumentId.toString()) + ) + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString()) + + verify(runtimeService).createMessageCorrelation(messageName) + verify(builder).processInstanceBusinessKey(buildingBlockDocumentId.toString()) + } + + @Test + fun `should not correlate the case business key twice when a provider returns it`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(caseDocumentId.toString())) + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString()) + + verify(runtimeService, never()).createMessageCorrelation(any()) + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + } + + @Test + fun `should deliver to the case only when no providers are registered`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService() + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString()) + + verify(runtimeService, never()).createMessageCorrelation(any()) + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), anyOrNull>() + ) + } + + @Test + fun `should return an empty list without throwing when nothing is subscribed`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + + val results = service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString()) + + assertEquals(emptyList(), results) + } + + // --- Variables --- + + @Test + fun `should pass a variables map to both the case and the building block delivery`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + val variables = mapOf("key1" to "value1" as Any?) + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString(), variables) + + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), eq(variables) + ) + verify(builder).setVariables(variables) + } + + @Test + fun `should convert vararg variables to a map`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString(), "k1", "v1", "k2", 42) + + val expected = mapOf("k1" to "v1", "k2" to 42) + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), eq(expected) + ) + verify(builder).setVariables(expected) + } + + @Test + fun `should convert vararg variables to a map on the execution overload`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + + service.sendCatchEventMessageToCase(messageName, execution(caseDocumentId.toString()), "k1", "v1") + + verify(correlationService).sendCatchEventMessageToAll( + eq(messageName), eq(caseDocumentId.toString()), eq(mapOf("k1" to "v1")) + ) + } + + @Test + fun `should not set variables when none are given`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(providerReturning(buildingBlockDocumentId.toString())) + + service.sendCatchEventMessageToCase(messageName, caseDocumentId.toString()) + + verify(builder, never()).setVariables(any()) + } + + // --- sendStartMessageToCase --- + + @Test + fun `should start every contributed process definition with the case business key`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val processDefinitionIdOne = "notify-process:1:${UUID.randomUUID()}" + val processDefinitionIdTwo = "reminder-process:1:${UUID.randomUUID()}" + val service = caseCorrelationService( + emptyList(), + listOf(startTargetProviderReturning(processDefinitionIdOne, processDefinitionIdTwo)) + ) + val processInstance = mock() + whenever(builder.correlateStartMessage()).thenReturn(processInstance) + + val results = service.sendStartMessageToCase(messageName, caseDocumentId.toString()) + + assertEquals(listOf(processInstance, processInstance), results) + verify(builder).processDefinitionId(processDefinitionIdOne) + verify(builder).processDefinitionId(processDefinitionIdTwo) + verify(builder, times(2)).processInstanceBusinessKey(caseDocumentId.toString()) + verify(builder, times(2)).correlateStartMessage() + } + + @Test + fun `should de-duplicate start targets contributed by multiple providers`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val processDefinitionId = "notify-process:1:${UUID.randomUUID()}" + val service = caseCorrelationService( + emptyList(), + listOf( + startTargetProviderReturning(processDefinitionId), + startTargetProviderReturning(processDefinitionId) + ) + ) + whenever(builder.correlateStartMessage()).thenReturn(mock()) + + val results = service.sendStartMessageToCase(messageName, caseDocumentId.toString()) + + assertEquals(1, results.size) + verify(builder).correlateStartMessage() + } + + @Test + fun `should not start anything when no start target matches the message`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService(emptyList(), listOf(startTargetProviderReturning())) + + val results = service.sendStartMessageToCase(messageName, caseDocumentId.toString()) + + assertEquals(emptyList(), results) + verify(runtimeService, never()).createMessageCorrelation(any()) + } + + @Test + fun `should resolve the current case before starting from an execution`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(buildingBlockDocumentId)).thenReturn(caseDocumentId) + val processDefinitionId = "notify-process:1:${UUID.randomUUID()}" + val service = caseCorrelationService( + emptyList(), + listOf(startTargetProviderReturning(processDefinitionId)) + ) + whenever(builder.correlateStartMessage()).thenReturn(mock()) + + service.sendStartMessageToCase(messageName, execution(buildingBlockDocumentId.toString())) + + verify(builder).processInstanceBusinessKey(caseDocumentId.toString()) + } + + @Test + fun `should throw when the current case cannot be determined for a start message`() { + val service = caseCorrelationService(emptyList(), listOf(startTargetProviderReturning("some-id"))) + + assertFailsWith { + service.sendStartMessageToCase(messageName, execution("not-a-uuid")) + } + } + + @Test + fun `should set variables on started processes`() { + whenever(caseDocumentResolver.resolveCaseDocumentId(caseDocumentId)).thenReturn(caseDocumentId) + val service = caseCorrelationService( + emptyList(), + listOf(startTargetProviderReturning("notify-process:1:${UUID.randomUUID()}")) + ) + whenever(builder.correlateStartMessage()).thenReturn(mock()) + + service.sendStartMessageToCase(messageName, caseDocumentId.toString(), "k1", "v1") + + verify(builder).setVariables(mapOf("k1" to "v1")) + } + + // --- Helper methods --- + + private fun caseCorrelationService( + vararg providers: CaseCorrelationBusinessKeyProvider + ) = caseCorrelationService(providers.toList(), emptyList()) + + private fun caseCorrelationService( + businessKeyProviders: List, + startTargetProviders: List, + ) = CaseCorrelationServiceImpl( + runtimeService = runtimeService, + correlationService = correlationService, + caseDocumentResolver = caseDocumentResolver, + businessKeyProviders = businessKeyProviders, + startTargetProviders = startTargetProviders, + ) + + private fun providerReturning(vararg businessKeys: String) = + CaseCorrelationBusinessKeyProvider { businessKeys.toList() } + + private fun startTargetProviderReturning(vararg processDefinitionIds: String) = + CaseCorrelationStartTargetProvider { _, _ -> processDefinitionIds.toList() } + + private fun execution(businessKey: String): DelegateExecution { + val execution = mock() + whenever(execution.businessKey).thenReturn(businessKey) + whenever(execution.processInstanceId).thenReturn(processInstanceId) + return execution + } +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceIntTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceIntTest.kt new file mode 100644 index 0000000000..4e2369cc59 --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/CaseCorrelationServiceIntTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.processdocument.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.document.domain.Document +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.document.service.DocumentService +import com.ritense.processdocument.BaseIntegrationTest +import com.ritense.processdocument.repository.ProcessDocumentInstanceRepository +import com.ritense.valtimo.operaton.repository.OperatonTaskSpecificationHelper.Companion.byName +import com.ritense.valtimo.service.OperatonTaskService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional + +/** + * Integration test for [CaseCorrelationService] restricted to case processes. The building-block + * module is on the test classpath, so its business-key provider is active and simply contributes + * nothing for these cases — delivery to the case's own processes must keep the behaviour of + * [CorrelationService], including the [com.ritense.processdocument.domain.ProcessDocumentInstance] + * association. + */ +@Transactional +class CaseCorrelationServiceIntTest : BaseIntegrationTest() { + + @Autowired + lateinit var caseCorrelationService: CaseCorrelationService + + @Autowired + lateinit var documentService: DocumentService + + @Autowired + lateinit var objectMapper: ObjectMapper + + @Autowired + lateinit var processDocumentInstanceRepository: ProcessDocumentInstanceRepository + + @Autowired + lateinit var runtimeService: RuntimeService + + @Autowired + lateinit var taskService: OperatonTaskService + + @Test + fun `should only correlate the case that was targeted`() { + val documentOne = createDocument() + val documentTwo = createDocument() + startCatchEventProcessOne(documentOne) + startCatchEventProcessTwo(documentTwo) + + val results = caseCorrelationService.sendCatchEventMessageToCase(MESSAGE, documentOne.id().toString()) + + assertThat(results).hasSize(1) + assertThat(findTask(TASK_ONE)).isNotNull() + assertThat(findTask(TASK_TWO)).isNull() + } + + @Test + fun `should associate the receiving case process with the case document`() { + val document = createDocument() + startCatchEventProcessOne(document) + + caseCorrelationService.sendCatchEventMessageToCase(MESSAGE, document.id().toString()) + + val associations = processDocumentInstanceRepository + .findAllByProcessDocumentInstanceIdDocumentId(JsonSchemaDocumentId.existingId(document.id().id)) + assertThat(associations.map { it.processName() }).containsExactly(PROCESS_ONE_NAME) + } + + @Test + fun `should set variables on the receiving case process`() { + val document = createDocument() + startCatchEventProcessOne(document) + + caseCorrelationService.sendCatchEventMessageToCase( + MESSAGE, + document.id().toString(), + "varName1", + "varValue1" + ) + + val task = findTask(TASK_ONE)!! + val variables = runWithoutAuthorization { + runtimeService.getVariables(task.getProcessInstanceId()) + } + assertThat(variables["varName1"]).isEqualTo("varValue1") + } + + @Test + fun `should return an empty result when nothing is subscribed`() { + val document = createDocument() + + val results = caseCorrelationService.sendCatchEventMessageToCase(MESSAGE, document.id().toString()) + + assertThat(results).isEmpty() + } + + private fun createDocument(): Document { + return runWithoutAuthorization { + documentService.createDocument( + NewDocumentRequest( + "house", + "house", + "1.0.0", + objectMapper.readTree(DOCUMENT_JSON) + ) + ).resultingDocument().orElseThrow() + } + } + + private fun startCatchEventProcessOne(document: Document) { + runtimeService.startProcessInstanceByKey(PROCESS_ONE_KEY, document.id().toString(), emptyMap()) + check(findTask(TASK_ONE) == null) { "Process '$PROCESS_ONE_KEY' should wait on the catch event" } + } + + private fun startCatchEventProcessTwo(document: Document) { + runtimeService.startProcessInstanceByKey(PROCESS_TWO_KEY, document.id().toString(), emptyMap()) + check(findTask(TASK_TWO) == null) { "Process '$PROCESS_TWO_KEY' should wait on the catch event" } + } + + private fun findTask(name: String) = runWithoutAuthorization { taskService.findTask(byName(name)) } + + companion object { + private const val MESSAGE = "intermediate-catch-event-ref" + private const val PROCESS_ONE_KEY = "intermediate-catch-event-sample-one-id" + private const val PROCESS_TWO_KEY = "intermediate-catch-event-sample-two-id" + private const val PROCESS_ONE_NAME = "intermediate-catch-event-sample-one" + private const val TASK_ONE = "intermediate-catch-event-1-user-task" + private const val TASK_TWO = "intermediate-catch-event-2-user-task" + private const val DOCUMENT_JSON = """{"street": "aStreet", "houseNumber": 1}""" + } +} diff --git a/documentation/features/building-blocks/README.md b/documentation/features/building-blocks/README.md index 7189772221..199dbff5c1 100644 --- a/documentation/features/building-blocks/README.md +++ b/documentation/features/building-blocks/README.md @@ -176,6 +176,73 @@ Ad-hoc start requires the building block's main process to have a start form pro [Start form](#start-form) below. {% endhint %} +## Send a message to a case + +A BPMN message sent with `caseCorrelationService` reaches **every** running process of a case: the case's own processes +and all of its building blocks, including nested ones. Use it to let a building block react to something that happens +elsewhere in the case, or to let building blocks signal each other. + +A regular message correlation is matched on a single business key, and a building block runs under its own document id +— so without this bean a message aimed at the case never arrives at its building blocks. + +### Deliver to a waiting message catch event + +Model an intermediate throw event (or any service task) in a case process, an ad-hoc process or a building block +process, and use the expression: + +``` +${caseCorrelationService.sendCatchEventMessageToCase("income-verified", execution)} +``` + +The case is derived from the sending process. This also works from within a building block, so a building block can +message its sibling building blocks and the case processes. + +Variables can be passed as alternating name/value pairs, or as a map: + +``` +${caseCorrelationService.sendCatchEventMessageToCase("income-verified", execution, "income", 42000)} +``` + +To message a **different** case — a related case, for example — pass its document id instead of the execution: + +``` +${caseCorrelationService.sendCatchEventMessageToCase("income-verified", relatedCaseId)} +``` + +The value has to be a document id. A building block document id is accepted too and is resolved to the case that owns +it. + +### Start a building block by message + +A building block whose main process starts with a **message start event** can be started for a case: + +``` +${caseCorrelationService.sendStartMessageToCase("case-notification", execution)} +``` + +Every building block that is linked to the case definition (see +[Linking building blocks to a case](#linking-building-blocks-to-a-case)) and whose main process declares a message start +event with that name is started, in the version the link pins. The new instance is bootstrapped exactly like an ad-hoc +start: it gets its own document, the link's `inputMappings` are applied, and its `outputMappings` are synced back to the +case when the configured `syncTiming` fires. + +{% hint style="info" %} +Message payload and document content are separate things. Variables passed along with the message end up as **process +variables** on the started instance; the content of the new building block document comes from the link's +`inputMappings`. +{% endhint %} + +### Modelling guidance + +* **Use a distinct message name per intent.** Delivery is a fan-out: every subscribed process instance of the case + receives the message. Do not reuse a name across unrelated flows, and do not use the same name for a catch event and + a start event. +* **Start building blocks with `sendStartMessageToCase`, not with a plain message start correlation.** A plain start + correlation always picks the latest deployed version of a process, while a case is linked to a specific building + block version — it would run the wrong version. +* **Nothing listening is not an error.** When no process of the case is waiting for the message, nothing happens: a + warning is logged and the sending process simply continues. + ## Import and export building blocks Building blocks are automatically included in case definition exports. You can also export or import a building block diff --git a/documentation/release-notes/13.x.x/13.41.0/README.md b/documentation/release-notes/13.x.x/13.41.0/README.md index a400ce5c85..9b6cb179b0 100644 --- a/documentation/release-notes/13.x.x/13.41.0/README.md +++ b/documentation/release-notes/13.x.x/13.41.0/README.md @@ -6,9 +6,19 @@ ## New Features -* **New feature title** +* **Send a message to a case and its building blocks** + + Processes can now send a message to a whole case: the case's own processes and all of its building blocks receive + it. This makes it possible to let a building block react to something that happens elsewhere in the case, and to let + building blocks signal each other. Messages can also be sent to another case, for example a related one. See + [Send a message to a case](../../../features/building-blocks/README.md#send-a-message-to-a-case). + +* **Start a building block with a message** + + A building block can now be started by sending a message to the case, instead of only from a call activity or from + the case's **Start** menu. See + [Start a building block by message](../../../features/building-blocks/README.md#start-a-building-block-by-message). - New feature explanation. ## Enhancements