Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/plugin/plugin.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pluginGroupId=com.ritense.valtimoplugins
pluginArtifactId=slack
pluginVersion=6.1.0
pluginVersion=6.1.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,15 +72,17 @@ class SlackAutoConfiguration {
repositoryService: RepositoryService,
processPropertyService: ProcessPropertyService,
processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService,
processDocumentService: ProcessDocumentService,
documentService: DocumentService,
processDocumentAssociationService: ProcessDocumentAssociationService,
caseDefinitionService: CaseDefinitionService,
): SlackMessageProcessStarter =
SlackMessageProcessStarter(
runtimeService,
repositoryService,
processPropertyService,
processDefinitionCaseDefinitionService,
processDocumentService,
documentService,
processDocumentAssociationService,
caseDefinitionService,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
) {
/**
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,33 +38,58 @@ 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
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)
Expand All @@ -64,14 +98,61 @@ class SlackMessageProcessStarterTest : BaseTest() {
starter =
SlackMessageProcessStarter(
runtimeService,
mock<RepositoryService>(),
mock<ProcessPropertyService>(),
mock<ProcessDefinitionCaseDefinitionService>(),
mock<ProcessDocumentService>(),
mock<CaseDefinitionService>(),
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")
Expand Down Expand Up @@ -269,6 +350,84 @@ class SlackMessageProcessStarterTest : BaseTest() {
.thenReturn(messageTs)
}

private fun messageStartEvent(): PluginProcessLink =
mock<PluginProcessLink>().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<Document>()
whenever(document.id()).thenReturn(documentId)
val createResult = mock<CreateDocumentResult>()
doReturn(Optional.of(document)).whenever(createResult).resultingDocument()
val processDefinition = mock<ProcessDefinition>()
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<Message>()
whenever(message.name).thenReturn(messageName)
val messageEventDefinition = mock<MessageEventDefinition>()
whenever(messageEventDefinition.message).thenReturn(message)
val catchEvent = mock<CatchEvent>()
whenever(catchEvent.eventDefinitions).thenReturn(listOf(messageEventDefinition))
val model = mock<BpmnModelInstance>()
doReturn(catchEvent).whenever(model).getModelElementById<CatchEvent>(any())
whenever(repositoryService.getBpmnModelInstance(any())).thenReturn(model)

val processInstance = mock<ProcessInstance>()
whenever(processInstance.id).thenReturn(processInstanceId)
val correlation = mock<MessageCorrelationBuilder>()
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<PluginProcessLink>().also {
whenever(it.activityType).thenReturn(ActivityTypeWithEventName.RECEIVE_TASK_END)
Expand Down
5 changes: 5 additions & 0 deletions documentation/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading