From 9a7a436216ec6298d30bfdcf6b73f16dbb5c822d Mon Sep 17 00:00:00 2001 From: Klaas Schuijtemaker Date: Wed, 16 Sep 2026 15:40:53 +0200 Subject: [PATCH] Start the case at the message's own start event instead of by process key, release 6.1.1 A process may have more than one start event - a plain one for the start form and the message start event the `receive-message` link hangs on - and Operaton enters a process started by key at whichever it considers the initial activity, which is the plain one. The message's own start event then never runs, so neither do the execution listeners that put the message on the case, and the case is created empty. The case is now created directly and the start message of the linked element correlated to it, with the document id as business key so `doc:` resolves before the process-document association exists. --- backend/plugin/plugin.properties | 2 +- .../SlackAutoConfiguration.kt | 9 +- .../service/SlackMessageProcessStarter.kt | 75 +++++--- .../service/SlackMessageProcessStarterTest.kt | 181 ++++++++++++++++-- documentation/release-notes.md | 5 + frontend/projects/plugin/package.json | 2 +- 6 files changed, 236 insertions(+), 38 deletions(-) diff --git a/backend/plugin/plugin.properties b/backend/plugin/plugin.properties index 2032f6a..dc81318 100644 --- a/backend/plugin/plugin.properties +++ b/backend/plugin/plugin.properties @@ -1,3 +1,3 @@ pluginGroupId=com.ritense.valtimoplugins pluginArtifactId=slack -pluginVersion=6.1.0 +pluginVersion=6.1.1 diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt index 770fcb8..d622d0b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/autoconfiguration/SlackAutoConfiguration.kt @@ -18,9 +18,10 @@ package com.ritense.valtimoplugins.slack.autoconfiguration import com.fasterxml.jackson.databind.ObjectMapper import com.ritense.case.service.CaseDefinitionService +import com.ritense.document.service.DocumentService import com.ritense.plugin.service.PluginService import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService -import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processdocument.service.ProcessDocumentAssociationService import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.resource.service.TemporaryResourceStorageService import com.ritense.valtimo.contract.config.LiquibaseMasterChangeLogLocation @@ -71,7 +72,8 @@ class SlackAutoConfiguration { repositoryService: RepositoryService, processPropertyService: ProcessPropertyService, processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, - processDocumentService: ProcessDocumentService, + documentService: DocumentService, + processDocumentAssociationService: ProcessDocumentAssociationService, caseDefinitionService: CaseDefinitionService, ): SlackMessageProcessStarter = SlackMessageProcessStarter( @@ -79,7 +81,8 @@ class SlackAutoConfiguration { repositoryService, processPropertyService, processDefinitionCaseDefinitionService, - processDocumentService, + documentService, + processDocumentAssociationService, caseDefinitionService, ) diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt index c265a3f..ed0a09b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarter.kt @@ -17,13 +17,14 @@ package com.ritense.valtimoplugins.slack.service import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.ritense.authorization.AuthorizationContext import com.ritense.case.service.CaseDefinitionService import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.document.service.DocumentService import com.ritense.plugin.domain.PluginProcessLink import com.ritense.processdocument.domain.ProcessDefinitionId -import com.ritense.processdocument.domain.impl.request.NewDocumentAndStartProcessRequest import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService -import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processdocument.service.ProcessDocumentAssociationService import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.service.ProcessPropertyService @@ -35,6 +36,7 @@ import org.operaton.bpm.engine.runtime.Execution import org.operaton.bpm.model.bpmn.instance.CatchEvent import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition import org.springframework.stereotype.Service +import java.util.UUID /** * Starts, or resumes, the process behind a `receive-message` process link. @@ -57,7 +59,8 @@ class SlackMessageProcessStarter( private val repositoryService: RepositoryService, private val processPropertyService: ProcessPropertyService, private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, - private val processDocumentService: ProcessDocumentService, + private val documentService: DocumentService, + private val processDocumentAssociationService: ProcessDocumentAssociationService, private val caseDefinitionService: CaseDefinitionService, ) { /** @@ -173,28 +176,56 @@ class SlackMessageProcessStarter( "canInitializeDocument is false on the linked case definition." } - val processDefinitionKey = - processDefinitionCaseDefinition.processDefinitionKey - ?: error("No process definition key found for '${processLink.processDefinitionId}'") - - val request = - NewDocumentAndStartProcessRequest( - processDefinitionKey, - NewDocumentRequest( - activeCaseDefinition.id.key, - activeCaseDefinition.id.key, - activeCaseDefinition.id.versionTag.toString(), - JsonNodeFactory.instance.objectNode(), - ), - ).withProcessVars(variables) - + val messageName = messageNameOf(processLink) logger.info { "Creating a case for case definition '${activeCaseDefinition.id.key}' " + - "(${activeCaseDefinition.id.versionTag}) with process '$processDefinitionKey'" + "(${activeCaseDefinition.id.versionTag}) by correlating start message '$messageName' to " + + "'${processLink.activityId}'" } - val result = processDocumentService.newDocumentAndStartProcess(request) - if (result.errors().isNotEmpty()) { - error("Failed to create a case for the incoming Slack message: ${result.errors()}") + + val newDocumentRequest = + NewDocumentRequest( + activeCaseDefinition.id.key, + activeCaseDefinition.id.key, + activeCaseDefinition.id.versionTag.toString(), + JsonNodeFactory.instance.objectNode(), + ) + val documentResult = + AuthorizationContext.runWithoutAuthorization { + documentService.createDocument(newDocumentRequest) + } + val document = + documentResult.resultingDocument().orElse(null) + ?: error("Failed to create a case for the incoming Slack message: ${documentResult.errors()}") + + // Correlated to the start message rather than started by process definition key. + // ProcessDocumentService.newDocumentAndStartProcess, the obvious alternative, starts a + // process by key, and Operaton then enters it at whichever start event it considers the + // process's initial activity. A process with both a plain start event (someone fills in + // the start form) and this message start event is a normal shape, and on that shape "by + // key" silently lands on the plain one: the message's own start event never runs, so + // neither do its execution listeners, and the case is created without any of the Slack + // message on it. + val processInstance = + runtimeService + .createMessageCorrelation(messageName) + .processDefinitionId(processLink.processDefinitionId) + // Valtimo resolves `doc:` for a process instance through the process-document + // association, and falls back to the business key while that association does + // not exist yet. It cannot exist yet here - it is created below, once the + // instance has an id - so the start event's own listeners depend on this + // business key to reach the document. Valtimo's own start path sets it the + // same way. + .processInstanceBusinessKey(document.id().toString()) + .setVariables(variables) + .correlateStartMessage() + + AuthorizationContext.runWithoutAuthorization { + processDocumentAssociationService.createProcessDocumentInstance( + processInstance.id, + UUID.fromString(document.id().toString()), + repositoryService.getProcessDefinition(processLink.processDefinitionId).name, + ) } return true } diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt index 24243a9..860d486 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/slack/service/SlackMessageProcessStarterTest.kt @@ -17,10 +17,19 @@ package com.ritense.valtimoplugins.slack.service import com.ritense.case.service.CaseDefinitionService +import com.ritense.case_.domain.definition.CaseDefinition +import com.ritense.document.domain.Document +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import com.ritense.document.service.DocumentService +import com.ritense.document.service.result.CreateDocumentResult import com.ritense.plugin.domain.PluginProcessLink +import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinition +import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinitionId +import com.ritense.processdocument.domain.ProcessDefinitionId import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService -import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processdocument.service.ProcessDocumentAssociationService import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.service.ProcessPropertyService import com.ritense.valtimoplugins.slack.BaseTest import com.ritense.valtimoplugins.slack.domain.SlackMessage @@ -29,6 +38,7 @@ import org.assertj.core.api.Assertions.entry import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -36,26 +46,50 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.operaton.bpm.engine.RepositoryService import org.operaton.bpm.engine.RuntimeService +import org.operaton.bpm.engine.repository.ProcessDefinition import org.operaton.bpm.engine.runtime.Execution import org.operaton.bpm.engine.runtime.ExecutionQuery +import org.operaton.bpm.engine.runtime.MessageCorrelationBuilder +import org.operaton.bpm.engine.runtime.ProcessInstance +import org.operaton.bpm.model.bpmn.BpmnModelInstance +import org.operaton.bpm.model.bpmn.instance.CatchEvent +import org.operaton.bpm.model.bpmn.instance.Message +import org.operaton.bpm.model.bpmn.instance.MessageEventDefinition +import org.semver4j.Semver +import java.util.Optional +import java.util.UUID /** - * Covers the reply side of the starter: which of the instances parked at a receive task a - * given Slack message may resume. + * Covers both sides of the starter: where a case is started when the link hangs on a message + * start event, and which of the instances parked at a receive task a given Slack message may + * resume. * - * The broadcast this guards against is not a hypothetical. The execution query is scoped to - * a process definition and an activity, not to a case, so without correlation every case - * waiting for its own answer would be handed the first message that arrived for any of them. + * The broadcast the resume side guards against is not a hypothetical. The execution query is + * scoped to a process definition and an activity, not to a case, so without correlation every + * case waiting for its own answer would be handed the first message that arrived for any of + * them. */ class SlackMessageProcessStarterTest : BaseTest() { private lateinit var runtimeService: RuntimeService private lateinit var executionQuery: ExecutionQuery + private lateinit var repositoryService: RepositoryService + private lateinit var processPropertyService: ProcessPropertyService + private lateinit var processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService + private lateinit var documentService: DocumentService + private lateinit var processDocumentAssociationService: ProcessDocumentAssociationService + private lateinit var caseDefinitionService: CaseDefinitionService private lateinit var starter: SlackMessageProcessStarter @BeforeEach fun setUp() { runtimeService = mock() executionQuery = mock() + repositoryService = mock() + processPropertyService = mock() + processDefinitionCaseDefinitionService = mock() + documentService = mock() + processDocumentAssociationService = mock() + caseDefinitionService = mock() whenever(runtimeService.createExecutionQuery()).thenReturn(executionQuery) whenever(executionQuery.processDefinitionId(any())).thenReturn(executionQuery) @@ -64,14 +98,61 @@ class SlackMessageProcessStarterTest : BaseTest() { starter = SlackMessageProcessStarter( runtimeService, - mock(), - mock(), - mock(), - mock(), - mock(), + repositoryService, + processPropertyService, + processDefinitionCaseDefinitionService, + documentService, + processDocumentAssociationService, + caseDefinitionService, ) } + /** + * The regression this guards against: starting the process by its definition key instead. + * + * A process may have more than one start event - a plain one for the start form and this + * message start event for the channel - and Operaton enters a process started by key at + * whichever it considers the initial activity, which is the plain one. The message's own + * start event then never runs, so neither do the execution listeners that put the message on + * the case, and the case is created empty. + */ + @Test + fun `should start a case by correlating the start message of the linked element`() { + val link = messageStartEvent() + val documentId = JsonSchemaDocumentId.existingId(UUID.fromString("11111111-2222-3333-4444-555555555555")) + givenACaseCanBeStartedFor(link, documentId) + val correlation = givenStartMessageCorrelates("IncomingSlackMessage", "process-instance-1") + + val started = starter.start(link, reply(threadTs = null)) + + assertThat(started).isTrue() + verify(correlation).processDefinitionId("Verwerk_slackbericht_handmatig:1:abc") + // Valtimo resolves `doc:` through the business key until the association below exists, + // so the start event's own listeners depend on it being the document id. + verify(correlation).processInstanceBusinessKey(documentId.toString()) + verify(correlation).correlateStartMessage() + verify(processDocumentAssociationService).createProcessDocumentInstance( + eq("process-instance-1"), + eq(UUID.fromString("11111111-2222-3333-4444-555555555555")), + eq("Verwerk slackbericht handmatig"), + ) + } + + @Test + fun `should hand the message on as process variables when it starts a case`() { + val link = messageStartEvent() + givenACaseCanBeStartedFor( + link, + JsonSchemaDocumentId.existingId(UUID.fromString("11111111-2222-3333-4444-555555555555")), + ) + val correlation = givenStartMessageCorrelates("IncomingSlackMessage", "process-instance-1") + val message = reply(threadTs = null) + + starter.start(link, message) + + verify(correlation).setVariables(message.toProcessVariables()) + } + @Test fun `should resume only the case the reply belongs to`() { waiting("execution-a" to "instance-a", "execution-b" to "instance-b") @@ -269,6 +350,84 @@ class SlackMessageProcessStarterTest : BaseTest() { .thenReturn(messageTs) } + private fun messageStartEvent(): PluginProcessLink = + mock().also { + whenever(it.activityType).thenReturn(ActivityTypeWithEventName.MESSAGE_START_EVENT_START) + whenever(it.activityId).thenReturn("Event_0beitek") + whenever(it.processDefinitionId).thenReturn("Verwerk_slackbericht_handmatig:1:abc") + } + + /** + * Everything between "this link points at a message start event" and "a case may be created + * for it": the process is not a system process, it belongs to the active version of a case + * definition that allows document initialisation, and the document itself is created. + */ + private fun givenACaseCanBeStartedFor( + link: PluginProcessLink, + documentId: JsonSchemaDocumentId, + ) { + // Real objects rather than mocks: these are plain JPA entities whose `val`s Mockito + // cannot stub, and building them is no more code than stubbing them would be. + val caseDefinitionId = CaseDefinitionId("slackverwerking", Semver("1.1.0")) + val processDefinitionCaseDefinition = + ProcessDefinitionCaseDefinition( + ProcessDefinitionCaseDefinitionId( + ProcessDefinitionId(link.processDefinitionId), + caseDefinitionId, + ), + canInitializeDocument = true, + ) + val caseDefinition = + CaseDefinition( + id = caseDefinitionId, + name = "Slackverwerking", + createdDate = null, + canHaveAssignee = true, + ) + + // Each mock is finished before the next stubbing starts; stubbing one inside another + // `thenReturn(...)` is what Mockito reports as unfinished stubbing. + val document = mock() + whenever(document.id()).thenReturn(documentId) + val createResult = mock() + doReturn(Optional.of(document)).whenever(createResult).resultingDocument() + val processDefinition = mock() + whenever(processDefinition.name).thenReturn("Verwerk slackbericht handmatig") + + whenever(processPropertyService.isSystemProcessById(link.processDefinitionId)).thenReturn(false) + whenever(processDefinitionCaseDefinitionService.findByProcessDefinitionId(any())) + .thenReturn(processDefinitionCaseDefinition) + whenever(caseDefinitionService.getActiveCaseDefinition("slackverwerking")).thenReturn(caseDefinition) + whenever(documentService.createDocument(any())).thenReturn(createResult) + whenever(repositoryService.getProcessDefinition(link.processDefinitionId)).thenReturn(processDefinition) + } + + /** Stubs the BPMN lookup of the message name and the correlation builder chain. */ + private fun givenStartMessageCorrelates( + messageName: String, + processInstanceId: String, + ): MessageCorrelationBuilder { + val message = mock() + whenever(message.name).thenReturn(messageName) + val messageEventDefinition = mock() + whenever(messageEventDefinition.message).thenReturn(message) + val catchEvent = mock() + whenever(catchEvent.eventDefinitions).thenReturn(listOf(messageEventDefinition)) + val model = mock() + doReturn(catchEvent).whenever(model).getModelElementById(any()) + whenever(repositoryService.getBpmnModelInstance(any())).thenReturn(model) + + val processInstance = mock() + whenever(processInstance.id).thenReturn(processInstanceId) + val correlation = mock() + whenever(runtimeService.createMessageCorrelation(messageName)).thenReturn(correlation) + whenever(correlation.processDefinitionId(any())).thenReturn(correlation) + whenever(correlation.processInstanceBusinessKey(any())).thenReturn(correlation) + whenever(correlation.setVariables(any())).thenReturn(correlation) + whenever(correlation.correlateStartMessage()).thenReturn(processInstance) + return correlation + } + private fun receiveTask(): PluginProcessLink = mock().also { whenever(it.activityType).thenReturn(ActivityTypeWithEventName.RECEIVE_TASK_END) diff --git a/documentation/release-notes.md b/documentation/release-notes.md index ba32139..7e77a47 100644 --- a/documentation/release-notes.md +++ b/documentation/release-notes.md @@ -2,6 +2,11 @@ Overzicht van wijzigingen per versie van de Slack-plugin. +## 6.1.1 + +Een dossier dat door een binnenkomend Slack-bericht wordt gestart, toont nu de gegevens van dat +bericht in plaats van leeg te blijven. + ## 6.1.0 Nieuwe actie `receive-message`: een Slack-kanaal uitlezen en per bericht een dossier starten, diff --git a/frontend/projects/plugin/package.json b/frontend/projects/plugin/package.json index 92a5dba..e6bf94e 100644 --- a/frontend/projects/plugin/package.json +++ b/frontend/projects/plugin/package.json @@ -1,7 +1,7 @@ { "name": "@valtimo-plugins/slack", "license": "EUPL-1.2", - "version": "6.1.0", + "version": "6.1.1", "peerDependencies": { "@angular/common": "19.2.20", "@angular/core": "19.2.20"