diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/BuildingBlockStartFormFlowIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/BuildingBlockStartFormFlowIT.kt new file mode 100644 index 0000000000..8257f7293e --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/BuildingBlockStartFormFlowIT.kt @@ -0,0 +1,256 @@ +/* + * 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.processlink.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.buildingblock.BaseIntegrationTest +import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository +import com.ritense.buildingblock.service.CaseDefinitionBuildingBlockLinkService +import com.ritense.buildingblock.web.rest.dto.CreateCaseDefinitionBuildingBlockLinkDto +import com.ritense.document.domain.impl.request.NewDocumentRequest +import com.ritense.formflow.FormFlowTaskOpenResultProperties +import com.ritense.formflow.domain.definition.FormFlowDefinition +import com.ritense.formflow.domain.definition.FormFlowDefinitionId +import com.ritense.formflow.domain.definition.FormFlowStep +import com.ritense.formflow.domain.definition.FormFlowStepId +import com.ritense.formflow.domain.definition.configuration.FormFlowStepType +import com.ritense.formflow.domain.definition.configuration.step.FormStepTypeProperties +import com.ritense.formflow.domain.instance.FormFlowInstanceId +import com.ritense.formflow.repository.FormFlowDefinitionRepository +import com.ritense.formflow.service.FormFlowService +import com.ritense.formflow.web.rest.FormFlowResource +import com.ritense.formflow.web.rest.dto.FormFlowProcessLinkCreateRequestDto +import com.ritense.processdocument.domain.impl.request.NewDocumentAndStartProcessRequest +import com.ritense.processdocument.service.ProcessDocumentService +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.service.ProcessLinkActivityService +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.service.OperatonProcessService +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.io.ByteArrayInputStream +import java.util.UUID + +/** + * Regression tests for GZAC issue 840: a form flow configured as the start form of a building block's main + * process must open, and submitting it must start the building block version that is linked to the case. + * + * Two defects were fixed: + * 1. Resolving the start form threw a NullPointerException, because the form flow definition was looked up + * through the case-definition link and a building-block-owned process definition has no such link row. + * 2. Only the process definition key reached the start request, and a key cannot tell versions of a + * building-block-owned process apart - every version redeploys the same key. + */ +@Transactional +class BuildingBlockStartFormFlowIT @Autowired constructor( + private val buildingBlockInstanceRepository: BuildingBlockInstanceRepository, + private val caseDefinitionBuildingBlockLinkService: CaseDefinitionBuildingBlockLinkService, + private val formFlowDefinitionRepository: FormFlowDefinitionRepository, + private val processLinkActivityService: ProcessLinkActivityService, + private val processDocumentService: ProcessDocumentService, + private val operatonProcessService: OperatonProcessService, + private val formFlowResource: FormFlowResource, + private val formFlowService: FormFlowService, + private val repositoryService: RepositoryService, + private val runtimeService: RuntimeService, + private val objectMapper: ObjectMapper, +) : BaseIntegrationTest() { + + @Test + fun `should open the start form flow of a building block and start the linked version on submit`() { + val buildingBlockDefinitionId = BuildingBlockDefinitionId.of(BUILDING_BLOCK_KEY, BUILDING_BLOCK_VERSION) + val linkedVersionProcessDefinitionId = mainProcessDefinitionIdOf(BUILDING_BLOCK_VERSION) + + deployStartFormFlow(buildingBlockDefinitionId) + linkFormFlowToMainProcessStartEvent(buildingBlockDefinitionId, linkedVersionProcessDefinitionId) + linkBuildingBlockToCase() + val caseDocumentId = startCase() + + deployNewerBuildingBlockVersionOfMainProcess() + + // Defect 1: this used to throw a NullPointerException, so no start form opened at all. + val startEventResult = runWithoutAuthorization { + processLinkActivityService.getStartEventObject( + linkedVersionProcessDefinitionId, + caseDocumentId, + null + ) + } + assertThat(startEventResult).isNotNull + assertThat(startEventResult!!.type).isEqualTo("form-flow") + assertThat(startEventResult.properties).isInstanceOf(FormFlowTaskOpenResultProperties::class.java) + + val formFlowInstance = formFlowService.getInstanceById( + FormFlowInstanceId.existingId( + (startEventResult.properties as FormFlowTaskOpenResultProperties).formFlowInstanceId + ) + ) + // Defect 2: the exact version has to travel with the form flow instance - the key cannot identify it. + assertThat(formFlowInstance.getAdditionalProperties()) + .containsEntry("processDefinitionId", linkedVersionProcessDefinitionId) + .containsEntry("processDefinitionKey", MAIN_PROCESS_KEY) + .containsEntry("documentId", caseDocumentId) + + runWithoutAuthorization { + formFlowResource.completeStep( + formFlowInstance.id.id.toString(), + formFlowInstance.currentFormFlowStepInstanceId!!.id.toString(), + objectMapper.readTree("""{"straatnaam":"Hoofdstraat"}""") + ) + } + + // The listener derives the building block version from the started definition's version tag, so + // starting the wrong version leaves the case without a building block instance altogether. + val instances = buildingBlockInstanceRepository.findAll().filter { it.caseDocumentId == caseDocumentId } + assertThat(instances).hasSize(1) + assertThat(instances.first().definition.id).isEqualTo(buildingBlockDefinitionId) + + // The main process waits on a message, so its instance is still running and assertable. Its business + // key is the building block document by now - the start event listener re-pointed it - so the instance + // is looked up through the building block instance instead. + val startedInstance = runtimeService.createProcessInstanceQuery() + .processInstanceId(instances.first().processInstanceId) + .singleResult() + ?: error("Building block main process is not running for case document $caseDocumentId") + assertThat(startedInstance.processDefinitionId).isEqualTo(linkedVersionProcessDefinitionId) + } + + /** + * Saves the form flow definition straight through the repository. The `bezwaar` fixture is final, so the + * importer's "final building block" write check would reject it. + */ + private fun deployStartFormFlow(buildingBlockDefinitionId: BuildingBlockDefinitionId) { + val step = FormFlowStep( + id = FormFlowStepId(FORM_FLOW_STEP_KEY), + onComplete = listOf("\${valtimoFormFlow.startSupportingProcess(instance.id, {'doc:/straatnaam':'/straatnaam'})}"), + type = FormFlowStepType("form", FormStepTypeProperties("bb-form")) + ) + formFlowDefinitionRepository.save( + FormFlowDefinition( + FormFlowDefinitionId.existingId(FORM_FLOW_KEY, buildingBlockDefinitionId), + FORM_FLOW_STEP_KEY, + setOf(step) + ) + ) + } + + private fun linkFormFlowToMainProcessStartEvent( + buildingBlockDefinitionId: BuildingBlockDefinitionId, + mainProcessDefinitionId: String + ) { + processLinkService.createProcessLink( + FormFlowProcessLinkCreateRequestDto( + mainProcessDefinitionId, + START_EVENT_ID, + ActivityTypeWithEventName.START_EVENT_START, + FORM_FLOW_KEY + ), + buildingBlockDefinitionId + ) + } + + private fun linkBuildingBlockToCase() { + runWithoutAuthorization { + caseDefinitionBuildingBlockLinkService.createLink( + CaseDefinitionId.of(CASE_DEFINITION_KEY, CASE_DEFINITION_VERSION), + CreateCaseDefinitionBuildingBlockLinkDto(BUILDING_BLOCK_KEY, BUILDING_BLOCK_VERSION) + ) + } + } + + /** + * Resolves the process definition of a building block version by its version tag rather than through the + * `main` link, so the test does not depend on state other integration tests may have committed. + */ + private fun mainProcessDefinitionIdOf(versionTag: String): String { + return repositoryService.createProcessDefinitionQuery() + .processDefinitionKey(MAIN_PROCESS_KEY) + .versionTag("BB:$BUILDING_BLOCK_KEY:$versionTag") + .orderByProcessDefinitionVersion() + .desc() + .list() + .firstOrNull() + ?.id + ?: throw IllegalStateException("No process definition for building block $BUILDING_BLOCK_KEY:$versionTag") + } + + /** + * Redeploys the building block's main process under a newer building block version tag - the same thing + * creating a draft version does - so both versions share one process definition key. That ambiguity is + * what made the wrong version start. + */ + private fun deployNewerBuildingBlockVersionOfMainProcess() { + val bpmn = requireNotNull(javaClass.classLoader.getResourceAsStream(MAIN_PROCESS_RESOURCE)) { + "Missing test resource $MAIN_PROCESS_RESOURCE" + }.use { it.readBytes() } + + runWithoutAuthorization { + operatonProcessService.deploy( + BuildingBlockDefinitionId.of(BUILDING_BLOCK_KEY, DRAFT_VERSION), + "$MAIN_PROCESS_KEY.bpmn", + ByteArrayInputStream(bpmn), + true, + true + ) + } + + assertThat(mainProcessDefinitionIdOf(DRAFT_VERSION)) + .isNotEqualTo(mainProcessDefinitionIdOf(BUILDING_BLOCK_VERSION)) + } + + private fun startCase(): UUID { + val result = runWithoutAuthorization { + processDocumentService.newDocumentAndStartProcess( + NewDocumentAndStartProcessRequest( + CASE_MAIN_PROCESS_KEY, + NewDocumentRequest( + CASE_DEFINITION_KEY, + CASE_DEFINITION_KEY, + CASE_DEFINITION_VERSION, + objectMapper.createObjectNode() + ) + ) + ) + } + return result.resultingDocument() + .orElseThrow { IllegalStateException("Case document not created: ${result.errors()}") } + .id() + .id + } + + private companion object { + const val BUILDING_BLOCK_KEY = "bezwaar" + const val BUILDING_BLOCK_VERSION = "1.0.0" + // Deliberately distinctive so other integration tests in this module cannot have created it. + const val DRAFT_VERSION = "8.4.0" + const val CASE_DEFINITION_KEY = "bb-case" + const val CASE_DEFINITION_VERSION = "1.0.0" + const val CASE_MAIN_PROCESS_KEY = "bb-case-plain-main" + const val MAIN_PROCESS_KEY = "building-block-process" + const val START_EVENT_ID = "StartEvent_1" + const val FORM_FLOW_KEY = "bb-start-form-flow" + const val FORM_FLOW_STEP_KEY = "step1" + const val MAIN_PROCESS_RESOURCE = + "config/building-block/bezwaar/1-0-0/bpmn/building-block-process.bpmn" + } +} diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandler.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandler.kt index d20fb1660e..20e90ef447 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandler.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandler.kt @@ -34,6 +34,8 @@ import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition import com.ritense.valtimo.operaton.domain.OperatonTask import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId import org.operaton.bpm.engine.RuntimeService import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @@ -97,13 +99,15 @@ class FormFlowProcessLinkActivityHandler( ): ProcessLinkActivityResult { return withLoggingContext(ProcessLink::class, processLink.id) { processLink as FormFlowProcessLink - val processDefinitionCaseDefinitionLink = processDefinitionCaseDefinitionService.findByProcessDefinitionId(ProcessDefinitionId(processDefinitionId)) - val formFlowDefinition = formFlowService.findDefinition(processLink.formFlowDefinitionKey, processDefinitionCaseDefinitionLink.id.caseDefinitionId)!! - val processDefinition = AuthorizationContext.runWithoutAuthorization { - repositoryService.findProcessDefinitionById(processDefinitionId)!! - } + val processDefinition = findProcessDefinition(processDefinitionId) + val formFlowDefinition = resolveFormFlowDefinition(processDefinition, processLink.formFlowDefinitionKey) - val additionalProperties = mutableMapOf("processDefinitionKey" to processDefinition.key) + val additionalProperties = mutableMapOf( + "processDefinitionKey" to processDefinition.key, + // The key alone cannot identify a version: every building block version redeploys the same + // process definition key. Pass the id on so submitting starts the linked version. + "processDefinitionId" to processDefinition.id, + ) documentId?.let { additionalProperties["documentId"] = it } documentDefinitionName?.let { additionalProperties["documentDefinitionName"] = it } @@ -126,23 +130,46 @@ class FormFlowProcessLinkActivityHandler( private fun createFormFlowInstance(task: OperatonTask, processLink: FormFlowProcessLink): FormFlowInstance { val additionalProperties = getAdditionalProperties(task) - val formFlowDefinition = findFormFlowDefinition(processLink) + val processDefinition = findProcessDefinition(processLink.processDefinitionId) + val formFlowDefinition = resolveFormFlowDefinition(processDefinition, processLink.formFlowDefinitionKey) return formFlowService.save(formFlowDefinition.createInstance(additionalProperties)) } - private fun findFormFlowDefinition(processLink: FormFlowProcessLink): FormFlowDefinition { - val caseLink = try { - processDefinitionCaseDefinitionService - .findByProcessDefinitionId(ProcessDefinitionId(processLink.processDefinitionId)) - } catch (e: Exception) { - null - } - return if (caseLink != null) { - formFlowService.findDefinition(processLink.formFlowDefinitionKey, caseLink.id.caseDefinitionId) + private fun findProcessDefinition(processDefinitionId: String): OperatonProcessDefinition { + return AuthorizationContext.runWithoutAuthorization { + repositoryService.findProcessDefinitionById(processDefinitionId) + } ?: throw IllegalStateException("Process definition '$processDefinitionId' not found") + } + + /** + * Form flow definitions are owned by a blueprint - either a case definition or a building block + * definition. A case-definition link is authoritative when one exists, but a building-block-owned + * process definition has no such link row, because [com.ritense.valtimo.service.OperatonProcessService] + * only links `CD:` blueprints. Fall back to the blueprint encoded in the process definition's version + * tag, which covers both kinds. + */ + private fun resolveFormFlowDefinition( + processDefinition: OperatonProcessDefinition, + formFlowDefinitionKey: String + ): FormFlowDefinition { + val caseDefinitionId = processDefinitionCaseDefinitionService + .findByProcessDefinitionIdOrNull(ProcessDefinitionId(processDefinition.id)) + ?.id?.caseDefinitionId + val definition = if (caseDefinitionId != null) { + formFlowService.findDefinitionOrNull(formFlowDefinitionKey, caseDefinitionId) } else { - formFlowService.findDefinitionByKey(processLink.formFlowDefinitionKey) - ?: throw IllegalStateException("FormFlow definition '${processLink.formFlowDefinitionKey}' not found") + when (val blueprintId = processDefinition.getBlueprintId()) { + is BuildingBlockDefinitionId -> formFlowService.findDefinitionOrNull(formFlowDefinitionKey, blueprintId) + is CaseDefinitionId -> formFlowService.findDefinitionOrNull(formFlowDefinitionKey, blueprintId) + else -> null + } } + // Last resort for process definitions deployed without a blueprint version tag. + return definition + ?: formFlowService.findDefinitionByKey(formFlowDefinitionKey) + ?: throw IllegalStateException( + "FormFlow definition '$formFlowDefinitionKey' not found for process definition '${processDefinition.id}'" + ) } } diff --git a/backend/form-flow/src/main/kotlin/com/ritense/formflow/common/ValtimoFormFlow.kt b/backend/form-flow/src/main/kotlin/com/ritense/formflow/common/ValtimoFormFlow.kt index 1ea7b815f9..9c6937cf6a 100644 --- a/backend/form-flow/src/main/kotlin/com/ritense/formflow/common/ValtimoFormFlow.kt +++ b/backend/form-flow/src/main/kotlin/com/ritense/formflow/common/ValtimoFormFlow.kt @@ -25,6 +25,7 @@ import com.ritense.authorization.AuthorizationContext import com.ritense.document.domain.impl.JsonSchemaDocumentId import com.ritense.document.domain.impl.request.NewDocumentRequest import com.ritense.document.service.DocumentService +import com.ritense.formflow.domain.instance.FormFlowInstance import com.ritense.formflow.domain.instance.FormFlowInstanceId import com.ritense.formflow.expression.FormFlowBean import com.ritense.formflow.service.FormFlowService @@ -136,7 +137,7 @@ open class ValtimoFormFlow( document.id(), processDefinitionKey, submittedByType["pv"] as Map? - ) + ).withLinkedProcessDefinitionId(formFlowInstance) //TODO: PBAC START/CREATE check val startProcessForDocumentResult = AuthorizationContext.runWithoutAuthorization { processDocumentService.startProcessForDocument(startProcessForDocumentRequest) @@ -177,7 +178,7 @@ open class ValtimoFormFlow( JsonSchemaDocumentId.existingId(UUID.fromString(documentId)), processDefinitionKey, submittedByType["pv"] as Map? - ) + ).withLinkedProcessDefinitionId(formFlowInstance) //TODO: PBAC START/CREATE check val startProcessForDocumentResult = AuthorizationContext.runWithoutAuthorization { processDocumentService.startProcessForDocument(startProcessForDocumentRequest) @@ -191,6 +192,19 @@ open class ValtimoFormFlow( } } + /** + * Starts the exact process definition version the start form was opened for, when the form flow instance + * knows it. A process definition key cannot identify a version of a building-block-owned process, because + * every building block version redeploys the same key. Instances created before this property existed + * fall back to resolving by key. + */ + private fun StartProcessForDocumentRequest.withLinkedProcessDefinitionId( + formFlowInstance: FormFlowInstance + ): StartProcessForDocumentRequest { + val processDefinitionId = formFlowInstance.getAdditionalProperties()["processDefinitionId"] as String? + return processDefinitionId?.let { withProcessDefinitionId(it) } ?: this + } + private fun getRequiredAdditionalProperty(additionalProperties: Map, propertyName: String): Any { if (!additionalProperties.containsKey(propertyName)) throw IllegalStateException("Properties for form flow does not contain $propertyName") diff --git a/backend/form-flow/src/test/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandlerIntTest.kt b/backend/form-flow/src/test/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandlerIntTest.kt index 4ea5055e58..532f870c34 100644 --- a/backend/form-flow/src/test/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandlerIntTest.kt +++ b/backend/form-flow/src/test/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandlerIntTest.kt @@ -171,6 +171,7 @@ internal class FormFlowProcessLinkActivityHandlerIntTest: BaseIntegrationTest() val additionalProperties = dbFormFlowInstances[0].getAdditionalProperties() assertEquals(additionalProperties["documentDefinitionName"], "some-document") assertEquals(additionalProperties["processDefinitionKey"], "formflow-one-task-process") + assertEquals(additionalProperties["processDefinitionId"], processDefinition.id) } companion object { diff --git a/backend/form/src/main/kotlin/com/ritense/form/service/impl/DefaultFormSubmissionService.kt b/backend/form/src/main/kotlin/com/ritense/form/service/impl/DefaultFormSubmissionService.kt index f87de899e9..2539aedb12 100644 --- a/backend/form/src/main/kotlin/com/ritense/form/service/impl/DefaultFormSubmissionService.kt +++ b/backend/form/src/main/kotlin/com/ritense/form/service/impl/DefaultFormSubmissionService.kt @@ -44,8 +44,6 @@ import com.ritense.form.web.rest.dto.FormSubmissionResultFailed import com.ritense.form.web.rest.dto.FormSubmissionResultSucceeded import com.ritense.logging.LoggableResource import com.ritense.logging.withLoggingContext -import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinition -import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinitionId import com.ritense.processdocument.domain.ProcessDefinitionId import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndCompleteTaskRequest import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndStartProcessRequest @@ -121,10 +119,7 @@ class DefaultFormSubmissionService( val processDefinition = getProcessDefinition(processLink) val documentDefinitionNameToUse = document?.definitionId()?.name() ?: documentDefinitionName - ?: getProcessDocumentDefinition(processDefinition, document).run { - documentDefinitionService.findByBlueprintId(this.id.caseDefinitionId).orElseThrow().id?.name() - ?: throw ProcessDocumentDefinitionNotFoundException("DocumentDefinition not found for processDefinitionId: ${processDefinition.id}") - } + ?: resolveDocumentDefinitionName(processDefinition) val processVariables = getProcessVariables(taskInstanceId) val formDefinition = formDefinitionService.getFormDefinitionById(processLink.formDefinitionId).orElseThrow() @@ -310,24 +305,27 @@ class DefaultFormSubmissionService( } } - private fun getProcessDocumentDefinition( - processDefinition: OperatonProcessDefinition, - document: Document? - ): ProcessDefinitionCaseDefinition { - val processDefinitionId = - ProcessDefinitionId(processDefinition.id) - return runWithoutAuthorization { - if (document == null) { - processDefinitionCaseDefinitionService.findByProcessDefinitionId(processDefinitionId) - } else { - processDefinitionCaseDefinitionService.findById( - ProcessDefinitionCaseDefinitionId( - processDefinitionId, - document.definitionId().caseDefinitionId() - ) - )!! - } - } + /** + * Derives the document definition name from the blueprint that owns the process definition. A + * case-definition link is used when one exists; a building-block-owned process definition has no such + * link row, so the blueprint from the process definition's version tag is used instead. + */ + private fun resolveDocumentDefinitionName( + processDefinition: OperatonProcessDefinition + ): String { + val blueprintId = runWithoutAuthorization { + processDefinitionCaseDefinitionService + .findByProcessDefinitionIdOrNull(ProcessDefinitionId(processDefinition.id)) + }?.id?.caseDefinitionId + ?: processDefinition.getBlueprintId() + ?: throw ProcessDocumentDefinitionNotFoundException( + "Blueprint not found for processDefinitionId: ${processDefinition.id}" + ) + + return documentDefinitionService.findByBlueprintId(blueprintId).orElse(null)?.id?.name() + ?: throw ProcessDocumentDefinitionNotFoundException( + "DocumentDefinition not found for processDefinitionId: ${processDefinition.id}" + ) } private fun getDocumentDefinition(documentId: String): Document { diff --git a/backend/form/src/test/kotlin/com/ritense/form/service/DefaultFormSubmissionServiceTest.kt b/backend/form/src/test/kotlin/com/ritense/form/service/DefaultFormSubmissionServiceTest.kt index b7a446e08a..11ca4365f6 100644 --- a/backend/form/src/test/kotlin/com/ritense/form/service/DefaultFormSubmissionServiceTest.kt +++ b/backend/form/src/test/kotlin/com/ritense/form/service/DefaultFormSubmissionServiceTest.kt @@ -405,6 +405,37 @@ class DefaultFormSubmissionServiceTest { assertThat(captor.firstValue.processDefinitionId()).isEqualTo(PROCESS_DEFINITION_ID) } + @Test + fun `should resolve the document definition name from the building block blueprint when there is no case definition link`() { + val formData = formData() + val document = createDocument(JsonDocumentContent.build(formData), caseDefinitionId) + // A building-block-owned process definition has no case-definition link row, so the blueprint from + // its version tag is the only thing left to resolve the document definition with. + whenever(processDefinition.getBlueprintId()) + .thenReturn(BuildingBlockDefinitionId.of("bezwaar", "1.0.0")) + whenever(processDefinitionCaseDefinitionService.findByProcessDefinitionIdOrNull(any())) + .thenReturn(null) + whenever(documentDefinitionService.findByBlueprintId(any())) + .thenReturn(Optional.of(definition(caseDefinitionId))) + whenever(processDocumentService.dispatch(any())) + .thenReturn(ModifyDocumentAndCompleteTaskResultSucceeded(document)) + + val formSubmissionResult = defaultFormSubmissionService.handleSubmission( + processLinkId = formProcessLink(START_EVENT_START).id, + formData = formData, + documentId = null, + taskInstanceId = null, + documentDefinitionName = null + ) + + assertThat(formSubmissionResult).isInstanceOf(FormSubmissionResultSucceeded::class.java) + val captor = argumentCaptor() + verify(processDocumentService).dispatch(captor.capture()) + assertThat(captor.firstValue.newDocumentRequest().documentDefinitionName()).isEqualTo("person") + assertThat(captor.firstValue.newDocumentRequest().buildingBlockDefinitionKey()).isEqualTo("bezwaar") + assertThat(captor.firstValue.processDefinitionId()).isEqualTo(PROCESS_DEFINITION_ID) + } + private fun formProcessLink(activityType: ActivityTypeWithEventName = USER_TASK_CREATE): FormProcessLink { val formProcessLink = FormProcessLink( id = UUID.randomUUID(), diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/OperatonProcessJsonSchemaDocumentService.java b/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/OperatonProcessJsonSchemaDocumentService.java index 71e366a331..549b4af4c6 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/OperatonProcessJsonSchemaDocumentService.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/OperatonProcessJsonSchemaDocumentService.java @@ -348,11 +348,19 @@ public StartProcessForDocumentResult startProcessForDocument(StartProcessForDocu processInstanceWithDefinition.getProcessInstanceDto().getId() ); - runWithoutAuthorization(() -> processDocumentAssociationService.createProcessDocumentInstance( - operatonProcessInstanceId.toString(), - document.id().getId(), - processInstanceWithDefinition.getProcessDefinition().getName() - )); + // Starting the process may already have associated the instance with another document: a building + // block's start event listener creates the building block document and points the association at + // it. Leave that association alone, the same way modifyDocumentAndStartProcess does. + runWithoutAuthorization(() -> { + if (processDocumentAssociationService.findProcessDocumentInstance(operatonProcessInstanceId).isEmpty()) { + processDocumentAssociationService.createProcessDocumentInstance( + operatonProcessInstanceId.toString(), + document.id().getId(), + processInstanceWithDefinition.getProcessDefinition().getName() + ); + } + return null; + }); request.doAdditionalModifications(document); diff --git a/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/service/URLProcessLinkService.kt b/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/service/URLProcessLinkService.kt index 299d7c2bc7..9eab3617c7 100644 --- a/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/service/URLProcessLinkService.kt +++ b/backend/process-link-url/src/main/kotlin/com/ritense/processlink/url/service/URLProcessLinkService.kt @@ -24,7 +24,6 @@ import com.ritense.document.domain.Document import com.ritense.document.domain.impl.request.ModifyDocumentRequest import com.ritense.document.domain.impl.request.NewDocumentRequest import com.ritense.document.service.impl.JsonSchemaDocumentService -import com.ritense.processdocument.domain.ProcessDefinitionCaseDefinition import com.ritense.processdocument.domain.ProcessDefinitionId import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndCompleteTaskRequest import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndStartProcessRequest @@ -74,7 +73,7 @@ class URLProcessLinkService( val processDefinition = getProcessDefinition(processLink) val documentDefinitionNameToUse = document?.definitionId()?.name() ?: documentDefinitionName - ?: getProcessDefinitionCaseDefinition(processDefinition).id.caseDefinitionId.key + ?: resolveDocumentDefinitionName(processDefinition) val request = getRequest( processLink, @@ -112,13 +111,24 @@ class URLProcessLinkService( } } - private fun getProcessDefinitionCaseDefinition( + /** + * Derives the document definition name from the blueprint that owns the process definition. A + * case-definition link is used when one exists; a building-block-owned process definition has no such + * link row, so the blueprint from the process definition's version tag is used instead. The blueprint + * key doubles as the document definition name for both kinds. + */ + private fun resolveDocumentDefinitionName( processDefinition: OperatonProcessDefinition - ): ProcessDefinitionCaseDefinition { + ): String { val processDefinitionId = ProcessDefinitionId(processDefinition.id) - return AuthorizationContext.runWithoutAuthorization { - processDefinitionCaseDefinitionService.findByProcessDefinitionId(processDefinitionId) + val caseDefinitionLink = AuthorizationContext.runWithoutAuthorization { + processDefinitionCaseDefinitionService.findByProcessDefinitionIdOrNull(processDefinitionId) } + return caseDefinitionLink?.id?.caseDefinitionId?.key + ?: processDefinition.getBlueprintId()?.getIdKey() + ?: throw IllegalStateException( + "Could not determine the document definition name for process definition '${processDefinition.id}'" + ) } private fun getRequest( diff --git a/backend/process-link-url/src/test/kotlin/com/ritense/processlink/url/service/URLProcessLinkServiceTest.kt b/backend/process-link-url/src/test/kotlin/com/ritense/processlink/url/service/URLProcessLinkServiceTest.kt index 1ee51d5389..88f1c5bc13 100644 --- a/backend/process-link-url/src/test/kotlin/com/ritense/processlink/url/service/URLProcessLinkServiceTest.kt +++ b/backend/process-link-url/src/test/kotlin/com/ritense/processlink/url/service/URLProcessLinkServiceTest.kt @@ -23,6 +23,7 @@ import com.ritense.document.domain.impl.JsonSchemaDocumentDefinitionId import com.ritense.document.domain.impl.JsonSchemaDocumentId import com.ritense.document.service.impl.JsonSchemaDocumentService import com.ritense.processdocument.domain.impl.request.ModifyDocumentAndCompleteTaskRequest +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.result.DocumentFunctionResult @@ -30,16 +31,19 @@ import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.service.ProcessLinkService import com.ritense.processlink.url.domain.URLProcessLink import com.ritense.processlink.url.domain.URLVariables +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valtimo.service.OperatonTaskService +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -119,4 +123,49 @@ class URLProcessLinkServiceTest { verify(processDocumentService).dispatch(any()) } + + @Test + fun `should resolve the document definition name from the building block blueprint when there is no case definition link`() { + val processLinkId = UUID.randomUUID() + val processDefinitionId = UUID.randomUUID().toString() + val buildingBlockDefinitionId = BuildingBlockDefinitionId.of("bezwaar", "1.0.0") + + val processLink = mock() + whenever(processLink.processDefinitionId).thenReturn(processDefinitionId) + whenever(processLink.activityType).thenReturn(ActivityTypeWithEventName.START_EVENT_START) + whenever(processLinkService.getProcessLink(processLinkId, URLProcessLink::class.java)) + .thenReturn(processLink) + + val operatonProcessDefinition = mock() + whenever(operatonProcessDefinition.id).thenReturn(processDefinitionId) + whenever(operatonProcessDefinition.key).thenReturn("building-block-process") + whenever(operatonProcessDefinition.getBlueprintId()).thenReturn(buildingBlockDefinitionId) + whenever(repositoryService.findProcessDefinitionById(processDefinitionId)) + .thenReturn(operatonProcessDefinition) + + // A building-block-owned process definition has no case-definition link row. + whenever(processDefinitionCaseDefinitionService.findByProcessDefinitionIdOrNull(any())) + .thenReturn(null) + + whenever(objectMapper.createObjectNode()).thenReturn(ObjectMapper().createObjectNode()) + + val document = mock() + whenever(document.id()).thenReturn(JsonSchemaDocumentId.existingId(UUID.randomUUID())) + val resultSucceeded = mock>() + whenever(resultSucceeded.errors()).thenReturn(emptyList()) + whenever(resultSucceeded.resultingDocument()).thenReturn(Optional.of(document)) + whenever(processDocumentService.dispatch(any())).thenReturn(resultSucceeded) + + urlProcessLinkService.submit(processLinkId, null, null, null) + + val requestCaptor = argumentCaptor() + verify(processDocumentService).dispatch(requestCaptor.capture()) + assertThat(requestCaptor.firstValue.newDocumentRequest().documentDefinitionName()) + .isEqualTo("bezwaar") + assertThat(requestCaptor.firstValue.newDocumentRequest().buildingBlockDefinitionKey()) + .isEqualTo("bezwaar") + assertThat(requestCaptor.firstValue.newDocumentRequest().buildingBlockDefinitionVersionTag()) + .isEqualTo("1.0.0") + assertThat(requestCaptor.firstValue.processDefinitionId()).isEqualTo(processDefinitionId) + } } \ No newline at end of file diff --git a/documentation/features/case/form-flow.md b/documentation/features/case/form-flow.md index 7b76eba53a..eac0a46929 100644 --- a/documentation/features/case/form-flow.md +++ b/documentation/features/case/form-flow.md @@ -293,6 +293,34 @@ Each of these properties supports more than one expression, e.g. when a step is Valtimo provides access to certain variables in the SpEL context, e.g. what the current step is. Which properties are available can be found in the [available SpEL context properties section](forms/forms/whitelist-spring-bean.md#available-properties-in-spel-context). +### Completing a form flow + +The last step of a form flow normally hands control back to the process it belongs to. Valtimo provides the `valtimoFormFlow` bean for this. Which expression you need depends on where the form flow is linked, and using the wrong one fails at runtime because each expression needs different context: + +| Expression | Use when the form flow is linked to | Effect | +|------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------| +| `valtimoFormFlow.completeTask(additionalProperties)` | a **user task** | Completes the user task, so the process continues. | +| `valtimoFormFlow.completeTask(additionalProperties, step.submissionData)` | a **user task** | Completes the user task, so the process continues. | +| `valtimoFormFlow.completeTask(additionalProperties, step.submissionData, {'doc:/target':'/source'})` | a **user task** | Completes the user task, so the process continues. | +| `valtimoFormFlow.startCase(instance.id, {'doc:/target':'/source'})` | the **start event** of a process that creates a new case | Creates the case document and starts the process. | +| `valtimoFormFlow.startSupportingProcess(instance.id, {'doc:/target':'/source'})` | the **start event** of a process that runs for an existing case, including a building block's main process | Starts the process for the case the form flow was opened from. | + +Note the difference in arguments. `completeTask` takes `step.submissionData`, so it only sees the data of the step it is declared on. `startCase` and `startSupportingProcess` take `instance.id` and read the submission data of *all* steps, which is what you want in a multi-step form flow where the fields to store are spread over several forms. + +Both `startCase` and `startSupportingProcess` take a map of save paths, where the key is the destination and the value is a JSON pointer into the submission data. The `doc:` prefix writes to the document, `pv:` sets a process variable: + +{% code title="*/resources/config/case/loan/1-0-0/form-flow/example.form-flow.json" overflow="wrap" %} +```json +{ + "onComplete": [ + "${valtimoFormFlow.startSupportingProcess(instance.id, {'doc:/address/streetName':'/street', 'pv:approved':'/approval'})}" + ] +} +``` +{% endcode %} + +For a building block, `doc:` writes to the **case** document, not to the building block document. The building block document does not exist yet when the form flow is submitted: it is created while the building block's main process starts, and filled from the input mappings of the link between the case and the building block. To get a submitted value into the building block document, map it there with an input mapping on that link. + ### Examples The step condition below will only go to the step `loanApprovedStep` when a user has entered an age that is above 21: @@ -344,7 +372,7 @@ The `onOpen` expressions delete any existing submission data of the step, before ``` {% endcode %} -An example of a complete form flow: +An example of a complete form flow. This one is linked to a user task, so its last step calls `completeTask`. Replace that with `startCase` or `startSupportingProcess` when the form flow is linked to a start event, as described in [completing a form flow](#completing-a-form-flow): {% code title="*/resources/config/case/loan/1-0-0/form-flow/example.form-flow.json" overflow="wrap" %} ```json diff --git a/documentation/release-notes/13.x.x/13.42.0/README.md b/documentation/release-notes/13.x.x/13.42.0/README.md index b80cee2b5c..8f13d62d81 100644 --- a/documentation/release-notes/13.x.x/13.42.0/README.md +++ b/documentation/release-notes/13.x.x/13.42.0/README.md @@ -18,6 +18,13 @@ ## Bugfixes + +* **A form flow can now be used as the start form of a building block** + + Starting a building block from the actions of a case now opens its form flow start form, and submitting that + form starts the building block version that is linked to the case. Previously the start form did not open at + all and the building block could not be started this way, while the same setup with a regular form did work. + * **Deleting a process linked to a case now cleans up properly** When a process that was linked to a case definition was deleted, the link remained in the database.