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"