From 32e0287c870d9c4e535289f6a1571c41ddc780fb Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Tue, 21 Jul 2026 13:36:44 +0200 Subject: [PATCH 1/7] plugin value resolver results and use in bb --- .../BuildingBlockPluginDefinitionService.kt | 64 +- ...uildingBlockPluginDefinitionServiceTest.kt | 153 +++++ .../configuration/CaseAutoConfiguration.kt | 9 +- .../CaseDefinitionImportPreviewService.kt | 2 + .../ritense/case/service/CaseTabImporter.kt | 61 +- .../case/web/rest/CaseDefinitionResource.kt | 17 +- .../rest/dto/PluginConfigurationPreviewDto.kt | 2 + .../case/service/CaseTabImporterTest.kt | 142 ++++- .../web/rest/CaseDefinitionResourceTest.kt | 2 +- .../importer/ImportPreviewContribution.kt | 9 +- .../PluginConfigurationMappingResolver.kt | 9 +- .../liquibase/13-32-0/13-32-0-master.xml | 2 + ...716-external-plugin-granted-capability.xml | 2 +- ...20260720-plugin-action-result-mappings.xml | 38 ++ ...tion-reference-external-plugin-version.xml | 141 +++++ .../docker-compose-base-test-mysql.yml | 1 + .../docker-compose-base-test-postgresql.yml | 1 + backend/external-plugin/build.gradle | 21 + .../docker-compose-override-mysql.yml | 6 + .../docker-compose-override-postgresql.yml | 6 + .../ExternalPluginAutoConfiguration.kt | 60 +- .../domain/ExternalPluginProcessLink.kt | 40 +- .../ExternalPluginTaskFormProcessLink.kt | 20 +- .../ExternalPluginImportPreviewContributor.kt | 136 ++++ .../ExternalPluginProcessLinkMapper.kt | 271 +++++++- .../ExternalPluginServiceTaskStartListener.kt | 123 +++- ...ExternalPluginTaskFormProcessLinkMapper.kt | 91 ++- ...ExternalPluginTaskFormSubmissionService.kt | 5 +- ...ternalPluginProcessLinkCreateRequestDto.kt | 15 +- .../dto/ExternalPluginProcessLinkDeployDto.kt | 9 +- ...ernalPluginProcessLinkExportResponseDto.kt | 9 +- .../ExternalPluginProcessLinkResponseDto.kt | 9 +- ...ternalPluginProcessLinkUpdateRequestDto.kt | 13 +- ...ginTaskFormProcessLinkExportResponseDto.kt | 3 +- ...nalPluginTaskFormProcessLinkResponseDto.kt | 2 +- .../ExternalPluginProcessLinkRepository.kt | 2 + ...rnalPluginTaskFormProcessLinkRepository.kt | 2 + ...ernalPluginConfigurationMappingResolver.kt | 237 +++++++ .../ExternalPluginHostUsageResolver.kt | 8 +- .../externalplugin/BaseIntegrationTest.kt | 38 ++ .../ritense/externalplugin/TestApplication.kt | 28 + ...uginConfigurationMappingResolverIntTest.kt | 63 ++ ...ternalPluginAutoConfigurationWiringTest.kt | 44 ++ ...ernalPluginImportPreviewContributorTest.kt | 201 ++++++ .../ExternalPluginProcessLinkImportIntTest.kt | 171 +++++ .../ExternalPluginProcessLinkMapperTest.kt | 598 ++++++++++++++++++ ...ernalPluginProcessLinkTypeDeductionTest.kt | 5 +- ...ernalPluginServiceTaskStartListenerTest.kt | 230 ++++++- ...nTaskFormProcessLinkActivityHandlerTest.kt | 14 +- ...rnalPluginTaskFormProcessLinkMapperTest.kt | 187 +++++- ...rnalPluginTaskFormSubmissionServiceTest.kt | 9 +- ...lPluginConfigurationMappingResolverTest.kt | 340 ++++++++++ .../ExternalPluginHostUsageResolverTest.kt | 75 ++- .../resources/config/application-mysql.yml | 16 + .../config/application-postgresql.yml | 14 + .../src/test/resources/config/application.yml | 39 ++ .../bpmn/external-plugin-import-process.bpmn | 39 ++ .../autodeploy.case-definition.json | 7 + backend/plugin-valtimo/build.gradle | 1 + .../ProcessLinkAutoConfiguration.kt | 6 +- .../ProcessLinkChangedEventListener.kt | 12 +- .../mapper/PluginProcessLinkDeployDto.kt | 2 + .../PluginProcessLinkExportResponseDto.kt | 2 + .../mapper/PluginProcessLinkMapper.kt | 16 + .../ValtimoPluginProcessLinkRepository.kt | 31 + .../ProcessLinkAutoConfigurationWiringTest.kt | 69 ++ .../ProcessLinkChangedEventListenerTest.kt | 19 +- .../mapper/PluginProcessLinkMapperTest.kt | 21 + .../configuration/PluginAutoConfiguration.kt | 10 + .../domain/PluginActionResultMapping.kt | 29 + .../domain/PluginConfigurationReference.kt | 15 +- .../plugin/domain/PluginProcessLink.kt | 12 +- .../service/PluginActionResultHandler.kt | 106 ++++ .../PluginActionResultMappingValidator.kt | 44 ++ .../ritense/plugin/service/PluginService.kt | 22 +- .../request/PluginProcessLinkCreateDto.kt | 2 + .../request/PluginProcessLinkUpdateDto.kt | 4 +- .../PluginDefinitionsWithDependenciesDto.kt | 19 +- .../rest/result/PluginProcessLinkResultDto.kt | 4 +- ...luginConfigurationReferenceStiSpikeTest.kt | 98 +++ .../PluginConfigurationReferenceTest.kt | 19 + .../StiSpikeExternalPluginProcessLink.kt | 58 ++ .../service/PluginActionResultHandlerTest.kt | 174 +++++ .../plugin/service/PluginServiceTest.kt | 84 +++ .../importer/ProcessLinkImporter.kt | 27 +- .../processlink/mapper/ProcessLinkMapper.kt | 51 ++ .../importer/ProcessLinkImporterTest.kt | 6 + .../mapper/RemapConfigurationIdFieldTest.kt | 86 +++ .../case-management-detail.component.ts | 5 +- ...ssing-plugin-configurations.component.html | 27 +- ...ssing-plugin-configurations.component.scss | 12 + ...missing-plugin-configurations.component.ts | 242 +++++-- .../case-management-upload.component.html | 43 +- .../case-management-upload.component.scss | 13 + .../case-management-upload.component.ts | 224 +++++-- .../src/lib/models/case-deployment.model.ts | 14 + .../lib/components/select/select.component.ts | 45 +- .../value-path-selector.component.ts | 29 +- .../src/lib/models/external-plugin.model.ts | 7 + .../valtimo/plugin/src/lib/models/plugin.ts | 1 + ...gure-building-block-plugins.component.html | 37 +- ...gure-building-block-plugins.component.scss | 8 + ...figure-building-block-plugins.component.ts | 135 +++- ...plugin-action-configuration.component.html | 136 ++-- .../plugin-action-configuration.component.ts | 225 ++++++- ...ugin-action-result-mappings.component.html | 113 ++++ ...ugin-action-result-mappings.component.scss | 69 ++ ...plugin-action-result-mappings.component.ts | 253 ++++++++ .../process-link-modal.component.html | 7 +- .../select-plugin-action.component.ts | 26 +- .../select-plugin-configuration.component.ts | 39 +- .../lib/constants/process-link.test-ids.ts | 10 + .../lib/models/building-block-field.model.ts | 11 + .../src/lib/models/plugin.model.ts | 19 +- .../src/lib/models/process-link.model.ts | 42 +- .../src/lib/process-link.module.ts | 2 + .../services/building-block-state.service.ts | 51 +- .../src/lib/services/plugin-state.service.ts | 79 ++- .../lib/services/process-link-step.service.ts | 97 ++- .../valtimo/shared/assets/core/en.json | 15 +- .../valtimo/shared/assets/core/nl.json | 15 +- plugin-host/app/src/plugin-manager.ts | 1 + .../plugin-sdk/src/manifest-validation.ts | 37 ++ plugin-host/plugin-sdk/src/models/types.ts | 15 + .../sample-plugins/case-summary/README.md | 14 +- .../sample-plugins/case-summary/manifest.json | 3 +- .../sample-plugins/case-summary/src/plugin.ts | 9 + .../scratch/external-plugin-system-plan.md | 189 +++++- 128 files changed, 6767 insertions(+), 462 deletions(-) create mode 100644 backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt create mode 100644 backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml create mode 100644 backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml create mode 100644 backend/external-plugin/docker-compose-override-mysql.yml create mode 100644 backend/external-plugin/docker-compose-override-postgresql.yml create mode 100644 backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt create mode 100644 backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt create mode 100644 backend/external-plugin/src/test/resources/config/application-mysql.yml create mode 100644 backend/external-plugin/src/test/resources/config/application-postgresql.yml create mode 100644 backend/external-plugin/src/test/resources/config/application.yml create mode 100644 backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn create mode 100644 backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json create mode 100644 backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceStiSpikeTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/plugin/domain/StiSpikeExternalPluginProcessLink.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt create mode 100644 backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt create mode 100644 frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html create mode 100644 frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss create mode 100644 frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt index eedbbf8227..7cc439a486 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionService.kt @@ -20,6 +20,8 @@ import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository import com.ritense.plugin.service.PluginService import com.ritense.plugin.web.rest.result.PluginDefinitionsWithDependenciesDto +import com.ritense.plugin.web.rest.result.PluginRequirementSource +import com.ritense.plugin.web.rest.result.PluginWithDependenciesDto import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.processlink.service.ProcessLinkService import com.ritense.valtimo.contract.annotation.SkipComponentScan @@ -85,12 +87,70 @@ class BuildingBlockPluginDefinitionService( return keys.toSet() } + /** + * External plugin references (`external_plugin` process links with a `BUILDING_BLOCK` + * reference) required by a building block, keyed by `pluginId` + manifest version, including + * references from nested building blocks. + */ + fun getExternalPluginReferencesForBuildingBlock( + buildingBlockDefinitionId: BuildingBlockDefinitionId + ): Set> { + return getExternalPluginReferencesForBuildingBlockRecursive(buildingBlockDefinitionId, mutableSetOf()) + } + + private fun getExternalPluginReferencesForBuildingBlockRecursive( + buildingBlockDefinitionId: BuildingBlockDefinitionId, + visitedBuildingBlocks: MutableSet + ): Set> { + if (visitedBuildingBlocks.contains(buildingBlockDefinitionId)) { + return emptySet() + } + visitedBuildingBlocks.add(buildingBlockDefinitionId) + + val processDefinitionIds = processDefinitionBuildingBlockDefinitionRepository + .findAllByIdBuildingBlockDefinitionId(buildingBlockDefinitionId) + .map { it.id.processDefinitionId.id } + + if (processDefinitionIds.isEmpty()) { + return emptySet() + } + + val directReferences = pluginProcessLinkRepository + .findExternalPluginReferencesByProcessDefinitionIds(processDefinitionIds) + .map { it.getPluginDefinitionKey() to it.getPluginDefinitionVersion() } + .toSet() + + val nestedBuildingBlockDefinitionIds = processDefinitionIds.flatMap { processDefinitionId -> + processLinkService.getProcessLinks(processDefinitionId) + .filterIsInstance() + .map { it.buildingBlockDefinitionId } + }.toSet() + + val nestedReferences = nestedBuildingBlockDefinitionIds.flatMap { nestedBuildingBlockId -> + getExternalPluginReferencesForBuildingBlockRecursive(nestedBuildingBlockId, visitedBuildingBlocks) + }.toSet() + + return directReferences + nestedReferences + } + fun getPluginDefinitionsWithDependenciesForBuildingBlock( buildingBlockId: BuildingBlockDefinitionId ): PluginDefinitionsWithDependenciesDto { val pluginKeys = getPluginDefinitionKeysForBuildingBlock(buildingBlockId) + val embedded = pluginService.getPluginDefinitionsWithDependencies(pluginKeys) + + val externalReferences = getExternalPluginReferencesForBuildingBlock(buildingBlockId) + val external = externalReferences.map { (pluginId, version) -> + PluginWithDependenciesDto( + pluginDefinitionKey = pluginId, + dependencies = emptyList(), + source = PluginRequirementSource.EXTERNAL, + pluginDefinitionVersion = version, + ) + } - return pluginService - .getPluginDefinitionsWithDependencies(pluginKeys) + return PluginDefinitionsWithDependenciesDto( + plugins = embedded.plugins + external + ) } } diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt new file mode 100644 index 0000000000..e90ae019a2 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/service/BuildingBlockPluginDefinitionServiceTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.buildingblock.service + +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinition +import com.ritense.buildingblock.domain.ProcessDefinitionBuildingBlockDefinitionId +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import com.ritense.buildingblock.repository.ProcessDefinitionBuildingBlockDefinitionRepository +import com.ritense.plugin.service.PluginService +import com.ritense.plugin.web.rest.result.PluginDefinitionsWithDependenciesDto +import com.ritense.plugin.web.rest.result.PluginRequirementSource +import com.ritense.plugin.web.rest.result.PluginWithDependenciesDto +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processlink.repository.ExternalPluginReferenceProjection +import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository +import com.ritense.processlink.service.ProcessLinkService +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BuildingBlockPluginDefinitionServiceTest { + + private lateinit var pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository + private lateinit var processDefinitionBuildingBlockDefinitionRepository: ProcessDefinitionBuildingBlockDefinitionRepository + private lateinit var pluginService: PluginService + private lateinit var processLinkService: ProcessLinkService + private lateinit var service: BuildingBlockPluginDefinitionService + + private val buildingBlockId = BuildingBlockDefinitionId.of("my-building-block", "1.0.0") + private val processDefinitionId = "my-building-block-process:1:abc" + + @BeforeEach + fun setUp() { + pluginProcessLinkRepository = mock() + processDefinitionBuildingBlockDefinitionRepository = mock() + pluginService = mock() + processLinkService = mock() + service = BuildingBlockPluginDefinitionService( + pluginProcessLinkRepository, + processDefinitionBuildingBlockDefinitionRepository, + pluginService, + processLinkService, + ) + + whenever(processDefinitionBuildingBlockDefinitionRepository.findAllByIdBuildingBlockDefinitionId(buildingBlockId)) + .thenReturn( + listOf( + ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of(processDefinitionId), + buildingBlockDefinitionId = buildingBlockId, + ), + main = true, + ) + ) + ) + whenever(processLinkService.getProcessLinks(processDefinitionId)).thenReturn(emptyList()) + } + + @Test + fun `combines embedded and external plugin requirements with a source discriminator`() { + whenever(pluginProcessLinkRepository.findPluginDefinitionKeysByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(listOf("embedded-plugin")) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(listOf(referenceProjection("case-summary", "0.1.0"))) + whenever(pluginService.getPluginDefinitionsWithDependencies(setOf("embedded-plugin"))).thenReturn( + PluginDefinitionsWithDependenciesDto( + plugins = listOf( + PluginWithDependenciesDto( + pluginDefinitionKey = "embedded-plugin", + dependencies = emptyList(), + ) + ) + ) + ) + + val result = service.getPluginDefinitionsWithDependenciesForBuildingBlock(buildingBlockId) + + assertThat(result.plugins).hasSize(2) + assertThat(result.plugins).anySatisfy { plugin -> + assertThat(plugin.pluginDefinitionKey).isEqualTo("embedded-plugin") + assertThat(plugin.source).isEqualTo(PluginRequirementSource.EMBEDDED) + assertThat(plugin.pluginDefinitionVersion).isNull() + } + assertThat(result.plugins).anySatisfy { plugin -> + assertThat(plugin.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(plugin.source).isEqualTo(PluginRequirementSource.EXTERNAL) + assertThat(plugin.pluginDefinitionVersion).isEqualTo("0.1.0") + } + } + + @Test + fun `external plugin requirements are collected recursively through nested building blocks`() { + val nestedBuildingBlockId = BuildingBlockDefinitionId.of("nested-building-block", "2.0.0") + val nestedProcessDefinitionId = "nested-building-block-process:1:def" + + whenever(pluginProcessLinkRepository.findPluginDefinitionKeysByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(emptyList()) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(processDefinitionId))) + .thenReturn(emptyList()) + whenever(pluginService.getPluginDefinitionsWithDependencies(emptySet())) + .thenReturn(PluginDefinitionsWithDependenciesDto(plugins = emptyList())) + + val nestedLink = mock { + on { this.buildingBlockDefinitionId } doReturn nestedBuildingBlockId + } + whenever(processLinkService.getProcessLinks(processDefinitionId)).thenReturn(listOf(nestedLink)) + + whenever(processDefinitionBuildingBlockDefinitionRepository.findAllByIdBuildingBlockDefinitionId(nestedBuildingBlockId)) + .thenReturn( + listOf( + ProcessDefinitionBuildingBlockDefinition( + id = ProcessDefinitionBuildingBlockDefinitionId( + processDefinitionId = ProcessDefinitionId.of(nestedProcessDefinitionId), + buildingBlockDefinitionId = nestedBuildingBlockId, + ), + main = true, + ) + ) + ) + whenever(processLinkService.getProcessLinks(nestedProcessDefinitionId)).thenReturn(emptyList()) + whenever(pluginProcessLinkRepository.findExternalPluginReferencesByProcessDefinitionIds(listOf(nestedProcessDefinitionId))) + .thenReturn(listOf(referenceProjection("nested-plugin", "1.2.3"))) + + val references = service.getExternalPluginReferencesForBuildingBlock(buildingBlockId) + + assertThat(references).containsExactly("nested-plugin" to "1.2.3") + } + + private fun referenceProjection(pluginId: String, version: String): ExternalPluginReferenceProjection = + object : ExternalPluginReferenceProjection { + override fun getPluginDefinitionKey() = pluginId + override fun getPluginDefinitionVersion() = version + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt index a2ee341061..c43e111f9e 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt @@ -127,7 +127,7 @@ class CaseAutoConfiguration { caseDefinitionChecker: CaseDefinitionChecker, configurationIssueRepository: CaseDefinitionConfigurationIssueRepository, caseDefinitionImportPreviewService: CaseDefinitionImportPreviewService, - pluginConfigurationMappingResolver: PluginConfigurationMappingResolver?, + pluginConfigurationMappingResolvers: List, ): CaseDefinitionResource { return CaseDefinitionResource( service, @@ -138,7 +138,7 @@ class CaseAutoConfiguration { caseDefinitionChecker, configurationIssueRepository, caseDefinitionImportPreviewService, - pluginConfigurationMappingResolver, + pluginConfigurationMappingResolvers, ) } @@ -359,8 +359,9 @@ class CaseAutoConfiguration { @ConditionalOnMissingBean(CaseTabImporter::class) fun caseTabImporter( objectMapper: ObjectMapper, - caseTabRepository: CaseTabRepository - ) = CaseTabImporter(objectMapper, caseTabRepository) + caseTabRepository: CaseTabRepository, + applicationEventPublisher: ApplicationEventPublisher + ) = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher) @Bean @ConditionalOnMissingBean(CaseTaskListExporter::class) diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt index 9cd00a4640..74c835cc4b 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionImportPreviewService.kt @@ -54,6 +54,8 @@ class CaseDefinitionImportPreviewService( processDefinitionKey = it.processDefinitionKey, activityId = it.activityId, existsInTargetEnvironment = it.existsInTargetEnvironment, + source = it.source, + pluginDefinitionVersion = it.pluginDefinitionVersion, ) }, ) diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt index 1fa5ee67b4..8d3ec5ae73 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt @@ -23,17 +23,21 @@ import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabId import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.service.event.CaseTabCreatedEvent import com.ritense.importer.ImportRequest import com.ritense.importer.Importer import com.ritense.importer.ValtimoImportTypes.Companion.CASE_TAB import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.springframework.context.ApplicationEventPublisher import org.springframework.transaction.annotation.Transactional +import java.util.UUID @Transactional class CaseTabImporter( private val objectMapper: ObjectMapper, private val caseTabRepository: CaseTabRepository, + private val applicationEventPublisher: ApplicationEventPublisher, ) : Importer { override fun type() = CASE_TAB @@ -42,28 +46,63 @@ class CaseTabImporter( override fun supports(fileName: String) = fileName.matches(FILENAME_REGEX) override fun import(request: ImportRequest) { - deploy(request.content.toString(Charsets.UTF_8), request.caseDefinitionId!!) + deploy(request) } - private fun deploy(fileContent: String, caseDefinitionId: CaseDefinitionId) { + private fun deploy(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId!! + val fileContent = request.content.toString(Charsets.UTF_8) val tabs = try { objectMapper.readValue(fileContent, object : TypeReference>() {}) } catch (e: Exception) { throw IllegalArgumentException("Failed to parse file content as valid case widget tabs: ${e.message}", e) } - val toSave = tabs.mapIndexed { index, tab -> - CaseTab( - id = CaseTabId(caseDefinitionId, tab.key), - name = tab.name, - tabOrder = index, - type = tab.type, - contentKey = tab.contentKey, - showTasks = tab.showTasks + val savedTabs = tabs.mapIndexed { index, tab -> + val contentKey = if (tab.type == CaseTabType.EXTERNAL_PLUGIN) { + remapExternalPluginContentKey(tab.contentKey, request.pluginConfigurationMappings) + } else { + tab.contentKey + } + + caseTabRepository.save( + CaseTab( + id = CaseTabId(caseDefinitionId, tab.key), + name = tab.name, + tabOrder = index, + type = tab.type, + contentKey = contentKey, + showTasks = tab.showTasks + ) ) } - caseTabRepository.saveAll(toSave) + savedTabs + .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } + .forEach { applicationEventPublisher.publishEvent(CaseTabCreatedEvent(it)) } + } + + /** + * `contentKey` must stay non-blank ([CaseTab]'s invariant), so a mapping value of `null` (admin + * left the tab's configuration unmapped) leaves the original, now-dangling id in place rather + * than producing an empty/invalid content key. + */ + private fun remapExternalPluginContentKey(contentKey: String, mappings: Map?): String { + if (mappings.isNullOrEmpty()) { + return contentKey + } + + val configPart = contentKey.substringBefore(':') + val bundlePart = contentKey.substringAfter(':', "") + val originalConfigId = try { + UUID.fromString(configPart) + } catch (_: IllegalArgumentException) { + null + } ?: return contentKey + + val mappedConfigId = mappings[originalConfigId] ?: return contentKey + + return if (bundlePart.isEmpty()) mappedConfigId.toString() else "$mappedConfigId:$bundlePart" } private companion object { diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt index 3533ce56bd..9ecc417d10 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/CaseDefinitionResource.kt @@ -89,7 +89,7 @@ class CaseDefinitionResource( private val caseDefinitionChecker: CaseDefinitionChecker, private val configurationIssueRepository: CaseDefinitionConfigurationIssueRepository, private val caseDefinitionImportPreviewService: CaseDefinitionImportPreviewService, - private val pluginConfigurationMappingResolver: PluginConfigurationMappingResolver?, + private val pluginConfigurationMappingResolvers: List, ) { @RunWithoutAuthorization @@ -544,10 +544,12 @@ class CaseDefinitionResource( @LoggableResource("caseDefinitionKey") @PathVariable caseDefinitionKey: String, @LoggableResource("caseDefinitionVersionTag") @PathVariable caseDefinitionVersionTag: String, ): ResponseEntity> { - val resolver = pluginConfigurationMappingResolver - ?: return ResponseEntity.ok(emptyList()) + if (pluginConfigurationMappingResolvers.isEmpty()) { + return ResponseEntity.ok(emptyList()) + } val caseDefinitionId = CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag) - return ResponseEntity.ok(resolver.getDanglingPluginConfigurations(caseDefinitionId)) + val dangling = pluginConfigurationMappingResolvers.flatMap { it.getDanglingPluginConfigurations(caseDefinitionId) } + return ResponseEntity.ok(dangling) } @EndpointDescription( @@ -561,10 +563,11 @@ class CaseDefinitionResource( @LoggableResource("caseDefinitionVersionTag") @PathVariable caseDefinitionVersionTag: String, @RequestBody mappings: Map, ): ResponseEntity { - val resolver = pluginConfigurationMappingResolver - ?: return ResponseEntity.status(501).build() + if (pluginConfigurationMappingResolvers.isEmpty()) { + return ResponseEntity.status(501).build() + } val caseDefinitionId = CaseDefinitionId.of(caseDefinitionKey, caseDefinitionVersionTag) - resolver.resolve(caseDefinitionId, mappings) + pluginConfigurationMappingResolvers.forEach { it.resolve(caseDefinitionId, mappings) } return ResponseEntity.noContent().build() } diff --git a/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt b/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt index 1e1fc1c05c..33ddb12378 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/web/rest/dto/PluginConfigurationPreviewDto.kt @@ -25,4 +25,6 @@ data class PluginConfigurationPreviewDto( val processDefinitionKey: String, val activityId: String, val existsInTargetEnvironment: Boolean, + val source: String = "embedded", + val pluginDefinitionVersion: String? = null, ) diff --git a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt index 941a212eb4..24d9555e86 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 Ritense BV, the Netherlands. + * 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. @@ -16,28 +16,44 @@ package com.ritense.case.service -import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.service.event.CaseTabCreatedEvent import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION import com.ritense.importer.ValtimoImportTypes.Companion.FORM +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.json.MapperSingleton import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.util.UUID @ExtendWith(MockitoExtension::class) class CaseTabImporterTest( - @Mock private val objectMapper: ObjectMapper, - @Mock private val caseTabRepository: CaseTabRepository + @Mock private val caseTabRepository: CaseTabRepository, ) { + private val objectMapper = MapperSingleton.get() + private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var importer: CaseTabImporter @BeforeEach fun before() { - importer = CaseTabImporter(objectMapper, caseTabRepository) + applicationEventPublisher = mock() + importer = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher) + whenever(caseTabRepository.save(any())).thenAnswer { it.arguments[0] } } @Test @@ -61,7 +77,121 @@ class CaseTabImporterTest( assertThat(importer.supports("/case/tab/test.case-tab-json")).isFalse() } + @Test + fun `should remap the config id embedded in the contentKey of an EXTERNAL_PLUGIN tab`() { + val sourceConfigId = UUID.randomUUID() + val targetConfigId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$sourceConfigId:overview", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + pluginConfigurationMappings = mapOf(sourceConfigId to targetConfigId), + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(caseTabRepository).save(captor.capture()) + assertThat(captor.firstValue.contentKey).isEqualTo("$targetConfigId:overview") + } + + @Test + fun `should leave the contentKey unchanged when no mapping applies`() { + val sourceConfigId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$sourceConfigId", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + pluginConfigurationMappings = null, + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(caseTabRepository).save(captor.capture()) + assertThat(captor.firstValue.contentKey).isEqualTo(sourceConfigId.toString()) + } + + @Test + fun `should publish CaseTabCreatedEvent for imported EXTERNAL_PLUGIN tabs so the side row gets created`() { + val configId = UUID.randomUUID() + val content = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + ) + + importer.import(request) + + val captor = argumentCaptor() + verify(applicationEventPublisher).publishEvent(captor.capture()) + assertThat(captor.firstValue.tab.contentKey).isEqualTo(configId.toString()) + assertThat(captor.firstValue.tab.type).isEqualTo(CaseTabType.EXTERNAL_PLUGIN) + } + + @Test + fun `should not publish CaseTabCreatedEvent for non-EXTERNAL_PLUGIN tabs`() { + val content = """ + [ + { + "key": "widgets", + "name": "Widgets", + "type": "widgets", + "contentKey": "widgets-tab", + "showTasks": false + } + ] + """.trimIndent() + + val request = ImportRequest( + fileName = FILENAME, + content = content.toByteArray(Charsets.UTF_8), + caseDefinitionId = CASE_DEFINITION_ID, + ) + + importer.import(request) + + verify(applicationEventPublisher, never()).publishEvent(any()) + } + private companion object { const val FILENAME = "/case/tab/my-doc-def.case-tab.json" + val CASE_DEFINITION_ID: CaseDefinitionId = CaseDefinitionId.of("my-doc-def", "1.0.0") } -} \ No newline at end of file +} diff --git a/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt b/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt index dad7e5dbf3..252c6d040b 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/web/rest/CaseDefinitionResourceTest.kt @@ -93,7 +93,7 @@ class CaseDefinitionResourceTest : BaseTest() { caseDefinitionChecker, configurationIssueRepository, caseDefinitionImportPreviewService, - null, + emptyList(), ) mapper = MapperSingleton.get() diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt index 455d015eca..6d1ad046f9 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/importer/ImportPreviewContribution.kt @@ -25,4 +25,11 @@ data class ImportPreviewContribution( val processDefinitionKey: String, val activityId: String, val existsInTargetEnvironment: Boolean, -) + val source: String = SOURCE_EMBEDDED, + val pluginDefinitionVersion: String? = null, +) { + companion object { + const val SOURCE_EMBEDDED = "embedded" + const val SOURCE_EXTERNAL = "external" + } +} diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt index cd7aeffe15..815918d9d1 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt @@ -22,7 +22,14 @@ import java.util.UUID data class DanglingPluginConfigurationDto( val pluginDefinitionKey: String?, val sourcePluginConfigurationIds: Set, -) + val source: String = SOURCE_EMBEDDED, + val pluginDefinitionVersion: String? = null, +) { + companion object { + const val SOURCE_EMBEDDED = "embedded" + const val SOURCE_EXTERNAL = "external" + } +} interface PluginConfigurationMappingResolver { fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml index 6cb809ad22..852d7b8f01 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml @@ -28,5 +28,7 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liqui + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml index 48e19dc833..18a5a60444 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260716-external-plugin-granted-capability.xml @@ -31,7 +31,7 @@ - + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml new file mode 100644 index 0000000000..e0667c245e --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-action-result-mappings.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml new file mode 100644 index 0000000000..55c90095b3 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE process_link + SET plugin_definition_key = epd.plugin_id, + plugin_definition_version = epd.version + FROM external_plugin_configuration epc + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + WHERE process_link.external_plugin_config_id = epc.id + AND process_link.process_link_type = 'external_plugin' + + + + + + + + + + + + UPDATE process_link pl + INNER JOIN external_plugin_configuration epc ON pl.external_plugin_config_id = epc.id + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + SET pl.plugin_definition_key = epd.plugin_id, + pl.plugin_definition_version = epd.version + WHERE pl.process_link_type = 'external_plugin' + + + + + + + + + + + + + UPDATE process_link + SET plugin_definition_key = epd.plugin_id, + plugin_definition_version = epd.version + FROM external_plugin_configuration epc + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + WHERE process_link.external_plugin_task_form_config_id = epc.id + AND process_link.process_link_type = 'external_plugin_task_form' + + + + + + + + + + + + UPDATE process_link pl + INNER JOIN external_plugin_configuration epc ON pl.external_plugin_task_form_config_id = epc.id + INNER JOIN external_plugin_definition epd ON epc.definition_id = epd.id + SET pl.plugin_definition_key = epd.plugin_id, + pl.plugin_definition_version = epd.version + WHERE pl.process_link_type = 'external_plugin_task_form' + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docker-resources/docker-compose-base-test-mysql.yml b/backend/docker-resources/docker-compose-base-test-mysql.yml index 91f89ef9ce..d4bb9e621b 100644 --- a/backend/docker-resources/docker-compose-base-test-mysql.yml +++ b/backend/docker-resources/docker-compose-base-test-mysql.yml @@ -74,5 +74,6 @@ services: # url: jdbc:mysql://localhost:3365/admin-settings-test # url: jdbc:mysql://localhost:3366/documenten-api-preview-test # url: jdbc:mysql://localhost:3367/aws-test +# url: jdbc:mysql://localhost:3368/external-plugin-test # url: localhost:55672 <--outbox rabbitmq # url: localhost:55673 <--inbox rabbitmq diff --git a/backend/docker-resources/docker-compose-base-test-postgresql.yml b/backend/docker-resources/docker-compose-base-test-postgresql.yml index d0f15e0458..c2de8c76bf 100644 --- a/backend/docker-resources/docker-compose-base-test-postgresql.yml +++ b/backend/docker-resources/docker-compose-base-test-postgresql.yml @@ -65,5 +65,6 @@ services: # url: jdbc:postgresql://localhost:3365/admin-settings-test # url: jdbc:postgresql://localhost:3366/documenten-api-preview-test # url: jdbc:postgresql://localhost:3367/aws-test +# url: jdbc:postgresql://localhost:3368/external-plugin-test # url: localhost:55672 <--rabbitmq # url: localhost:55673 <--rabbitmq diff --git a/backend/external-plugin/build.gradle b/backend/external-plugin/build.gradle index f4e7b9e45e..b18fc0be61 100644 --- a/backend/external-plugin/build.gradle +++ b/backend/external-plugin/build.gradle @@ -14,6 +14,25 @@ * limitations under the License. */ +dockerCompose { + projectName = "externalplugin" + + securityTesting { + isRequiredBy(project.tasks.securityTesting) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-postgresql.yml", "docker-compose-override-postgresql.yml") + } + + integrationTestingPostgresql { + isRequiredBy(project.tasks.integrationTestingPostgresql) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-postgresql.yml", "docker-compose-override-postgresql.yml") + } + + integrationTestingMysql { + isRequiredBy(project.tasks.integrationTestingMysql) + useComposeFiles.addAll("../docker-resources/docker-compose-base-test-mysql.yml", "docker-compose-override-mysql.yml") + } +} + dependencies { implementation project(":backend:process-link") implementation project(":backend:process-document") @@ -41,6 +60,8 @@ dependencies { testImplementation "org.mockito.kotlin:mockito-kotlin:${mockitoKotlinVersion}" testImplementation "org.jetbrains.kotlin:kotlin-test" testImplementation project(":backend:test-utils-common") + testImplementation project(":backend:importer") + testImplementation project(":backend:plugin-valtimo") // Modules whose controllers EndpointDescriptionCoverageTest scans — it requires every endpoint // to carry an @EndpointDescription, so those controllers must be on the test classpath diff --git a/backend/external-plugin/docker-compose-override-mysql.yml b/backend/external-plugin/docker-compose-override-mysql.yml new file mode 100644 index 0000000000..79d3abbdc2 --- /dev/null +++ b/backend/external-plugin/docker-compose-override-mysql.yml @@ -0,0 +1,6 @@ +services: + db: + ports: + - "3368:3306" + environment: + - MYSQL_DATABASE=external-plugin-test diff --git a/backend/external-plugin/docker-compose-override-postgresql.yml b/backend/external-plugin/docker-compose-override-postgresql.yml new file mode 100644 index 0000000000..9c6f071a70 --- /dev/null +++ b/backend/external-plugin/docker-compose-override-postgresql.yml @@ -0,0 +1,6 @@ +services: + db: + ports: + - "3368:5432" + environment: + - POSTGRES_DB=external-plugin-test diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt index 2c8833d6c7..6bdf837aab 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -22,6 +22,7 @@ import com.ritense.externalplugin.compatibility.DefaultGzacVersionProvider import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker import com.ritense.externalplugin.compatibility.GzacVersionProvider import com.ritense.externalplugin.compatibility.PluginPackageInspector +import com.ritense.externalplugin.preview.ExternalPluginImportPreviewContributor import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper import com.ritense.externalplugin.processlink.ExternalPluginServiceTaskStartListener import com.ritense.externalplugin.processlink.ExternalPluginSupportedProcessLinkTypeHandler @@ -50,6 +51,7 @@ import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider import com.ritense.externalplugin.service.EndpointDescriptionService import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver import com.ritense.externalplugin.service.ExternalPluginCaseTabResolverImpl +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService import com.ritense.externalplugin.service.ExternalPluginDiscoveryJob @@ -63,7 +65,15 @@ import com.ritense.externalplugin.service.PluginPropertyEncryptor import com.ritense.externalplugin.web.rest.ExternalPluginManagementResource import com.ritense.externalplugin.web.rest.ExternalPluginMenuPageResource import com.ritense.externalplugin.web.rest.ExternalPluginUserTokenResource +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.service.PluginActionResultHandler +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import com.ritense.valtimo.operaton.service.OperatonRepositoryService import com.ritense.valueresolver.ValueResolverService import org.operaton.bpm.engine.RepositoryService @@ -72,6 +82,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.boot.convert.DurationStyle import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.context.ApplicationEventPublisher import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.Order @@ -362,8 +373,12 @@ class ExternalPluginAutoConfiguration { @Bean @ConditionalOnMissingBean(ExternalPluginProcessLinkMapper::class) - fun externalPluginProcessLinkMapper(objectMapper: ObjectMapper) = - ExternalPluginProcessLinkMapper(objectMapper) + fun externalPluginProcessLinkMapper( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + processLinkRepository: ExternalPluginProcessLinkRepository, + ) = ExternalPluginProcessLinkMapper(objectMapper, configurationRepository, definitionRepository, processLinkRepository) @Bean @Order(40) @@ -372,8 +387,12 @@ class ExternalPluginAutoConfiguration { @Bean @ConditionalOnMissingBean(ExternalPluginTaskFormProcessLinkMapper::class) - fun externalPluginTaskFormProcessLinkMapper(objectMapper: ObjectMapper) = - ExternalPluginTaskFormProcessLinkMapper(objectMapper) + fun externalPluginTaskFormProcessLinkMapper( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + ) = ExternalPluginTaskFormProcessLinkMapper(objectMapper, configurationRepository, definitionRepository, taskFormProcessLinkRepository) @Bean @Order(41) @@ -430,6 +449,8 @@ class ExternalPluginAutoConfiguration { hostClient: ExternalPluginHostClient, valueResolverService: ValueResolverService, objectMapper: ObjectMapper, + pluginActionResultHandler: PluginActionResultHandler, + buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, ) = ExternalPluginServiceTaskStartListener( processLinkRepository, configurationService, @@ -438,10 +459,41 @@ class ExternalPluginAutoConfiguration { hostClient, valueResolverService, objectMapper, + pluginActionResultHandler, + buildingBlockPluginConfigurationResolver, ) @Bean @Order(430) @ConditionalOnMissingBean(ExternalPluginHttpSecurityConfigurer::class) fun externalPluginHttpSecurityConfigurer() = ExternalPluginHttpSecurityConfigurer() + + @Bean + @ConditionalOnMissingBean(ExternalPluginImportPreviewContributor::class) + fun externalPluginImportPreviewContributor( + objectMapper: ObjectMapper, + configurationRepository: ExternalPluginConfigurationRepository, + ): ImportPreviewContributor = ExternalPluginImportPreviewContributor(objectMapper, configurationRepository) + + @Bean + @ConditionalOnMissingBean(ExternalPluginConfigurationMappingResolver::class) + fun externalPluginConfigurationMappingResolver( + processLinkRepository: ExternalPluginProcessLinkRepository, + taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + configurationRepository: ExternalPluginConfigurationRepository, + caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + caseTabRepository: CaseTabRepository, + processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + caseDefinitionChecker: CaseDefinitionChecker, + applicationEventPublisher: ApplicationEventPublisher, + ): PluginConfigurationMappingResolver = ExternalPluginConfigurationMappingResolver( + processLinkRepository, + taskFormProcessLinkRepository, + configurationRepository, + caseExternalPluginTabRepository, + caseTabRepository, + processDefinitionCaseDefinitionService, + caseDefinitionChecker, + applicationEventPublisher, + ) } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt index baa6feda3e..b83c263652 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginProcessLink.kt @@ -18,15 +18,31 @@ package com.ritense.externalplugin.domain import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.domain.ProcessLink import io.hypersistence.utils.hibernate.type.json.JsonType import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded import jakarta.persistence.Entity import org.hibernate.annotations.Type import java.util.UUID +/** + * Service-task action link. [pluginConfigurationReference] carries `pluginId` (as + * [PluginConfigurationReference.pluginDefinitionKey]) and the manifest version as design-time + * metadata only (validation, UI warnings, import chooser). The **runtime** invocation version + * always derives from the resolved configuration's definition + * ([externalPluginConfigurationId] -> configuration -> definition), never from this reference — + * invoking one version's plugin code with another version's configuration must be impossible. + * + * Reuses [PluginConfigurationReference] — the same embeddable mapped by the embedded-plugin + * `PluginProcessLink` — on the same `process_link` columns (`reference_type`, + * `plugin_definition_key`, `plugin_definition_version`); verified safe for two STI siblings to + * share by `PluginConfigurationReferenceStiSpikeTest` in `:backend:plugin`. + */ @Entity @DiscriminatorValue(PROCESS_LINK_TYPE) class ExternalPluginProcessLink( @@ -36,17 +52,21 @@ class ExternalPluginProcessLink( activityType: ActivityTypeWithEventName, @Column(name = "external_plugin_config_id") - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID?, @Column(name = "external_plugin_action_key") val actionKey: String, - @Column(name = "external_plugin_version") - val pluginVersion: String, + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), @Type(value = JsonType::class) @Column(name = "external_plugin_action_properties", columnDefinition = "JSON") val actionProperties: ObjectNode? = null, + + @Type(value = JsonType::class) + @Column(name = "action_result_mappings", columnDefinition = "JSON") + val actionResultMappings: List = emptyList(), ) : ProcessLink( id, processDefinitionId, @@ -66,10 +86,11 @@ class ExternalPluginProcessLink( processDefinitionId: String = this.processDefinitionId, activityId: String = this.activityId, activityType: ActivityTypeWithEventName = this.activityType, - externalPluginConfigurationId: UUID = this.externalPluginConfigurationId, + externalPluginConfigurationId: UUID? = this.externalPluginConfigurationId, actionKey: String = this.actionKey, - pluginVersion: String = this.pluginVersion, + pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, actionProperties: ObjectNode? = this.actionProperties, + actionResultMappings: List = this.actionResultMappings, ) = ExternalPluginProcessLink( id = id, processDefinitionId = processDefinitionId, @@ -77,8 +98,9 @@ class ExternalPluginProcessLink( activityType = activityType, externalPluginConfigurationId = externalPluginConfigurationId, actionKey = actionKey, - pluginVersion = pluginVersion, + pluginConfigurationReference = pluginConfigurationReference, actionProperties = actionProperties, + actionResultMappings = actionResultMappings, ) override fun equals(other: Any?): Boolean { @@ -90,16 +112,20 @@ class ExternalPluginProcessLink( if (externalPluginConfigurationId != other.externalPluginConfigurationId) return false if (actionKey != other.actionKey) return false + if (pluginConfigurationReference != other.pluginConfigurationReference) return false if (actionProperties != other.actionProperties) return false + if (actionResultMappings != other.actionResultMappings) return false return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + externalPluginConfigurationId.hashCode() + result = 31 * result + (externalPluginConfigurationId?.hashCode() ?: 0) result = 31 * result + actionKey.hashCode() + result = 31 * result + pluginConfigurationReference.hashCode() result = 31 * result + (actionProperties?.hashCode() ?: 0) + result = 31 * result + actionResultMappings.hashCode() return result } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt index 77f55e5076..32e682be07 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/domain/ExternalPluginTaskFormProcessLink.kt @@ -17,10 +17,12 @@ package com.ritense.externalplugin.domain import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.domain.ProcessLink import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded import jakarta.persistence.Entity import java.util.UUID @@ -31,6 +33,12 @@ import java.util.UUID * the downscoped user token. The link only records which plugin configuration and which `task-form` * bundle to render — [bundleKey] is optional and, when null, the plugin's sole `task-form` bundle is * used. + * + * [pluginConfigurationReference] carries the design-time-only `pluginId`/version metadata, mirroring + * [ExternalPluginProcessLink] — it reuses the same shared `process_link` columns + * (`reference_type`, `plugin_definition_key`, `plugin_definition_version`) rather than a + * task-form-specific version column. The **runtime** invocation version always derives from the + * resolved configuration's definition, never from this reference. */ @Entity @DiscriminatorValue(PROCESS_LINK_TYPE) @@ -46,8 +54,8 @@ class ExternalPluginTaskFormProcessLink( @Column(name = "external_plugin_task_form_bundle_key") val bundleKey: String? = null, - @Column(name = "external_plugin_task_form_version") - val pluginVersion: String, + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), ) : ProcessLink( id, processDefinitionId, @@ -69,7 +77,7 @@ class ExternalPluginTaskFormProcessLink( activityType: ActivityTypeWithEventName = this.activityType, externalPluginConfigurationId: UUID = this.externalPluginConfigurationId, bundleKey: String? = this.bundleKey, - pluginVersion: String = this.pluginVersion, + pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, ) = ExternalPluginTaskFormProcessLink( id = id, processDefinitionId = processDefinitionId, @@ -77,7 +85,7 @@ class ExternalPluginTaskFormProcessLink( activityType = activityType, externalPluginConfigurationId = externalPluginConfigurationId, bundleKey = bundleKey, - pluginVersion = pluginVersion, + pluginConfigurationReference = pluginConfigurationReference, ) override fun equals(other: Any?): Boolean { @@ -89,7 +97,7 @@ class ExternalPluginTaskFormProcessLink( if (externalPluginConfigurationId != other.externalPluginConfigurationId) return false if (bundleKey != other.bundleKey) return false - if (pluginVersion != other.pluginVersion) return false + if (pluginConfigurationReference != other.pluginConfigurationReference) return false return true } @@ -98,7 +106,7 @@ class ExternalPluginTaskFormProcessLink( var result = super.hashCode() result = 31 * result + externalPluginConfigurationId.hashCode() result = 31 * result + (bundleKey?.hashCode() ?: 0) - result = 31 * result + pluginVersion.hashCode() + result = 31 * result + pluginConfigurationReference.hashCode() return result } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt new file mode 100644 index 0000000000..bb4ca11448 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt @@ -0,0 +1,136 @@ +/* + * 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.externalplugin.preview + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.valtimo.contract.importer.ImportPreviewContribution +import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL +import com.ritense.valtimo.contract.importer.ImportPreviewContributor +import java.util.UUID + +/** + * Surfaces external-plugin configuration references in the case-definition import preview, mirroring + * `PluginConfigurationImportPreviewContributor` (embedded plugins) for the two places an + * `externalPluginConfigurationId` can appear: `*.process-link.json` (`external_plugin` / + * `external_plugin_task_form` links) and `*.case-tab.json` (`EXTERNAL_PLUGIN` tabs, whose config id + * is embedded in `contentKey` as `"[:]"`). + */ +class ExternalPluginImportPreviewContributor( + private val objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, +) : ImportPreviewContributor { + + override fun contributePreview(zipEntries: Map): List { + val result = mutableListOf() + for ((fileName, content) in zipEntries) { + when { + PROCESS_LINK_REGEX.matches(fileName) -> result += contributeFromProcessLink(fileName, content) + CASE_TAB_REGEX.matches(fileName) -> result += contributeFromCaseTab(fileName, content) + } + } + return result + } + + private fun contributeFromProcessLink(fileName: String, content: ByteArray): List { + val match = PROCESS_LINK_REGEX.matchEntire(fileName) ?: return emptyList() + val processDefinitionKey = match.groupValues[1] + + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (node in jsonTree) { + val processLinkType = node.path("processLinkType").asText(null) ?: continue + if (processLinkType != "external_plugin" && processLinkType != "external_plugin_task_form") continue + + val referenceType = node.path("referenceType").asText("FIXED") + if (referenceType != "FIXED") continue + + val configIdText = node.path("externalPluginConfigurationId").asText(null) ?: continue + val configId = configIdText.toUuidOrNull() ?: continue + + val pluginDefinitionKey = node.path("pluginDefinitionKey").asText(null) + val pluginDefinitionVersion = node.path("pluginVersion").asText(null) + val activityId = node.path("activityId").asText(null) ?: continue + val actionKey = node.path("actionKey").asText(processLinkType) + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = pluginDefinitionKey, + pluginActionDefinitionKey = actionKey, + processDefinitionKey = processDefinitionKey, + activityId = activityId, + existsInTargetEnvironment = configurationRepository.existsById(configId), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + ) + } + return result + } + + private fun contributeFromCaseTab(fileName: String, content: ByteArray): List { + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (node in jsonTree) { + val type = node.path("type").asText(null) ?: continue + if (type != "external_plugin") continue + + val contentKey = node.path("contentKey").asText(null) ?: continue + val configId = contentKey.substringBefore(':').toUuidOrNull() ?: continue + val tabKey = node.path("key").asText(fileName) + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = null, + pluginActionDefinitionKey = "case-tab", + processDefinitionKey = fileName, + activityId = tabKey, + existsInTargetEnvironment = configurationRepository.existsById(configId), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = null, + ) + ) + } + return result + } + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } + + private companion object { + val PROCESS_LINK_REGEX = """.*/?process-link/(?:.*/)?(.+)\.process-link\.json""".toRegex() + val CASE_TAB_REGEX = """.*/?case/tab/([^/]+)\.case-tab\.json""".toRegex() + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt index 99b58dbea6..665b10f96d 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapper.kt @@ -17,6 +17,8 @@ package com.ritense.externalplugin.processlink import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginDefinition import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkCreateRequestDto @@ -24,18 +26,45 @@ import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkD import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkExportResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.domain.PluginConfigurationReferenceType.BUILDING_BLOCK +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED +import com.ritense.plugin.service.PluginActionResultMappingValidator import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto import com.ritense.valtimo.contract.BlueprintId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valueresolver.exception.ValueResolverValidationException +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher import java.util.UUID +/** + * Reference-model invariants mirror `PluginProcessLinkMapper.validateReference`: + * - `FIXED`: [ExternalPluginProcessLinkCreateRequestDto.externalPluginConfigurationId] is required + * (nullable only for dangling imports); `pluginDefinitionKey`/`pluginVersion` are + * *derived from the configuration* at save time — the frontend only ever sends the config id. + * - `BUILDING_BLOCK`: config id must be `NULL`; `pluginDefinitionKey` + `pluginVersion` are required + * from the DTO. + */ class ExternalPluginProcessLinkMapper( objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val processLinkRepository: ExternalPluginProcessLinkRepository, ) : ProcessLinkMapper { init { @@ -59,13 +88,30 @@ class ExternalPluginProcessLinkMapper( activityType = processLink.activityType, externalPluginConfigurationId = processLink.externalPluginConfigurationId, actionKey = processLink.actionKey, - pluginVersion = processLink.pluginVersion, actionProperties = processLink.actionProperties, + referenceType = processLink.pluginConfigurationReference.type, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + actionResultMappings = processLink.actionResultMappings, ) } override fun toNewProcessLink(createRequestDto: ProcessLinkCreateRequestDto, blueprintId: BlueprintId?): ProcessLink { createRequestDto as ExternalPluginProcessLinkCreateRequestDto + val reference = createReference( + createRequestDto.referenceType, + createRequestDto.externalPluginConfigurationId, + createRequestDto.pluginDefinitionKey, + createRequestDto.pluginVersion, + ) + validateReference(reference.type, createRequestDto.externalPluginConfigurationId) + PluginActionResultMappingValidator.validate(createRequestDto.actionResultMappings) + validateActionResultMappingSources( + createRequestDto.externalPluginConfigurationId, + reference, + createRequestDto.actionKey, + createRequestDto.actionResultMappings, + ) return ExternalPluginProcessLink( id = UUID.randomUUID(), processDefinitionId = createRequestDto.processDefinitionId, @@ -73,8 +119,9 @@ class ExternalPluginProcessLinkMapper( activityType = createRequestDto.activityType, externalPluginConfigurationId = createRequestDto.externalPluginConfigurationId, actionKey = createRequestDto.actionKey, - pluginVersion = createRequestDto.pluginVersion, + pluginConfigurationReference = reference, actionProperties = createRequestDto.actionProperties, + actionResultMappings = createRequestDto.actionResultMappings, ) } @@ -85,6 +132,20 @@ class ExternalPluginProcessLinkMapper( ): ProcessLink { updateRequestDto as ExternalPluginProcessLinkUpdateRequestDto assert(processLinkToUpdate.id == updateRequestDto.id) + val reference = createReference( + updateRequestDto.referenceType, + updateRequestDto.externalPluginConfigurationId, + updateRequestDto.pluginDefinitionKey, + updateRequestDto.pluginVersion, + ) + validateReference(reference.type, updateRequestDto.externalPluginConfigurationId) + PluginActionResultMappingValidator.validate(updateRequestDto.actionResultMappings) + validateActionResultMappingSources( + updateRequestDto.externalPluginConfigurationId, + reference, + updateRequestDto.actionKey, + updateRequestDto.actionResultMappings, + ) return ExternalPluginProcessLink( id = updateRequestDto.id, processDefinitionId = processLinkToUpdate.processDefinitionId, @@ -92,8 +153,9 @@ class ExternalPluginProcessLinkMapper( activityType = processLinkToUpdate.activityType, externalPluginConfigurationId = updateRequestDto.externalPluginConfigurationId, actionKey = updateRequestDto.actionKey, - pluginVersion = updateRequestDto.pluginVersion, + pluginConfigurationReference = reference, actionProperties = updateRequestDto.actionProperties, + actionResultMappings = updateRequestDto.actionResultMappings, ) } @@ -105,8 +167,11 @@ class ExternalPluginProcessLinkMapper( activityType = deployDto.activityType, externalPluginConfigurationId = deployDto.externalPluginConfigurationId, actionKey = deployDto.actionKey, - pluginVersion = deployDto.pluginVersion, actionProperties = deployDto.actionProperties, + referenceType = deployDto.referenceType, + pluginDefinitionKey = deployDto.pluginDefinitionKey, + pluginVersion = deployDto.pluginVersion, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -120,8 +185,11 @@ class ExternalPluginProcessLinkMapper( id = existingProcessLinkId, externalPluginConfigurationId = deployDto.externalPluginConfigurationId, actionKey = deployDto.actionKey, - pluginVersion = deployDto.pluginVersion, actionProperties = deployDto.actionProperties, + referenceType = deployDto.referenceType, + pluginDefinitionKey = deployDto.pluginDefinitionKey, + pluginVersion = deployDto.pluginVersion, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -132,8 +200,199 @@ class ExternalPluginProcessLinkMapper( activityType = processLink.activityType, externalPluginConfigurationId = processLink.externalPluginConfigurationId, actionKey = processLink.actionKey, - pluginVersion = processLink.pluginVersion, actionProperties = processLink.actionProperties, + referenceType = processLink.pluginConfigurationReference.type, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, + actionResultMappings = processLink.actionResultMappings, ) } + + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "externalPluginConfigurationId", mappings) + } + + /** + * Mirrors [com.ritense.valtimo.processlink.mapper.PluginProcessLinkMapper.afterImport]: a `FIXED` + * reference whose configuration id is `null` (left dangling by the import wizard) or no longer + * resolves to an existing configuration is a configuration issue. `BUILDING_BLOCK` references are + * never dangling in this sense — they have no configuration id to lose. + */ + override fun afterImport( + caseDefinitionId: CaseDefinitionId, + processDefinitionIds: Set, + applicationEventPublisher: ApplicationEventPublisher + ) { + val allLinks = processDefinitionIds.flatMap { pdId -> processLinkRepository.findByProcessDefinitionId(pdId) } + + val hasIssue = allLinks.any { link -> + if (link.pluginConfigurationReference.type != FIXED) { + return@any false + } + + val configId = link.externalPluginConfigurationId ?: return@any true + !configurationRepository.existsById(configId) + } + + if (hasIssue) { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } else { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE) + ) + } + } + + /** + * For `FIXED`, `pluginDefinitionKey`/`pluginVersion` are always derived from the configuration + * — even if the caller (deploy DTOs, legacy imports) supplied values, those are ignored so the + * reference can never drift from the actual configuration it points at. For `BUILDING_BLOCK`, + * both must be supplied by the caller since there is no configuration id to derive them from. + */ + private fun createReference( + type: PluginConfigurationReferenceType, + externalPluginConfigurationId: UUID?, + pluginDefinitionKey: String?, + pluginVersion: String?, + ): PluginConfigurationReference { + return when (type) { + FIXED -> { + val definition = externalPluginConfigurationId?.let { configId -> + configurationRepository.findById(configId) + .map { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + .orElse(null) + } + PluginConfigurationReference( + type = type, + pluginDefinitionKey = definition?.pluginId ?: pluginDefinitionKey, + pluginDefinitionVersion = definition?.version ?: pluginVersion, + ) + } + BUILDING_BLOCK -> PluginConfigurationReference( + type = type, + pluginDefinitionKey = requireNotNull(pluginDefinitionKey) { + "pluginDefinitionKey is required when reference type is BUILDING_BLOCK" + }, + pluginDefinitionVersion = requireNotNull(pluginVersion) { + "pluginVersion is required when reference type is BUILDING_BLOCK" + }, + ) + } + } + + private fun validateReference( + type: PluginConfigurationReferenceType, + externalPluginConfigurationId: UUID?, + ) { + when (type) { + FIXED -> {} // externalPluginConfigurationId may be null during import (dangling) + BUILDING_BLOCK -> require(externalPluginConfigurationId == null) { + "externalPluginConfigurationId must be empty when reference type is BUILDING_BLOCK" + } + } + } + + /** + * A mapping source is only meaningful when the action declares its output shape in the + * manifest — there is no free-text pointer anymore, the frontend stepper offers a dropdown of + * the declared `outputs` keys. Resolves the link's definition ([FIXED] via the configuration, + * [BUILDING_BLOCK] via `pluginId`+version) and checks each mapping's first JSON-pointer segment + * is a declared key; an action with no/empty `outputs` cannot carry any mappings at all. + * Lenient (skip validation, log a warning) when the definition or its manifest can't be + * resolved — import/legacy scenarios where the plugin isn't installed yet or predates this + * feature must not block saving the link. + */ + private fun validateActionResultMappingSources( + externalPluginConfigurationId: UUID?, + reference: PluginConfigurationReference, + actionKey: String, + mappings: List, + ) { + if (mappings.isEmpty()) { + return + } + + val definition = resolveDefinition(externalPluginConfigurationId, reference) + if (definition == null) { + logger.warn { + "Could not resolve the definition for external plugin process link action '$actionKey' " + + "(reference '${reference.pluginDefinitionKey}@${reference.pluginDefinitionVersion}') — " + + "skipping action result mapping source validation" + } + return + } + + val declaredOutputs = declaredActionOutputs(definition, actionKey) + if (declaredOutputs == null) { + logger.warn { + "Plugin '${definition.pluginId}@${definition.version}' manifest does not declare its " + + "'actions' — skipping action result mapping source validation for action '$actionKey'" + } + return + } + + if (declaredOutputs.isEmpty()) { + throw ValueResolverValidationException( + "Action '$actionKey' of plugin '${definition.pluginId}@${definition.version}' does not " + + "declare any outputs — it cannot be used with action result mappings" + ) + } + + mappings.forEach { mapping -> + val key = mapping.source.removePrefix("/").substringBefore("/") + if (key !in declaredOutputs) { + throw ValueResolverValidationException( + "Action result mapping source '${mapping.source}' does not match a declared output of " + + "action '$actionKey' (plugin '${definition.pluginId}@${definition.version}') — " + + "declared outputs: ${declaredOutputs.joinToString()}" + ) + } + } + } + + private fun resolveDefinition( + externalPluginConfigurationId: UUID?, + reference: PluginConfigurationReference, + ): ExternalPluginDefinition? { + return when (reference.type) { + FIXED -> externalPluginConfigurationId + ?.let { configId -> configurationRepository.findById(configId).orElse(null) } + ?.let { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + + BUILDING_BLOCK -> { + val pluginId = reference.pluginDefinitionKey + val version = reference.pluginDefinitionVersion + if (pluginId == null || version == null) { + null + } else { + definitionRepository.findByPluginIdAndVersion(pluginId, version) + } + } + } + } + + /** + * `null` means the manifest doesn't declare `actions` at all (lenient); an empty list means the + * action key wasn't found, or was found without an `outputs` array — both treated as "no + * declared outputs" (reject any mapping). + */ + private fun declaredActionOutputs(definition: ExternalPluginDefinition, actionKey: String): List? { + val actions = definition.manifestJson?.get("actions") ?: return null + if (!actions.isArray) { + return null + } + val action = actions.firstOrNull { it.get("key")?.asText() == actionKey } ?: return emptyList() + val outputs = action.get("outputs") ?: return emptyList() + if (!outputs.isArray) { + return emptyList() + } + return outputs.mapNotNull { it.asText(null) } + } + + companion object { + const val ISSUE_TYPE = "external-plugin-process-link" + private val logger = KotlinLogging.logger {} + } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt index 1b6b72a332..f232e359b1 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt @@ -29,6 +29,9 @@ import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService import com.ritense.externalplugin.service.ExternalPluginHostService import com.ritense.logging.withLoggingContext +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.PluginActionResultHandler import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.contract.annotation.SkipComponentScan import com.ritense.valtimo.event.OperatonExecutionEvent @@ -37,6 +40,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.operaton.bpm.engine.delegate.DelegateExecution import org.springframework.context.event.EventListener import org.springframework.stereotype.Component +import java.util.UUID @Component @SkipComponentScan class ExternalPluginServiceTaskStartListener( @@ -47,6 +51,8 @@ class ExternalPluginServiceTaskStartListener( private val hostClient: ExternalPluginHostClient, private val valueResolverService: ValueResolverService, private val objectMapper: ObjectMapper, + private val pluginActionResultHandler: PluginActionResultHandler, + private val buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver? = null, ) { @EventListener( @@ -66,29 +72,119 @@ class ExternalPluginServiceTaskStartListener( } private fun invoke(execution: DelegateExecution, processLink: ExternalPluginProcessLink) { - val configuration = configurationService.get(processLink.externalPluginConfigurationId) + val configurationId = resolveConfigurationId(execution, processLink) + val configuration = configurationService.get(configurationId) val definition = definitionService.get(configuration.definitionId) + validateResolvedDefinition(processLink, definition) val host = hostService.get(definition.hostId) val hostSecret = hostService.decryptedSecret(host) val resolvedProperties = resolveActionProperties(execution, processLink) val payload = buildPayload(execution, processLink, configuration, resolvedProperties) + // Version always comes from the resolved configuration's definition, never from the link's + // (design-time-only) reference — invoking v1 plugin code with a v2 config's token must be + // impossible. See PluginConfigurationReference / D1. val response = hostClient.invokeAction( baseUrl = host.baseUrl, pluginId = definition.pluginId, - version = processLink.pluginVersion, + version = definition.version, actionKey = processLink.actionKey, payload = payload, hostSecret = hostSecret, ) when { - response.status in 200..299 -> applySuccess(execution, response.body) + response.status in 200..299 -> applySuccess(execution, processLink, response.body) else -> throw actionFailed(response, definition, processLink) } } + /** + * `FIXED` links carry the configuration id directly. `BUILDING_BLOCK` links carry a + * `pluginId`/version pair (design-time metadata, D1) and are resolved through the shared + * [BuildingBlockPluginConfigurationResolver] SPI (already in `:backend:plugin`; no new module + * dependency) using the namespaced key `external-plugin:@` (D2) — the same + * `pluginConfigurationMappings` map the embedded system's `PluginService.invoke` reads, just + * under a distinct key so the two systems can never collide. + */ + private fun resolveConfigurationId(execution: DelegateExecution, processLink: ExternalPluginProcessLink): UUID { + return when (processLink.pluginConfigurationReference.type) { + PluginConfigurationReferenceType.FIXED -> requireNotNull(processLink.externalPluginConfigurationId) { + "External plugin process link '${processLink.id}' has no configuration id" + } + + PluginConfigurationReferenceType.BUILDING_BLOCK -> { + val pluginId = processLink.pluginConfigurationReference.pluginDefinitionKey + ?: throw IllegalStateException( + "External plugin process link '${processLink.id}' has a BUILDING_BLOCK reference " + + "without a pluginDefinitionKey" + ) + val version = processLink.pluginConfigurationReference.pluginDefinitionVersion + ?: throw IllegalStateException( + "External plugin process link '${processLink.id}' has a BUILDING_BLOCK reference " + + "without a pluginDefinitionVersion" + ) + val resolver = buildingBlockPluginConfigurationResolver + ?: throw IllegalStateException( + "Building block plugin configuration resolver is not available — cannot resolve " + + "external plugin process link '${processLink.id}'" + ) + + val mappingKey = buildingBlockMappingKey(pluginId, version) + resolver.resolve(execution, mappingKey) + ?: throw IllegalStateException( + "No plugin configuration mapping provided for external plugin '$mappingKey' " + + "(process link '${processLink.id}')" + ) + } + } + } + + /** + * Version mismatches between the (design-time) reference and the resolved configuration's + * definition are allowed — the resolved definition's version always wins at runtime (D1) — but + * are surfaced as a warning since they usually mean the BB mapping was pinned to a version that + * later moved. A `pluginId` mismatch is not recoverable: it means the mapped configuration + * belongs to an entirely different plugin, so the action would be invoked against unrelated code. + * Regardless of reference type, the resolved definition's manifest must still declare the + * action key the link invokes — a `BUILDING_BLOCK` mapping can point at a configuration whose + * definition no longer exposes this action. + */ + private fun validateResolvedDefinition(processLink: ExternalPluginProcessLink, definition: ExternalPluginDefinition) { + val reference = processLink.pluginConfigurationReference + val expectedPluginId = reference.pluginDefinitionKey + + if (expectedPluginId != null) { + require(definition.pluginId == expectedPluginId) { + "External plugin process link '${processLink.id}' expects plugin '$expectedPluginId' " + + "but resolved configuration belongs to plugin '${definition.pluginId}'" + } + + val expectedVersion = reference.pluginDefinitionVersion + if (expectedVersion != null && expectedVersion != definition.version) { + logger.warn { + "External plugin process link '${processLink.id}' reference pins version " + + "'$expectedVersion' for plugin '$expectedPluginId', but the resolved configuration " + + "uses version '${definition.version}' — proceeding with the resolved configuration's version" + } + } + } + + require(definitionDeclaresActionKey(definition, processLink.actionKey)) { + "External plugin process link '${processLink.id}' invokes action '${processLink.actionKey}' " + + "which plugin '${definition.pluginId}@${definition.version}' does not declare in its manifest" + } + } + + private fun definitionDeclaresActionKey(definition: ExternalPluginDefinition, actionKey: String): Boolean { + val actions = definition.manifestJson?.get("actions") ?: return true + if (!actions.isArray) return true + return actions.any { it.get("key")?.asText() == actionKey } + } + + private fun buildingBlockMappingKey(pluginId: String, version: String) = "external-plugin:$pluginId@$version" + private fun resolveActionProperties(execution: DelegateExecution, processLink: ExternalPluginProcessLink): ObjectNode { val rawProperties = processLink.actionProperties ?: objectMapper.createObjectNode() val keysToResolve = mutableListOf() @@ -127,11 +223,22 @@ class ExternalPluginServiceTaskStartListener( return payload } - private fun applySuccess(execution: DelegateExecution, body: JsonNode?) { - val variables = body?.get("variables") ?: return - if (!variables.isObject) return - variables.fields().forEachRemaining { (key, value) -> - execution.setVariable(key, objectMapper.treeToValue(value, Any::class.java)) + /** + * `variables` and `result` are separate channels that never interfere: `variables` keeps its + * existing process-variable behavior unconditionally, while `result` only feeds the configured + * [PluginActionResultMapping][com.ritense.plugin.domain.PluginActionResultMapping]s. Plugins built + * against an older SDK simply omit `result` and have nothing to map. + */ + private fun applySuccess(execution: DelegateExecution, processLink: ExternalPluginProcessLink, body: JsonNode?) { + val variables = body?.get("variables") + if (variables != null && variables.isObject) { + variables.fields().forEachRemaining { (key, value) -> + execution.setVariable(key, objectMapper.treeToValue(value, Any::class.java)) + } + } + + if (processLink.actionResultMappings.isNotEmpty()) { + pluginActionResultHandler.handle(execution, body?.get("result"), processLink.actionResultMappings) } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt index dcf33cc2a0..c8a8685da0 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt @@ -17,6 +17,7 @@ package com.ritense.externalplugin.processlink import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink.Companion.PROCESS_LINK_TYPE import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkCreateRequestDto @@ -24,18 +25,39 @@ import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProc import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkExportResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto import com.ritense.valtimo.contract.BlueprintId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import org.springframework.context.ApplicationEventPublisher import java.util.UUID +/** + * The task-form link is always a `FIXED` reference today; `pluginDefinitionKey`/`pluginVersion` are + * derived from + * [ExternalPluginTaskFormProcessLinkCreateRequestDto.externalPluginConfigurationId]'s definition at + * save time — mirrors [ExternalPluginProcessLinkMapper]'s `FIXED` handling. The frontend keeps + * sending `pluginVersion` for backward compatibility, but it is only used as a dangling-import + * fallback when the configuration can no longer be resolved. + */ class ExternalPluginTaskFormProcessLinkMapper( objectMapper: ObjectMapper, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val processLinkRepository: ExternalPluginTaskFormProcessLinkRepository, ) : ProcessLinkMapper { init { @@ -58,7 +80,7 @@ class ExternalPluginTaskFormProcessLinkMapper( activityId = processLink.activityId, activityType = processLink.activityType, externalPluginConfigurationId = processLink.externalPluginConfigurationId, - pluginVersion = processLink.pluginVersion, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, bundleKey = processLink.bundleKey, ) } @@ -71,7 +93,10 @@ class ExternalPluginTaskFormProcessLinkMapper( activityId = createRequestDto.activityId, activityType = createRequestDto.activityType, externalPluginConfigurationId = createRequestDto.externalPluginConfigurationId, - pluginVersion = createRequestDto.pluginVersion, + pluginConfigurationReference = createReference( + createRequestDto.externalPluginConfigurationId, + createRequestDto.pluginVersion, + ), bundleKey = createRequestDto.bundleKey, ) } @@ -89,7 +114,10 @@ class ExternalPluginTaskFormProcessLinkMapper( activityId = processLinkToUpdate.activityId, activityType = processLinkToUpdate.activityType, externalPluginConfigurationId = updateRequestDto.externalPluginConfigurationId, - pluginVersion = updateRequestDto.pluginVersion, + pluginConfigurationReference = createReference( + updateRequestDto.externalPluginConfigurationId, + updateRequestDto.pluginVersion, + ), bundleKey = updateRequestDto.bundleKey, ) } @@ -126,8 +154,63 @@ class ExternalPluginTaskFormProcessLinkMapper( activityId = processLink.activityId, activityType = processLink.activityType, externalPluginConfigurationId = processLink.externalPluginConfigurationId, - pluginVersion = processLink.pluginVersion, + pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, + pluginVersion = processLink.pluginConfigurationReference.pluginDefinitionVersion, bundleKey = processLink.bundleKey, ) } + + /** + * `externalPluginConfigurationId` is non-nullable on this link's deploy DTO (always `FIXED`), + * so a mapping value of `null` (admin chose to leave it dangling) is left as the original, + * unmapped id rather than nulled out — nulling it would fail deserialization outright. + */ + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "externalPluginConfigurationId", mappings, allowNull = false) + } + + /** + * Mirrors [ExternalPluginProcessLinkMapper.afterImport]. Unlike the service-task link, the + * task-form link's `externalPluginConfigurationId` is never nullable — it always references a + * `FIXED` configuration — so "dangling" here only means the referenced configuration no longer + * exists in the target environment. + */ + override fun afterImport( + caseDefinitionId: CaseDefinitionId, + processDefinitionIds: Set, + applicationEventPublisher: ApplicationEventPublisher + ) { + val allLinks = processDefinitionIds.flatMap { pdId -> processLinkRepository.findByProcessDefinitionId(pdId) } + + val hasIssue = allLinks.any { link -> !configurationRepository.existsById(link.externalPluginConfigurationId) } + + if (hasIssue) { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginProcessLinkMapper.ISSUE_TYPE) + ) + } else { + applicationEventPublisher.publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginProcessLinkMapper.ISSUE_TYPE) + ) + } + } + + /** + * Always `FIXED` today: `pluginDefinitionKey`/`pluginVersion` are derived from + * [externalPluginConfigurationId]'s definition, falling back to the dto-supplied [pluginVersion] + * only when the configuration can no longer be resolved (dangling import). + */ + private fun createReference( + externalPluginConfigurationId: UUID, + pluginVersion: String, + ): PluginConfigurationReference { + val definition = configurationRepository.findById(externalPluginConfigurationId) + .map { configuration -> definitionRepository.findById(configuration.definitionId).orElse(null) } + .orElse(null) + return PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = definition?.pluginId, + pluginDefinitionVersion = definition?.version ?: pluginVersion, + ) + } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt index c0b9c6bf30..61d6f16115 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionService.kt @@ -198,10 +198,13 @@ class ExternalPluginTaskFormSubmissionService( // is required to route the hook. val submitKey = bundle.get("key")?.asText() ?: return null val host = hostService.get(definition.hostId) + // Version always comes from the resolved configuration's definition, never from the link's + // (design-time-only) reference — same rule as ExternalPluginServiceTaskStartListener. See + // PluginConfigurationReference / D1. return SubmitHook( baseUrl = host.baseUrl, pluginId = definition.pluginId, - version = processLink.pluginVersion, + version = definition.version, submitKey = submitKey, hostSecret = hostService.decryptedSecret(host), ) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt index dc3a7af768..861228f58c 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkCreateRequestDto.kt @@ -19,19 +19,30 @@ package com.ritense.externalplugin.processlink.web.dto import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import java.util.UUID +/** + * [pluginVersion]/[pluginDefinitionKey] are only required for `BUILDING_BLOCK` references — for + * `FIXED` the mapper derives both from [externalPluginConfigurationId]'s definition at save time, + * mirroring the embedded mapper's definition-key fallback (D1). The frontend keeps sending just the + * config id for `FIXED`. + */ @JsonTypeName(PROCESS_LINK_TYPE) data class ExternalPluginProcessLinkCreateRequestDto( override val processDefinitionId: String, override val activityId: String, override val activityType: ActivityTypeWithEventName, - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID? = null, val actionKey: String, - val pluginVersion: String, val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkCreateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt index 75522be48a..1a158f7b4b 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkDeployDto.kt @@ -19,6 +19,8 @@ package com.ritense.externalplugin.processlink.web.dto import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ActivityTypeWithEventName import java.util.UUID @@ -28,10 +30,13 @@ class ExternalPluginProcessLinkDeployDto( override val processDefinitionId: String, override val activityId: String, override val activityType: ActivityTypeWithEventName, - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID? = null, val actionKey: String, - val pluginVersion: String, val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkDeployDto { override val processLinkType: String get() = PROCESS_LINK_TYPE diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt index b69665ba60..8f01113978 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkExportResponseDto.kt @@ -19,6 +19,8 @@ package com.ritense.externalplugin.processlink.web.dto import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto import java.util.UUID @@ -27,10 +29,13 @@ import java.util.UUID class ExternalPluginProcessLinkExportResponseDto( override val activityId: String, override val activityType: ActivityTypeWithEventName, - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID?, val actionKey: String, - val pluginVersion: String, val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkExportResponseDto { override val processLinkType: String get() = PROCESS_LINK_TYPE diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt index d8fec1e608..3112c8f97d 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkResponseDto.kt @@ -18,6 +18,8 @@ package com.ritense.externalplugin.processlink.web.dto import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.web.rest.dto.ProcessLinkResponseDto import java.util.UUID @@ -27,10 +29,13 @@ data class ExternalPluginProcessLinkResponseDto( override val processDefinitionId: String, override val activityId: String, override val activityType: ActivityTypeWithEventName, - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID?, val actionKey: String, - val pluginVersion: String, val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkResponseDto { override val processLinkType: String get() = PROCESS_LINK_TYPE diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt index ebd1f86c7b..c130c2e83b 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginProcessLinkUpdateRequestDto.kt @@ -19,16 +19,25 @@ package com.ritense.externalplugin.processlink.web.dto import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.externalplugin.domain.ExternalPluginProcessLink.Companion.PROCESS_LINK_TYPE +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto import java.util.UUID +/** + * See [ExternalPluginProcessLinkCreateRequestDto] for why [pluginVersion]/[pluginDefinitionKey] are + * nullable here (only required for `BUILDING_BLOCK`). + */ @JsonTypeName(PROCESS_LINK_TYPE) data class ExternalPluginProcessLinkUpdateRequestDto( override val id: UUID, - val externalPluginConfigurationId: UUID, + val externalPluginConfigurationId: UUID? = null, val actionKey: String, - val pluginVersion: String, val actionProperties: ObjectNode? = null, + val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkUpdateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt index a049dbf3d6..56be3aaa50 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkExportResponseDto.kt @@ -27,7 +27,8 @@ class ExternalPluginTaskFormProcessLinkExportResponseDto( override val activityId: String, override val activityType: ActivityTypeWithEventName, val externalPluginConfigurationId: UUID, - val pluginVersion: String, + val pluginDefinitionKey: String? = null, + val pluginVersion: String? = null, val bundleKey: String? = null, ) : ProcessLinkExportResponseDto { override val processLinkType: String diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt index 05921b92fc..80db4eedce 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/web/dto/ExternalPluginTaskFormProcessLinkResponseDto.kt @@ -27,7 +27,7 @@ data class ExternalPluginTaskFormProcessLinkResponseDto( override val activityId: String, override val activityType: ActivityTypeWithEventName, val externalPluginConfigurationId: UUID, - val pluginVersion: String, + val pluginVersion: String? = null, val bundleKey: String? = null, ) : ProcessLinkResponseDto { override val processLinkType: String diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt index 8d718c46a7..48d94432ad 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt @@ -29,6 +29,8 @@ interface ExternalPluginProcessLinkRepository : JpaRepository + fun findByProcessDefinitionId(processDefinitionId: String): List + fun findAllByExternalPluginConfigurationIdIn( externalPluginConfigurationIds: Collection, ): List diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt index 3e01bf5d93..83d6b6df00 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginTaskFormProcessLinkRepository.kt @@ -22,6 +22,8 @@ import java.util.UUID interface ExternalPluginTaskFormProcessLinkRepository : JpaRepository { + fun findByProcessDefinitionId(processDefinitionId: String): List + fun findAllByExternalPluginConfigurationIdIn( externalPluginConfigurationIds: Collection, ): List diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt new file mode 100644 index 0000000000..c9b57239eb --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt @@ -0,0 +1,237 @@ +/* + * 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.externalplugin.service + +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case.repository.CaseTabSpecificationHelper +import com.ritense.case_.domain.tab.CaseExternalPluginTab +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper.Companion.ISSUE_TYPE +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType.FIXED +import com.ritense.processdocument.domain.ProcessDefinitionId +import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto.Companion.SOURCE_EXTERNAL +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * External-plugin counterpart to `PluginConfigurationMappingResolverImpl` (D1). Repairs `FIXED` + * external-plugin process links (service-task and task-form), and `EXTERNAL_PLUGIN` case tabs whose + * `contentKey`-embedded configuration id is dangling — behind the same + * `dangling-plugin-configurations` / `plugin-configuration-mappings` endpoints, distinguished by the + * `source` discriminator on [DanglingPluginConfigurationDto]. + */ +@Transactional +open class ExternalPluginConfigurationMappingResolver( + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val caseExternalPluginTabRepository: CaseExternalPluginTabRepository, + private val caseTabRepository: CaseTabRepository, + private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, + private val caseDefinitionChecker: CaseDefinitionChecker, + private val applicationEventPublisher: ApplicationEventPublisher, +) : PluginConfigurationMappingResolver { + + override fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) { + caseDefinitionChecker.assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, ISSUE_TYPE) + + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + + resolveProcessLinks(processDefinitionIds, mappings) + resolveTaskFormProcessLinks(processDefinitionIds, mappings) + resolveCaseTabs(caseDefinitionId, mappings) + + checkForRemainingIssues(caseDefinitionId, processDefinitionIds) + } + + override fun getDanglingPluginConfigurations(caseDefinitionId: CaseDefinitionId): List { + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + + val danglingLinks = allLinks(processDefinitionIds) + .filter { it.pluginConfigurationReference.type == FIXED } + .filter { link -> + val configId = link.externalPluginConfigurationId + configId == null || !configurationRepository.existsById(configId) + } + .groupBy { it.pluginConfigurationReference.pluginDefinitionKey to it.pluginConfigurationReference.pluginDefinitionVersion } + .map { (keyAndVersion, links) -> + val (definitionKey, version) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = definitionKey, + sourcePluginConfigurationIds = links.map { it.externalPluginConfigurationId ?: it.id }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = version, + ) + } + + val danglingTaskFormLinks = allTaskFormLinks(processDefinitionIds) + .filter { !configurationRepository.existsById(it.externalPluginConfigurationId) } + .groupBy { it.pluginConfigurationReference.pluginDefinitionKey to it.pluginConfigurationReference.pluginDefinitionVersion } + .map { (keyAndVersion, links) -> + val (definitionKey, version) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = definitionKey, + sourcePluginConfigurationIds = links.map { it.externalPluginConfigurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = version, + ) + } + + val danglingTabConfigurations = danglingTabsFor(caseDefinitionId) + + return danglingLinks + danglingTaskFormLinks + danglingTabConfigurations + } + + override fun recheckIssuesForProcessDefinition(processDefinitionId: String) { + val link = processDefinitionCaseDefinitionService + .findByProcessDefinitionIdOrNull(ProcessDefinitionId.of(processDefinitionId)) + ?: return + val caseDefinitionId = link.id.caseDefinitionId + val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) + checkForRemainingIssues(caseDefinitionId, processDefinitionIds) + } + + private fun resolveProcessLinks(processDefinitionIds: List, mappings: Map) { + val links = allLinks(processDefinitionIds).filter { it.pluginConfigurationReference.type == FIXED } + for (link in links) { + val lookupId = link.externalPluginConfigurationId ?: link.id + val mappedId = mappings[lookupId] ?: continue + + val updated = link.copy( + externalPluginConfigurationId = mappedId, + pluginConfigurationReference = PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = link.pluginConfigurationReference.pluginDefinitionKey, + pluginDefinitionVersion = link.pluginConfigurationReference.pluginDefinitionVersion, + ), + ) + processLinkRepository.save(updated) + } + } + + private fun resolveTaskFormProcessLinks(processDefinitionIds: List, mappings: Map) { + val links = allTaskFormLinks(processDefinitionIds) + for (link in links) { + val mappedId = mappings[link.externalPluginConfigurationId] ?: continue + + val updated = link.copy( + externalPluginConfigurationId = mappedId, + pluginConfigurationReference = PluginConfigurationReference( + type = FIXED, + pluginDefinitionKey = link.pluginConfigurationReference.pluginDefinitionKey, + pluginDefinitionVersion = link.pluginConfigurationReference.pluginDefinitionVersion, + ), + ) + taskFormProcessLinkRepository.save(updated) + } + } + + private fun resolveCaseTabs(caseDefinitionId: CaseDefinitionId, mappings: Map) { + val externalPluginTabs = caseTabsFor(caseDefinitionId) + .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } + + for (tab in externalPluginTabs) { + val configPart = tab.contentKey.substringBefore(':') + val bundlePart = tab.contentKey.substringAfter(':', "") + val originalId = configPart.toUuidOrNull() ?: continue + val mappedId = mappings[originalId] ?: continue + + val newContentKey = if (bundlePart.isEmpty()) mappedId.toString() else "$mappedId:$bundlePart" + val updatedTab = tab.copy(contentKey = newContentKey) + caseTabRepository.save(updatedTab) + caseExternalPluginTabRepository.save( + CaseExternalPluginTab( + id = updatedTab.id, + externalPluginConfigurationId = mappedId, + bundleKey = bundlePart.ifEmpty { null }, + ) + ) + } + } + + private fun danglingTabsFor(caseDefinitionId: CaseDefinitionId): List { + val danglingConfigIds = caseTabsFor(caseDefinitionId) + .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } + .mapNotNull { it.contentKey.substringBefore(':').toUuidOrNull() } + .filter { !configurationRepository.existsById(it) } + .toSet() + + if (danglingConfigIds.isEmpty()) return emptyList() + + return listOf( + DanglingPluginConfigurationDto( + pluginDefinitionKey = null, + sourcePluginConfigurationIds = danglingConfigIds, + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = null, + ) + ) + } + + private fun checkForRemainingIssues(caseDefinitionId: CaseDefinitionId, processDefinitionIds: List) { + val hasProcessLinkIssue = allLinks(processDefinitionIds).any { link -> + link.pluginConfigurationReference.type == FIXED && + (link.externalPluginConfigurationId == null || !configurationRepository.existsById(link.externalPluginConfigurationId)) + } + val hasTaskFormIssue = allTaskFormLinks(processDefinitionIds).any { link -> + !configurationRepository.existsById(link.externalPluginConfigurationId) + } + val hasTabIssue = danglingTabsFor(caseDefinitionId).isNotEmpty() + + if (hasProcessLinkIssue || hasTaskFormIssue || hasTabIssue) { + applicationEventPublisher.publishEvent(CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE)) + } else { + applicationEventPublisher.publishEvent(CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE)) + } + } + + private fun processDefinitionIdsFor(caseDefinitionId: CaseDefinitionId): List = + processDefinitionCaseDefinitionService + .findProcessDefinitionCaseDefinitions(caseDefinitionId) + .map { it.id.processDefinitionId.id } + + private fun allLinks(processDefinitionIds: List): List = + processDefinitionIds.flatMap { processLinkRepository.findByProcessDefinitionId(it) } + + private fun allTaskFormLinks(processDefinitionIds: List): List = + processDefinitionIds.flatMap { taskFormProcessLinkRepository.findByProcessDefinitionId(it) } + + private fun caseTabsFor(caseDefinitionId: CaseDefinitionId): List = + caseTabRepository.findAll(CaseTabSpecificationHelper.byCaseDefinitionId(caseDefinitionId)) + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt index d2f375e758..5d09b337b7 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt @@ -120,8 +120,14 @@ class ExternalPluginHostUsageResolver( } private fun collectUsageLinks(configurationIds: Collection): List { + // externalPluginConfigurationId is nullable on the entity (BUILDING_BLOCK references, Phase + // 2, carry no fixed config id) but findAllByExternalPluginConfigurationIdIn only ever + // returns rows whose id is one of the (non-null) ids queried for — the mapNotNull is a type + //-level formality, not an expected filter. val actionLinks = processLinkRepository.findAllByExternalPluginConfigurationIdIn(configurationIds) - .map { UsageLink(it.id, it.processDefinitionId, it.activityId, it.externalPluginConfigurationId) } + .mapNotNull { link -> + link.externalPluginConfigurationId?.let { UsageLink(link.id, link.processDefinitionId, link.activityId, it) } + } val taskFormLinks = taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(configurationIds) .map { UsageLink(it.id, it.processDefinitionId, it.activityId, it.externalPluginConfigurationId) } return actionLinks + taskFormLinks diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt new file mode 100644 index 0000000000..fb32e04528 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/BaseIntegrationTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2015-2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.externalplugin + +import com.ritense.valtimo.contract.authentication.UserManagementService +import com.ritense.valtimo.contract.mail.MailSender +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringExtension + +@SpringBootTest +@ExtendWith(SpringExtension::class) +@Tag("integration") +class BaseIntegrationTest { + + @MockitoBean + lateinit var userManagementService: UserManagementService + + @MockitoBean + lateinit var mailSender: MailSender + +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt new file mode 100644 index 0000000000..1401283215 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/TestApplication.kt @@ -0,0 +1,28 @@ +/* + * 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.externalplugin + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class TestApplication { + + fun main(args: Array) { + runApplication(*args) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt new file mode 100644 index 0000000000..18106bf1d1 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/DualPluginConfigurationMappingResolverIntTest.kt @@ -0,0 +1,63 @@ +/* + * 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.externalplugin.autoconfigure + +import com.ritense.externalplugin.BaseIntegrationTest +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import com.ritense.valtimo.processlink.listener.ProcessLinkChangedEventListener +import com.ritense.valtimo.processlink.service.PluginConfigurationMappingResolverImpl +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext + +/** + * Boots a Spring context combining `plugin-valtimo` and `external-plugin` on the same classpath — + * every real application does, since `valtimo-dependencies` pulls in both as `api` deps. Regresses + * two historical bean-registration bugs found in this codebase: + * + * - `ProcessLinkAutoConfiguration.processLinkChangedEventListener` used to take a single-value + * [PluginConfigurationMappingResolver] constructor parameter, which fails context refresh with a + * `NoUniqueBeanDefinitionException` once both [PluginConfigurationMappingResolverImpl] and + * [ExternalPluginConfigurationMappingResolver] are registered beans of that interface. + * - `ProcessLinkAutoConfiguration.pluginConfigurationMappingResolver` used to carry + * `@ConditionalOnMissingBean(PluginConfigurationMappingResolver::class)` (the shared interface) + * instead of its own concrete class, which silently suppresses whichever of the two resolver + * beans loses the `@Configuration` class processing order race — no boot failure, just one + * resolver family quietly never running. + */ +class DualPluginConfigurationMappingResolverIntTest @Autowired constructor( + private val applicationContext: ApplicationContext, +) : BaseIntegrationTest() { + + @Test + fun `context contains exactly two PluginConfigurationMappingResolver beans`() { + val resolvers = applicationContext.getBeansOfType(PluginConfigurationMappingResolver::class.java) + + assertThat(resolvers).hasSize(2) + assertThat(resolvers.values).hasAtLeastOneElementOfType(PluginConfigurationMappingResolverImpl::class.java) + assertThat(resolvers.values).hasAtLeastOneElementOfType(ExternalPluginConfigurationMappingResolver::class.java) + } + + @Test + fun `ProcessLinkChangedEventListener bean is present with both resolvers injected`() { + val listeners = applicationContext.getBeansOfType(ProcessLinkChangedEventListener::class.java) + + assertThat(listeners).hasSize(1) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt new file mode 100644 index 0000000000..fe141d2777 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfigurationWiringTest.kt @@ -0,0 +1,44 @@ +/* + * 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.externalplugin.autoconfigure + +import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean + +/** + * The embedded plugin system registers its own `PluginConfigurationMappingResolver` bean next to + * this module's, and every real application has both modules on the classpath. An interface-typed + * `@ConditionalOnMissingBean` would make whichever module's configuration is processed first + * silently suppress the other resolver, so the condition must target this module's concrete class. + * No single-module context test exercises that cross-module combination — pinned by reflection. + */ +class ExternalPluginAutoConfigurationWiringTest { + + @Test + fun `resolver bean condition targets its own concrete class, not the shared interface`() { + val beanMethod = ExternalPluginAutoConfiguration::class.java.declaredMethods + .single { it.name == "externalPluginConfigurationMappingResolver" } + + val condition = beanMethod.getAnnotation(ConditionalOnMissingBean::class.java) + + assertThat(condition).isNotNull + assertThat(condition.value.map { it.java }) + .containsExactly(ExternalPluginConfigurationMappingResolver::class.java) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt new file mode 100644 index 0000000000..507f5a64f2 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt @@ -0,0 +1,201 @@ +/* + * 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.externalplugin.preview + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.UUID + +class ExternalPluginImportPreviewContributorTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var contributor: ExternalPluginImportPreviewContributor + + @BeforeEach + fun setUp() { + configurationRepository = mock() + contributor = ExternalPluginImportPreviewContributor(ObjectMapper(), configurationRepository) + } + + @Test + fun `contributes an entry for a FIXED external_plugin process link and checks existence`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "externalPluginConfigurationId": "$configId", + "actionKey": "send", + "referenceType": "FIXED", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "1.2.3" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("1.2.3") + assertThat(entry.processDefinitionKey).isEqualTo("my-process") + assertThat(entry.activityId).isEqualTo("Task_1") + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `contributes an entry for an external_plugin_task_form process link`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.existsById(configId)).thenReturn(false) + + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:UserTask:create", + "processLinkType": "external_plugin_task_form", + "externalPluginConfigurationId": "$configId", + "referenceType": "FIXED", + "pluginVersion": "0.1.0" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + assertThat(result.single().existsInTargetEnvironment).isFalse() + assertThat(result.single().source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `ignores BUILDING_BLOCK external plugin references (no configuration id to check)`() { + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "actionKey": "send", + "referenceType": "BUILDING_BLOCK", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "1.2.3" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `ignores embedded plugin process links`() { + val json = """ + [ + { + "activityId": "Task_1", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "plugin", + "pluginConfigurationId": "${UUID.randomUUID()}", + "pluginActionDefinitionKey": "create-zaak" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("process-link/my-process.process-link.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `contributes an entry for an EXTERNAL_PLUGIN case tab and checks existence`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `ignores non-EXTERNAL_PLUGIN case tabs`() { + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "widgets", + "contentKey": "widget-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + + @Test + fun `ignores unrelated files`() { + val result = contributor.contributePreview( + mapOf("case/definition/my-doc.case-definition.json" to "{}".toByteArray()) + ) + + assertThat(result).isEmpty() + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt new file mode 100644 index 0000000000..fe06fc89c7 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt @@ -0,0 +1,171 @@ +/* + * 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.externalplugin.processlink + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.authorization.AuthorizationContext +import com.ritense.externalplugin.BaseIntegrationTest +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginHost +import com.ritense.externalplugin.domain.ExternalPluginHostStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginHostRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.importer.ImportRequest +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.importer.ProcessLinkImporter +import com.ritense.valtimo.operaton.service.OperatonRepositoryService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.util.UUID + +@Transactional +class ExternalPluginProcessLinkImportIntTest @Autowired constructor( + private val processLinkImporter: ProcessLinkImporter, + private val processLinkRepository: ExternalPluginProcessLinkRepository, + private val hostRepository: ExternalPluginHostRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val repositoryService: OperatonRepositoryService, +) : BaseIntegrationTest() { + + @Test + fun `import maps a FIXED external plugin link to the seeded configuration`() { + val configuration = seedConfiguration() + val sourceConfigurationId = UUID.randomUUID() + + processLinkImporter.import( + ImportRequest( + "/process-link/$PROCESS_DEFINITION_KEY.process-link.json", + fixture(sourceConfigurationId).toByteArray(Charsets.UTF_8), + null, + null, + null, + null, + mapOf(sourceConfigurationId to configuration.id), + ) + ) + + val processDefinition = getLatestProcessDefinition() + val processLink = requireNotNull( + processLinkRepository.findByProcessDefinitionId(processDefinition).singleOrNull() + ) + + assertThat(processLink.externalPluginConfigurationId).isEqualTo(configuration.id) + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo(PLUGIN_ID) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo(PLUGIN_VERSION) + assertThat(processLink.actionKey).isEqualTo(ACTION_KEY) + assertThat(processLink.actionResultMappings).isEmpty() + } + + @Test + fun `import without a target mapping leaves the configuration id dangling`() { + val sourceConfigurationId = UUID.randomUUID() + + processLinkImporter.import( + ImportRequest( + "/process-link/$PROCESS_DEFINITION_KEY.process-link.json", + fixture(sourceConfigurationId).toByteArray(Charsets.UTF_8), + null, + null, + null, + null, + mapOf(sourceConfigurationId to null), + ) + ) + + val processDefinition = getLatestProcessDefinition() + val processLink = requireNotNull( + processLinkRepository.findByProcessDefinitionId(processDefinition).singleOrNull() + ) + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + } + + private fun seedConfiguration(): ExternalPluginConfiguration { + val host = hostRepository.save( + ExternalPluginHost( + id = UUID.randomUUID(), + name = "Test host", + baseUrl = "http://localhost:1234", + secret = "secret", + status = ExternalPluginHostStatus.CONNECTED, + ) + ) + val definition = definitionRepository.save( + ExternalPluginDefinition( + id = UUID.randomUUID(), + pluginId = PLUGIN_ID, + version = PLUGIN_VERSION, + hostId = host.id, + baseUrl = host.baseUrl, + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + return configurationRepository.save( + ExternalPluginConfiguration( + id = UUID.randomUUID(), + definitionId = definition.id, + title = "Test configuration", + createdAt = Instant.now(), + ) + ) + } + + private fun getLatestProcessDefinition(): String { + return AuthorizationContext.runWithoutAuthorization { + requireNotNull(repositoryService.findLatestProcessDefinition(PROCESS_DEFINITION_KEY)).id + } + } + + private fun fixture(externalPluginConfigurationId: UUID): String { + return """ + [ + { + "processDefinitionId": "will-be-overwritten", + "activityId": "my-service-task", + "activityType": "bpmn:ServiceTask:start", + "processLinkType": "external_plugin", + "externalPluginConfigurationId": "$externalPluginConfigurationId", + "actionKey": "$ACTION_KEY", + "referenceType": "FIXED", + "actionResultMappings": [] + } + ] + """.trimIndent().let { + // processDefinitionId is set by the importer itself when absent from the node; + // drop the placeholder so ProcessLinkImporter fills it in. + it.replace(""""processDefinitionId": "will-be-overwritten",\n """, "") + } + } + + private companion object { + const val PROCESS_DEFINITION_KEY = "external-plugin-import-process" + const val PLUGIN_ID = "test-plugin" + const val PLUGIN_VERSION = "1.0.0" + const val ACTION_KEY = "test-action" + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt new file mode 100644 index 0000000000..2cb43c637c --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkMapperTest.kt @@ -0,0 +1,598 @@ +/* + * 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.externalplugin.processlink + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkCreateRequestDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkDeployDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkExportResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkResponseDto +import com.ritense.externalplugin.processlink.web.dto.ExternalPluginProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valueresolver.exception.ValueResolverValidationException +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class ExternalPluginProcessLinkMapperTest { + + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + private lateinit var mapper: ExternalPluginProcessLinkMapper + + private val definitionId = UUID.randomUUID() + private val configId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + processLinkRepository = mock() + mapper = ExternalPluginProcessLinkMapper( + ObjectMapper(), + configurationRepository, + definitionRepository, + processLinkRepository, + ) + + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Case summary configuration", + createdAt = Instant.now(), + ) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + } + + @Test + fun `create accepts action result mappings whose sources match declared action outputs`() { + val definitionWithOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", listOf("summary", "title")), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/summary", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + @Test + fun `create rejects an action result mapping source that is not a declared action output`() { + val definitionWithOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", listOf("summary", "title")), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/unknownKey", target = "doc:/summary")), + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(ValueResolverValidationException::class.java) + .hasMessageContaining("does not match a declared output") + } + + @Test + fun `create rejects any action result mapping when the action declares no outputs`() { + val definitionWithoutOutputs = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifestWithActionOutputs("send", emptyList()), + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithoutOutputs)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/summary", target = "doc:/summary")), + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(ValueResolverValidationException::class.java) + .hasMessageContaining("does not declare any outputs") + } + + @Test + fun `create is lenient and skips source validation when the definition cannot be resolved`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginVersion = "1.0.0", + actionResultMappings = listOf(PluginActionResultMapping(source = "/anything", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + @Test + fun `create is lenient and skips source validation when the resolved definition has no manifest`() { + val definitionWithoutManifest = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = null, + ) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definitionWithoutManifest)) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + actionResultMappings = listOf(PluginActionResultMapping(source = "/anything", target = "doc:/summary")), + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.actionResultMappings).hasSize(1) + } + + private fun manifestWithActionOutputs(actionKey: String, outputs: List): com.fasterxml.jackson.databind.node.ObjectNode { + val objectMapper = ObjectMapper() + val manifest = objectMapper.createObjectNode() + val actions = manifest.putArray("actions") + val action = actions.addObject() + action.put("key", actionKey) + val outputsArray = action.putArray("outputs") + outputs.forEach { outputsArray.add(it) } + return manifest + } + + @Test + fun `supports only the external_plugin link type`() { + assertThat(mapper.supportsProcessLinkType("external_plugin")).isTrue() + assertThat(mapper.supportsProcessLinkType("external_plugin_task_form")).isFalse() + assertThat(mapper.supportsProcessLinkType("plugin")).isFalse() + } + + @Test + fun `FIXED create derives pluginDefinitionKey and pluginVersion from the configuration, ignoring any values on the dto`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + // deliberately wrong/stale values — must be overridden by the derived configuration + pluginDefinitionKey = "some-other-plugin", + pluginVersion = "9.9.9", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isEqualTo(configId) + assertThat(processLink.actionKey).isEqualTo("send") + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + } + + @Test + fun `FIXED create falls back to dto-supplied key and version when the configuration cannot be resolved (dangling import)`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + pluginDefinitionKey = "case-summary", + pluginVersion = "1.0.0", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.0.0") + } + + @Test + fun `FIXED create allows a null configuration id (dangling import placeholder)`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.FIXED, + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isNull() + } + + @Test + fun `BUILDING_BLOCK create requires pluginDefinitionKey and pluginVersion from the dto and rejects a configuration id`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginVersion = "2.0.0", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + + assertThat(processLink.externalPluginConfigurationId).isNull() + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.BUILDING_BLOCK) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("2.0.0") + } + + @Test + fun `BUILDING_BLOCK create rejects a non-null configuration id`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginVersion = "2.0.0", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("externalPluginConfigurationId must be empty") + } + + @Test + fun `BUILDING_BLOCK create requires pluginDefinitionKey`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginVersion = "2.0.0", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("pluginDefinitionKey is required") + } + + @Test + fun `BUILDING_BLOCK create requires pluginVersion`() { + val createDto = ExternalPluginProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionKey = "send", + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + ) + + assertThatThrownBy { mapper.toNewProcessLink(createDto, null) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("pluginVersion is required") + } + + @Test + fun `FIXED update derives pluginDefinitionKey and pluginVersion from the configuration`() { + val id = UUID.randomUUID() + val existing = ExternalPluginProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = UUID.randomUUID(), + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "old-plugin", + pluginDefinitionVersion = "0.0.1", + ), + ) + val updateDto = ExternalPluginProcessLinkUpdateRequestDto( + id = id, + externalPluginConfigurationId = configId, + actionKey = "send", + ) + + val updated = mapper.toUpdatedProcessLink(existing, updateDto, null) as ExternalPluginProcessLink + + assertThat(updated.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updated.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(updated.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a process link to a response dto exposing the reference fields`() { + val id = UUID.randomUUID() + val processLink = ExternalPluginProcessLink( + id = id, + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + + val dto = mapper.toProcessLinkResponseDto(processLink) as ExternalPluginProcessLinkResponseDto + + assertThat(dto.id).isEqualTo(id) + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.actionKey).isEqualTo("send") + assertThat(dto.referenceType).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(dto.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.pluginVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a process link to an export response dto exposing the reference fields`() { + val processLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + + val dto = mapper.toProcessLinkExportResponseDto(processLink) as ExternalPluginProcessLinkExportResponseDto + + assertThat(dto.activityId).isEqualTo("activity-1") + assertThat(dto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(dto.actionKey).isEqualTo("send") + assertThat(dto.referenceType).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(dto.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.pluginVersion).isEqualTo("1.2.3") + } + + @Test + fun `maps a deploy dto to a create request and back to an update request`() { + val deployDto = ExternalPluginProcessLinkDeployDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + ) + + val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, null) + as ExternalPluginProcessLinkCreateRequestDto + assertThat(createDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(createDto.actionKey).isEqualTo("send") + + val existingId = UUID.randomUUID() + val updateDto = mapper.toProcessLinkUpdateRequestDto(deployDto, existingId, null) + as ExternalPluginProcessLinkUpdateRequestDto + assertThat(updateDto.id).isEqualTo(existingId) + assertThat(updateDto.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updateDto.actionKey).isEqualTo("send") + } + + @Test + fun `applyPluginConfigurationMappings rewrites externalPluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings nulls externalPluginConfigurationId when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("externalPluginConfigurationId").isNull).isTrue() + } + + @Test + fun `afterImport emits detected event when FIXED link has null externalPluginConfigurationId`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport emits detected event when FIXED link configuration no longer exists`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = danglingConfigId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + } + + @Test + fun `afterImport emits resolved event when all FIXED links have existing configurations`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = configId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport ignores BUILDING_BLOCK links`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val link = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt index 707b811a55..d3fe07761c 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkTypeDeductionTest.kt @@ -27,6 +27,7 @@ import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto import com.ritense.valtimo.contract.json.MapperSingleton import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock import java.util.UUID /** @@ -43,8 +44,8 @@ class ExternalPluginProcessLinkTypeDeductionTest { // Register both mappers' subtypes on one ObjectMapper, exactly as the auto-configuration does, so // deduction sees the same candidate set as at runtime. private val mapper = MapperSingleton.get().copy().also { - ExternalPluginProcessLinkMapper(it) - ExternalPluginTaskFormProcessLinkMapper(it) + ExternalPluginProcessLinkMapper(it, mock(), mock(), mock()) + ExternalPluginTaskFormProcessLinkMapper(it, mock(), mock(), mock()) } @Test diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt index c810a867dc..e483e124c0 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt @@ -27,6 +27,11 @@ import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.PluginActionResultHandler import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.event.OperatonExecutionEvent import com.ritense.valueresolver.ValueResolverService @@ -36,7 +41,10 @@ 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.delegate.BpmnError import org.operaton.bpm.engine.delegate.DelegateExecution @@ -51,6 +59,7 @@ class ExternalPluginServiceTaskStartListenerTest { private lateinit var hostClient: ExternalPluginHostClient private lateinit var valueResolverService: ValueResolverService private lateinit var objectMapper: ObjectMapper + private lateinit var pluginActionResultHandler: PluginActionResultHandler private lateinit var listener: ExternalPluginServiceTaskStartListener private val configurationId = UUID.randomUUID() @@ -66,6 +75,7 @@ class ExternalPluginServiceTaskStartListenerTest { hostClient = mock() valueResolverService = mock() objectMapper = ObjectMapper() + pluginActionResultHandler = mock() listener = ExternalPluginServiceTaskStartListener( processLinkRepository, configurationService, @@ -74,6 +84,7 @@ class ExternalPluginServiceTaskStartListenerTest { hostClient, valueResolverService, objectMapper, + pluginActionResultHandler, ) val configuration = mock { @@ -83,6 +94,7 @@ class ExternalPluginServiceTaskStartListenerTest { val definition = mock { on { this.hostId } doReturn hostId on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" } val host = mock { on { baseUrl } doReturn "http://localhost:8090" @@ -136,6 +148,218 @@ class ExternalPluginServiceTaskStartListenerTest { .hasMessageContaining("case-summary") } + @Test + fun `BUILDING_BLOCK reference resolves the configuration via the namespaced key`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.1.0")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(resolver).resolve(execution, "external-plugin:case-summary@0.1.0") + verify(hostClient).invokeAction( + baseUrl = eq("http://localhost:8090"), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = eq("case-summary"), + payload = any(), + hostSecret = eq("secret"), + ) + } + + @Test + fun `BUILDING_BLOCK reference with mismatched pluginId throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // configurationId resolves to a definition with pluginId "case-summary" (see setUp), + // but the reference expects a different plugin — must not silently invoke the wrong plugin. + val processLink = buildingBlockProcessLink(pluginId = "other-plugin", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:other-plugin@0.1.0")).thenReturn(configurationId) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("other-plugin") + .hasMessageContaining("case-summary") + } + + @Test + fun `BUILDING_BLOCK reference with a version mismatch proceeds with the resolved configuration's version`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // setUp wires the definition to version "0.1.0"; the reference is pinned to "0.0.9". + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.0.9") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.0.9")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(hostClient).invokeAction( + baseUrl = any(), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = any(), + payload = any(), + hostSecret = any(), + ) + } + + @Test + fun `BUILDING_BLOCK reference to a definition whose manifest no longer declares the action key throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val staleDefinitionId = UUID.randomUUID() + val staleConfigurationId = UUID.randomUUID() + val staleConfiguration = mock { + on { id } doReturn staleConfigurationId + on { this.definitionId } doReturn staleDefinitionId + } + val staleDefinition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.2.0" + on { manifestJson } doReturn objectMapper.readTree( + """{"actions":[{"key":"some-other-action"}]}""", + ) as com.fasterxml.jackson.databind.node.ObjectNode + } + whenever(configurationService.get(staleConfigurationId)).thenReturn(staleConfiguration) + whenever(definitionService.get(staleDefinitionId)).thenReturn(staleDefinition) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.2.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.2.0")).thenReturn(staleConfigurationId) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("case-summary") + .hasMessageContaining("does not declare") + } + + @Test + fun `BUILDING_BLOCK reference without a resolver bean throws a clear error`() { + // listener from setUp() was constructed with a null resolver (the default) + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + assertThatThrownBy { listener.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("resolver") + } + + @Test + fun `BUILDING_BLOCK reference with no mapping throws a clear error`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.1.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.1.0")).thenReturn(null) + + assertThatThrownBy { listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("external-plugin:case-summary@0.1.0") + } + + private fun buildingBlockProcessLink(pluginId: String, version: String): ExternalPluginProcessLink = + ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-id", + activityId = "ServiceTask_ExternalPlugin", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = pluginId, + pluginDefinitionVersion = version, + ), + actionProperties = null, + ) + + private fun executionFor(processLink: ExternalPluginProcessLink): DelegateExecution { + whenever( + processLinkRepository.findByProcessDefinitionIdAndActivityIdAndActivityType( + "process-def-id", + "ServiceTask_ExternalPlugin", + ActivityTypeWithEventName.SERVICE_TASK_START, + ), + ).thenReturn(listOf(processLink)) + + return mock { + on { processDefinitionId } doReturn "process-def-id" + on { currentActivityId } doReturn "ServiceTask_ExternalPlugin" + on { processInstanceId } doReturn "process-instance-id" + on { processBusinessKey } doReturn "business-key" + } + } + private fun globalProcessServiceTaskEvent(): OperatonExecutionEvent { val processLink = ExternalPluginProcessLink( id = UUID.randomUUID(), @@ -144,7 +368,11 @@ class ExternalPluginServiceTaskStartListenerTest { activityType = ActivityTypeWithEventName.SERVICE_TASK_START, externalPluginConfigurationId = configurationId, actionKey = "case-summary", - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), actionProperties = null, ) whenever( diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt index 7e73b6d1c7..6d23071e08 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkActivityHandlerTest.kt @@ -19,6 +19,8 @@ package com.ritense.externalplugin.processlink import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.operaton.domain.OperatonExecution import com.ritense.valtimo.operaton.domain.OperatonTask @@ -46,7 +48,11 @@ class ExternalPluginTaskFormProcessLinkActivityHandlerTest { activityType = ActivityTypeWithEventName.SERVICE_TASK_START, externalPluginConfigurationId = UUID.randomUUID(), actionKey = "some-action", - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), ) ) ).isFalse() @@ -111,6 +117,10 @@ class ExternalPluginTaskFormProcessLinkActivityHandlerTest { activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = configurationId, bundleKey = bundleKey, - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), ) } diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt index 16321e37de..41526d25f7 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapperTest.kt @@ -17,20 +17,71 @@ package com.ritense.externalplugin.processlink import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkCreateRequestDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkDeployDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkExportResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkResponseDto import com.ritense.externalplugin.processlink.web.dto.ExternalPluginTaskFormProcessLinkUpdateRequestDto +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import java.time.Instant +import java.util.Optional import java.util.UUID class ExternalPluginTaskFormProcessLinkMapperTest { - private val mapper = ExternalPluginTaskFormProcessLinkMapper(ObjectMapper()) + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var processLinkRepository: ExternalPluginTaskFormProcessLinkRepository + private lateinit var mapper: ExternalPluginTaskFormProcessLinkMapper + + private val definitionId = UUID.randomUUID() + private val configId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + processLinkRepository = mock() + mapper = ExternalPluginTaskFormProcessLinkMapper(ObjectMapper(), configurationRepository, definitionRepository, processLinkRepository) + + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Case summary configuration", + createdAt = Instant.now(), + ) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "1.2.3", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:8090", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + } @Test fun `supports only the task-form link type`() { @@ -40,14 +91,14 @@ class ExternalPluginTaskFormProcessLinkMapperTest { } @Test - fun `maps a create request to a new process link`() { - val configId = UUID.randomUUID() + fun `maps a create request to a new process link, deriving the reference from the configuration`() { val createDto = ExternalPluginTaskFormProcessLinkCreateRequestDto( processDefinitionId = "pd-1", activityId = "activity-1", activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = configId, - pluginVersion = "0.1.0", + // deliberately stale — must be overridden by the derived configuration + pluginVersion = "9.9.9", bundleKey = "review", ) @@ -57,15 +108,36 @@ class ExternalPluginTaskFormProcessLinkMapperTest { assertThat(processLink.activityId).isEqualTo("activity-1") assertThat(processLink.activityType).isEqualTo(ActivityTypeWithEventName.USER_TASK_CREATE) assertThat(processLink.externalPluginConfigurationId).isEqualTo(configId) - assertThat(processLink.pluginVersion).isEqualTo("0.1.0") + assertThat(processLink.pluginConfigurationReference.type).isEqualTo(PluginConfigurationReferenceType.FIXED) + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") assertThat(processLink.bundleKey).isEqualTo("review") assertThat(processLink.processLinkType).isEqualTo("external_plugin_task_form") } + @Test + fun `create falls back to the dto-supplied pluginVersion when the configuration cannot be resolved (dangling import)`() { + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.findById(danglingConfigId)).thenReturn(Optional.empty()) + + val createDto = ExternalPluginTaskFormProcessLinkCreateRequestDto( + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = danglingConfigId, + pluginVersion = "1.0.0", + bundleKey = "review", + ) + + val processLink = mapper.toNewProcessLink(createDto, null) as ExternalPluginTaskFormProcessLink + + assertThat(processLink.pluginConfigurationReference.pluginDefinitionKey).isNull() + assertThat(processLink.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.0.0") + } + @Test fun `maps a process link to a response dto`() { val id = UUID.randomUUID() - val configId = UUID.randomUUID() val processLink = ExternalPluginTaskFormProcessLink( id = id, processDefinitionId = "pd-1", @@ -73,7 +145,11 @@ class ExternalPluginTaskFormProcessLinkMapperTest { activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = configId, bundleKey = "review", - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), ) val dto = mapper.toProcessLinkResponseDto(processLink) as ExternalPluginTaskFormProcessLinkResponseDto @@ -86,7 +162,7 @@ class ExternalPluginTaskFormProcessLinkMapperTest { } @Test - fun `maps an update request onto the existing process link`() { + fun `maps an update request onto the existing process link, deriving the reference from the new configuration`() { val id = UUID.randomUUID() val existing = ExternalPluginTaskFormProcessLink( id = id, @@ -95,12 +171,15 @@ class ExternalPluginTaskFormProcessLinkMapperTest { activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = UUID.randomUUID(), bundleKey = "review", - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "old-plugin", + pluginDefinitionVersion = "0.0.1", + ), ) - val newConfigId = UUID.randomUUID() val updateDto = ExternalPluginTaskFormProcessLinkUpdateRequestDto( id = id, - externalPluginConfigurationId = newConfigId, + externalPluginConfigurationId = configId, pluginVersion = "0.2.0", bundleKey = "approve", ) @@ -110,14 +189,14 @@ class ExternalPluginTaskFormProcessLinkMapperTest { assertThat(updated.id).isEqualTo(id) assertThat(updated.processDefinitionId).isEqualTo("pd-1") assertThat(updated.activityId).isEqualTo("activity-1") - assertThat(updated.externalPluginConfigurationId).isEqualTo(newConfigId) - assertThat(updated.pluginVersion).isEqualTo("0.2.0") + assertThat(updated.externalPluginConfigurationId).isEqualTo(configId) + assertThat(updated.pluginConfigurationReference.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(updated.pluginConfigurationReference.pluginDefinitionVersion).isEqualTo("1.2.3") assertThat(updated.bundleKey).isEqualTo("approve") } @Test fun `maps a deploy dto to a create request`() { - val configId = UUID.randomUUID() val deployDto = ExternalPluginTaskFormProcessLinkDeployDto( processDefinitionId = "pd-1", activityId = "activity-1", @@ -142,7 +221,6 @@ class ExternalPluginTaskFormProcessLinkMapperTest { @Test fun `maps a deploy dto to an update request onto the existing link id`() { val existingId = UUID.randomUUID() - val configId = UUID.randomUUID() val deployDto = ExternalPluginTaskFormProcessLinkDeployDto( processDefinitionId = "pd-1", activityId = "activity-1", @@ -164,7 +242,6 @@ class ExternalPluginTaskFormProcessLinkMapperTest { @Test fun `maps a process link to an export response dto without the process definition id`() { - val configId = UUID.randomUUID() val processLink = ExternalPluginTaskFormProcessLink( id = UUID.randomUUID(), processDefinitionId = "pd-1", @@ -172,7 +249,11 @@ class ExternalPluginTaskFormProcessLinkMapperTest { activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = configId, bundleKey = "review", - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), ) val dto = mapper.toProcessLinkExportResponseDto(processLink) @@ -192,7 +273,7 @@ class ExternalPluginTaskFormProcessLinkMapperTest { processDefinitionId = "pd-1", activityId = "activity-1", activityType = ActivityTypeWithEventName.USER_TASK_CREATE, - externalPluginConfigurationId = UUID.randomUUID(), + externalPluginConfigurationId = configId, pluginVersion = "0.1.0", ) @@ -200,4 +281,74 @@ class ExternalPluginTaskFormProcessLinkMapperTest { assertThat(processLink.bundleKey).isNull() } + + @Test + fun `applyPluginConfigurationMappings rewrites externalPluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings leaves externalPluginConfigurationId unchanged when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = ObjectMapper().createObjectNode().put("externalPluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("externalPluginConfigurationId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `afterImport emits detected event when the referenced configuration no longer exists`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + val danglingConfigId = UUID.randomUUID() + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + val link = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = danglingConfigId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `afterImport emits resolved event when all referenced configurations exist`() { + val applicationEventPublisher: ApplicationEventPublisher = mock() + whenever(configurationRepository.existsById(configId)).thenReturn(true) + val link = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "activity-1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = configId, + bundleKey = "review", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + mapper.afterImport(CaseDefinitionId("my-case", "1.0.0"), setOf("pd-1"), applicationEventPublisher) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } } diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt index 43d96bfb0e..82ae20f199 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormSubmissionServiceTest.kt @@ -29,6 +29,8 @@ import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService import com.ritense.externalplugin.service.ExternalPluginHostService +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processdocument.service.ProcessDocumentService import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.service.ProcessLinkService @@ -157,7 +159,11 @@ class ExternalPluginTaskFormSubmissionServiceTest { activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = configurationId, bundleKey = bundleKey, - pluginVersion = "0.1.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), ) whenever( processLinkService.getProcessLink(processLinkId, ExternalPluginTaskFormProcessLink::class.java), @@ -187,6 +193,7 @@ class ExternalPluginTaskFormSubmissionServiceTest { on { manifestJson } doReturn manifest on { this.hostId } doReturn hostId on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" } whenever(configurationService.get(configurationId)).thenReturn(configuration) whenever(definitionService.get(definitionId)).thenReturn(definition) diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt new file mode 100644 index 0000000000..e5d94a9352 --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt @@ -0,0 +1,340 @@ +/* + * 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.externalplugin.service + +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink +import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +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.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.case_.CaseDefinitionChecker +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.event.CaseConfigurationIssueDetectedEvent +import com.ritense.valtimo.contract.event.CaseConfigurationIssueResolvedEvent +import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto.Companion.SOURCE_EXTERNAL +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.Mockito.lenient +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.jpa.domain.Specification +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class ExternalPluginConfigurationMappingResolverTest { + + @Mock + lateinit var processLinkRepository: ExternalPluginProcessLinkRepository + + @Mock + lateinit var taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository + + @Mock + lateinit var configurationRepository: ExternalPluginConfigurationRepository + + @Mock + lateinit var caseExternalPluginTabRepository: CaseExternalPluginTabRepository + + @Mock + lateinit var caseTabRepository: CaseTabRepository + + @Mock + lateinit var processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService + + @Mock + lateinit var caseDefinitionChecker: CaseDefinitionChecker + + @Mock + lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var resolver: ExternalPluginConfigurationMappingResolver + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @BeforeEach + fun before() { + resolver = ExternalPluginConfigurationMappingResolver( + processLinkRepository, + taskFormProcessLinkRepository, + configurationRepository, + caseExternalPluginTabRepository, + caseTabRepository, + processDefinitionCaseDefinitionService, + caseDefinitionChecker, + applicationEventPublisher, + ) + lenient().whenever(taskFormProcessLinkRepository.findByProcessDefinitionId(any())).thenReturn(emptyList()) + lenient().whenever(caseTabRepository.findAll(any>())).thenReturn(emptyList()) + } + + @Test + fun `resolve asserts user can update case definition configuration`() { + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + + resolver.resolve(caseDefinitionId, emptyMap()) + + verify(caseDefinitionChecker).assertCanUpdateCaseDefinitionConfiguration( + caseDefinitionId, + ExternalPluginProcessLinkMapper.ISSUE_TYPE, + ) + } + + @Test + fun `resolve replaces externalPluginConfigurationId based on source UUID mapping`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = processLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val captor = argumentCaptor() + verify(processLinkRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(targetId) + } + + @Test + fun `resolve replaces the configuration id on task-form links`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = taskFormLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(taskFormProcessLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val captor = argumentCaptor() + verify(taskFormProcessLinkRepository).save(captor.capture()) + assertThat(captor.firstValue.externalPluginConfigurationId).isEqualTo(targetId) + } + + @Test + fun `resolve remaps a dangling case tab contentKey and creates the side row`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "$sourceId:bundle-key", + ) + stubProcessDefinitions() + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + val tabCaptor = argumentCaptor() + verify(caseTabRepository).save(tabCaptor.capture()) + assertThat(tabCaptor.firstValue.contentKey).isEqualTo("$targetId:bundle-key") + + verify(caseExternalPluginTabRepository).save( + com.ritense.case_.domain.tab.CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = targetId, + bundleKey = "bundle-key", + ) + ) + } + + @Test + fun `resolve does not save links that are not in the mapping`() { + val link = processLink(externalPluginConfigurationId = UUID.randomUUID()) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + resolver.resolve(caseDefinitionId, mapOf(UUID.randomUUID() to UUID.randomUUID())) + + verify(processLinkRepository, never()).save(any()) + } + + @Test + fun `resolve emits resolved event when no dangling links remain`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val link = processLink(externalPluginConfigurationId = sourceId) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + verify(applicationEventPublisher).publishEvent(any()) + } + + @Test + fun `resolve emits detected event when dangling links remain`() { + val danglingLink = processLink(externalPluginConfigurationId = UUID.randomUUID()) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(danglingLink)) + whenever(configurationRepository.existsById(any())).thenReturn(false) + + resolver.resolve(caseDefinitionId, emptyMap()) + + verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher, never()).publishEvent(any()) + } + + @Test + fun `getDanglingPluginConfigurations groups links by plugin definition key and version, tagged external`() { + val danglingId1 = UUID.randomUUID() + val danglingId2 = UUID.randomUUID() + val existingId = UUID.randomUUID() + + val link1 = processLink(externalPluginConfigurationId = danglingId1, pluginDefinitionKey = "case-summary") + val link2 = processLink(externalPluginConfigurationId = danglingId2, pluginDefinitionKey = "case-summary") + val link3 = processLink(externalPluginConfigurationId = existingId, pluginDefinitionKey = "other-plugin") + + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link1, link2, link3)) + whenever(configurationRepository.existsById(any())).thenAnswer { invocation -> + invocation.arguments[0] == existingId + } + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.sourcePluginConfigurationIds).containsExactlyInAnyOrder(danglingId1, danglingId2) + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `getDanglingPluginConfigurations skips BUILDING_BLOCK links`() { + val link = processLink( + externalPluginConfigurationId = null, + referenceType = PluginConfigurationReferenceType.BUILDING_BLOCK, + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(link)) + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).isEmpty() + } + + @Test + fun `getDanglingPluginConfigurations includes dangling EXTERNAL_PLUGIN case tabs`() { + val danglingConfigId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = danglingConfigId.toString(), + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + assertThat(result.single().sourcePluginConfigurationIds).containsExactly(danglingConfigId) + assertThat(result.single().source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `recheckIssuesForProcessDefinition emits resolved event when all links are valid`() { + val pdId = ProcessDefinitionId.of("pd-1") + val link = processDefinitionCaseDefinition("pd-1") + whenever(processDefinitionCaseDefinitionService.findByProcessDefinitionIdOrNull(eq(pdId))).thenReturn(link) + whenever(processDefinitionCaseDefinitionService.findProcessDefinitionCaseDefinitions(eq(caseDefinitionId))) + .thenReturn(listOf(link)) + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + + resolver.recheckIssuesForProcessDefinition("pd-1") + + verify(applicationEventPublisher).publishEvent(any()) + } + + private fun stubProcessDefinitions(vararg processDefinitionIds: String) { + val links = processDefinitionIds.map { processDefinitionCaseDefinition(it) } + whenever(processDefinitionCaseDefinitionService.findProcessDefinitionCaseDefinitions(eq(caseDefinitionId))) + .thenReturn(links) + } + + private fun processDefinitionCaseDefinition(processDefinitionId: String) = + ProcessDefinitionCaseDefinition( + id = ProcessDefinitionCaseDefinitionId( + processDefinitionId = ProcessDefinitionId.of(processDefinitionId), + caseDefinitionId = caseDefinitionId, + ), + ) + + private fun processLink( + externalPluginConfigurationId: UUID?, + referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey: String? = "case-summary", + ): ExternalPluginProcessLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "Task_1", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = externalPluginConfigurationId, + actionKey = "send", + pluginConfigurationReference = PluginConfigurationReference( + type = referenceType, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = if (referenceType == PluginConfigurationReferenceType.BUILDING_BLOCK) "1.0.0" else null, + ), + ) + + private fun taskFormLink( + externalPluginConfigurationId: UUID, + ): ExternalPluginTaskFormProcessLink = ExternalPluginTaskFormProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "pd-1", + activityId = "Task_1", + activityType = ActivityTypeWithEventName.USER_TASK_CREATE, + externalPluginConfigurationId = externalPluginConfigurationId, + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + ), + ) +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt index 9035102b6d..f678da161f 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt @@ -25,6 +25,8 @@ import com.ritense.externalplugin.repository.ExternalPluginConfigurationReposito import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.web.rest.dto.PluginUsageParentType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition @@ -111,6 +113,50 @@ class ExternalPluginHostUsageResolverTest { assertThat(usages).isEmpty() } + /** + * A `BUILDING_BLOCK`-reference `ExternalPluginProcessLink` carries a `null` + * `externalPluginConfigurationId` (Phase 2) — it must never block deletion of a configuration, + * mirroring the embedded system's `PluginConfigurationUsageResolver` (`BUILDING_BLOCK` + * references there are likewise ignored by the delete guard). The repository query itself + * (`IN (...)` over non-null ids) already excludes such rows; this exercises the resolver's own + * `mapNotNull` defense so the guarantee holds even if a caller ever passes a broader result set. + */ + @Test + fun `BUILDING_BLOCK link with null configuration id is ignored by the delete guard`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id) + val fixedLink = processLink( + externalPluginConfigurationId = configuration.id, + processDefinitionId = "bezwaar:3:abc", + activityId = "SendLetter", + ) + val buildingBlockLink = buildingBlockReferenceProcessLink( + processDefinitionId = "send-notification:2:bb-hash", + activityId = "PostMessage", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(listOf(fixedLink, buildingBlockLink)) + whenever(operatonRepositoryService.findProcessDefinitionById("bezwaar:3:abc")).thenReturn( + operatonProcessDefinition( + id = "bezwaar:3:abc", + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance("bezwaar:3:abc")).thenReturn(mock()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + assertThat(usages[0].processLinkId).isEqualTo(fixedLink.id) + assertThat(usages.map { it.processDefinitionId }).doesNotContain("send-notification:2:bb-hash") + } + @Test fun `process tied to a case definition is classified as CASE`() { val hostId = UUID.randomUUID() @@ -465,7 +511,28 @@ class ExternalPluginHostUsageResolverTest { activityType = ActivityTypeWithEventName.SERVICE_TASK_START, externalPluginConfigurationId = externalPluginConfigurationId, actionKey = "action", - pluginVersion = "1.0.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "plugin", + pluginDefinitionVersion = "1.0.0", + ), + ) + + private fun buildingBlockReferenceProcessLink( + processDefinitionId: String, + activityId: String, + ): ExternalPluginProcessLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = null, + actionKey = "action", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "plugin", + pluginDefinitionVersion = "1.0.0", + ), ) private fun taskFormProcessLink( @@ -478,7 +545,11 @@ class ExternalPluginHostUsageResolverTest { activityId = activityId, activityType = ActivityTypeWithEventName.USER_TASK_CREATE, externalPluginConfigurationId = externalPluginConfigurationId, - pluginVersion = "1.0.0", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "plugin", + pluginDefinitionVersion = "1.0.0", + ), bundleKey = "review", ) diff --git a/backend/external-plugin/src/test/resources/config/application-mysql.yml b/backend/external-plugin/src/test/resources/config/application-mysql.yml new file mode 100644 index 0000000000..8408c1a47f --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application-mysql.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3368/external-plugin-test + username: valtimo + password: password + hikari: + auto-commit: false + data-source-properties: + serverTimezone: UTC + jpa: + database-platform: org.hibernate.dialect.MySQL8Dialect + database: mysql + +valtimo: + database: mysql diff --git a/backend/external-plugin/src/test/resources/config/application-postgresql.yml b/backend/external-plugin/src/test/resources/config/application-postgresql.yml new file mode 100644 index 0000000000..7c8438cbc9 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application-postgresql.yml @@ -0,0 +1,14 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:3368/external-plugin-test + username: valtimo + password: password + hikari: + auto-commit: false + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + database: postgresql + +valtimo: + database: postgres diff --git a/backend/external-plugin/src/test/resources/config/application.yml b/backend/external-plugin/src/test/resources/config/application.yml new file mode 100644 index 0000000000..2fc83df815 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/application.yml @@ -0,0 +1,39 @@ +spring: + datasource: + type: com.zaxxer.hikari.HikariDataSource + liquibase: + enabled: false + jpa: + show_sql: true + open-in-view: false + properties: + hibernate: + hbm2ddl.auto: none + generate_statistics: false + naming-strategy: org.springframework.boot.orm.jpa.hibernate.SpringNamingStrategy + cache: + use_second_level_cache: false + use_query_cache: false + region.factory_class: org.hibernate.cache.ehcache.SingletonEhCacheRegionFactory + format_sql: true + jdbc: + time_zone: UTC + connection: + provider_disables_autocommit: true + hibernate: + ddl-auto: none + +valtimo: + plugin: + encryption-secret: "abcdefghijklmnop" + +spring-actuator: + username: admin + password: password + +operaton: + bpm: + history-level: audit + generic-properties: + properties: + enforceHistoryTimeToLive: false diff --git a/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn new file mode 100644 index 0000000000..b081dbd60d --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/bpmn/external-plugin-import-process.bpmn @@ -0,0 +1,39 @@ + + + + + + SequenceFlow_1abb79g + + + SequenceFlow_0j10547 + + + + + SequenceFlow_1abb79g + SequenceFlow_0j10547 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json new file mode 100644 index 0000000000..87e2c08527 --- /dev/null +++ b/backend/external-plugin/src/test/resources/config/case/autodeploy/1-0-0/case/definition/autodeploy.case-definition.json @@ -0,0 +1,7 @@ +{ + "key": "autodeploy", + "name": "Autodeploy", + "versionTag": "1.0.0", + "canHaveAssignee": true, + "autoAssignTasks": true +} diff --git a/backend/plugin-valtimo/build.gradle b/backend/plugin-valtimo/build.gradle index 562771f4d5..0953c890df 100644 --- a/backend/plugin-valtimo/build.gradle +++ b/backend/plugin-valtimo/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation project(":backend:core") implementation project(":backend:logging") implementation project(":backend:process-document") + implementation project(":backend:value-resolver") implementation "org.springframework.boot:spring-boot-starter" implementation "org.springframework.boot:spring-boot-starter-data-jpa" diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt index e81da7a28b..53899854fc 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfiguration.kt @@ -211,13 +211,13 @@ class ProcessLinkAutoConfiguration { @Bean @ConditionalOnMissingBean(ProcessLinkChangedEventListener::class) fun processLinkChangedEventListener( - pluginConfigurationMappingResolver: PluginConfigurationMappingResolver, + pluginConfigurationMappingResolvers: List, ): ProcessLinkChangedEventListener { - return ProcessLinkChangedEventListener(pluginConfigurationMappingResolver) + return ProcessLinkChangedEventListener(pluginConfigurationMappingResolvers) } @Bean - @ConditionalOnMissingBean(PluginConfigurationMappingResolver::class) + @ConditionalOnMissingBean(PluginConfigurationMappingResolverImpl::class) fun pluginConfigurationMappingResolver( pluginProcessLinkRepository: ValtimoPluginProcessLinkRepository, pluginConfigurationRepository: PluginConfigurationRepository, diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt index b89eaa3538..7bb5a62bad 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListener.kt @@ -25,7 +25,7 @@ import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener class ProcessLinkChangedEventListener( - private val pluginConfigurationMappingResolver: PluginConfigurationMappingResolver + private val pluginConfigurationMappingResolvers: List ) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -44,10 +44,12 @@ class ProcessLinkChangedEventListener( } private fun recheckIssues(processDefinitionId: String) { - try { - pluginConfigurationMappingResolver.recheckIssuesForProcessDefinition(processDefinitionId) - } catch (e: Exception) { - logger.debug(e) { "Could not recheck plugin configuration issues for process definition $processDefinitionId" } + pluginConfigurationMappingResolvers.forEach { resolver -> + try { + resolver.recheckIssuesForProcessDefinition(processDefinitionId) + } catch (e: Exception) { + logger.debug(e) { "Could not recheck plugin configuration issues for process definition $processDefinitionId" } + } } } diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt index 497b820c41..dd168c6cb0 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkDeployDto.kt @@ -19,6 +19,7 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.autodeployment.ProcessLinkDeployDto @@ -35,6 +36,7 @@ class PluginProcessLinkDeployDto( val actionProperties: ObjectNode? = JsonNodeFactory.instance.objectNode(), val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkDeployDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt index 3af2f3206a..819fff7d8c 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkExportResponseDto.kt @@ -19,6 +19,7 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.JsonNodeFactory import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -34,6 +35,7 @@ class PluginProcessLinkExportResponseDto( val actionProperties: ObjectNode? = JsonNodeFactory.instance.objectNode(), val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkExportResponseDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt index 64434271b3..75cbe53601 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapper.kt @@ -17,13 +17,16 @@ package com.ritense.valtimo.processlink.mapper import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.logging.LoggableResource import com.ritense.logging.withLoggingContext +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationId import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.domain.PluginProcessLink import com.ritense.plugin.repository.PluginConfigurationRepository +import com.ritense.plugin.service.PluginActionResultMappingValidator import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.plugin.web.rest.request.PluginProcessLinkCreateDto import com.ritense.plugin.web.rest.request.PluginProcessLinkUpdateDto @@ -31,6 +34,7 @@ import com.ritense.plugin.web.rest.result.PluginProcessLinkResultDto import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.repository.ValtimoPluginProcessLinkRepository import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto @@ -76,6 +80,7 @@ class PluginProcessLinkMapper( pluginDefinitionKey = processLink.pluginConfigurationReference.pluginDefinitionKey, pluginActionDefinitionKey = processLink.pluginActionDefinitionKey, actionProperties = processLink.actionProperties, + actionResultMappings = processLink.actionResultMappings, ) } } @@ -91,6 +96,7 @@ class PluginProcessLinkMapper( activityType = deployDto.activityType, referenceType = deployDto.referenceType, pluginDefinitionKey = deployDto.pluginDefinitionKey, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -107,6 +113,7 @@ class PluginProcessLinkMapper( actionProperties = deployDto.actionProperties, referenceType = deployDto.referenceType, pluginDefinitionKey = deployDto.pluginDefinitionKey, + actionResultMappings = deployDto.actionResultMappings, ) } @@ -127,6 +134,7 @@ class PluginProcessLinkMapper( actionProperties = processLink.actionProperties, referenceType = processLink.pluginConfigurationReference.type, pluginDefinitionKey = definitionKey, + actionResultMappings = processLink.actionResultMappings, ) } } @@ -136,6 +144,7 @@ class PluginProcessLinkMapper( val reference = createReference(createRequestDto.referenceType, createRequestDto.pluginDefinitionKey) val configurationId = createRequestDto.pluginConfigurationId?.let { PluginConfigurationId.existingId(it) } validateReference(reference.type, configurationId) + PluginActionResultMappingValidator.validate(createRequestDto.actionResultMappings) return PluginProcessLink( id = UUID.randomUUID(), processDefinitionId = createRequestDto.processDefinitionId, @@ -145,6 +154,7 @@ class PluginProcessLinkMapper( pluginConfigurationReference = reference, pluginActionDefinitionKey = createRequestDto.pluginActionDefinitionKey, actionProperties = createRequestDto.actionProperties, + actionResultMappings = createRequestDto.actionResultMappings, ) } @@ -158,6 +168,7 @@ class PluginProcessLinkMapper( val reference = createReference(updateRequestDto.referenceType, updateRequestDto.pluginDefinitionKey) val configurationId = updateRequestDto.pluginConfigurationId?.let { PluginConfigurationId.existingId(it) } validateReference(reference.type, configurationId) + PluginActionResultMappingValidator.validate(updateRequestDto.actionResultMappings) PluginProcessLink( id = updateRequestDto.id, processDefinitionId = processLinkToUpdate.processDefinitionId, @@ -167,6 +178,7 @@ class PluginProcessLinkMapper( pluginConfigurationReference = reference, pluginActionDefinitionKey = updateRequestDto.pluginActionDefinitionKey, actionProperties = updateRequestDto.actionProperties, + actionResultMappings = updateRequestDto.actionResultMappings, ) } } @@ -189,6 +201,10 @@ class PluginProcessLinkMapper( } } + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "pluginConfigurationId", mappings) + } + override fun afterImport( caseDefinitionId: CaseDefinitionId, processDefinitionIds: Set, diff --git a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt index 9459d3e6ef..a7850df5c0 100644 --- a/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt +++ b/backend/plugin-valtimo/src/main/kotlin/com/ritense/valtimo/processlink/repository/ValtimoPluginProcessLinkRepository.kt @@ -33,4 +33,35 @@ interface ValtimoPluginProcessLinkRepository : BaseProcessLinkRepository ): List + + /** + * `ExternalPluginProcessLink` (the `external_plugin` `process_link_type` row) lives in + * `:backend:external-plugin`, which `:backend:building-block` does not depend on. Rather than + * introduce that module dependency, this reads the shared `process_link` columns + * (`plugin_definition_key`/`plugin_definition_version`, populated by both the embedded and + * external plugin systems via the shared `PluginConfigurationReference` embeddable) natively, + * filtered to external `BUILDING_BLOCK` references only. + */ + @Query( + value = """ + SELECT DISTINCT + plugin_definition_key AS pluginDefinitionKey, + plugin_definition_version AS pluginDefinitionVersion + FROM process_link + WHERE process_link_type = 'external_plugin' + AND reference_type = 'BUILDING_BLOCK' + AND process_definition_id IN :processDefinitionIds + AND plugin_definition_key IS NOT NULL + AND plugin_definition_version IS NOT NULL + """, + nativeQuery = true + ) + fun findExternalPluginReferencesByProcessDefinitionIds( + @Param("processDefinitionIds") processDefinitionIds: Collection + ): List +} + +interface ExternalPluginReferenceProjection { + fun getPluginDefinitionKey(): String + fun getPluginDefinitionVersion(): String } diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt new file mode 100644 index 0000000000..98e19fbf74 --- /dev/null +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/ProcessLinkAutoConfigurationWiringTest.kt @@ -0,0 +1,69 @@ +/* + * 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.valtimo.processlink + +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import com.ritense.valtimo.processlink.listener.ProcessLinkChangedEventListener +import com.ritense.valtimo.processlink.service.PluginConfigurationMappingResolverImpl +import java.lang.reflect.ParameterizedType +import java.lang.reflect.WildcardType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean + +/** + * Multiple modules register a [PluginConfigurationMappingResolver] bean (this module's embedded + * resolver and external-plugin's), and every real application has both modules on the classpath. + * That cross-module wiring is not exercised by any single-module context test, so these contracts + * are pinned by reflection: + * + * - the resolver bean's `@ConditionalOnMissingBean` must target its own concrete class — an + * interface-typed condition makes whichever module's configuration is processed first silently + * suppress the other module's resolver; + * - every consumer that used to inject the single resolver must inject the full list — a + * single-valued injection point fails the application boot with a + * `NoUniqueBeanDefinitionException` once the second resolver exists. + */ +class ProcessLinkAutoConfigurationWiringTest { + + @Test + fun `resolver bean condition targets its own concrete class, not the shared interface`() { + val beanMethod = ProcessLinkAutoConfiguration::class.java.declaredMethods + .single { it.name == "pluginConfigurationMappingResolver" } + + val condition = beanMethod.getAnnotation(ConditionalOnMissingBean::class.java) + + assertThat(condition).isNotNull + assertThat(condition.value.map { it.java }).containsExactly(PluginConfigurationMappingResolverImpl::class.java) + } + + @Test + fun `process link changed event listener injects all mapping resolvers`() { + val constructor = ProcessLinkChangedEventListener::class.java.constructors.single() + val parameterType = constructor.genericParameterTypes.single() + + assertThat(parameterType).isInstanceOf(ParameterizedType::class.java) + parameterType as ParameterizedType + assertThat(parameterType.rawType).isEqualTo(List::class.java) + // Kotlin's List surfaces as List in Java reflection. + val elementType = when (val argument = parameterType.actualTypeArguments.single()) { + is WildcardType -> argument.upperBounds.single() + else -> argument + } + assertThat(elementType).isEqualTo(PluginConfigurationMappingResolver::class.java) + } +} diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt index 9862b2ce70..2848f1ee90 100644 --- a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/listener/ProcessLinkChangedEventListenerTest.kt @@ -27,6 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -40,7 +41,7 @@ class ProcessLinkChangedEventListenerTest { @BeforeEach fun before() { - listener = ProcessLinkChangedEventListener(pluginConfigurationMappingResolver) + listener = ProcessLinkChangedEventListener(listOf(pluginConfigurationMappingResolver)) } @Test @@ -73,4 +74,20 @@ class ProcessLinkChangedEventListenerTest { assertThatCode { listener.onProcessLinkCreated(ProcessLinkCreatedEvent("plugin", "pd-1")) } .doesNotThrowAnyException() } + + @Test + fun `delegates to every registered resolver, one throwing does not block the others`() { + val secondResolver: PluginConfigurationMappingResolver = mock() + doThrow(RuntimeException("boom")) + .whenever(pluginConfigurationMappingResolver) + .recheckIssuesForProcessDefinition("pd-1") + val multiResolverListener = + ProcessLinkChangedEventListener(listOf(pluginConfigurationMappingResolver, secondResolver)) + + assertThatCode { multiResolverListener.onProcessLinkCreated(ProcessLinkCreatedEvent("plugin", "pd-1")) } + .doesNotThrowAnyException() + + verify(pluginConfigurationMappingResolver).recheckIssuesForProcessDefinition("pd-1") + verify(secondResolver).recheckIssuesForProcessDefinition("pd-1") + } } diff --git a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt index 11cfffb864..51e00ef61c 100644 --- a/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt +++ b/backend/plugin-valtimo/src/test/kotlin/com/ritense/valtimo/processlink/mapper/PluginProcessLinkMapperTest.kt @@ -72,6 +72,27 @@ class PluginProcessLinkMapperTest { ) } + @Test + fun `applyPluginConfigurationMappings rewrites pluginConfigurationId to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = jacksonObjectMapper().createObjectNode().put("pluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to targetId)) + + assertThat(node.get("pluginConfigurationId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `applyPluginConfigurationMappings nulls pluginConfigurationId when mapping value is null`() { + val sourceId = UUID.randomUUID() + val node = jacksonObjectMapper().createObjectNode().put("pluginConfigurationId", sourceId.toString()) + + mapper.applyPluginConfigurationMappings(node, mapOf(sourceId to null)) + + assertThat(node.get("pluginConfigurationId").isNull).isTrue() + } + @Test fun `afterImport emits detected event when FIXED link has missing pluginConfigurationId`() { val configId = PluginConfigurationId.existingId(UUID.randomUUID()) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt index 59cb2d8ab9..6eeef7722c 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/configuration/PluginAutoConfiguration.kt @@ -34,6 +34,7 @@ import com.ritense.plugin.repository.PluginPropertyRepository import com.ritense.plugin.security.config.PluginHttpSecurityConfigurer import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver import com.ritense.plugin.service.EncryptionService +import com.ritense.plugin.service.PluginActionResultHandler import com.ritense.plugin.service.PluginConfigurationListener import com.ritense.plugin.service.PluginConfigurationUsageResolver import com.ritense.plugin.service.PluginService @@ -156,6 +157,7 @@ class PluginAutoConfiguration { caseDefinitionChecker: CaseDefinitionChecker, buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, pluginConfigurationUsageResolver: PluginConfigurationUsageResolver, + pluginActionResultHandler: PluginActionResultHandler, ): PluginService { return PluginService( pluginDefinitionRepository, @@ -173,9 +175,17 @@ class PluginAutoConfiguration { caseDefinitionChecker, buildingBlockPluginConfigurationResolver, pluginConfigurationUsageResolver, + pluginActionResultHandler, ) } + @Bean + @ConditionalOnMissingBean(PluginActionResultHandler::class) + fun pluginActionResultHandler( + valueResolverService: ValueResolverService, + objectMapper: ObjectMapper, + ) = PluginActionResultHandler(valueResolverService, objectMapper) + @Bean @ConditionalOnMissingBean fun pluginConfigurationSearchRepository(entityManager: EntityManager): PluginConfigurationSearchRepository { diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt new file mode 100644 index 0000000000..7ac1156c55 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginActionResultMapping.kt @@ -0,0 +1,29 @@ +/* + * 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.plugin.domain + +/** + * A single write-back rule for a `@PluginAction` (or external-plugin action) return value. + * [source] is an RFC 6901 JSON pointer into the action's result (an empty string selects the + * whole result); [target] is a value-resolver-prefixed key (`doc:`, `pv:`, `case:`) describing + * where to write it. Stored as a JSON list on the `process_link.action_result_mappings` column, + * shared by both [PluginProcessLink] (embedded) and the external-plugin process link. + */ +data class PluginActionResultMapping( + val source: String, + val target: String, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt index 6e03b3bfd7..024f95a84f 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginConfigurationReference.kt @@ -28,7 +28,20 @@ data class PluginConfigurationReference( val type: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, @Column(name = "plugin_definition_key") - val pluginDefinitionKey: String? = null + val pluginDefinitionKey: String? = null, + + /** + * Design-time metadata only, populated exclusively by the external-plugin system (embedded + * plugin definitions are unversioned, so embedded usage always keeps this `null`). Used for + * validation, UI warnings and the import chooser; the **runtime** invocation version always + * derives from the resolved configuration's definition, never from this field. + * + * Whether `null` is allowed/required per [type] and per caller (embedded vs. external) is + * enforced by the respective `ProcessLinkMapper`, not here — this embeddable is shared by both + * systems and cannot encode a rule that only applies to one of them. + */ + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, ) { init { when (type) { diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt index c76d9a02f3..40e3986f1d 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/domain/PluginProcessLink.kt @@ -48,7 +48,11 @@ class PluginProcessLink( val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), @Column(name = "plugin_action_definition_key", nullable = false) - val pluginActionDefinitionKey: String + val pluginActionDefinitionKey: String, + + @Type(value = JsonType::class) + @Column(name = "action_result_mappings", columnDefinition = "JSON") + val actionResultMappings: List = emptyList(), ) : ProcessLink( id, @@ -107,6 +111,7 @@ class PluginProcessLink( pluginConfigurationId: PluginConfigurationId? = this.pluginConfigurationId, pluginConfigurationReference: PluginConfigurationReference = this.pluginConfigurationReference, pluginActionDefinitionKey: String = this.pluginActionDefinitionKey, + actionResultMappings: List = this.actionResultMappings, ) = PluginProcessLink( id = id, processDefinitionId = processDefinitionId, @@ -115,7 +120,8 @@ class PluginProcessLink( actionProperties = actionProperties, pluginConfigurationId = pluginConfigurationId, pluginConfigurationReference = pluginConfigurationReference, - pluginActionDefinitionKey = pluginActionDefinitionKey + pluginActionDefinitionKey = pluginActionDefinitionKey, + actionResultMappings = actionResultMappings, ) override fun equals(other: Any?): Boolean { @@ -129,6 +135,7 @@ class PluginProcessLink( if (pluginConfigurationId != other.pluginConfigurationId) return false if (pluginConfigurationReference != other.pluginConfigurationReference) return false if (pluginActionDefinitionKey != other.pluginActionDefinitionKey) return false + if (actionResultMappings != other.actionResultMappings) return false return true } @@ -139,6 +146,7 @@ class PluginProcessLink( result = 31 * result + (pluginConfigurationId?.hashCode() ?: 0) result = 31 * result + pluginConfigurationReference.hashCode() result = 31 * result + pluginActionDefinitionKey.hashCode() + result = 31 * result + actionResultMappings.hashCode() return result } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt new file mode 100644 index 0000000000..702262b04f --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt @@ -0,0 +1,106 @@ +/* + * 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.plugin.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valueresolver.ValueResolverService +import io.github.oshai.kotlinlogging.KotlinLogging +import org.operaton.bpm.engine.delegate.DelegateExecution +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * Writes a plugin action's JSON result back to process variables / the case (or building-block) + * document through [PluginActionResultMapping]s, reusing the same split-and-dispatch shape as + * `BuildingBlockCallActivityListener.onCallActivityEnd` (`pv:` targets go to the process instance, + * everything else to `execution`'s business-key document — inside a building-block process that is + * the BB instance document, which BB output mappings then sync to the case as usual). + */ +@Component +@SkipComponentScan +class PluginActionResultHandler( + private val valueResolverService: ValueResolverService, + private val objectMapper: ObjectMapper, +) { + + /** + * @param execution the execution the action ran on; its business key (if present) is the + * document targeted by non-`pv:` mappings. + * @param result the action's return value, already serialized to JSON — `null` is a no-op + * except for a warning when mappings were configured (author error: an action that can + * return nothing was wired to a result mapping). + */ + fun handle(execution: DelegateExecution, result: JsonNode?, mappings: List) { + if (mappings.isEmpty()) { + return + } + if (result == null || result.isNull || result.isMissingNode) { + logger.warn { + "Plugin action for activity '${execution.currentActivityId}' of process instance " + + "'${execution.processInstanceId}' has ${mappings.size} result mapping(s) configured, " + + "but returned no result to map." + } + return + } + + val valuesToHandle = mappings.mapNotNull { mapping -> + val pointer = mapping.source.ifBlank { "" } + val node = result.at(pointer) + if (node.isMissingNode || node.isNull) { + // A null result value is skipped like a missing one: writing JSON null would wipe + // existing data and fails schema validation on non-nullable document fields. + logger.warn { + "Plugin action result mapping source pointer '${mapping.source}' was " + + (if (node.isNull) "null" else "absent") + + " in the action result for activity '${execution.currentActivityId}' " + + "of process instance '${execution.processInstanceId}' — target " + + "'${mapping.target}' was not written." + } + return@mapNotNull null + } + mapping.target to objectMapper.treeToValue(node, Any::class.java) + }.toMap() + + if (valuesToHandle.isEmpty()) { + return + } + + val pvTargets = valuesToHandle.filter { it.key.startsWith("pv:") } + val otherTargets = valuesToHandle.filter { !it.key.startsWith("pv:") } + + if (pvTargets.isNotEmpty()) { + valueResolverService.handleValues(execution.processInstanceId, execution, pvTargets) + } + + if (otherTargets.isNotEmpty()) { + val businessKey = execution.processBusinessKey + ?: error( + "Cannot write plugin action result mappings for activity " + + "'${execution.currentActivityId}' — process instance " + + "'${execution.processInstanceId}' has no business-key document." + ) + valueResolverService.handleValues(UUID.fromString(businessKey), otherTargets) + } + } + + private companion object { + val logger = KotlinLogging.logger {} + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt new file mode 100644 index 0000000000..e0f9f4f223 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultMappingValidator.kt @@ -0,0 +1,44 @@ +/* + * 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.plugin.service + +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valueresolver.exception.ValueResolverValidationException + +/** + * Save-time guard shared by the embedded ([com.ritense.plugin.domain.PluginProcessLink]) and + * external process-link mappers. Only `doc:`, `pv:` and `case:` targets support writes today + * (mirrors `ValueResolverService.handleValues`'s actual capabilities — `zaak:` and friends are + * read-only resolvers); anything else is rejected before it reaches the database so authors get + * immediate feedback instead of a silent no-op at process-run time. + */ +object PluginActionResultMappingValidator { + + private val WRITABLE_PREFIXES = setOf("doc", "pv", "case") + + fun validate(mappings: List) { + mappings.forEach { mapping -> + val prefix = mapping.target.substringBefore(":", missingDelimiterValue = "") + if (prefix !in WRITABLE_PREFIXES) { + throw ValueResolverValidationException( + "Action result mapping target '${mapping.target}' is not writable — only " + + "${WRITABLE_PREFIXES.joinToString { "'$it:'" }} targets are supported." + ) + } + } + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt index 5e09b4ab9d..5dc4b8975b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginService.kt @@ -103,6 +103,7 @@ class PluginService( private val caseDefinitionChecker: CaseDefinitionChecker, private val buildingBlockPluginConfigurationResolver: BuildingBlockPluginConfigurationResolver?, private val pluginConfigurationUsageResolver: PluginConfigurationUsageResolver, + private val pluginActionResultHandler: PluginActionResultHandler, ) { fun getObjectMapper(): ObjectMapper { @@ -481,7 +482,9 @@ class PluginService( logger.debug { "Invoking method ${method.name} of class ${instance.javaClass.simpleName} for activity ${execution.currentActivityId} of process-instance ${execution.processInstanceId}" } - method.invoke(instance, *methodArguments) + val result = method.invoke(instance, *methodArguments) + applyActionResultMappings(execution, processLink, result) + result } } @@ -503,10 +506,25 @@ class PluginService( logger.debug { "Invoking method ${method.name} of class ${instance.javaClass.simpleName} for task ${task.taskDefinitionKey} of process-instance ${task.processInstanceId}" } - method.invoke(instance, *methodArguments) + val result = method.invoke(instance, *methodArguments) + applyActionResultMappings(task.execution, processLink, result) + result } } + /** + * Covers every listener that calls [invoke] (service task, user task create, call activity, + * send/receive/intermediate events) with zero listener changes — the return value a + * `@PluginAction` method produces was discarded here before result mappings existed. + */ + private fun applyActionResultMappings(execution: DelegateExecution, processLink: PluginProcessLink, result: Any?) { + if (processLink.actionResultMappings.isEmpty()) { + return + } + val resultNode = result?.let { objectMapper.valueToTree(it) } + pluginActionResultHandler.handle(execution, resultNode, processLink.actionResultMappings) + } + fun getPluginDefinitionsWithDependencies( pluginDefinitionKeys: Collection diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt index 460a377c84..1c5b105b24 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkCreateDto.kt @@ -18,6 +18,7 @@ package com.ritense.plugin.web.rest.request import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -34,6 +35,7 @@ data class PluginProcessLinkCreateDto( override val activityType: ActivityTypeWithEventName, val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkCreateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt index e40fbe8b0c..a1266d4d81 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/request/PluginProcessLinkUpdateDto.kt @@ -18,6 +18,7 @@ package com.ritense.plugin.web.rest.request import com.fasterxml.jackson.annotation.JsonTypeName import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.web.rest.dto.ProcessLinkUpdateRequestDto @@ -30,7 +31,8 @@ data class PluginProcessLinkUpdateDto( val pluginActionDefinitionKey: String, val actionProperties: ObjectNode? = null, val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, - val pluginDefinitionKey: String? = null + val pluginDefinitionKey: String? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkUpdateRequestDto { override val processLinkType: String get() = PROCESS_LINK_TYPE_PLUGIN diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt index 4e6d044be5..a971899b3e 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginDefinitionsWithDependenciesDto.kt @@ -22,7 +22,22 @@ data class PluginDefinitionsWithDependenciesDto( val plugins: List ) +/** + * [source] discriminates embedded plugin definitions (identified by [pluginDefinitionKey] alone, + * unversioned) from external plugin definitions referenced via a `BUILDING_BLOCK` + * `PluginConfigurationReference` (identified by [pluginDefinitionKey] == `pluginId` + + * [pluginDefinitionVersion]). Defaults to [PluginRequirementSource.EMBEDDED] and leaves + * [pluginDefinitionVersion] `null` so existing frontend consumers built against the embedded-only + * shape keep working unchanged. + */ data class PluginWithDependenciesDto( val pluginDefinitionKey: String, - val dependencies: List -) \ No newline at end of file + val dependencies: List, + val source: PluginRequirementSource = PluginRequirementSource.EMBEDDED, + val pluginDefinitionVersion: String? = null, +) + +enum class PluginRequirementSource { + EMBEDDED, + EXTERNAL, +} \ No newline at end of file diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt index ecee285820..8282ea3007 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/result/PluginProcessLinkResultDto.kt @@ -17,6 +17,7 @@ package com.ritense.plugin.web.rest.result import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.PluginService.Companion.PROCESS_LINK_TYPE_PLUGIN import com.ritense.processlink.domain.ActivityTypeWithEventName @@ -33,5 +34,6 @@ data class PluginProcessLinkResultDto( val referenceType: PluginConfigurationReferenceType = PluginConfigurationReferenceType.FIXED, val pluginDefinitionKey: String? = null, val pluginActionDefinitionKey: String, - val actionProperties: ObjectNode? = null + val actionProperties: ObjectNode? = null, + val actionResultMappings: List = emptyList(), ) : ProcessLinkResponseDto diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceStiSpikeTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceStiSpikeTest.kt new file mode 100644 index 0000000000..392b89c387 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceStiSpikeTest.kt @@ -0,0 +1,98 @@ +/* + * 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.plugin.domain + +import org.assertj.core.api.Assertions.assertThat +import org.hibernate.boot.MetadataSources +import org.hibernate.boot.registry.StandardServiceRegistryBuilder +import org.hibernate.cfg.AvailableSettings +import org.hibernate.dialect.PostgreSQLDialect +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.hibernate.boot.registry.StandardServiceRegistry + +/** + * Verifies that Hibernate accepts the shared [PluginConfigurationReference] embeddable — mapping + * columns `reference_type` / `plugin_definition_key` / `plugin_definition_version` — being embedded + * by *two* single-table-inheritance siblings of `process_link`: [PluginProcessLink] (existing) and a + * stand-in for the planned `ExternalPluginProcessLink` rework ([StiSpikeExternalPluginProcessLink] + * below, a minimal local copy so this test does not depend on the not-yet-changed real entity). + * + * `buildSessionFactory()` (not just `buildMetadata()`) is exercised — no DataSource/connection is + * opened, `hibernate.dialect` is set explicitly so boot does not try to auto-detect one from a + * (non-existent) connection — because building the mapping model / persisters is where Hibernate + * 6 actually validates column consistency, not metadata collection alone. + * + * Result: mapping the same three columns from the same embeddable on both STI siblings builds + * cleanly — Hibernate does not consider this a conflict, because the two entities never both + * populate the same row (discriminated by `process_link_type`); the embeddable is simply reused as + * a value type on each. This was cross-checked with two negative controls during the spike (not + * committed, since they'd otherwise permanently fail the build): + * - Mapping `reference_type` a second time as a plain column *within the same entity* (alongside + * the embeddable) → Hibernate does throw: `MappingException: Column 'reference_type' is + * duplicated in mapping for entity ...`. This confirms genuine duplicate-column mappings within + * one entity are still caught, i.e. the harness is not silently permissive by construction. + * - Mapping `reference_type` as a `String` on one sibling while [PluginConfigurationReference] + * maps it as an enum on another sibling → still no error. Hibernate does not cross-validate + * column types between sibling STI subclasses at boot; that would only surface against a real + * schema (`hbm2ddl.auto=validate`) or at query/flush time for whichever subclass mismaps it. + * Not a concern here because both `PluginProcessLink` and the reworked `ExternalPluginProcessLink` + * will map the *same* embeddable type with *identical* column definitions — there is no + * divergence to catch. + * + * Conclusion: the shared-column design in D1 is safe to implement as specified. The plan's fallback + * (distinct external column names, e.g. `external_plugin_reference_type`) is **not needed**. + */ +class PluginConfigurationReferenceStiSpikeTest { + + private var registry: StandardServiceRegistry? = null + + @AfterEach + fun tearDown() { + registry?.let { StandardServiceRegistryBuilder.destroy(it) } + } + + @Test + fun `two STI siblings can both embed PluginConfigurationReference on the shared columns`() { + registry = StandardServiceRegistryBuilder() + .applySetting(AvailableSettings.DIALECT, PostgreSQLDialect::class.java.name) + // No JDBC connection is opened for buildMetadata(); this only avoids Hibernate + // trying (and failing) to reach out to a ConnectionProvider for dialect resolution. + .applySetting(AvailableSettings.CONNECTION_PROVIDER_DISABLES_AUTOCOMMIT, "false") + .build() + + val metadataSources = MetadataSources(registry) + .addAnnotatedClass(com.ritense.processlink.domain.ProcessLink::class.java) + .addAnnotatedClass(PluginProcessLink::class.java) + .addAnnotatedClass(StiSpikeExternalPluginProcessLink::class.java) + + val metadata = metadataSources.buildMetadata() + // buildSessionFactory (not just buildMetadata) triggers the persister/mapping-model build, + // which is where Hibernate 6 actually validates per-table column consistency across STI + // subclasses (buildMetadata alone does not). + val sessionFactory = metadata.buildSessionFactory() + sessionFactory.close() + + val processLinkBinding = metadata.getEntityBinding("com.ritense.processlink.domain.ProcessLink") + assertThat(processLinkBinding).isNotNull + + // Both siblings' persistent classes exist and Hibernate could resolve the shared columns + // without throwing — the true assertion is that buildSessionFactory() above did not raise. + assertThat(metadata.getEntityBinding(PluginProcessLink::class.java.name)).isNotNull + assertThat(metadata.getEntityBinding(StiSpikeExternalPluginProcessLink::class.java.name)).isNotNull + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt index 60d4d9994c..517fbd360e 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/PluginConfigurationReferenceTest.kt @@ -71,4 +71,23 @@ class PluginConfigurationReferenceTest { ) }.doesNotThrowAnyException() } + + @Test + fun `pluginDefinitionVersion defaults to null`() { + val ref = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "zaken-api", + ) + assertThat(ref.pluginDefinitionVersion).isNull() + } + + @Test + fun `pluginDefinitionVersion can be set for external plugin usage`() { + val ref = PluginConfigurationReference( + type = PluginConfigurationReferenceType.BUILDING_BLOCK, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "1.2.3", + ) + assertThat(ref.pluginDefinitionVersion).isEqualTo("1.2.3") + } } diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/StiSpikeExternalPluginProcessLink.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/StiSpikeExternalPluginProcessLink.kt new file mode 100644 index 0000000000..6d74dc3939 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/domain/StiSpikeExternalPluginProcessLink.kt @@ -0,0 +1,58 @@ +/* + * 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.plugin.domain + +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.domain.ProcessLink +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import java.util.UUID + +/** + * Test-only stand-in for `ExternalPluginProcessLink`: an STI sibling of [PluginProcessLink] that + * also embeds [PluginConfigurationReference] on the same shared columns (`reference_type`, + * `plugin_definition_key`, `plugin_definition_version`). Exists solely so + * [PluginConfigurationReferenceStiSpikeTest] can build Hibernate metadata for two siblings sharing + * the embeddable without this module depending on the real + * `com.ritense.externalplugin.domain.ExternalPluginProcessLink` in `:backend:external-plugin`. + */ +@Entity +@DiscriminatorValue("_sti_spike_external_plugin") +class StiSpikeExternalPluginProcessLink( + id: UUID, + processDefinitionId: String, + activityId: String, + activityType: ActivityTypeWithEventName, + + @Embedded + val pluginConfigurationReference: PluginConfigurationReference = PluginConfigurationReference(), +) : ProcessLink( + id, + processDefinitionId, + activityId, + activityType, + "_sti_spike_external_plugin", +) { + override fun copy(id: UUID, processDefinitionId: String) = StiSpikeExternalPluginProcessLink( + id = id, + processDefinitionId = processDefinitionId, + activityId = activityId, + activityType = activityType, + pluginConfigurationReference = pluginConfigurationReference, + ) +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt new file mode 100644 index 0000000000..9ae491b6a4 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt @@ -0,0 +1,174 @@ +/* + * 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.plugin.service + +import com.ritense.plugin.domain.PluginActionResultMapping +import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valueresolver.ValueResolverService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +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.delegate.DelegateExecution +import java.util.UUID + +class PluginActionResultHandlerTest { + + private lateinit var valueResolverService: ValueResolverService + private lateinit var handler: PluginActionResultHandler + private lateinit var execution: DelegateExecution + + @BeforeEach + fun init() { + valueResolverService = mock() + handler = PluginActionResultHandler(valueResolverService, MapperSingleton.get()) + execution = mock() + whenever(execution.processInstanceId).thenReturn("process-instance-1") + whenever(execution.currentActivityId).thenReturn("activity-1") + } + + @Test + fun `does nothing when no mappings are configured`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + + handler.handle(execution, result, emptyList()) + + verify(valueResolverService, never()).handleValues(any(), any(), any()) + verify(valueResolverService, never()).handleValues(any(), any()) + } + + @Test + fun `extracts the source pointer and writes it to the target`() { + val result = MapperSingleton.get().readTree("""{"value": 123, "nested": {"field": "abc"}}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/nested/field", target = "doc:/summary")) + ) + + verify(valueResolverService).handleValues(eq(businessKey), eq(mapOf("doc:/summary" to "abc"))) + } + + @Test + fun `an empty source pointer selects the whole result`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "", target = "doc:/whole")) + ) + + verify(valueResolverService).handleValues( + eq(businessKey), + org.mockito.kotlin.check { values -> + val written = values["doc:/whole"] + org.assertj.core.api.Assertions.assertThat(written).isInstanceOf(Map::class.java) + } + ) + } + + @Test + fun `splits pv target from document targets across two handleValues calls`() { + val result = MapperSingleton.get().readTree("""{"a": 1, "b": 2}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf( + PluginActionResultMapping(source = "/a", target = "pv:varA"), + PluginActionResultMapping(source = "/b", target = "doc:/fieldB"), + ) + ) + + verify(valueResolverService).handleValues("process-instance-1", execution, mapOf("pv:varA" to 1)) + verify(valueResolverService).handleValues(businessKey, mapOf("doc:/fieldB" to 2)) + } + + @Test + fun `logs a warning and skips the target when the source pointer does not match`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + whenever(execution.processBusinessKey).thenReturn(UUID.randomUUID().toString()) + + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/missing", target = "doc:/summary")) + ) + + verify(valueResolverService, never()).handleValues(any(), any()) + } + + @Test + fun `skips null result values but still writes the non-null ones`() { + val result = MapperSingleton.get().readTree("""{"remarks": null, "decision": "APPROVED"}""") + val businessKey = UUID.randomUUID() + whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) + + handler.handle( + execution, + result, + listOf( + PluginActionResultMapping(source = "/remarks", target = "doc:/reviewerRemarks"), + PluginActionResultMapping(source = "/decision", target = "doc:/approvalDecision"), + ) + ) + + verify(valueResolverService).handleValues( + businessKey, + mapOf("doc:/approvalDecision" to "APPROVED") + ) + } + + @Test + fun `logs a warning and does not fail the process when the result is null but mappings are configured`() { + handler.handle( + execution, + null, + listOf(PluginActionResultMapping(source = "/value", target = "doc:/summary")) + ) + + verify(valueResolverService, never()).handleValues(any(), any()) + verify(valueResolverService, never()).handleValues(any(), any(), any()) + } + + @Test + fun `throws when a non-pv target is configured but the execution has no business key`() { + val result = MapperSingleton.get().readTree("""{"value": 123}""") + whenever(execution.processBusinessKey).thenReturn(null) + + val exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) { + handler.handle( + execution, + result, + listOf(PluginActionResultMapping(source = "/value", target = "doc:/summary")) + ) + } + org.assertj.core.api.Assertions.assertThat(exception.message).contains("business-key document") + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt index 413bb6c181..29f154390e 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginServiceTest.kt @@ -23,6 +23,7 @@ import com.ritense.plugin.annotation.PluginAction import com.ritense.plugin.annotation.PluginActionProperty import com.ritense.plugin.domain.PluginActionDefinition import com.ritense.plugin.domain.PluginActionDefinitionId +import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfiguration import com.ritense.plugin.domain.PluginConfigurationId import com.ritense.plugin.domain.PluginConfigurationReference @@ -80,6 +81,7 @@ internal class PluginServiceTest { lateinit var encryptionService: EncryptionService lateinit var environment: Environment lateinit var pluginConfigurationUsageResolver: PluginConfigurationUsageResolver + lateinit var pluginActionResultHandler: PluginActionResultHandler @BeforeEach fun init() { @@ -94,6 +96,7 @@ internal class PluginServiceTest { encryptionService = mock() environment = mock() pluginConfigurationUsageResolver = mock() + pluginActionResultHandler = mock() pluginService = spy(PluginService( pluginDefinitionRepository, pluginConfigurationRepository, @@ -110,6 +113,7 @@ internal class PluginServiceTest { mock(), null, pluginConfigurationUsageResolver, + pluginActionResultHandler, )) } @@ -452,6 +456,71 @@ internal class PluginServiceTest { verify(testDependency).processInt(null) } + @Test + fun `should apply action result mappings when the link declares them`() { + val execution = mock() + val processLink = PluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process", + activityId = "activity", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionProperties = MapperSingleton.get().readTree("{\"test\":123}") as ObjectNode, + pluginConfigurationId = PluginConfigurationId.newId(), + pluginConfigurationReference = PluginConfigurationReference(), + pluginActionDefinitionKey = "test-action-with-result", + actionResultMappings = listOf(PluginActionResultMapping(source = "/value", target = "pv:result")), + ) + + val pluginDefinition = newPluginDefinition() + val pluginConfiguration = newPluginConfiguration(pluginDefinition) + val testDependency = mock() + + whenever(pluginConfigurationRepository.getReferenceById(any())).thenReturn(pluginConfiguration) + whenever(pluginFactory.canCreate(any())).thenReturn(true) + whenever(pluginFactory.create(any())).thenReturn(TestPlugin(testDependency)) + whenever(execution.processInstanceId).thenReturn("test") + whenever(valueResolverService.resolveValues(any(), any(), any())).thenReturn(mapOf("test" to 123)) + + pluginService.invoke(execution, processLink) + + val resultCaptor = argumentCaptor() + verify(pluginActionResultHandler).handle( + org.mockito.kotlin.eq(execution), + resultCaptor.capture(), + org.mockito.kotlin.eq(processLink.actionResultMappings), + ) + assertEquals(123, resultCaptor.firstValue.get("value").intValue()) + } + + @Test + fun `should not touch the result handler when the link declares no result mappings`() { + val execution = mock() + val processLink = PluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process", + activityId = "activity", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + actionProperties = MapperSingleton.get().readTree("{\"test\":123}") as ObjectNode, + pluginConfigurationId = PluginConfigurationId.newId(), + pluginConfigurationReference = PluginConfigurationReference(), + pluginActionDefinitionKey = "test-action-with-result", + ) + + val pluginDefinition = newPluginDefinition() + val pluginConfiguration = newPluginConfiguration(pluginDefinition) + val testDependency = mock() + + whenever(pluginConfigurationRepository.getReferenceById(any())).thenReturn(pluginConfiguration) + whenever(pluginFactory.canCreate(any())).thenReturn(true) + whenever(pluginFactory.create(any())).thenReturn(TestPlugin(testDependency)) + whenever(execution.processInstanceId).thenReturn("test") + whenever(valueResolverService.resolveValues(any(), any(), any())).thenReturn(mapOf("test" to 123)) + + pluginService.invoke(execution, processLink) + + verify(pluginActionResultHandler, org.mockito.kotlin.never()).handle(any(), any(), any()) + } + @Test fun `should throw exception when invoking delegateExecution method with resolved variable where result does not match argument type`(){ val execution = mock() @@ -501,6 +570,7 @@ internal class PluginServiceTest { mock(), resolver, pluginConfigurationUsageResolver, + pluginActionResultHandler, ) ) @@ -562,6 +632,7 @@ internal class PluginServiceTest { mock(), resolver, pluginConfigurationUsageResolver, + pluginActionResultHandler, ) ) @@ -778,8 +849,21 @@ internal class PluginServiceTest { fun doThing2(@PluginActionProperty test: Int?) { testDependency.processInt(test) } + + @PluginAction( + key = "test-action-with-result", + title = "Test action with result", + description = "This is an action used to verify result-mapping write-back", + activityTypes = [ActivityTypeWithEventName.SERVICE_TASK_START] + ) + fun doThingWithResult(@PluginActionProperty test: Int): TestActionResult { + testDependency.processInt(test) + return TestActionResult(test) + } } + data class TestActionResult(val value: Int) + class TestPlugin2 { @com.ritense.plugin.annotation.PluginProperty(key = "name", required = false, secret = false) var name: String? = null diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt index 5f20a823dc..80cb704e55 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/importer/ProcessLinkImporter.kt @@ -77,29 +77,18 @@ open class ProcessLinkImporter( node.set("processDefinitionId", TextNode.valueOf(processDefinitionId)) } + val processLinkType = node.path("processLinkType").asText(null) + ?: throw IllegalStateException( + "Error while processing file ${request.fileName}. Item at index $index has no 'processLinkType'!" + ) + val mapper = processLinkService.getProcessLinkMapper(processLinkType) + val mappings = request.pluginConfigurationMappings - if (mappings != null && node.has("pluginConfigurationId")) { - val originalIdText = node.get("pluginConfigurationId").asText(null) - if (originalIdText != null) { - val originalId = try { - java.util.UUID.fromString(originalIdText) - } catch (_: IllegalArgumentException) { - null - } - if (originalId != null && mappings.containsKey(originalId)) { - val mappedId = mappings[originalId] - if (mappedId != null) { - node.set("pluginConfigurationId", TextNode.valueOf(mappedId.toString())) - } else { - node.putNull("pluginConfigurationId") - } - } - } + if (mappings != null) { + mapper.applyPluginConfigurationMappings(node, mappings) } val deployDto = objectMapper.treeToValue(node) - - val mapper = processLinkService.getProcessLinkMapper(deployDto.processLinkType) val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, request.caseDefinitionId) try { diff --git a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt index 6a8f7a1f33..10f7be77ab 100644 --- a/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt +++ b/backend/process-link/src/main/kotlin/com/ritense/processlink/mapper/ProcessLinkMapper.kt @@ -16,6 +16,7 @@ package com.ritense.processlink.mapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.exporter.request.ExportRequest import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ProcessLink @@ -69,4 +70,54 @@ interface ProcessLinkMapper { processDefinitionIds: Set, applicationEventPublisher: ApplicationEventPublisher ) { /* no-op default */ } + + /** + * Called by the process-link importer for every deployment node this mapper is responsible for, + * before the node is deserialized into a [ProcessLinkDeployDto]. Lets each mapper rewrite the + * plugin-configuration-id field(s) it owns using the imported-to-target-environment mapping + * (source configuration UUID -> target configuration UUID, or `null` when left dangling). + * Default is a no-op — mappers that don't reference a plugin configuration by id don't need this. + * + * @param node the mutable deployment JSON node about to be deserialized + * @param mappings source plugin-configuration UUID -> target plugin-configuration UUID (`null` + * value means "leave dangling", e.g. because the admin chose not to map it during import) + */ + fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { /* no-op default */ } +} + +/** + * Rewrites a single UUID-valued text field on [node] using [mappings] (source UUID -> target UUID, + * `null` meaning "leave dangling"). No-op when the field is absent, blank, not a valid UUID, or has + * no entry in [mappings]. Shared by [ProcessLinkMapper] implementations backing + * [ProcessLinkMapper.applyPluginConfigurationMappings]. + * + * [allowNull] controls what happens when [mappings] resolves the original id to `null`: when `true` + * (default) the field is nulled out on the node, leaving the id dangling for [ProcessLinkMapper]s + * whose deploy DTO declares the field nullable. When `false` — for DTOs whose field is non-nullable + * (e.g. `ExternalPluginTaskFormProcessLinkDeployDto.externalPluginConfigurationId`, where the + * reference is always `FIXED` and a `null` configuration id has no meaning) — the field is left + * unchanged rather than nulled out, since nulling it would fail deserialization; the id then simply + * stays dangling against its original (unmapped) value. + */ +fun remapConfigurationIdField( + node: ObjectNode, + fieldName: String, + mappings: Map, + allowNull: Boolean = true, +) { + if (!node.has(fieldName)) return + val originalIdText = node.get(fieldName).asText(null) ?: return + val originalId = try { + UUID.fromString(originalIdText) + } catch (_: IllegalArgumentException) { + return + } + if (!mappings.containsKey(originalId)) return + + val mappedId = mappings[originalId] + if (mappedId != null) { + node.put(fieldName, mappedId.toString()) + } else if (allowNull) { + node.putNull(fieldName) + } } diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt index 4b9f4a3d8b..b2bfaeaba3 100644 --- a/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/importer/ProcessLinkImporterTest.kt @@ -17,6 +17,7 @@ package com.ritense.processlink.importer import com.fasterxml.jackson.annotation.JsonTypeName +import com.fasterxml.jackson.databind.node.ObjectNode import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.PROCESS_DEFINITION import com.ritense.processdocument.service.ProcessDefinitionCaseDefinitionService @@ -24,6 +25,7 @@ import com.ritense.processlink.autodeployment.ProcessLinkDeployDto import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.processlink.domain.ProcessLink import com.ritense.processlink.mapper.ProcessLinkMapper +import com.ritense.processlink.mapper.remapConfigurationIdField import com.ritense.processlink.service.ProcessLinkService import com.ritense.processlink.web.rest.dto.ProcessLinkCreateRequestDto import com.ritense.processlink.web.rest.dto.ProcessLinkExportResponseDto @@ -247,6 +249,10 @@ class ProcessLinkImporterTest { ): ProcessLink { throw UnsupportedOperationException() } + + override fun applyPluginConfigurationMappings(node: ObjectNode, mappings: Map) { + remapConfigurationIdField(node, "pluginConfigurationId", mappings) + } } private companion object { diff --git a/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt b/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt new file mode 100644 index 0000000000..f6fa0595c2 --- /dev/null +++ b/backend/process-link/src/test/kotlin/com/ritense/processlink/mapper/RemapConfigurationIdFieldTest.kt @@ -0,0 +1,86 @@ +/* + * 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.processlink.mapper + +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class RemapConfigurationIdFieldTest { + + private val objectMapper = ObjectMapper() + + @Test + fun `rewrites the field to the mapped target id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to targetId)) + + assertThat(node.get("configId").asText()).isEqualTo(targetId.toString()) + } + + @Test + fun `nulls the field when the mapping value is null and allowNull is true`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to null)) + + assertThat(node.get("configId").isNull).isTrue() + } + + @Test + fun `leaves the field unchanged when the mapping value is null and allowNull is false`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(sourceId to null), allowNull = false) + + assertThat(node.get("configId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `leaves the field unchanged when there is no mapping entry for the original id`() { + val sourceId = UUID.randomUUID() + val node = objectMapper.createObjectNode().put("configId", sourceId.toString()) + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.get("configId").asText()).isEqualTo(sourceId.toString()) + } + + @Test + fun `is a no-op when the field is absent`() { + val node = objectMapper.createObjectNode() + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.has("configId")).isFalse() + } + + @Test + fun `is a no-op when the field is not a valid UUID`() { + val node = objectMapper.createObjectNode().put("configId", "not-a-uuid") + + remapConfigurationIdField(node, "configId", mapOf(UUID.randomUUID() to UUID.randomUUID())) + + assertThat(node.get("configId").asText()).isEqualTo("not-a-uuid") + } +} diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts index 39b8c6bc4d..a93acdac83 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts @@ -104,7 +104,10 @@ export class CaseManagementDetailComponent implements OnInit, OnDestroy { private readonly _refreshConfigurationIssues$ = new BehaviorSubject(null); public readonly hasPluginProcessLinkIssue$: Observable = - this.configurationIssueService.hasIssue$('plugin-process-link'); + this.configurationIssueService.hasAnyOfIssues$([ + 'plugin-process-link', + 'external-plugin-process-link', + ]); private readonly _tabIssueCache = new Map>(); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html index 1457aedcaf..4bf43a2700 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.html @@ -44,12 +44,27 @@

@if (row.status === 'available') { -
- +
+
+ +
+ + @if (selectedConfigurationVersion(row, i); as mismatchedVersion) { +
+ {{ + 'caseManagement.missingPluginConfigurations.externalVersionMismatchWarning' + | translate + : { + selectedVersion: mismatchedVersion, + requiredVersion: row.pluginDefinitionVersion + } + }} +
+ }
} @else if (row.status === 'not-installed') {
diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss index eeffaa3fb1..50efea0d70 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.scss @@ -69,6 +69,18 @@ width: 100%; } + &__target { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-02); + } + + &__version-warning { + font-size: 0.75rem; + color: var(--cds-support-warning); + padding: 0 1rem; + } + &__unavailable { font-size: 0.875rem; color: var(--cds-text-disabled); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts index becb5a339a..322d3b9055 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts @@ -20,7 +20,7 @@ import {CommonModule} from '@angular/common'; import {ActivatedRoute} from '@angular/router'; import {TranslateModule, TranslateService} from '@ngx-translate/core'; import {ArrowRight16, Save16} from '@carbon/icons'; -import {SelectModule} from '@valtimo/components'; +import {SelectItem, SelectModule} from '@valtimo/components'; import { ButtonModule, IconModule, @@ -29,6 +29,10 @@ import { NotificationModule, } from 'carbon-components-angular'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -39,14 +43,29 @@ import { getCaseManagementRouteParams, GlobalNotificationService, } from '@valtimo/shared'; -import {BehaviorSubject, combineLatest, filter, map, Observable, switchMap, take} from 'rxjs'; +import { + BehaviorSubject, + catchError, + combineLatest, + filter, + forkJoin, + map, + Observable, + of, + switchMap, + take, +} from 'rxjs'; import {CaseManagementService} from '../../../../../../services'; import { DanglingPluginConfiguration, MappingRow, + PluginConfigurationPreviewSource, PluginMappingStatus, } from '../../../../../../models/case-deployment.model'; +const EMBEDDED_ISSUE_TYPE = 'plugin-process-link'; +const EXTERNAL_ISSUE_TYPE = 'external-plugin-process-link'; + @Component({ selector: 'valtimo-case-management-missing-plugin-configurations', templateUrl: './case-management-missing-plugin-configurations.component.html', @@ -63,7 +82,10 @@ import { ], }) export class CaseManagementMissingPluginConfigurationsComponent implements OnInit { - public readonly hasIssue$ = this.configurationIssueService.hasIssue$('plugin-process-link'); + public readonly hasIssue$ = this.configurationIssueService.hasAnyOfIssues$([ + EMBEDDED_ISSUE_TYPE, + EXTERNAL_ISSUE_TYPE, + ]); public readonly mappingRows$ = new BehaviorSubject([]); public readonly hasUnknownPluginConfigurations$ = new BehaviorSubject(false); public readonly visible$ = combineLatest([ @@ -87,6 +109,7 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni constructor( private readonly caseManagementService: CaseManagementService, private readonly configurationIssueService: ConfigurationIssueService, + private readonly externalPluginService: ExternalPluginService, private readonly globalNotificationService: GlobalNotificationService, private readonly iconService: IconService, private readonly pluginManagementService: PluginManagementService, @@ -121,6 +144,22 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni this._selections.set(index, selectedId ? String(selectedId) : null); } + /** + * The actual definition version of the currently selected external configuration for the row at + * [index], `null` when the selection is an exact `pluginId@version` match, embedded, or the row + * has no selection yet (D3 non-blocking warning). + */ + public selectedConfigurationVersion(row: MappingRow, index: number): string | null { + if (!row.mismatchedVersionsById || row.mismatchedVersionsById.size === 0) { + return null; + } + const selectedId = this._selections.get(index); + if (!selectedId) { + return null; + } + return row.mismatchedVersionsById.get(selectedId) ?? null; + } + public save(): void { const mappings: Record = {}; const rows = this.mappingRows$.value; @@ -182,80 +221,179 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni return; } - this.pluginManagementService - .getPluginDefinitions() + const embeddedDangling = knownKeyDangling.filter(d => (d.source ?? 'embedded') === 'embedded'); + const externalDangling = knownKeyDangling.filter(d => d.source === 'external'); + + combineLatest([ + this.loadEmbeddedRows(embeddedDangling), + this.loadExternalRows(externalDangling), + ]) .pipe(take(1)) - .subscribe(definitions => { - const installedKeys = new Set(definitions.map(d => d.key)); - this.loadPluginConfigurations(knownKeyDangling, installedKeys); + .subscribe(([embeddedRows, externalRows]) => { + this._selections.clear(); + this.mappingRows$.next([...embeddedRows, ...externalRows]); }); } + private loadEmbeddedRows(dangling: DanglingPluginConfiguration[]): Observable { + if (dangling.length === 0) { + return of([]); + } + + return this.pluginManagementService.getPluginDefinitions().pipe( + take(1), + switchMap(definitions => { + const installedKeys = new Set(definitions.map(d => d.key)); + return this.loadPluginConfigurations(dangling, installedKeys); + }) + ); + } + private loadPluginConfigurations( dangling: DanglingPluginConfiguration[], installedKeys: Set - ): void { + ): Observable { const uniqueKeys = [...new Set(dangling.map(d => d.pluginDefinitionKey).filter(Boolean))]; const installableKeys = uniqueKeys.filter(k => installedKeys.has(k)); - const configsByKey = new Map(); - let remaining = installableKeys.length; - if (remaining === 0) { - this.buildRows(dangling, configsByKey, installedKeys); - return; + if (installableKeys.length === 0) { + return of(this.buildEmbeddedRows(dangling, new Map(), installedKeys)); } + const configRequests: Record> = {}; for (const key of installableKeys) { - this.pluginManagementService + configRequests[key] = this.pluginManagementService .getPluginConfigurationsByPluginDefinitionKey(key) - .pipe(take(1)) - .subscribe({ - next: configs => { - configsByKey.set(key, configs); - remaining--; - if (remaining === 0) this.buildRows(dangling, configsByKey, installedKeys); - }, - error: () => { - configsByKey.set(key, []); - remaining--; - if (remaining === 0) this.buildRows(dangling, configsByKey, installedKeys); - }, - }); + .pipe( + take(1), + catchError(() => of([] as PluginConfiguration[])) + ); } + + return forkJoin(configRequests).pipe( + take(1), + map(results => { + const configsByKey = new Map(Object.entries(results)); + return this.buildEmbeddedRows(dangling, configsByKey, installedKeys); + }) + ); } - private buildRows( + private buildEmbeddedRows( dangling: DanglingPluginConfiguration[], configsByKey: Map, installedKeys: Set - ): void { - this._selections.clear(); - this.mappingRows$.next( - dangling.map(d => { - const key = d.pluginDefinitionKey; - const isInstalled = key ? installedKeys.has(key) : false; - const available = configsByKey.get(key) || []; - - let status: PluginMappingStatus; - if (!isInstalled) { - status = 'not-installed'; - } else if (available.length === 0) { - status = 'no-configurations'; - } else { - status = 'available'; - } + ): MappingRow[] { + return dangling.map(d => { + const key = d.pluginDefinitionKey; + const isInstalled = key ? installedKeys.has(key) : false; + const available = configsByKey.get(key) || []; + + const status = this.determineStatus(isInstalled, available.length > 0); + + return { + pluginDefinitionKey: key, + pluginDefinitionTitle: this.getPluginTitle(key), + sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, + selectItems: available.map(c => ({id: c.id, text: c.title})), + status, + source: 'embedded' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: null, + }; + }); + } + + private loadExternalRows(dangling: DanglingPluginConfiguration[]): Observable { + if (dangling.length === 0) { + return of([]); + } + + return combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + take(1), + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const lang = this.translateService.currentLang; + + return dangling.map(d => { + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === d.pluginDefinitionKey; + }); - return { - pluginDefinitionKey: key, - pluginDefinitionTitle: this.getPluginTitle(key), - sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, - selectItems: available.map(c => ({id: c.id, text: c.title})), - status, - }; + const isInstalled = [...definitionById.values()].some( + def => def.pluginId === d.pluginDefinitionKey + ); + const status = this.determineStatus(isInstalled, matchingConfigurations.length > 0); + + const mismatchedVersionsById = new Map(); + const selectItems: SelectItem[] = matchingConfigurations.map(configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== d.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + id: configuration.id, + text: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + }; + }); + + return { + pluginDefinitionKey: d.pluginDefinitionKey, + pluginDefinitionTitle: this.getExternalPluginTitle(d, definitionById), + sourcePluginConfigurationIds: d.sourcePluginConfigurationIds, + selectItems, + status, + source: 'external' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: d.pluginDefinitionVersion ?? null, + mismatchedVersionsById, + }; + }); }) ); } + private getExternalPluginTitle( + dangling: DanglingPluginConfiguration, + definitionById: Map + ): string { + if (!dangling.pluginDefinitionKey) { + return this.translateService.instant( + 'caseManagement.missingPluginConfigurations.unknownPlugin' + ); + } + const lang = this.translateService.currentLang; + const matchingDefinition = [...definitionById.values()].find( + d => + d.pluginId === dangling.pluginDefinitionKey && + d.version === dangling.pluginDefinitionVersion + ); + if (matchingDefinition) { + return getExternalPluginDisplayName(matchingDefinition, lang); + } + return dangling.pluginDefinitionVersion + ? `${dangling.pluginDefinitionKey} (${dangling.pluginDefinitionVersion})` + : dangling.pluginDefinitionKey; + } + + private determineStatus(isInstalled: boolean, hasConfigurations: boolean): PluginMappingStatus { + if (!isInstalled) { + return 'not-installed'; + } + if (!hasConfigurations) { + return 'no-configurations'; + } + return 'available'; + } + private getPluginTitle(pluginDefinitionKey: string | null): string { if (!pluginDefinitionKey) { return this.translateService.instant( diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html index a1ce58bda5..a927a41621 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.html @@ -205,20 +205,35 @@

> - - - +
+ + + + +
+ {{ + 'caseManagement.importDefinition.plugins.externalVersionMismatchWarning' + | translate + : { + selectedVersion: mismatchedVersion, + requiredVersion: row.pluginDefinitionVersion + } + }} +
+
{{ 'caseManagement.importDefinition.plugins.notInstalled' | translate }}
diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss index 4b9a95331e..f4d21033cf 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.scss @@ -151,6 +151,19 @@ width: 100%; } + &__plugins-target { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-02); + align-self: center; + } + + &__plugins-version-warning { + font-size: 0.75rem; + color: var(--cds-support-warning); + padding: 0 1rem; + } + &__plugins-unavailable { font-size: 0.875rem; color: var(--cds-text-disabled); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts index 3e2dd7bcf3..bf58cdc39f 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts @@ -26,6 +26,10 @@ import {AbstractControl, FormBuilder, FormGroup, Validators} from '@angular/form import {TranslateService} from '@ngx-translate/core'; import {CARBON_CONSTANTS} from '@valtimo/components'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -33,13 +37,16 @@ import { import {FileItem, ListItem} from 'carbon-components-angular'; import { BehaviorSubject, + catchError, combineLatest, debounceTime, distinctUntilChanged, forkJoin, map, Observable, + of, Subscription, + switchMap, take, } from 'rxjs'; import { @@ -53,6 +60,7 @@ import {CASE_MANAGEMENT_UPLOAD_TEST_IDS} from '../../constants'; import { CaseDefinitionImportPreview, PluginConfigurationPreview, + PluginConfigurationPreviewSource, } from '../../models/case-deployment.model'; type PluginMappingStatus = 'available' | 'no-configurations' | 'not-installed'; @@ -64,6 +72,14 @@ interface PluginMappingRow { existsInTargetEnvironment: boolean; listItems: ListItem[]; status: PluginMappingStatus; + source: PluginConfigurationPreviewSource; + pluginDefinitionVersion: string | null; + /** + * External configuration id -> actual definition version, populated only for options whose + * version differs from `pluginDefinitionVersion` (D3 non-blocking warning). Empty for embedded + * rows and exact-version-only sets. + */ + mismatchedVersionsById: Map; } @Component({ @@ -164,7 +180,8 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private readonly fb: FormBuilder, private readonly translateService: TranslateService, private readonly pluginManagementService: PluginManagementService, - private readonly pluginTranslationService: PluginTranslationService + private readonly pluginTranslationService: PluginTranslationService, + private readonly externalPluginService: ExternalPluginService ) {} public ngOnInit(): void { @@ -235,6 +252,23 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { return row.sourcePluginConfigurationId; } + /** + * The actual definition version of the currently selected external configuration for [row], + * `null` when the selection is an exact `pluginId@version` match or the row has no selection + * (D3 non-blocking warning). Reads the live form control value so the warning stays in sync as + * the admin changes the dropdown selection. + */ + public selectedConfigurationVersion(row: PluginMappingRow): string | null { + if (row.mismatchedVersionsById.size === 0) { + return null; + } + const selectedId = this.pluginMappingForm.get(row.sourcePluginConfigurationId)?.value; + if (!selectedId) { + return null; + } + return row.mismatchedVersionsById.get(selectedId) ?? null; + } + /** * Works around a carbon-components-angular bug where clearing a single-select * cds-combo-box with itemValueKey set writes `[]` to the FormControl instead @@ -309,53 +343,72 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { return; } - // Fetch all installed plugin definitions first, then configs per key - this.pluginManagementService - .getPluginDefinitions() + const embeddedConfigs = uniqueConfigs.filter(c => (c.source ?? 'embedded') === 'embedded'); + const externalConfigs = uniqueConfigs.filter(c => c.source === 'external'); + + combineLatest([ + this.loadEmbeddedMappingRows(embeddedConfigs), + this.loadExternalMappingRows(externalConfigs), + ]) .pipe(take(1)) - .subscribe(definitions => { - const installedKeys = new Set(definitions.map(d => d.key)); - this.loadPluginConfigurations(uniqueConfigs, installedKeys); + .subscribe(([embeddedRows, externalRows]) => { + this.clearPluginMappingForm(); + const rows = [...embeddedRows, ...externalRows]; + for (const row of rows) { + if (row.status === 'available') { + this.pluginMappingForm.addControl( + row.sourcePluginConfigurationId, + this.fb.control(row.listItems.find(item => item.selected)?.id ?? null) + ); + } + } + this.pluginMappingRows$.next(rows); }); } - private loadPluginConfigurations( - uniqueConfigs: PluginConfigurationPreview[], - installedKeys: Set - ): void { - const uniqueDefinitionKeys = [ - ...new Set(uniqueConfigs.map(c => c.pluginDefinitionKey).filter(Boolean)), - ]; + private loadEmbeddedMappingRows( + uniqueConfigs: PluginConfigurationPreview[] + ): Observable { + if (uniqueConfigs.length === 0) { + return of([]); + } - const installableKeys = uniqueDefinitionKeys.filter(k => installedKeys.has(k)); + return this.pluginManagementService.getPluginDefinitions().pipe( + take(1), + switchMap(definitions => { + const installedKeys = new Set(definitions.map(d => d.key)); + const installableKeys = [ + ...new Set(uniqueConfigs.map(c => c.pluginDefinitionKey).filter(Boolean)), + ].filter(key => installedKeys.has(key)); - if (installableKeys.length === 0) { - this.buildMappingRows(uniqueConfigs, new Map(), installedKeys); - return; - } + if (installableKeys.length === 0) { + return of(this.buildEmbeddedRows(uniqueConfigs, new Map(), installedKeys)); + } - const configRequests: Record> = {}; - for (const key of installableKeys) { - configRequests[key] = this.pluginManagementService - .getPluginConfigurationsByPluginDefinitionKey(key) - .pipe(take(1)); - } + const configRequests: Record> = {}; + for (const key of installableKeys) { + configRequests[key] = this.pluginManagementService + .getPluginConfigurationsByPluginDefinitionKey(key) + .pipe(take(1)); + } - forkJoin(configRequests) - .pipe(take(1)) - .subscribe(results => { - const configsByKey = new Map(Object.entries(results)); - this.buildMappingRows(uniqueConfigs, configsByKey, installedKeys); - }); + return forkJoin(configRequests).pipe( + take(1), + map(results => { + const configsByKey = new Map(Object.entries(results)); + return this.buildEmbeddedRows(uniqueConfigs, configsByKey, installedKeys); + }) + ); + }) + ); } - private buildMappingRows( + private buildEmbeddedRows( uniqueConfigs: PluginConfigurationPreview[], configsByKey: Map, installedKeys: Set - ): void { - this.clearPluginMappingForm(); - const rows: PluginMappingRow[] = uniqueConfigs.map(config => { + ): PluginMappingRow[] { + return uniqueConfigs.map(config => { const key = config.pluginDefinitionKey; const isInstalled = key ? installedKeys.has(key) : false; const available = configsByKey.get(key) || []; @@ -378,13 +431,6 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { selected: c.id === defaultSelectionId, })); - if (status === 'available') { - this.pluginMappingForm.addControl( - config.pluginConfigurationId, - this.fb.control(defaultSelectionId) - ); - } - return { pluginDefinitionKey: key, pluginDefinitionTitle: this.getPluginTitle(key), @@ -392,9 +438,101 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { existsInTargetEnvironment: config.existsInTargetEnvironment, listItems, status, + source: 'embedded' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: null, + mismatchedVersionsById: new Map(), }; }); - this.pluginMappingRows$.next(rows); + } + + private loadExternalMappingRows( + uniqueConfigs: PluginConfigurationPreview[] + ): Observable { + if (uniqueConfigs.length === 0) { + return of([]); + } + + return combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + take(1), + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const lang = this.translateService.currentLang; + + return uniqueConfigs.map(config => { + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === config.pluginDefinitionKey; + }); + + let status: PluginMappingStatus; + if (matchingConfigurations.length === 0) { + status = definitionById.size > 0 && + [...definitionById.values()].some(d => d.pluginId === config.pluginDefinitionKey) + ? 'no-configurations' + : 'not-installed'; + } else { + status = 'available'; + } + + const defaultSelectionId = config.existsInTargetEnvironment + ? config.pluginConfigurationId + : null; + + const mismatchedVersionsById = new Map(); + const listItems: ListItem[] = matchingConfigurations.map(configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== config.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + content: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + id: configuration.id, + selected: configuration.id === defaultSelectionId, + }; + }); + + return { + pluginDefinitionKey: config.pluginDefinitionKey, + pluginDefinitionTitle: this.getExternalPluginTitle(config, definitionById), + sourcePluginConfigurationId: config.pluginConfigurationId, + existsInTargetEnvironment: config.existsInTargetEnvironment, + listItems, + status, + source: 'external' as PluginConfigurationPreviewSource, + pluginDefinitionVersion: config.pluginDefinitionVersion ?? null, + mismatchedVersionsById, + }; + }); + }) + ); + } + + private getExternalPluginTitle( + config: PluginConfigurationPreview, + definitionById: Map + ): string { + if (!config.pluginDefinitionKey) { + return this.translateService.instant('caseManagement.importDefinition.plugins.unknownPlugin'); + } + const lang = this.translateService.currentLang; + const matchingDefinition = [...definitionById.values()].find( + d => d.pluginId === config.pluginDefinitionKey && d.version === config.pluginDefinitionVersion + ); + if (matchingDefinition) { + return getExternalPluginDisplayName(matchingDefinition, lang); + } + return config.pluginDefinitionVersion + ? `${config.pluginDefinitionKey} (${config.pluginDefinitionVersion})` + : config.pluginDefinitionKey; } private clearPluginMappingForm(): void { diff --git a/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts b/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts index 3391ffda56..b45fe4cec8 100644 --- a/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts +++ b/frontend/projects/valtimo/case-management/src/lib/models/case-deployment.model.ts @@ -66,6 +66,8 @@ export interface CaseDefinitionConfigurationIssue { resolvedAt: string | null; } +export type PluginConfigurationPreviewSource = 'embedded' | 'external'; + export interface PluginConfigurationPreview { pluginConfigurationId: string; pluginDefinitionKey: string | null; @@ -73,6 +75,8 @@ export interface PluginConfigurationPreview { processDefinitionKey: string; activityId: string; existsInTargetEnvironment: boolean; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; } export interface CaseDefinitionImportPreview { @@ -86,6 +90,8 @@ export interface CaseDefinitionImportPreview { export interface DanglingPluginConfiguration { pluginDefinitionKey: string | null; sourcePluginConfigurationIds: string[]; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; } export type PluginMappingStatus = 'available' | 'no-configurations' | 'not-installed'; @@ -96,6 +102,14 @@ export interface MappingRow { sourcePluginConfigurationIds: string[]; selectItems: SelectItem[]; status: PluginMappingStatus; + source?: PluginConfigurationPreviewSource; + pluginDefinitionVersion?: string | null; + /** + * External configuration id -> actual definition version, populated only for options whose + * version differs from `pluginDefinitionVersion` (D3 non-blocking warning). Empty/undefined for + * embedded rows and exact-version-only sets. + */ + mismatchedVersionsById?: Map; } export interface ConfigurationIssueUpdatedSseEvent { diff --git a/frontend/projects/valtimo/components/src/lib/components/select/select.component.ts b/frontend/projects/valtimo/components/src/lib/components/select/select.component.ts index 1a0280669f..3758c1452b 100644 --- a/frontend/projects/valtimo/components/src/lib/components/select/select.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/select/select.component.ts @@ -134,7 +134,9 @@ export class SelectComponent implements OnInit, AfterViewInit, OnChanges, OnDest ); private _selectedSubscription!: Subscription; private _clearSubjectSubscription!: Subscription; + private _displayFlushSubscription!: Subscription; private _displayFlushTimeoutId?: ReturnType; + private _destroyed = false; private _onChange: (value: SelectedValue) => void = () => {}; private _onTouched: () => void = () => {}; @@ -164,18 +166,11 @@ export class SelectComponent implements OnInit, AfterViewInit, OnChanges, OnDest this.setDefaultSelection(); this.openSelectedSubscription(); this.openClearSubjectSubscription(); + this.openDisplayFlushSubscription(); } public ngAfterViewInit(): void { - // The underlying Carbon combobox computes its displayed value asynchronously, in a setTimeout - // scheduled during its own view initialization. When this component lives under an OnPush - // ancestor that is not marked dirty, that update is never flushed to the DOM, so a preselected - // value only becomes visible after the user interacts with the control. Because this setTimeout - // is scheduled after the combobox's (lifecycle hooks of child views run first), it runs once - // the combobox has set its display value, and detectChanges() flushes it to the DOM. A - // setTimeout is used instead of NgZone.onStable so the flush also works in apps with - // long-running background tasks that keep the zone from ever stabilizing. - this._displayFlushTimeoutId = setTimeout(() => this.changeDetectorRef.detectChanges()); + this.scheduleDisplayFlush(); } public ngOnChanges(changes: SimpleChanges): void { @@ -190,8 +185,10 @@ export class SelectComponent implements OnInit, AfterViewInit, OnChanges, OnDest } public ngOnDestroy(): void { + this._destroyed = true; this._selectedSubscription?.unsubscribe(); this._clearSubjectSubscription?.unsubscribe(); + this._displayFlushSubscription?.unsubscribe(); if (this._displayFlushTimeoutId !== undefined) clearTimeout(this._displayFlushTimeoutId); } @@ -263,4 +260,34 @@ export class SelectComponent implements OnInit, AfterViewInit, OnChanges, OnDest }); } } + + /** + * The underlying Carbon combobox computes its displayed value asynchronously, in a setTimeout it + * schedules while processing an items change. When this component lives under an OnPush ancestor + * that is not marked dirty, that update is never flushed to the DOM, so a (pre)selected value + * only becomes visible after the user interacts with the control. This applies to every items or + * selection change — not only the first render: a value written or items arriving after view + * init (prefill, late-loaded options, wizard step switches) hit the same race. Hence a flush is + * scheduled on every emission, in two passes: the first detectChanges renders the emission into + * the combobox (which then schedules its own internal display update), the nested one flushes + * that internal update — covering both orderings of the competing timeouts. setTimeout is used + * instead of NgZone.onStable so the flush also works in apps with long-running background tasks + * that keep the zone from ever stabilizing. + */ + private openDisplayFlushSubscription(): void { + this._displayFlushSubscription = combineLatest([this._listItems$, this.selected$]).subscribe( + () => this.scheduleDisplayFlush() + ); + } + + private scheduleDisplayFlush(): void { + if (this._displayFlushTimeoutId !== undefined) clearTimeout(this._displayFlushTimeoutId); + this._displayFlushTimeoutId = setTimeout(() => { + if (this._destroyed) return; + this.changeDetectorRef.detectChanges(); + this._displayFlushTimeoutId = setTimeout(() => { + if (!this._destroyed) this.changeDetectorRef.detectChanges(); + }); + }); + } } diff --git a/frontend/projects/valtimo/components/src/lib/components/value-path-selector/value-path-selector.component.ts b/frontend/projects/valtimo/components/src/lib/components/value-path-selector/value-path-selector.component.ts index 94d860f618..5805c1b7fd 100644 --- a/frontend/projects/valtimo/components/src/lib/components/value-path-selector/value-path-selector.component.ts +++ b/frontend/projects/valtimo/components/src/lib/components/value-path-selector/value-path-selector.component.ts @@ -363,10 +363,15 @@ export class ValuePathSelectorComponent implements OnInit, OnDestroy, ControlVal return mappedOption; }); }), - tap(() => this.loadingValuePathItems$.next(false)) + tap(() => { + this.loadingValuePathItems$.next(false); + this.scheduleDisplayFlush(); + }) ); private readonly _subscriptions = new Subscription(); + private _displayFlushTimeoutId?: ReturnType; + private _destroyed = false; constructor( private readonly valuePathSelectorService: ValuePathSelectorService, @@ -383,6 +388,8 @@ export class ValuePathSelectorComponent implements OnInit, OnDestroy, ControlVal } public ngOnDestroy(): void { + this._destroyed = true; + if (this._displayFlushTimeoutId !== undefined) clearTimeout(this._displayFlushTimeoutId); this._subscriptions.unsubscribe(); } @@ -392,6 +399,26 @@ export class ValuePathSelectorComponent implements OnInit, OnDestroy, ControlVal if (!value) { this._inputMode$.next(ValuePathSelectorInputMode.DROPDOWN); } + this.scheduleDisplayFlush(); + } + + /** + * The Carbon combobox computes its displayed value asynchronously after receiving items with a + * `selected` entry. Under an OnPush ancestor that is not marked dirty, neither the items binding + * nor Carbon's internal display update is ever flushed to the DOM, so the control's state is + * correct while its input renders empty until the user interacts. Flush in two passes after + * every options emission or written value: the first detectChanges renders the emission into the + * combobox (which then schedules its internal update), the nested one flushes that update. + */ + private scheduleDisplayFlush(): void { + if (this._displayFlushTimeoutId !== undefined) clearTimeout(this._displayFlushTimeoutId); + this._displayFlushTimeoutId = setTimeout(() => { + if (this._destroyed) return; + this.changeDetectorRef.detectChanges(); + this._displayFlushTimeoutId = setTimeout(() => { + if (!this._destroyed) this.changeDetectorRef.detectChanges(); + }); + }); } public registerOnChange(fn: (value: string) => void): void { diff --git a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts index 1b47d3f278..a7e59c4d2d 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts @@ -76,6 +76,13 @@ interface ExternalPluginAction { * action may be linked to — a user-task form is the separate `task-form` surface, not an action. */ activityTypes?: Array; + /** + * Keys the action's `result` object exposes for mapping. When present and non-empty, the + * process-link stepper offers a dedicated output-mapping step with a dropdown of these keys as + * mapping sources. Actions without `outputs` (or an empty array) have no declared shape and + * cannot use result mapping. + */ + outputs?: Array; } type ExternalPluginFrontendBundleType = diff --git a/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts b/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts index d7fde502e3..8bca319cb2 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/plugin.ts @@ -104,6 +104,7 @@ interface PluginFunction { description?: string; key: string; title?: string; + outputs?: string[]; } export { diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html index e551abb8d3..35341bab1e 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html @@ -78,17 +78,32 @@ {{ plugin.label }}

- - - +
+ + + + + @if (plugin.selectedConfigurationVersion) { +
+ {{ + 'processLinkConfiguration.buildingBlock.externalPluginVersionMismatchWarning' + | translate + : { + selectedVersion: plugin.selectedConfigurationVersion, + requiredVersion: plugin.pluginDefinitionVersion + } + }} +
+ } +
} } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss index 178f513d6d..df3e8be6d6 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss @@ -32,6 +32,14 @@ margin-top: 30px; } +.plugin-configuration-select { + &__warning { + margin-top: var(--cds-spacing-02); + color: var(--cds-support-warning); + font-size: var(--cds-label-01-font-size); + } +} + .configure-building-block-plugins__warning { display: flex; width: 100%; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts index 27ef88f6d1..2bc363f8b8 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.ts @@ -24,6 +24,10 @@ import { } from '../../services'; import {BuildingBlockStateService} from '../../services/building-block-state.service'; import { + ExternalPluginConfiguration, + ExternalPluginDefinition, + ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginManagementService, PluginTranslationService, @@ -34,6 +38,7 @@ import { PluginConfigurationViewModel, ProcessLink, ProcessLinkType, + RequiredPlugin, } from '../../models'; import {combineLatest, distinctUntilChanged, Observable, of, shareReplay, Subscription} from 'rxjs'; import {catchError, filter, map, switchMap, take, withLatestFrom} from 'rxjs/operators'; @@ -48,7 +53,7 @@ import {NotificationContent} from 'carbon-components-angular'; styleUrls: ['./configure-building-block-plugins.component.scss'], }) export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy { - public readonly pluginKeys$ = this.buildingBlockStateService.requiredPluginKeys$; + public readonly requiredPlugins$ = this.buildingBlockStateService.requiredPlugins$; public readonly isNestedBuildingBlock$ = this.buildingBlockStateService.isNestedBuildingBlock$; private readonly _pluginDependenciesWarningTranslationKey$: Observable = this.buildingBlockStateService.pluginDependencies$.pipe( @@ -122,28 +127,38 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy ); public readonly pluginConfigurationViewModels$: Observable> = combineLatest([ - this.pluginKeys$, + this.requiredPlugins$, this.buildingBlockStateService.pluginMappings$, this.configurationPlaceholder$, + this.translateService.stream('key'), ]).pipe( - switchMap(([pluginKeys, pluginMappings, placeholder]) => { - if (!pluginKeys?.length) { + switchMap(([requiredPlugins, pluginMappings, placeholder]) => { + if (!requiredPlugins?.length) { return of([]); } return combineLatest( - pluginKeys.map(pluginKey => - this.getConfigurationOptions(pluginKey).pipe( - map(options => ({ - key: pluginKey, - label: this.pluginLabel(pluginKey), - dropdownItems: this.buildDropdownItems( - options, - pluginMappings?.[pluginKey], - placeholder - ), - hasOptions: options.length > 0, - })) + requiredPlugins.map(requiredPlugin => + (requiredPlugin.source === 'EXTERNAL' + ? this.getExternalConfigurationOptions(requiredPlugin) + : this.getEmbeddedConfigurationOptions(requiredPlugin).pipe( + map(options => ({options, mismatchedVersionsById: new Map()})) + ) + ).pipe( + map(({options, mismatchedVersionsById}) => { + const selectedId = pluginMappings?.[requiredPlugin.mappingKey]; + return { + key: requiredPlugin.mappingKey, + label: this.pluginLabel(requiredPlugin), + dropdownItems: this.buildDropdownItems(options, selectedId, placeholder), + hasOptions: options.length > 0, + source: requiredPlugin.source, + pluginDefinitionVersion: requiredPlugin.pluginDefinitionVersion, + selectedConfigurationVersion: selectedId + ? mismatchedVersionsById.get(selectedId) + : undefined, + } as PluginConfigurationViewModel; + }) ) ) ); @@ -155,6 +170,10 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy string, Observable> >(); + private readonly _externalConfigurationOptionsCache = new Map< + string, + Observable<{options: Array; mismatchedVersionsById: Map}> + >(); constructor( private readonly stateService: ProcessLinkStateService, @@ -165,7 +184,8 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy private readonly pluginTranslationService: PluginTranslationService, private readonly processLinkService: ProcessLinkService, private readonly translateService: TranslateService, - private readonly processLinkBuildingBlockApiService: ProcessLinkBuildingBlockApiService + private readonly processLinkBuildingBlockApiService: ProcessLinkBuildingBlockApiService, + private readonly externalPluginService: ExternalPluginService ) {} public ngOnInit(): void { @@ -236,9 +256,10 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy this._subscriptions.unsubscribe(); } - private getConfigurationOptions( - pluginDefinitionKey: string + private getEmbeddedConfigurationOptions( + requiredPlugin: RequiredPlugin ): Observable> { + const pluginDefinitionKey = requiredPlugin.pluginDefinitionKey; if (!this._configurationOptionsCache.has(pluginDefinitionKey)) { this._configurationOptionsCache.set( pluginDefinitionKey, @@ -253,17 +274,79 @@ export class ConfigureBuildingBlockPluginsComponent implements OnInit, OnDestroy return this._configurationOptionsCache.get(pluginDefinitionKey) ?? of([]); } - public onMappingChange(pluginDefinitionKey: string, configurationId: string): void { - const normalizedValue = configurationId || null; - this.buildingBlockStateService.setPluginConfigurationMapping( - pluginDefinitionKey, - normalizedValue + /** + * Activated external configurations for the required plugin's `pluginId`. Configurations + * matching `pluginId@version` exactly are offered as normal options; configurations of the same + * `pluginId` at a different version are still offered (selectable), with their actual definition + * version recorded in `mismatchedVersionsById` so the template can render the D3 non-blocking + * warning when such a configuration is selected. + */ + private getExternalConfigurationOptions( + requiredPlugin: RequiredPlugin + ): Observable<{options: Array; mismatchedVersionsById: Map}> { + const cacheKey = requiredPlugin.mappingKey; + if (!this._externalConfigurationOptionsCache.has(cacheKey)) { + this._externalConfigurationOptionsCache.set( + cacheKey, + combineLatest([ + this.externalPluginService + .getConfigurations() + .pipe(catchError(() => of([] as Array))), + this.externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as Array))), + ]).pipe( + map(([configurations, definitions]) => { + const definitionById = new Map(definitions.map(d => [d.id, d])); + const matchingConfigurations = configurations.filter(configuration => { + const definition = definitionById.get(configuration.definitionId); + return definition?.pluginId === requiredPlugin.pluginDefinitionKey; + }); + + const lang = this.translateService.currentLang; + const mismatchedVersionsById = new Map(); + const options: Array = matchingConfigurations.map( + configuration => { + const definition = definitionById.get(configuration.definitionId); + if (definition && definition.version !== requiredPlugin.pluginDefinitionVersion) { + mismatchedVersionsById.set(configuration.id, definition.version); + } + return { + id: configuration.id, + title: definition + ? `${configuration.title} — ${getExternalPluginDisplayName(definition, lang)}` + : configuration.title, + properties: {}, + } as PluginConfiguration; + } + ); + + return {options, mismatchedVersionsById}; + }), + shareReplay(1) + ) + ); + } + return ( + this._externalConfigurationOptionsCache.get(cacheKey) ?? + of({options: [], mismatchedVersionsById: new Map()}) ); } - public pluginLabel(pluginDefinitionKey: string): string { + public onMappingChange(mappingKey: string, configurationId: string): void { + const normalizedValue = configurationId || null; + this.buildingBlockStateService.setPluginConfigurationMapping(mappingKey, normalizedValue); + } + + public pluginLabel(requiredPlugin: RequiredPlugin): string { + if (requiredPlugin.source === 'EXTERNAL') { + return requiredPlugin.pluginDefinitionVersion + ? `${requiredPlugin.pluginDefinitionKey} (${requiredPlugin.pluginDefinitionVersion})` + : requiredPlugin.pluginDefinitionKey; + } return ( - this.pluginTranslationService.instant('title', pluginDefinitionKey) || pluginDefinitionKey + this.pluginTranslationService.instant('title', requiredPlugin.pluginDefinitionKey) || + requiredPlugin.pluginDefinitionKey ); } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html index 8ae8cc8cf0..8e749d4437 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.html @@ -14,68 +14,88 @@ ~ limitations under the License. --> - - - -

- {{ 'processLinkConfiguration.externalPluginTaskForm.description' | translate }} -

-
+ + + + - - - + + + + +

+ {{ 'processLinkConfiguration.externalPluginTaskForm.description' | translate }} +

+
-
-

- {{ 'processLinkConfiguration.externalPluginActionProperties' | translate }} -

-
- - -
- {{ 'processLinkConfiguration.invalidJson' | translate }} + + + + +
+

+ {{ 'processLinkConfiguration.externalPluginActionProperties' | translate }} +

+
+ + +
+ {{ 'processLinkConfiguration.invalidJson' | translate }} +
-
- - - + + + - - + + - + + diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts index bca21bcd80..de4583069b 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-configuration/plugin-action-configuration.component.ts @@ -23,7 +23,7 @@ import { ProcessLinkStepService, } from '../../services'; import {BehaviorSubject, combineLatest, Observable, of, Subscription} from 'rxjs'; -import {filter, map, switchMap, take, withLatestFrom} from 'rxjs/operators'; +import {filter, map, shareReplay, switchMap, take, withLatestFrom} from 'rxjs/operators'; import { ExternalPluginDefinition, ExternalPluginService, @@ -31,18 +31,22 @@ import { isExternalPluginKey, PluginConfiguration, PluginConfigurationData, + PluginFunction, } from '@valtimo/plugin'; import { ExternalPluginProcessLinkCreateDto, ExternalPluginProcessLinkUpdateDto, ExternalPluginTaskFormProcessLinkCreateDto, ExternalPluginTaskFormProcessLinkUpdateDto, + PluginActionResultMapping, PluginConfigurationReferenceType, PluginProcessLinkCreateDto, PluginProcessLinkUpdateDto, ProcessLink, } from '../../models'; import {USER_TASK_ACTIVITY} from '../../constants'; +import {ActivatedRoute} from '@angular/router'; +import {getBuildingBlockManagementRouteParams, getCaseManagementRouteParams} from '@valtimo/shared'; @Component({ standalone: false, @@ -66,6 +70,49 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { map(definition => isExternalPluginKey(definition?.key)) ); + public readonly currentStepId$ = this._stepService.currentStepId$; + public readonly selectedFunction$: Observable = + this._pluginStateService.selectedPluginFunction$; + + /** + * The selected external action's declared `outputs`, resolved from the selected function when + * available and falling back to a manifest lookup (edit mode: `loadExternalPluginStateForProcessLink` + * only sets the selected function's `key`, not its `outputs`). Passed to the mappings editor as + * `sourceKeys` — a dropdown of these keys replaces the free-text pointer input. + */ + public readonly selectedFunctionOutputs$: Observable> = combineLatest([ + this.isExternalPlugin$, + this._pluginStateService.selectedPluginDefinition$, + this.selectedFunction$, + this._stateService.selectedProcessLink$, + ]).pipe( + switchMap(([isExternal, definition, selectedFunction, selectedProcessLink]) => { + if (!isExternal || !definition?.key) return of([]); + if (selectedFunction?.outputs?.length) return of(selectedFunction.outputs); + + const actionKey = selectedFunction?.key || selectedProcessLink?.actionKey; + if (!actionKey) return of([]); + + const definitionId = extractExternalDefinitionId(definition.key); + return this._externalPluginService.getDefinition(definitionId).pipe( + map((extDef: ExternalPluginDefinition) => { + const action = extDef.manifest?.actions?.find(a => a.key === actionKey); + return action?.outputs ?? []; + }) + ); + }), + shareReplay({bufferSize: 1, refCount: true}) + ); + + /** + * True when the selected external action declares `outputs` in its manifest — the only case the + * dedicated output-mapping step (and its Next/Save button swap) applies. Embedded actions have + * no declaration mechanism and never carry this. + */ + public readonly hasResultMappingsStep$: Observable = this.selectedFunctionOutputs$.pipe( + map(outputs => outputs.length > 0) + ); + /** * True when the external plugin is being linked to a user task: the plugin contributes a * `task-form` (rendered + completed by the plugin), not a service-task action. In that case this @@ -88,6 +135,15 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { public externalActionPropertiesJson = '{}'; public externalActionPropertiesValid = true; + /** + * `actionResultMappings` (#771): row-based JSON-pointer -> value-resolver-target write-back + * rules for the action's return value, kept by `PluginActionResultMappingsComponent` and read + * here purely as a plain value (that component owns its own form state). + */ + public actionResultMappings: Array = []; + + private readonly _resultMappingsValid$ = new BehaviorSubject(true); + /** URL for the process-link-action iframe bundle: undefined = loading, null = no bundle, string = bundle URL */ public readonly externalActionBundleUrl$ = new BehaviorSubject( undefined @@ -133,6 +189,10 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { ) ); + /** Case/building-block route context, forwarded to the result-mapping target's value-path selector. */ + public readonly caseParams$ = getCaseManagementRouteParams(this._route); + public readonly buildingBlockParams$ = getBuildingBlockManagementRouteParams(this._route); + private _subscriptions = new Subscription(); constructor( @@ -141,19 +201,44 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { private readonly _buttonService: ProcessLinkButtonService, private readonly _stepService: ProcessLinkStepService, private readonly _processLinkService: ProcessLinkService, - private readonly _externalPluginService: ExternalPluginService + private readonly _externalPluginService: ExternalPluginService, + private readonly _route: ActivatedRoute ) {} ngOnInit(): void { this.openBackButtonSubscription(); this.openSaveButtonSubscription(); + this.openNextButtonSubscription(); + this.openMappingsBackButtonSubscription(); this._subscriptions.add( - this.isExternalPlugin$.subscribe(isExternal => { - if (isExternal) { - this._buttonService.enableSaveButton(); + combineLatest([this.isExternalPlugin$, this.hasResultMappingsStep$]).subscribe( + ([isExternal, hasResultMappingsStep]) => { + // A pending output-mapping step keeps Next/Save handled by + // `setConfigurePluginActionSteps()`/`setConfigurePluginActionResultMappingsSteps()` — do + // not race that with an unconditional enableSaveButton() here. + if (isExternal && !hasResultMappingsStep) { + this._buttonService.enableSaveButton(); + } } - }) + ) + ); + + this._subscriptions.add( + // Save on the mappings step is gated only by the rows' own validity (zero rows is valid; + // every present row needs both source and target). The action properties were already + // validated to reach this step — their validity gated the Next button — and neither the + // create path (step service shows Save without enabling it) nor edit mode (the generic edit + // navigation only toggles visibility) enables Save on arrival. + combineLatest([this.currentStepId$, this._resultMappingsValid$]) + .pipe(filter(([stepId]) => stepId === 'configurePluginActionResultMappings')) + .subscribe(([, mappingsValid]) => { + if (mappingsValid) { + this._buttonService.enableSaveButton(); + } else { + this._buttonService.disableSaveButton(); + } + }) ); this._subscriptions.add( @@ -163,9 +248,11 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { this.externalActionPropertiesJson = json; this.externalActionProperties = processLink.actionProperties; } + this.actionResultMappings = processLink?.actionResultMappings ?? []; }) ); + this.openEditModeResultMappingsSubscription(); this.resolveExternalActionBundleUrl(); } @@ -199,11 +286,10 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { try { this.externalActionProperties = JSON.parse(value); this.externalActionPropertiesValid = true; - this._buttonService.enableSaveButton(); } catch { this.externalActionPropertiesValid = false; - this._buttonService.disableSaveButton(); } + this.applyActionPropertiesValidity(); } public onIframeConfigurationChanged(event: { @@ -214,12 +300,55 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { this.externalActionProperties = event.data; this.externalActionPropertiesJson = JSON.stringify(event.data, null, 2); this.externalActionPropertiesValid = event.valid; + this.applyActionPropertiesValidity(); + } - if (event.valid) { - this._buttonService.enableSaveButton(); - } else { - this._buttonService.disableSaveButton(); - } + /** + * The action-properties step's validity gates whichever button is visible on it: `Save` when the + * action has no pending output-mapping step, `Next` when it does (Save only appears on the + * mappings step in that case — see {@link openNextButtonSubscription}). + */ + private applyActionPropertiesValidity(): void { + this.hasResultMappingsStep$.pipe(take(1)).subscribe(hasResultMappingsStep => { + const toggle = hasResultMappingsStep + ? ['enableNextButton', 'disableNextButton'] + : ['enableSaveButton', 'disableSaveButton']; + this._buttonService[this.externalActionPropertiesValid ? toggle[0] : toggle[1]](); + }); + } + + public onActionResultMappingsChange(mappings: Array): void { + this.actionResultMappings = mappings; + } + + public onActionResultMappingsValidityChange(valid: boolean): void { + this._resultMappingsValid$.next(valid); + } + + /** + * Edit mode opens directly on the last step, bypassing `setConfigurePluginActionSteps()` (which + * normally decides whether the mappings step exists). `loadExternalPluginStateForProcessLink` + * only sets the selected function's `key` (no `outputs`), so this relies on + * {@link selectedFunctionOutputs$}'s manifest-lookup fallback to find the declared `outputs`. + */ + private openEditModeResultMappingsSubscription(): void { + this._subscriptions.add( + combineLatest([this._stateService.selectedProcessLink$, this.hasResultMappingsStep$]) + .pipe( + // The outputs resolution is async (definition + manifest lookups) and its first emission + // can be a premature empty result — taking a single emission would lock in the 3-step + // layout whenever those lookups lose the race. Wait for the first conclusive + // "has outputs" instead; links whose action declares none simply never switch. + filter( + ([selectedProcessLink, hasResultMappingsStep]) => + selectedProcessLink?.processLinkType === 'external_plugin' && hasResultMappingsStep + ), + take(1) + ) + .subscribe(() => { + this._stepService.initializeEditModeResultMappingsSteps(); + }) + ); } private resolveExternalActionBundleUrl(): void { @@ -300,6 +429,7 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { activityId: selectedProcessLink.activityId, referenceType: inferredReferenceType, pluginDefinitionKey: selectedProcessLink.pluginDefinitionKey, + actionResultMappings: this.actionResultMappings, }; this._stateService.sendProcessLinkUpdateEvent(updateProcessLinkRequest); @@ -351,6 +481,7 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { processLinkType: selectedProcessLinkTypeId, referenceType, pluginDefinitionKey, + actionResultMappings: this.actionResultMappings, }; this._stateService.sendProcessLinkCreateEvent(processLinkRequest); @@ -362,8 +493,10 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { this._subscriptions.add( this._buttonService.backButtonClick$ .pipe( - withLatestFrom(this._stateService.isEditing$), - filter(([, isEditing]) => !isEditing) + withLatestFrom(this._stateService.isEditing$, this.currentStepId$), + filter( + ([, isEditing, currentStepId]) => !isEditing && currentStepId === 'configurePluginAction' + ) ) .subscribe(() => { this._stepService.setChoosePluginActionSteps(); @@ -371,6 +504,43 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { ); } + /** + * Distinct from {@link openBackButtonSubscription}: back from the output-mapping step returns to + * the (still-alive) properties step instead of `choosePluginAction`, guarded on this component's + * own step relevance the same way the other back-handlers guard on `!isEditing`. + */ + private openMappingsBackButtonSubscription(): void { + this._subscriptions.add( + this._buttonService.backButtonClick$ + .pipe( + withLatestFrom(this._stateService.isEditing$, this.currentStepId$), + filter( + ([, isEditing, currentStepId]) => + !isEditing && currentStepId === 'configurePluginActionResultMappings' + ) + ) + .subscribe(() => { + this._stepService.setConfigurePluginActionSteps(); + }) + ); + } + + private openNextButtonSubscription(): void { + this._subscriptions.add( + this._buttonService.nextButtonClick$ + .pipe( + withLatestFrom(this._stateService.isEditing$, this.currentStepId$, this.hasResultMappingsStep$), + filter( + ([, isEditing, currentStepId, hasResultMappingsStep]) => + !isEditing && currentStepId === 'configurePluginAction' && hasResultMappingsStep + ) + ) + .subscribe(() => { + this._stepService.setConfigurePluginActionResultMappingsSteps(); + }) + ); + } + private openSaveButtonSubscription(): void { this._subscriptions.add( this._buttonService.saveButtonClick$ @@ -388,6 +558,8 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { private saveExternalPluginProcessLink(): void { this._stateService.startSaving(); + const isBuildingBlock = this._stateService.isBuildingBlockContext(); + combineLatest([ this._stateService.modalParams$, this._pluginStateService.selectedPluginConfiguration$, @@ -405,7 +577,11 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { selectedProcessLink, selectedDefinition, ]) => { - if (!selectedConfiguration || !selectedFunction || !selectedDefinition) { + if (!selectedFunction || !selectedDefinition) { + this._stateService.stopSaving(); + return of(null); + } + if (!isBuildingBlock && !selectedConfiguration) { this._stateService.stopSaving(); return of(null); } @@ -418,6 +594,7 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { selectedFunction, selectedProcessLink, pluginVersion: definition.version, + pluginDefinitionKey: definition.pluginId, })) ); } @@ -432,6 +609,7 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { selectedFunction, selectedProcessLink, pluginVersion, + pluginDefinitionKey, } = result; // A user-task link is the plugin's task-form (rendered + completed by the plugin), not a @@ -469,15 +647,23 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { } const actionProperties = this.externalActionProperties; + const actionResultMappings = this.actionResultMappings; + // In building-block context we send referenceType 'BUILDING_BLOCK' + pluginDefinitionKey + + // pluginVersion and omit externalPluginConfigurationId — the concrete configuration is + // resolved at runtime from the building block's plugin mappings (D1/D2), mirroring the + // embedded save path's building-block branch. if (selectedProcessLink) { const updateRequest: ExternalPluginProcessLinkUpdateDto = { id: selectedProcessLink.id, processLinkType: 'external_plugin', - externalPluginConfigurationId: selectedConfiguration.id, + externalPluginConfigurationId: isBuildingBlock ? undefined : selectedConfiguration.id, actionKey: selectedFunction.key, pluginVersion, + referenceType: isBuildingBlock ? 'BUILDING_BLOCK' : 'FIXED', + pluginDefinitionKey: isBuildingBlock ? pluginDefinitionKey : undefined, actionProperties, + actionResultMappings, }; this._stateService.sendProcessLinkUpdateEvent(updateRequest); } else { @@ -486,10 +672,13 @@ export class PluginActionConfigurationComponent implements OnInit, OnDestroy { activityId: modalData?.element?.id, activityType: modalData?.element?.activityListenerType ?? '', processLinkType: 'external_plugin', - externalPluginConfigurationId: selectedConfiguration.id, + externalPluginConfigurationId: isBuildingBlock ? undefined : selectedConfiguration.id, actionKey: selectedFunction.key, pluginVersion, + referenceType: isBuildingBlock ? 'BUILDING_BLOCK' : 'FIXED', + pluginDefinitionKey: isBuildingBlock ? pluginDefinitionKey : undefined, actionProperties, + actionResultMappings, }; this._stateService.sendProcessLinkCreateEvent(createRequest); } diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html new file mode 100644 index 0000000000..602ff773bb --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.html @@ -0,0 +1,113 @@ + + +
+

+ {{ 'processLinkConfiguration.resultMappings.description' | translate }} +

+ +
+ @if (mappingRows.controls.length > 0) { +
+ + + +
+ } + +
+ @for (group of mappingRows.controls; let rowIndex = $index; track rowIndex) { +
+
+
+ @if (hasDeclaredSourceKeys) { + + + } @else { + + } +
+ +
+
+ +
+
+ @if (hasDocumentContext) { + + } @else { + + } +
+ +
+ +
+
+
+ } +
+
+ + +
diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss new file mode 100644 index 0000000000..bfc409a61c --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.scss @@ -0,0 +1,69 @@ +/*! + * 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. + */ + +.result-mappings-section { + display: flex; + flex-direction: column; + + .section-description { + color: var(--cds-text-secondary); + margin-bottom: var(--cds-spacing-04); + font-size: 14px; + } + + .mapping-header, + .mapping-row { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + } + + .mapping-row { + margin-bottom: var(--cds-spacing-03); + padding: var(--cds-spacing-03); + background-color: var(--cds-layer); + + .mapping-part { + display: flex; + } + + .arrow { + text-align: center; + width: 40px; + line-height: 40px; + font-size: large; + } + + .mapping-cell { + flex: 1; + min-width: 0; + } + + .mapping-cell-actions { + display: flex; + align-self: flex-end; + margin-left: var(--cds-spacing-03); + + button { + margin-bottom: var(--cds-spacing-02); + + ::ng-deep .cds--btn__icon { + margin: 0 !important; + } + } + } + } +} diff --git a/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts new file mode 100644 index 0000000000..4a151f725c --- /dev/null +++ b/frontend/projects/valtimo/process-link/src/lib/components/plugin-action-result-mappings/plugin-action-result-mappings.component.ts @@ -0,0 +1,253 @@ +/* + * 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. + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import { + FormArray, + FormBuilder, + FormControl, + ReactiveFormsModule, +} from '@angular/forms'; +import {Subscription} from 'rxjs'; +import {isEqual} from 'lodash'; +import { + ButtonModule, + IconModule, + InputModule, + LayerModule, +} from 'carbon-components-angular'; +import {TranslateModule} from '@ngx-translate/core'; +import { + InputLabelModule, + SelectItem, + SelectModule, + ValuePathSelectorComponent, + ValuePathSelectorPrefix, +} from '@valtimo/components'; +import {PluginActionResultMapping} from '../../models'; +import {ResultMappingRowFormGroup, ResultMappingsFormGroup} from '../../models'; +import {PLUGIN_ACTION_RESULT_MAPPINGS_TEST_IDS} from '../../constants'; + +@Component({ + standalone: true, + selector: 'valtimo-plugin-action-result-mappings', + templateUrl: './plugin-action-result-mappings.component.html', + styleUrls: ['./plugin-action-result-mappings.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + InputModule, + ValuePathSelectorComponent, + InputLabelModule, + TranslateModule, + ButtonModule, + IconModule, + LayerModule, + SelectModule, + ], +}) +export class PluginActionResultMappingsComponent implements OnInit, OnDestroy { + protected readonly testIds = PLUGIN_ACTION_RESULT_MAPPINGS_TEST_IDS; + protected readonly ValuePathSelectorPrefix = ValuePathSelectorPrefix; + + private readonly formBuilder = inject(FormBuilder); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + + @Input() public set mappings(value: Array | null | undefined) { + const incoming = value ?? []; + if (isEqual(incoming, this.currentMappings())) { + return; + } + this.rebuildForm(incoming); + this.scheduleControlRefresh(); + } + + @Input() public caseDefinitionKey: string | null | undefined; + @Input() public caseDefinitionVersionTag: string | null | undefined; + @Input() public buildingBlockDefinitionKey: string | null | undefined; + @Input() public buildingBlockDefinitionVersionTag: string | null | undefined; + + /** + * Keys the selected external action declares in its manifest `outputs`. When non-empty, the + * source column renders as a dropdown restricted to these keys instead of a free-text JSON + * pointer — the persisted value is still an RFC 6901 pointer (`/` + key) so the backend handler + * stays untouched. + */ + @Input() public set sourceKeys(value: Array | null | undefined) { + this._sourceKeys = value ?? []; + this._sourceKeyItems = this._sourceKeys.map(key => ({id: key, text: key})); + if (this.hasDeclaredSourceKeys) { + // Keys can arrive after the rows (manifest lookup): rows created in free-text mode still + // hold pointer-shaped sources ('/key') that the dropdown items ('key') would not match. + this.mappingRows.controls.forEach(group => { + const source = group.controls.source.value ?? ''; + if (source.startsWith('/')) { + group.controls.source.setValue(source.replace(/^\//, ''), {emitEvent: false}); + } + }); + } + this.scheduleControlRefresh(); + } + + public get sourceKeys(): Array { + return this._sourceKeys; + } + + public get hasDeclaredSourceKeys(): boolean { + return this._sourceKeys.length > 0; + } + + public get sourceKeyItems(): Array { + return this._sourceKeyItems; + } + + private _sourceKeys: Array = []; + private _sourceKeyItems: Array = []; + + @Output() public mappingsChangeEvent = new EventEmitter>(); + + /** + * Emits whether the current rows are saveable: no rows at all is valid, but every present row + * must have both a source and a target. + */ + @Output() public validityChangeEvent = new EventEmitter(); + + public readonly mappingsForm: ResultMappingsFormGroup = this.formBuilder.group({ + mappings: this.formBuilder.array([]), + }); + + public get mappingRows(): FormArray { + return this.mappingsForm.controls.mappings; + } + + /** + * Without a case or building-block context there is no document schema to browse, so the + * target falls back to a free-text value-resolver key (typically `pv:`) — the same degradation + * the building-block mapping step applies for independent processes. + */ + public get hasDocumentContext(): boolean { + return ( + !!(this.caseDefinitionKey && this.caseDefinitionVersionTag) || + !!(this.buildingBlockDefinitionKey && this.buildingBlockDefinitionVersionTag) + ); + } + + private _subscriptions = new Subscription(); + private _refreshHandle: number | null = null; + private _destroyed = false; + + public ngOnInit(): void { + this._subscriptions.add( + this.mappingsForm.valueChanges.subscribe(() => { + this.mappingsChangeEvent.emit(this.currentMappings()); + this.validityChangeEvent.emit(this.isValid()); + }) + ); + this.validityChangeEvent.emit(this.isValid()); + } + + public ngOnDestroy(): void { + this._destroyed = true; + if (this._refreshHandle !== null) { + clearTimeout(this._refreshHandle); + } + this._subscriptions.unsubscribe(); + } + + public addRow(): void { + this.mappingRows.push(this.createRow()); + this.validityChangeEvent.emit(this.isValid()); + this.changeDetectorRef.detectChanges(); + } + + public deleteRow(index: number): void { + this.mappingRows.removeAt(index); + this.validityChangeEvent.emit(this.isValid()); + this.changeDetectorRef.detectChanges(); + } + + /** + * Deferred re-sync after rows or dropdown items change outside a user event (prefill, late + * manifest lookup): re-setting each control value once the current change-detection pass has + * finished makes the select components pick up selections they missed while initializing, and + * the explicit detectChanges renders it under OnPush. Mirrors the refresh + * `ConfigureBuildingBlockMappingsComponent` applies to its own rows. + */ + private scheduleControlRefresh(): void { + if (this._refreshHandle !== null) { + clearTimeout(this._refreshHandle); + } + this._refreshHandle = window.setTimeout(() => { + this._refreshHandle = null; + if (this._destroyed) return; + this.mappingRows.controls.forEach(group => { + group.controls.source.setValue(group.controls.source.value ?? '', {emitEvent: false}); + group.controls.target.setValue(group.controls.target.value ?? '', {emitEvent: false}); + }); + this.changeDetectorRef.detectChanges(); + }, 0); + } + + private createRow(mapping?: PluginActionResultMapping): ResultMappingRowFormGroup { + const source = mapping?.source ?? ''; + return this.formBuilder.group({ + source: new FormControl( + this.hasDeclaredSourceKeys ? source.replace(/^\//, '') : source, + {nonNullable: true} + ), + target: new FormControl(mapping?.target ?? '', {nonNullable: true}), + }); + } + + private rebuildForm(mappings: Array): void { + this.mappingRows.clear({emitEvent: false}); + mappings.forEach(mapping => + this.mappingRows.push(this.createRow(mapping), {emitEvent: false}) + ); + } + + private currentMappings(): Array { + // Incomplete rows are emitted as-is (not silently dropped) — the validity event keeps the + // save button disabled until every row carries both values. + return this.mappingRows.controls.map(group => { + const rawSource = group.controls.source.value?.trim() ?? ''; + const source = + this.hasDeclaredSourceKeys && rawSource && !rawSource.startsWith('/') + ? `/${rawSource}` + : rawSource; + return { + source, + target: group.controls.target.value?.trim() ?? '', + }; + }); + } + + private isValid(): boolean { + return this.currentMappings().every(mapping => !!mapping.source && !!mapping.target); + } +} diff --git a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html index 97d1a31d04..76d4419383 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/process-link-modal/process-link-modal.component.html @@ -122,7 +122,12 @@

- + diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts index 3e6a3d5543..3d531a9d78 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-action/select-plugin-action.component.ts @@ -95,6 +95,7 @@ export class SelectPluginActionComponent implements OnInit, OnDestroy { key: action.key, title: action.title ?? action.key, description: action.description ?? '', + outputs: action.outputs, })); }), catchError(() => of([])) @@ -184,16 +185,21 @@ export class SelectPluginActionComponent implements OnInit, OnDestroy { } private _openBackButtonSubscription(): void { - this._buttonService.backButtonClick$ - .pipe( - withLatestFrom(this._processLinkStateService.isEditing$), - filter(([, isEditing]) => !isEditing), - switchMap(() => this._stepService.hasOneProcessLinkType$), - take(1) - ) - .subscribe((hasOneOption: boolean) => { - this._stepService.setProcessLinkTypeSteps('plugin', hasOneOption); - }); + // Tracked in _subscriptions: an untracked handler here outlives the component (it is destroyed + // when the wizard moves past this step) and hijacks back-clicks made on later steps. + this._subscriptions.add( + this._buttonService.backButtonClick$ + .pipe( + withLatestFrom( + this._processLinkStateService.isEditing$, + this._stepService.hasOneProcessLinkType$ + ), + filter(([, isEditing]) => !isEditing) + ) + .subscribe(([, , hasOneOption]) => { + this._stepService.setProcessLinkTypeSteps('plugin', hasOneOption); + }) + ); } private _openNextButtonSubscription(): void { diff --git a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts index ea30e11b7a..88c77cbabb 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts +++ b/frontend/projects/valtimo/process-link/src/lib/components/select-plugin-configuration/select-plugin-configuration.component.ts @@ -62,13 +62,18 @@ export class SelectPluginConfigurationComponent implements OnInit, OnDestroy { modalData?.element?.activityListenerType ), this._pluginService.pluginSpecifications$, + this._externalPluginService + .getDefinitions() + .pipe(catchError(() => of([] as ExternalPluginDefinition[]))), + this._translateService.stream('key'), ]).pipe( - map(([definitions, specs]) => { + map(([definitions, specs, externalDefinitions]) => { + const lang = this._translateService.currentLang; const limitedDefinitions = definitions?.filter(definition => specs.some(spec => spec.pluginId === definition.key) ) ?? []; - const enriched: PluginListItem[] = limitedDefinitions.map(definition => { + const embeddedItems: PluginListItem[] = limitedDefinitions.map(definition => { const spec = specs.find(item => item.pluginId === definition.key); return { id: definition.key, @@ -83,8 +88,34 @@ export class SelectPluginConfigurationComponent implements OnInit, OnDestroy { isDefinition: true, }; }); - this._pluginDefinitionsCache = limitedDefinitions; - return enriched; + + // Building-block process links may reference an external plugin **definition** (not a + // concrete configuration) — the configuration is resolved at runtime from the building + // block's plugin mappings (D1/D2). List every known external definition here, labeled + // via getExternalPluginDisplayName so multiple versions of the same plugin stay + // distinguishable. + const externalDefinitionItems: PluginListItem[] = externalDefinitions.map( + externalDefinition => ({ + id: toExternalPluginKey(externalDefinition.id), + title: getExternalPluginDisplayName(externalDefinition, lang), + description: getExternalPluginDescription(externalDefinition, lang) ?? '', + logo: externalDefinition.logoUrl ?? null, + payload: toExternalPluginKey(externalDefinition.id), + isDefinition: true, + external: true, + externalDefinitionId: externalDefinition.id, + }) + ); + const externalPluginDefinitions: PluginDefinition[] = externalDefinitions.map( + externalDefinition => ({ + key: toExternalPluginKey(externalDefinition.id), + title: getExternalPluginDisplayName(externalDefinition, lang), + description: getExternalPluginDescription(externalDefinition, lang) ?? '', + }) + ); + + this._pluginDefinitionsCache = [...limitedDefinitions, ...externalPluginDefinitions]; + return [...embeddedItems, ...externalDefinitionItems]; }) ) : combineLatest([ diff --git a/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts b/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts index 635891b542..dd86c1320b 100644 --- a/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts +++ b/frontend/projects/valtimo/process-link/src/lib/constants/process-link.test-ids.ts @@ -28,3 +28,13 @@ export const BB_MAPPINGS_TEST_IDS = { inputTargetSelectWrapper: 'bbMappingsInputTargetSelectWrapper', outputSourceSelectWrapper: 'bbMappingsOutputSourceSelectWrapper', } as const; + +export const PLUGIN_ACTION_RESULT_MAPPINGS_TEST_IDS = { + section: 'pluginActionResultMappingsSection', + row: 'pluginActionResultMappingsRow', + sourceInput: 'pluginActionResultMappingsSourceInput', + sourceSelectWrapper: 'pluginActionResultMappingsSourceSelectWrapper', + targetSelectWrapper: 'pluginActionResultMappingsTargetSelectWrapper', + addButton: 'pluginActionResultMappingsAddButton', + deleteButton: 'pluginActionResultMappingsDeleteButton', +} as const; diff --git a/frontend/projects/valtimo/process-link/src/lib/models/building-block-field.model.ts b/frontend/projects/valtimo/process-link/src/lib/models/building-block-field.model.ts index e56f968e7a..49630df981 100644 --- a/frontend/projects/valtimo/process-link/src/lib/models/building-block-field.model.ts +++ b/frontend/projects/valtimo/process-link/src/lib/models/building-block-field.model.ts @@ -41,10 +41,21 @@ type OutputsFormGroup = FormGroup<{ outputs: FormArray; }>; +type ResultMappingRowFormGroup = FormGroup<{ + source: FormControl; + target: FormControl; +}>; + +type ResultMappingsFormGroup = FormGroup<{ + mappings: FormArray; +}>; + export { BuildingBlockField, InputsFormGroup, OutputsFormGroup, InputRowFormGroup, OutputRowFormGroup, + ResultMappingRowFormGroup, + ResultMappingsFormGroup, }; diff --git a/frontend/projects/valtimo/process-link/src/lib/models/plugin.model.ts b/frontend/projects/valtimo/process-link/src/lib/models/plugin.model.ts index 2af82046f9..d35a2d14e2 100644 --- a/frontend/projects/valtimo/process-link/src/lib/models/plugin.model.ts +++ b/frontend/projects/valtimo/process-link/src/lib/models/plugin.model.ts @@ -14,13 +14,30 @@ * limitations under the License. */ +type PluginRequirementSource = 'EMBEDDED' | 'EXTERNAL'; + interface PluginsWithDependencies { plugins: { pluginDefinitionKey: string; dependencies: { key: string; }[]; + source?: PluginRequirementSource; + pluginDefinitionVersion?: string | null; }[]; } -export {PluginsWithDependencies}; +/** + * A single plugin requirement resolved for a building block, keyed by the + * `pluginConfigurationMappings` entry it maps to. `mappingKey` is the plain `pluginDefinitionKey` + * for embedded requirements, or the namespaced `external-plugin:@` key (D2) for + * external requirements — see `BuildingBlockStateService`. + */ +interface RequiredPlugin { + mappingKey: string; + pluginDefinitionKey: string; + pluginDefinitionVersion: string | null; + source: PluginRequirementSource; +} + +export {PluginRequirementSource, PluginsWithDependencies, RequiredPlugin}; diff --git a/frontend/projects/valtimo/process-link/src/lib/models/process-link.model.ts b/frontend/projects/valtimo/process-link/src/lib/models/process-link.model.ts index 3a6abe714d..60c98667f8 100644 --- a/frontend/projects/valtimo/process-link/src/lib/models/process-link.model.ts +++ b/frontend/projects/valtimo/process-link/src/lib/models/process-link.model.ts @@ -16,6 +16,7 @@ import {PluginConfiguration} from '@valtimo/plugin'; import {ProcessInstanceTask} from '@valtimo/process'; import {ListItem} from 'carbon-components-angular/dropdown'; +import {PluginRequirementSource} from './plugin.model'; interface ProcessLink { id: string; @@ -30,6 +31,7 @@ interface ProcessLink { actionProperties?: { [key: string]: any; }; + actionResultMappings?: Array; formDefinitionId?: string; formFlowDefinitionKey?: string; viewModelEnabled?: boolean; @@ -66,6 +68,7 @@ type ProcessLinkConfigurationStep = | 'choosePluginConfiguration' | 'choosePluginAction' | 'configurePluginAction' + | 'configurePluginActionResultMappings' | 'selectForm' | 'selectFormFlow' | 'selectBuildingBlock' @@ -109,6 +112,7 @@ interface PluginProcessLinkCreateDto { actionProperties: object; referenceType?: PluginConfigurationReferenceType; pluginDefinitionKey?: string; + actionResultMappings?: Array; } interface PluginProcessLinkUpdateDto { @@ -121,6 +125,7 @@ interface PluginProcessLinkUpdateDto { }; referenceType: PluginConfigurationReferenceType; pluginDefinitionKey?: string; + actionResultMappings?: Array; } interface FormFlowProcessLinkUpdateRequestDto { @@ -208,24 +213,37 @@ interface BuildingBlockProcessLinkUpdateDto { outputMappings: Array; } +/** + * `externalPluginConfigurationId` is required for `FIXED` references and omitted for + * `BUILDING_BLOCK` references, mirroring the embedded plugin process link (D1). For + * `BUILDING_BLOCK`, `pluginDefinitionKey` (the external plugin's `pluginId`) and `pluginVersion` + * are required instead — field names must match + * `ExternalPluginProcessLinkCreateRequestDto`/`UpdateRequestDto` on the backend exactly. + */ interface ExternalPluginProcessLinkCreateDto { processDefinitionId: string; activityId: string; activityType: string; processLinkType: 'external_plugin'; - externalPluginConfigurationId: string; + externalPluginConfigurationId?: string; actionKey: string; pluginVersion: string; + referenceType?: PluginConfigurationReferenceType; + pluginDefinitionKey?: string; actionProperties?: object; + actionResultMappings?: Array; } interface ExternalPluginProcessLinkUpdateDto { id: string; processLinkType: 'external_plugin'; - externalPluginConfigurationId: string; + externalPluginConfigurationId?: string; actionKey: string; pluginVersion: string; + referenceType?: PluginConfigurationReferenceType; + pluginDefinitionKey?: string; actionProperties?: object; + actionResultMappings?: Array; } interface ExternalPluginTaskFormProcessLinkCreateDto { @@ -262,6 +280,17 @@ interface BuildingBlockOutputMapping { syncTiming: BuildingBlockSyncTiming; } +/** + * A single plugin-action result write-back rule (`com.ritense.plugin.domain.PluginActionResultMapping` + * on the backend). `source` is an RFC 6901 JSON pointer into the action's result (empty string = + * whole result); `target` is a value-resolver-prefixed key (`doc:`, `pv:`, `case:`) describing where + * to write it. + */ +interface PluginActionResultMapping { + source: string; + target: string; +} + type TaskProcessLinkType = | 'form' | 'form-flow' @@ -380,6 +409,14 @@ interface PluginConfigurationViewModel { label: string; dropdownItems: Array; hasOptions: boolean; + source: PluginRequirementSource; + pluginDefinitionVersion?: string | null; + /** + * The selected external configuration's actual definition version, set only when it differs from + * `pluginDefinitionVersion` (D3 non-blocking warning). `undefined` for embedded plugins and exact + * version matches. + */ + selectedConfigurationVersion?: string; } interface DuplicateProcessDefinitionDescriptor { @@ -419,6 +456,7 @@ export { BuildingBlockSyncTiming, GetProcessLinkRequest, GetProcessLinkResponse, + PluginActionResultMapping, PluginConfigurationViewModel, PluginProcessLinkCreateDto, PluginProcessLinkUpdateDto, diff --git a/frontend/projects/valtimo/process-link/src/lib/process-link.module.ts b/frontend/projects/valtimo/process-link/src/lib/process-link.module.ts index 0d70dee564..70a640ca99 100644 --- a/frontend/projects/valtimo/process-link/src/lib/process-link.module.ts +++ b/frontend/projects/valtimo/process-link/src/lib/process-link.module.ts @@ -75,6 +75,7 @@ import {SelectUIComponentComponent} from './components/select-ui-component/selec import {SelectBuildingBlockComponent} from './components/select-building-block'; import {ConfigureBuildingBlockPluginsComponent} from './components/configure-building-block-plugins'; import {ConfigureBuildingBlockMappingsComponent} from './components/configure-building-block-mappings/configure-building-block-mappings.component'; +import {PluginActionResultMappingsComponent} from './components/plugin-action-result-mappings/plugin-action-result-mappings.component'; @NgModule({ declarations: [ @@ -136,6 +137,7 @@ import {ConfigureBuildingBlockMappingsComponent} from './components/configure-bu CarbonRadioModule, ValuePathSelectorComponent, ConfigureBuildingBlockMappingsComponent, + PluginActionResultMappingsComponent, NotificationModule, ExternalPluginIframeComponent, ], diff --git a/frontend/projects/valtimo/process-link/src/lib/services/building-block-state.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/building-block-state.service.ts index 04b646e5be..9f637ce8e1 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/building-block-state.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/building-block-state.service.ts @@ -22,9 +22,28 @@ import { BuildingBlockOutputMapping, ProcessLink, } from '../models'; +import {PluginRequirementSource, RequiredPlugin} from '../models/plugin.model'; import {ProcessLinkBuildingBlockApiService} from './process-link-building-block-api.service'; import {ensureDocPrefix} from '../utils'; +const EXTERNAL_PLUGIN_MAPPING_KEY_PREFIX = 'external-plugin:'; + +/** + * The `pluginConfigurationMappings` key a required plugin is stored/looked-up under. Embedded + * requirements use the plain `pluginDefinitionKey`; external requirements are namespaced and + * versioned (`external-plugin:@`, D2) so the two systems can never collide on + * the same map key. + */ +function toMappingKey( + pluginDefinitionKey: string, + source: PluginRequirementSource | undefined, + pluginDefinitionVersion: string | null | undefined +): string { + return source === 'EXTERNAL' && pluginDefinitionVersion + ? `${EXTERNAL_PLUGIN_MAPPING_KEY_PREFIX}${pluginDefinitionKey}@${pluginDefinitionVersion}` + : pluginDefinitionKey; +} + @Injectable({ providedIn: 'root', }) @@ -33,6 +52,7 @@ export class BuildingBlockStateService implements OnDestroy { private readonly _definitionVersionTag$ = new BehaviorSubject(null); private readonly _versions$ = new BehaviorSubject>([]); private readonly _requiredPluginKeys$ = new BehaviorSubject>([]); + private readonly _requiredPlugins$ = new BehaviorSubject>([]); private readonly _pluginMappings$ = new BehaviorSubject>({}); private readonly _buildingBlockFields$ = new BehaviorSubject>([]); private readonly _inputMappings$ = new BehaviorSubject>([]); @@ -66,6 +86,10 @@ export class BuildingBlockStateService implements OnDestroy { return this._requiredPluginKeys$.asObservable(); } + public get requiredPlugins$(): Observable> { + return this._requiredPlugins$.asObservable(); + } + public get pluginMappings$(): Observable> { return this._pluginMappings$.asObservable(); } @@ -270,30 +294,42 @@ export class BuildingBlockStateService implements OnDestroy { .subscribe({ next: res => { const plugins = res?.plugins ?? []; - const pluginKeys = plugins.map(plugin => plugin.pluginDefinitionKey).filter(Boolean); + const requiredPlugins: Array = plugins + .filter(plugin => !!plugin.pluginDefinitionKey) + .map(plugin => ({ + mappingKey: toMappingKey( + plugin.pluginDefinitionKey, + plugin.source, + plugin.pluginDefinitionVersion + ), + pluginDefinitionKey: plugin.pluginDefinitionKey, + pluginDefinitionVersion: plugin.pluginDefinitionVersion ?? null, + source: plugin.source ?? 'EMBEDDED', + })); const dependencies: string[] = Array.from( new Set(plugins.flatMap(p => p.dependencies ?? []).map(d => d.key)) ); - this.applyPluginKeys(pluginKeys ?? []); + this.applyRequiredPlugins(requiredPlugins); this._pluginDependencies$.next(dependencies); this._loadingRequirements$.next(false); }, error: () => { - this.applyPluginKeys([]); + this.applyRequiredPlugins([]); this._pluginDependencies$.next([]); this._loadingRequirements$.next(false); }, }); } - private applyPluginKeys(pluginKeys: Array): void { + private applyRequiredPlugins(requiredPlugins: Array): void { const currentMappings = this._pluginMappings$.getValue(); const normalized: Record = {}; - pluginKeys.forEach(key => { - normalized[key] = currentMappings[key] ?? null; + requiredPlugins.forEach(plugin => { + normalized[plugin.mappingKey] = currentMappings[plugin.mappingKey] ?? null; }); - this._requiredPluginKeys$.next(pluginKeys); + this._requiredPlugins$.next(requiredPlugins); + this._requiredPluginKeys$.next(requiredPlugins.map(plugin => plugin.mappingKey)); this._pluginMappings$.next(normalized); } @@ -319,6 +355,7 @@ export class BuildingBlockStateService implements OnDestroy { private clearPluginRequirements(options: {preserveMappings?: boolean} = {}): void { this._requirementsSubscription?.unsubscribe(); this._requiredPluginKeys$.next([]); + this._requiredPlugins$.next([]); if (!options.preserveMappings) { this._pluginMappings$.next({}); } diff --git a/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts index adae1f9436..eb3628aede 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts @@ -136,6 +136,19 @@ export class PluginStateService { } private loadPluginDefinitionForProcessLink(processLink: ProcessLink): void { + // Seed the wizard synchronously from what the link itself carries: the configuration + // container only needs the key pair, and this service is a root singleton — waiting for the + // definitions request below leaves the container evaluating with a previously edited link's + // stale function/configuration until (or forever, if) that request completes. + if (processLink.pluginActionDefinitionKey) { + this._selectedPluginFunction$.next({ + key: processLink.pluginActionDefinitionKey, + } as PluginFunction); + } + if (!processLink.pluginConfigurationId) { + this._selectedPluginConfiguration$.next(undefined); + } + // Get the plugin definition key - either directly or from plugin specifications this.getPluginDefinitionKeyForProcessLink(processLink) .pipe(take(1)) @@ -146,18 +159,12 @@ export class PluginStateService { .getPluginDefinitions() .pipe( take(1), - map(definitions => definitions.find(d => d.key === pluginDefinitionKey)) + map(definitions => definitions.find(d => d.key === pluginDefinitionKey)), + catchError(() => of(undefined)) ) .subscribe(definition => { if (definition) { this._selectedPluginDefinition$.next(definition); - - // Also set the selected function if available - if (processLink.pluginActionDefinitionKey) { - this._selectedPluginFunction$.next({ - key: processLink.pluginActionDefinitionKey, - } as PluginFunction); - } } }); } @@ -169,7 +176,8 @@ export class PluginStateService { .getAllPluginConfigurations() .pipe( take(1), - map(configs => configs.find(c => c.id === processLink.pluginConfigurationId)) + map(configs => configs.find(c => c.id === processLink.pluginConfigurationId)), + catchError(() => of(undefined)) ) .subscribe(configuration => { if (configuration) { @@ -180,8 +188,47 @@ export class PluginStateService { } private loadExternalPluginStateForProcessLink(processLink: ProcessLink): void { + // The "function" is a service-task action key, or — for a task-form link — the task-form + // bundle key (empty string for the plugin's sole, unkeyed bundle) so the wizard's selection + // matches the option listed in the action step. Seeded synchronously; see + // loadPluginDefinitionForProcessLink for why. + const functionKey = + processLink.actionKey ?? + (processLink.processLinkType === 'external_plugin_task_form' + ? (processLink.bundleKey ?? '') + : undefined); + if (functionKey !== undefined) { + this._selectedPluginFunction$.next({key: functionKey} as PluginFunction); + } + const configId = processLink.externalPluginConfigurationId; - if (!configId) return; + if (!configId) { + // A BUILDING_BLOCK reference carries no configuration — resolve the definition from the + // link's pluginId (+ version when recorded) instead, and drop whatever configuration a + // previously edited link left behind in this singleton. + this._selectedPluginConfiguration$.next(undefined); + const pluginId = processLink.pluginDefinitionKey; + if (!pluginId) return; + this.externalPluginService + .getDefinitions() + .pipe( + take(1), + catchError(() => of([])) + ) + .subscribe(definitions => { + const definition = definitions.find( + d => + d.pluginId === pluginId && + (!processLink.pluginVersion || d.version === processLink.pluginVersion) + ); + if (definition) { + this._selectedPluginDefinition$.next({ + key: toExternalPluginKey(definition.id), + } as PluginDefinition); + } + }); + return; + } // Fetch all external configurations to find the one matching this process link this.externalPluginService @@ -205,18 +252,6 @@ export class PluginStateService { id: configId, pluginDefinition: {key: externalKey}, } as PluginConfiguration); - - // Set the selected "function": a service-task action key, or — for a task-form link — the - // task-form bundle key (empty string for the plugin's sole, unkeyed bundle) so the wizard's - // selection matches the option listed in the action step. - const functionKey = - processLink.actionKey ?? - (processLink.processLinkType === 'external_plugin_task_form' - ? (processLink.bundleKey ?? '') - : undefined); - if (functionKey !== undefined) { - this._selectedPluginFunction$.next({key: functionKey} as PluginFunction); - } }); } diff --git a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts index 48c30f6d03..a125b43b0f 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts @@ -211,10 +211,11 @@ export class ProcessLinkStepService { .subscribe(([hasOneType, selectedConfiguration, selectedFunction, selectedDefinition]) => { const pluginKey = selectedDefinition?.key || selectedConfiguration?.pluginDefinition?.key || ''; + const isExternal = isExternalPluginKey(pluginKey); // External plugin actions/forms have no plugin-translation bundle — their display name comes // from the manifest and is already carried on the selected function's `title`. Using the // translate lookup here would render the raw `{externalKey}.{key}` fallback. - const selectedFunctionTranslation = isExternalPluginKey(pluginKey) + const selectedFunctionTranslation = isExternal ? selectedFunction.title || selectedFunction.key : pluginKey ? this.pluginTranslateService.instant(selectedFunction.key, pluginKey) @@ -227,30 +228,97 @@ export class ProcessLinkStepService { selectedConfiguration, selectedDefinition ); + // Only an external action that declares its `outputs` in the manifest offers the dedicated + // output-mapping step — embedded actions have no declaration mechanism and never show it. + const hasResultMappingsStep = isExternal && (selectedFunction?.outputs?.length ?? 0) > 0; + const configureActionStep = hasResultMappingsStep + ? [ + {label: 'configurePluginAction'}, + {label: 'configurePluginActionResultMappings', disabled: true}, + ] + : [{label: 'configurePluginAction'}]; if (hasOneType) { this._steps$.next([ {label: selectionLabel, secondaryLabel: selectedPluginLabel}, {label: 'choosePluginAction', secondaryLabel: selectedFunctionTranslation}, - {label: 'configurePluginAction'}, + ...configureActionStep, ]); this._currentStepIndex$.next(2); - this.buttonService.hideNextButton(); - this.buttonService.showSaveButton(); } else { this._steps$.next([ {label: 'chooseProcessLinkType', secondaryLabel: 'processLinkType.plugin'}, {label: selectionLabel, secondaryLabel: selectedPluginLabel}, {label: 'choosePluginAction', secondaryLabel: selectedFunctionTranslation}, - {label: 'configurePluginAction'}, + ...configureActionStep, ]); this._currentStepIndex$.next(3); + } + + if (hasResultMappingsStep) { + this.buttonService.showNextButton(); + this.buttonService.showBackButton(); + this.buttonService.hideSaveButton(); + this.buttonService.enableNextButton(); + } else { this.buttonService.hideNextButton(); this.buttonService.showSaveButton(); } }); } + public setConfigurePluginActionResultMappingsSteps(): void { + combineLatest([ + this._hasOneProcessLinkType$, + this.pluginStateService.selectedPluginConfiguration$, + this.pluginStateService.selectedPluginFunction$, + this.pluginStateService.selectedPluginDefinition$, + ]) + .pipe(take(1)) + .subscribe(([hasOneType, selectedConfiguration, selectedFunction, selectedDefinition]) => { + const pluginKey = + selectedDefinition?.key || selectedConfiguration?.pluginDefinition?.key || ''; + const selectedFunctionTranslation = isExternalPluginKey(pluginKey) + ? selectedFunction.title || selectedFunction.key + : pluginKey + ? this.pluginTranslateService.instant(selectedFunction.key, pluginKey) + : selectedFunction.key; + const selectionLabel = + this._context === 'buildingBlock' + ? 'choosePluginDefinition' + : 'choosePluginConfiguration'; + const selectedPluginLabel = this.getSelectedPluginLabel( + selectedConfiguration, + selectedDefinition + ); + const configureActionSteps = [ + {label: 'configurePluginAction'}, + {label: 'configurePluginActionResultMappings'}, + ]; + + if (hasOneType) { + this._steps$.next([ + {label: selectionLabel, secondaryLabel: selectedPluginLabel}, + {label: 'choosePluginAction', secondaryLabel: selectedFunctionTranslation}, + ...configureActionSteps, + ]); + this._currentStepIndex$.next(3); + } else { + this._steps$.next([ + {label: 'chooseProcessLinkType', secondaryLabel: 'processLinkType.plugin'}, + {label: selectionLabel, secondaryLabel: selectedPluginLabel}, + {label: 'choosePluginAction', secondaryLabel: selectedFunctionTranslation}, + ...configureActionSteps, + ]); + this._currentStepIndex$.next(4); + } + + this.buttonService.hideNextButton(); + this.buttonService.showSaveButton(); + this.buttonService.showBackButton(); + }); + } + public setBuildingBlockSteps(): void { this._hasOneProcessLinkType$.pipe(take(1)).subscribe(hasOneType => { this._steps$.next([ @@ -556,6 +624,25 @@ export class ProcessLinkStepService { } } + /** + * Edit-mode variant of {@link initializeEditModeSteps} for an `external_plugin` link whose + * selected action declares `outputs` — inserts the fourth `configurePluginActionResultMappings` + * step, active, with Next shown (to reach it isn't needed, edit mode opens directly on it) and + * Save hidden until the admin steps forward from the properties step. + */ + public initializeEditModeResultMappingsSteps(): void { + this._steps$.next([ + {label: 'choosePluginConfiguration'}, + {label: 'choosePluginAction'}, + {label: 'configurePluginAction'}, + {label: 'configurePluginActionResultMappings'}, + ]); + this._currentStepIndex$.next(2); + this.buttonService.showNextButton(); + this.buttonService.hideSaveButton(); + this.buttonService.enableNextButton(); + } + /** * Navigate to a specific step by index (for edit mode navigation) */ diff --git a/frontend/projects/valtimo/shared/assets/core/en.json b/frontend/projects/valtimo/shared/assets/core/en.json index 4e2e917e66..861ec11f2f 100644 --- a/frontend/projects/valtimo/shared/assets/core/en.json +++ b/frontend/projects/valtimo/shared/assets/core/en.json @@ -1135,6 +1135,7 @@ "noPluginsTitle": "No plugins to configure", "notInstalled": "Plugin type not installed", "noConfigurations": "No configurations available", + "externalVersionMismatchWarning": "This configuration's plugin version ({{selectedVersion}}) does not match the exported version ({{requiredVersion}}). It may not support the same actions.", "unidentifiable": { "title": "Unidentified plugin configurations", "message": "Some plugin configurations could not be identified from the export. The import can continue, but you will need to manually configure the affected process links afterwards." @@ -1148,6 +1149,7 @@ "unknownPlugin": "Unknown plugin", "notInstalled": "Plugin type not installed", "noConfigurations": "No configurations available", + "externalVersionMismatchWarning": "This configuration's plugin version ({{selectedVersion}}) does not match the required version ({{requiredVersion}}). It may not support the same actions.", "saveSuccess": "Plugin configuration mappings saved successfully", "saveError": "Failed to save plugin configuration mappings", "legacyExport": { @@ -2631,6 +2633,15 @@ "externalPluginActionProperties": "Configure the action properties for this external plugin action as JSON. Click save when done.", "actionPropertiesJson": "Action properties (JSON)", "invalidJson": "Invalid JSON", + "resultMappings": { + "title": "Output mapping", + "description": "Map values from the action's result onto the case document or a process variable.", + "source": "Result path", + "target": "Destination", + "sourcePlaceholder": "/path/in/result", + "sourceSelectPlaceholder": "Select a result field...", + "addRow": "Add mapping" + }, "selectProcess": "Select process", "process": "Process", "version": "Version", @@ -2671,7 +2682,8 @@ "zaakInstanceAndTypeLink": "This building block may need both a zaak instance link and a zaak type link to be configured for the case definition under which this building block is used, in order to function correctly.", "zaakInstanceLink": "This building block may need a zaak instance link to be configured for the case definition under which this building block is used, in order to function correctly.", "zaakTypeLink": "This building block may need a zaak type link to be configured for the case definition under which this building block is used, in order to function correctly." - } + }, + "externalPluginVersionMismatchWarning": "The selected configuration's plugin version does not match the version ({{version}}) this building block was linked against. It may not support this action." } }, "processLinkType": { @@ -2745,6 +2757,7 @@ "choosePluginDefinition": "Select plugin definition", "choosePluginAction": "Choose your action", "configurePluginAction": "Configure your action", + "configurePluginActionResultMappings": "Output mapping", "selectURL": "Configure your URL", "subtitles": "Subtitles", "addSubtitle": "Add subtitle", diff --git a/frontend/projects/valtimo/shared/assets/core/nl.json b/frontend/projects/valtimo/shared/assets/core/nl.json index 0ce4fa8e59..75a14362b2 100644 --- a/frontend/projects/valtimo/shared/assets/core/nl.json +++ b/frontend/projects/valtimo/shared/assets/core/nl.json @@ -1142,6 +1142,7 @@ "noPluginsTitle": "Geen plugins te configureren", "notInstalled": "Plugintype niet geïnstalleerd", "noConfigurations": "Geen configuraties beschikbaar", + "externalVersionMismatchWarning": "De pluginversie van deze configuratie ({{selectedVersion}}) komt niet overeen met de geëxporteerde versie ({{requiredVersion}}). Mogelijk worden niet dezelfde acties ondersteund.", "unidentifiable": { "title": "Niet-identificeerbare pluginconfiguraties", "message": "Sommige pluginconfiguraties konden niet worden herleid uit het exportbestand. De import kan doorgaan, maar de betreffende process links moeten achteraf handmatig worden geconfigureerd." @@ -1155,6 +1156,7 @@ "unknownPlugin": "Onbekende plugin", "notInstalled": "Plugintype niet geïnstalleerd", "noConfigurations": "Geen configuraties beschikbaar", + "externalVersionMismatchWarning": "De pluginversie van deze configuratie ({{selectedVersion}}) komt niet overeen met de vereiste versie ({{requiredVersion}}). Mogelijk worden niet dezelfde acties ondersteund.", "saveSuccess": "Pluginconfiguratie-koppelingen succesvol opgeslagen", "saveError": "Kon pluginconfiguratie-koppelingen niet opslaan", "legacyExport": { @@ -2664,6 +2666,15 @@ "externalPluginActionProperties": "Configureer de actie-eigenschappen voor deze externe plugin actie als JSON. Klik op opslaan wanneer gereed.", "actionPropertiesJson": "Actie-eigenschappen (JSON)", "invalidJson": "Ongeldige JSON", + "resultMappings": { + "title": "Output-mapping", + "description": "Koppel waarden uit het resultaat van de actie aan het dossierdocument of een procesvariabele.", + "source": "Pad in resultaat", + "target": "Bestemming", + "sourcePlaceholder": "/pad/in/resultaat", + "sourceSelectPlaceholder": "Selecteer een resultaatveld...", + "addRow": "Mapping toevoegen" + }, "selectProcess": "Selecteer een proces", "process": "Proces", "version": "Versie", @@ -2704,7 +2715,8 @@ "zaakInstanceAndTypeLink": "Dit bouwblok heeft mogelijk zowel een zaakinstantiekoppeling als een zaaktypekoppeling nodig, die zijn geconfigureerd voor de dossierdefinitie waaronder dit bouwblok wordt geconfigureerd, om correct te functioneren.", "zaakInstanceLink": "Dit bouwblok heeft mogelijk een zaakinstantiekoppeling nodig, die is geconfigureerd voor de dossierdefinitie waaronder dit bouwblok wordt geconfigureerd, om correct te functioneren.", "zaakTypeLink": "Dit bouwblok heeft mogelijk een zaaktypekoppeling nodig, die is geconfigureerd voor de dossierdefinitie waaronder dit bouwblok is geconfigureerd, om correct te functioneren." - } + }, + "externalPluginVersionMismatchWarning": "De pluginversie van de geselecteerde configuratie komt niet overeen met de versie ({{version}}) waaraan dit bouwblok is gekoppeld. Deze actie wordt mogelijk niet ondersteund." } }, "processLinkType": { @@ -2778,6 +2790,7 @@ "choosePluginDefinition": "Kies plugin-definitie", "choosePluginAction": "Kies je actie", "configurePluginAction": "Configureer je actie", + "configurePluginActionResultMappings": "Uitvoer koppelen", "selectURL": "Configureer je URL", "subtitles": "Ondertitels", "addSubtitle": "Ondertitel toevoegen", diff --git a/plugin-host/app/src/plugin-manager.ts b/plugin-host/app/src/plugin-manager.ts index 97681f795d..5d1db912d1 100644 --- a/plugin-host/app/src/plugin-manager.ts +++ b/plugin-host/app/src/plugin-manager.ts @@ -288,6 +288,7 @@ export class PluginManager { ): Promise<{ status: string; variables?: Record; + result?: unknown; errorCode?: string; errorMessage?: string; }> { diff --git a/plugin-host/plugin-sdk/src/manifest-validation.ts b/plugin-host/plugin-sdk/src/manifest-validation.ts index f9c31e3e1f..f9f01f7521 100644 --- a/plugin-host/plugin-sdk/src/manifest-validation.ts +++ b/plugin-host/plugin-sdk/src/manifest-validation.ts @@ -113,6 +113,43 @@ export function validatePluginManifest(manifest: unknown): string[] { } } + const actions = m.actions; + if (actions !== undefined) { + if (!Array.isArray(actions)) { + errors.push("manifest.json 'actions' must be an array when present"); + } else { + actions.forEach((action, index) => { + if (typeof action !== "object" || action === null || Array.isArray(action)) { + errors.push(`manifest.json actions[${index}] must be an object`); + return; + } + const a = action as Record; + const outputs = a.outputs; + if (outputs !== undefined) { + if (!Array.isArray(outputs)) { + errors.push(`manifest.json actions[${index}].outputs must be an array when present`); + } else { + const seen = new Set(); + outputs.forEach((output, outputIndex) => { + if (typeof output !== "string" || output.trim() === "") { + errors.push( + `manifest.json actions[${index}].outputs[${outputIndex}] must be a non-empty string` + ); + return; + } + if (seen.has(output)) { + errors.push( + `manifest.json actions[${index}].outputs must contain unique keys; '${output}' is duplicated` + ); + } + seen.add(output); + }); + } + } + }); + } + } + const frontendBundles = m.frontendBundles; if (frontendBundles !== undefined) { if (!Array.isArray(frontendBundles)) { diff --git a/plugin-host/plugin-sdk/src/models/types.ts b/plugin-host/plugin-sdk/src/models/types.ts index e10afc3ba8..4f282b30b5 100644 --- a/plugin-host/plugin-sdk/src/models/types.ts +++ b/plugin-host/plugin-sdk/src/models/types.ts @@ -24,9 +24,16 @@ export interface ActionInput { properties: Record; } +/** + * `variables` is applied as process variables, unchanged from before. `result` is a separate, + * optional channel: an arbitrary JSON payload that GZAC's `action_result_mappings` (JSON-pointer → + * `doc:`/`pv:`/`case:` target) resolve against, entirely independent of `variables`. Plugins that + * don't set `result` are unaffected — there is nothing to map. + */ export interface ActionOutput { status: "completed" | "error"; variables?: Record; + result?: unknown; errorCode?: string; errorMessage?: string; } @@ -126,6 +133,14 @@ export interface ManifestAction { description?: string; activityTypes: string[]; properties?: ManifestActionProperty[]; + /** + * Keys the action's {@link ActionOutput.result} object exposes for result-mapping. When present + * (and non-empty), GZAC's process-link stepper offers a dedicated output-mapping step letting an + * admin map these keys to `doc:`/`pv:`/`case:` targets, with the source restricted to this + * declared set (a dropdown instead of free-text JSON pointers). Actions without `outputs` (or an + * empty array) have no declared shape and cannot use result mapping. + */ + outputs?: string[]; } export interface ManifestActionProperty { diff --git a/plugin-host/sample-plugins/case-summary/README.md b/plugin-host/sample-plugins/case-summary/README.md index 97e98a3ff5..50e1886b3c 100644 --- a/plugin-host/sample-plugins/case-summary/README.md +++ b/plugin-host/sample-plugins/case-summary/README.md @@ -10,6 +10,14 @@ Registers a single `case-summary` action that: - Calls back into GZAC via `gzacApi.get('/api/v1/document/{id}')` using the running case's `processBusinessKey` as the document ID. - Looks up the configured fields in `document.content` using RFC 6901 JSON pointers. - Returns a `caseSummary` process variable like `"Alice — EUR 12500 — (loan-application/)"`. +- Additionally returns a `result` payload (`{ summary, title, amount, currency }`) — a second, + independent channel from `variables` that GZAC's process-link **output mapping** + (`actionResultMappings`, #771) can write onto the case document or a process variable. The + action declares these keys in `manifest.json` (`actions[].outputs`), which is what makes the + dedicated "Output mapping" step appear in the process-link stepper at all — its source field is a + dropdown of the declared keys (`summary`, `title`, `amount`, `currency`), not a free-text JSON + pointer. This demonstrates the action-result write-back feature end-to-end; see "Manual Test Path" + below. This is the second sample plugin alongside `say-hello`. Where `say-hello` only uses static configuration and BPMN action properties, `case-summary` exercises the end-to-end host-function callback path. @@ -80,6 +88,10 @@ Both the service token and the GZAC base URL are pushed to the Plugin Host on co 1. Build & pack (`npm run build:pack`). 2. Upload `dist/case-summary-0.1.0.zip` to the Plugin Host (`POST /api/host/plugins`). 3. In GZAC admin UI: create a configuration of `case-summary` with `titleField=/applicantName` (and `amountField=/loanAmount` if your case has it). -4. Wire the `case-summary` action onto a BPMN service task. +4. Wire the `case-summary` action onto a BPMN service task. Because the action declares `outputs` in + its manifest, the stepper offers an extra "Output mapping" step after the action's properties — + select `summary` from the source dropdown and set the target to `doc:/summaryText` (or + `pv:summaryText` to write a process variable instead). 5. Start a case where `document.content` has those fields. 6. Verify on the running process instance: variable `caseSummary` is set; the Plugin Host log shows `gzac_api call` / `gzac_api response`; GZAC's request log shows the inbound `GET /api/v1/document/` authenticated as `external-plugin:case-summary:`. +7. Verify the output mapping: the case document now has a `/summaryText` field (or the process variable `summaryText`, if mapped to `pv:`) equal to the plugin's `result.summary` string — confirming the action-result write-back path independently of the `variables` channel. diff --git a/plugin-host/sample-plugins/case-summary/manifest.json b/plugin-host/sample-plugins/case-summary/manifest.json index c894df15ac..926a25705d 100644 --- a/plugin-host/sample-plugins/case-summary/manifest.json +++ b/plugin-host/sample-plugins/case-summary/manifest.json @@ -257,7 +257,8 @@ { "key": "amountField", "type": "string", "required": false }, { "key": "summaryVariable", "type": "string", "required": false }, { "key": "definitionKeyVariable", "type": "string", "required": false } - ] + ], + "outputs": ["summary", "title", "amount", "currency"] } ], "eventSubscriptions": [ diff --git a/plugin-host/sample-plugins/case-summary/src/plugin.ts b/plugin-host/sample-plugins/case-summary/src/plugin.ts index 0d39edfc28..8b8525f2ea 100644 --- a/plugin-host/sample-plugins/case-summary/src/plugin.ts +++ b/plugin-host/sample-plugins/case-summary/src/plugin.ts @@ -283,6 +283,15 @@ action("case-summary", (input: ActionInput) => { [summaryVariable]: summary, [definitionKeyVariable]: document.definitionId?.name, }, + // `result` is a separate channel from `variables`, consumed only by the process link's + // configured `actionResultMappings` (e.g. mapping "/summary" to a `doc:` path). Demonstrates + // the action-result write-back feature independently of the existing process-variable output. + result: { + summary, + title, + amount: amount ?? null, + currency, + }, }; }); diff --git a/plugin-host/scratch/external-plugin-system-plan.md b/plugin-host/scratch/external-plugin-system-plan.md index c0067c03a0..f01b650ee1 100644 --- a/plugin-host/scratch/external-plugin-system-plan.md +++ b/plugin-host/scratch/external-plugin-system-plan.md @@ -28,13 +28,16 @@ Status legend: ✅ implemented & verified · 🟡 implemented, POC-level · ⛔ | Core-app backend module | `backend/external-plugin/` | ✅ | | Endpoint descriptions (`@EndpointDescription` on every controller method) + contract annotation | `backend/*/.../web/rest/*Resource.{kt,java}`, `com.ritense.valtimo.contract.endpoint.EndpointDescription` | ✅ | | Plugin host (Node + Fastify + Extism, multi-version) | `plugin-host/app/` | 🟡 | -| Host capabilities (`gzac_api`, `http_request`, `kv`, `log`) — capability allowlist enforcement, new host functions, persistent storage (KV + logs), admin log view | `plugin-host/app/src/host-functions/{gzac-api,http-request,kv,log}.ts`, `plugin-host/app/src/db/{log-repository,kv-repository}.ts`, `plugin-host/app/src/routes/plugin-logs.ts` | ⛔ (`gzac_api` host function ✅, capability gate ⛔) | +| Host capabilities (`gzac_api`, `http_request`, `kv`, `log`) — capability allowlist enforcement, new host functions, persistent storage (KV + logs), admin log view | `plugin-host/app/src/host-functions/{gzac-api,http-request,kv,log}.ts`, `plugin-host/app/src/db/{log-repository,kv-repository}.ts`, `plugin-host/app/src/routes/plugin-logs.ts` | ✅ | | Event consumer (RabbitMQ → `handle_event`) | `plugin-host/app/src/rabbitmq/event-consumer.ts` | ✅ | -| Backend plugin SDK (`@valtimo/plugin-sdk`) — actions, events, requests (`handle_request`), `gzacApi` (+ `asUser`), `httpRequest`, `kv`, `log` (structured), frontend `t()` + parent-proxy data access (`callValtimo`/`getPluginData`) | `plugin-host/plugin-sdk/` | 🟡 (`gzacApi` ✅; SDK stubs for `http_request`/`kv`/structured `log` ⛔) | +| Backend plugin SDK (`@valtimo/plugin-sdk`) — actions, events, requests (`handle_request`), `gzacApi` (+ `asUser`), `httpRequest`, `kv`, `log` (structured), frontend `t()` + parent-proxy data access (`callValtimo`/`getPluginData`) | `plugin-host/plugin-sdk/` | ✅ | | Shared manifest validation (name/description-in-translations), one rule set for pack + host | `plugin-host/plugin-sdk/src/manifest-validation.ts` (subpath `@valtimo/plugin-sdk/manifest-validation`) | ✅ | | Sample plugin (action + event handler + logo + i18n) | `plugin-host/sample-plugins/case-summary/` | ✅ | | Frontend management UI + external models/service/iframe | `frontend/projects/valtimo/{plugin-management,plugin}/` | ✅ | -| Process-link (`SERVICE_TASK_START`) | `backend/external-plugin/.../processlink/` + frontend process-link | 🟡 | +| Process-link (`SERVICE_TASK_START`) — FIXED + BUILDING_BLOCK references, action result write-back | `backend/external-plugin/.../processlink/` + frontend process-link | ✅ | +| Building-block support (shared `PluginConfigurationReference`, namespaced config mappings, required-plugins endpoint, BB-context admin UX) | `backend/external-plugin/.../processlink/ExternalPluginServiceTaskStartListener.kt` + `backend/building-block/.../service/BuildingBlockPluginDefinitionService.kt` ↔ frontend `process-link/.../{select-plugin-configuration,configure-building-block-plugins}` (§19) | ✅ | +| Case-definition import/export parity (preview contributor, mapper remap hook, dangling repair, `EXTERNAL_PLUGIN` case-tab import) | `backend/external-plugin/.../{preview/ExternalPluginImportPreviewContributor,service/ExternalPluginConfigurationMappingResolver}.kt`, `backend/case/.../service/CaseTabImporter.kt` ↔ frontend `case-management/.../{case-management-upload,case-management-missing-plugin-configurations}` (§20) | ✅ | +| Action result write-back (`action_result_mappings` + `result` output channel), embedded **and** external | `backend/plugin/.../service/PluginActionResultHandler.kt` ↔ frontend `process-link/.../plugin-action-result-mappings` (§21) | ✅ | | Per-host broker / callback config + defaults endpoint | `backend/external-plugin/.../web/rest/ExternalPluginManagementResource.kt#hostDefaults` | ✅ | | Per-host durable event queue mode + TTL (live/durable, `x-expires`) + narrow PATCH endpoint | `backend/external-plugin/.../domain/EventQueueMode.kt`, `service/ExternalPluginHostService.updateEventQueue`, `web/rest/...#updateHostEventQueue` ↔ `plugin-host/app/src/rabbitmq/event-consumer.ts` | ✅ | | Plugin assets (logo + i18n bundle in manifest, served by host) | `plugin-host/plugin-sdk/bin/valtimo-plugin-pack.mjs`, `plugin-host/app/src/routes/plugin-bundles.ts` | ✅ | @@ -286,9 +289,17 @@ DDL lives in the **core** module's changelog, not the external-plugin module's o duplicate grant rows are structurally impossible. The replace-on-write `update()` flow deletes a configuration's endpoint grants and flushes that delete before re-inserting, so a replacement set that overlaps the previous grants stays within the constraint. -- `external_plugin_*` columns on `process_link` (`external_plugin_config_id`, - `external_plugin_action_key`, `external_plugin_version`, `external_plugin_action_properties`) for - the `SERVICE_TASK_START` action link. +- `external_plugin_*` columns on `process_link` for the `SERVICE_TASK_START` action link: + `external_plugin_config_id` (nullable — null for `BUILDING_BLOCK` references and dangling + imports), `external_plugin_action_key`, `external_plugin_action_properties`. The plugin identity + and version live on the **shared** reference columns `reference_type` / `plugin_definition_key` + (= `pluginId`) / `plugin_definition_version` — the same `PluginConfigurationReference` embeddable + the embedded `PluginProcessLink` maps; embedded rows keep `plugin_definition_version` null + (embedded definitions are unversioned). The reference version is design-time metadata only — + the runtime invocation version always derives from the resolved configuration's definition + (§19). `action_result_mappings` (json, also shared with the embedded link type) holds the + action's result write-back rules (§21). The task-form link's version likewise lives on the + shared reference columns; its own columns are `external_plugin_task_form_{config_id,bundle_key}`. Events add **no new table**: subscriptions come from `manifest_json`, the broker connection details come from the host row, and at push time they are pushed transiently to the host (held @@ -354,8 +365,10 @@ chain to avoid Extism reentrancy), sets `prefetch` on the broker channel, and ho properties}` — note it does **not** carry `actionKey` (URL param) or `configuration` (looked up host-side from the registry). The host assembles the **Wasm input** `{actionKey, configurationId, configuration, processInstanceId, documentId, activityId, properties}`; output `{status, - variables}` (plus `{errorCode, errorMessage}` on failure, surfaced to the process as a BPMN - error). + variables, result?}` (plus `{errorCode, errorMessage}` on failure, surfaced to the process as a + BPMN error). `variables` is applied as plain Operaton process variables; the optional `result` + is a separate channel evaluated only by the link's `action_result_mappings` (§21) — the two + never interfere, and a plugin that returns no `result` simply has nothing to map. - **Plugins run under Extism with `runInWorker: true`** so async host functions (`gzac_api`) can suspend the Wasm call until the host's fetch resolves. **This requires Node ≥ 22** (older Node fails to spawn the worker with `invalid execArgv flags: --disable-warning`). @@ -646,7 +659,15 @@ versions of the same plugin must run side-by-side indefinitely**: there is no pa coexist as separate rows. - The host loads each `(pluginId, version)` as a distinct Wasm module. - A `Configuration` references one specific `definition_id` → pinned to one version. -- A `ProcessLink` carries an explicit `pluginVersion` column → pinned to one version transitively. +- A **`FIXED`** `ProcessLink` references one specific configuration → pinned to one version + transitively. The link additionally records `pluginId`/version on the shared reference columns + (`plugin_definition_key` / `plugin_definition_version`) as design-time metadata — the runtime + call always uses the resolved configuration's definition version, so the two can never diverge + at invocation time. +- A **`BUILDING_BLOCK`** `ProcessLink` (§19) carries no configuration id — only the + `pluginId@version` reference. The concrete configuration is supplied per usage context through + the building block's `pluginConfigurationMappings`, keyed `external-plugin:@`, + so each case definition using the block pins its own configuration (and thereby version). **Operator flow for adding a new version (no upgrade required).** 1. Plugin author publishes v2 to the host alongside v1. @@ -746,10 +767,10 @@ there is no force override on any path. | Entity | Blocked when… | Surface | |--------|---------------|---------| | **ProcessLink** | never (BPMN authoring is the source of truth — the case definition is the gate) | — | -| **Configuration** (external) | any `ProcessLink` **or** any `EXTERNAL_PLUGIN` case tab references it | Server-side guard in `ExternalPluginConfigurationService.delete` throws `ExternalPluginConfigurationInUseException` (HTTP 409, `usages` payload). `ExternalPluginHostUsageResolver` folds in process-link usages **and** case-tab usages (via the case module's `CaseExternalPluginTabService.findUsagesForConfiguration`, §13.1). UI runs the pre-check and shows the read-only `PluginUsageModalComponent`, which renders both row kinds. No override. | +| **Configuration** (external) | any *fixed* `ProcessLink` (a `BUILDING_BLOCK` reference carries no configuration id and never blocks, §19) **or** any `EXTERNAL_PLUGIN` case tab references it | Server-side guard in `ExternalPluginConfigurationService.delete` throws `ExternalPluginConfigurationInUseException` (HTTP 409, `usages` payload). `ExternalPluginHostUsageResolver` folds in process-link usages **and** case-tab usages (via the case module's `CaseExternalPluginTabService.findUsagesForConfiguration`, §13.1). UI runs the pre-check and shows the read-only `PluginUsageModalComponent`, which renders both row kinds. No override. | | **Configuration** (embedded) | any *fixed* `PluginProcessLink` references it | Server-side guard in `PluginService.deletePluginConfiguration` throws `PluginConfigurationInUseException` (HTTP 409, `usages` payload). Same UI flow and modal. | | **Definition** | any `Configuration` exists for it | Not directly user-deletable; cleared by the discovery cycle when the upstream host no longer lists the version **and** no configurations remain. | -| **Host** | any `Configuration` under any definition on this host has at least one `ProcessLink` referencing it | Server-side guard in `ExternalPluginHostService.delete` throws `ExternalPluginHostInUseException` (HTTP 409, `usages` payload). Host delete in the UI shows the same `PluginUsageModalComponent`. Deletion of an entire host with active configurations remains blocked: removing the host would orphan service tokens, push paths, and broker bindings for live configurations. | +| **Host** | any `Configuration` under any definition on this host has at least one *fixed* `ProcessLink` referencing it | Server-side guard in `ExternalPluginHostService.delete` throws `ExternalPluginHostInUseException` (HTTP 409, `usages` payload). Host delete in the UI shows the same `PluginUsageModalComponent`. Deletion of an entire host with active configurations remains blocked: removing the host would orphan service tokens, push paths, and broker bindings for live configurations. | | **Plugin on host** (host-side route) ✅ | active config refers to plugin version | `DELETE /api/host/plugins/:id/:version` returns HTTP 409 with `configurationIds`. | A configuration that *has* been activated but has no process links yet **can** be deleted (it is @@ -983,9 +1004,11 @@ Structure: - **Process-link type** `external_plugin_task_form` is a distinct `ProcessLink` subtype, kept separate from the `external_plugin` service-task action type because the surfaces are unrelated (a form to render vs. a backend action to invoke). Entity `ExternalPluginTaskFormProcessLink` maps - `external_plugin_task_form_{config_id,bundle_key,version}` on the shared `process_link` table (DDL in - the release changelog `13-32-0/20260706-add-external-plugin-task-form-process-link.xml`, not in - `initial-setup`). It ships the five `ProcessLinkMapper` DTOs and a `SupportedProcessLinkTypeHandler` + `external_plugin_task_form_{config_id,bundle_key}` on the shared `process_link` table (DDL in + the release changelogs `13-32-0/20260706-add-external-plugin-task-form-process-link.xml` + + `13-32-0/20260720-plugin-configuration-reference-external-plugin-version.xml`, not in + `initial-setup`); the plugin identity/version rides on the shared reference columns like the + action link's (§5). It ships the five `ProcessLinkMapper` DTOs and a `SupportedProcessLinkTypeHandler` that declares **`USER_TASK_CREATE`** (the action type declares `SERVICE_TASK_START`). - **Render descriptor, no dedicated open controller.** A `ProcessLinkActivityHandler` (shaped like the URL/UI-component handlers — a render descriptor, not an execution listener) answers the generic @@ -1068,13 +1091,11 @@ Structure: ## 15. Roadmap (priority order) -1. **Host capabilities** (§18) — `gzac_api`, `http_request`, `kv`, `log` behind a per-plugin - capability allowlist; persistent storage for KV + logs; admin log view modal; demo plugin scenarios. -2. **Harden the host `/data` route** — capability/auth gating so a `handle_request` handler can't be +1. **Harden the host `/data` route** — capability/auth gating so a `handle_request` handler can't be triggered (and can't reach the service token) unauthenticated; tighten the allowlist surface. -3. Remaining iframe surfaces: HTMX pages, case widgets (case **tab** §13.1, **task form** §13.6, +2. Remaining iframe surfaces: HTMX pages, case widgets (case **tab** §13.1, **task form** §13.6, and **menu pages** §17 done). -4. Cleanup: align async-vs-sync SDK docs. +3. Cleanup: align async-vs-sync SDK docs. ## 16. Verification status @@ -1200,6 +1221,16 @@ Structure: sample `build:pack`, but a live browser run of all three levels against a running task is not yet in the verified record. Level 2 reuses the previously code-verified `handle_request`/`gzacApi.asUser` path. +- The **building-block, import-parity, and result-write-back features** (§19–§21) are verified by + unit suites (`:backend:{plugin,plugin-valtimo,external-plugin,building-block,process-link,case}:test`), + `:backend:external-plugin:integrationTestingPostgresql` / `:backend:plugin:integrationTesting{Postgresql,Mysql}` + (which run every Liquibase changelog against real databases), clean `ng build` of + `@valtimo/{process-link,case-management,plugin,shared}`, SDK/host `tsc`, and the sample + `build:pack` — but not by a live run. Outstanding live checks: a BB call activity resolving an + external action through the namespaced mapping and invoking the host; a result mapping writing to + a real case/BB document (`doc:`/`pv:`); the import wizard round-trip in the browser (external rows + mapped, external case tab surviving import with its side row); and the dangling-import → issue + banner → repair flow. ## 17. Apps — URL plugins ✅ @@ -1243,7 +1274,7 @@ the public `/plugin-manifest`, a correctly-signed `GET /api/host/plugins` (→ 2 plugin), and an unsigned / wrong-secret call (→ 401). Frontend `@valtimo/{plugin,plugin-management}` type-check clean; `en`/`nl` bundles updated (`addApp`, `tabs.integrations`, `labels.kind`, `kind.*`). -## 18. Host capabilities — `gzac_api`, `http_request`, `kv`, `log` ⛔ +## 18. Host capabilities — `gzac_api`, `http_request`, `kv`, `log` ✅ A capability is a host function a plugin may call. Every capability requires an explicit grant: the plugin declares what it needs in `manifest.permissions.capabilities`, the admin accepts each @@ -1347,7 +1378,7 @@ on a configuration that was pushed by an older GZAC (before the capability syste treats `gzac_api` as implicitly granted (backward compatibility). Once the GZAC is upgraded and re-pushes configurations with explicit `grantedCapabilities`, the host enforces the explicit list. -### 18.5 Capability: `gzac_api` (existing host function, capability gate ⛔) +### 18.5 Capability: `gzac_api` (host function + capability gate ✅) Already implemented as a host function (§3.5, `host-functions/gzac-api.ts`). The capability gate is the only addition: the host function checks `grantedCapabilities` before making the upstream @@ -1803,6 +1834,122 @@ every manifest-declared capability is present (§18.2). - Reconfigure without `http_request` → the `/external-data` handler returns a capability-denied error, the card shows the error state. +## 19. Building-block process links ✅ + +External plugin actions are usable inside building-block processes with the same abstraction the +embedded plugin system uses: a link is either a **`FIXED`** reference (a concrete configuration +UUID) or a **`BUILDING_BLOCK`** reference (an abstract `pluginId@version`, resolved to a concrete +configuration per usage context at runtime). + +**Reference model.** Both external link types embed the same `PluginConfigurationReference` +embeddable the embedded `PluginProcessLink` maps — shared `process_link` columns `reference_type`, +`plugin_definition_key` (= `pluginId`), and `plugin_definition_version` (external-only; embedded +rows keep it null because embedded definitions are unversioned). Two STI siblings mapping the same +embeddable columns is verified safe by `PluginConfigurationReferenceStiSpikeTest` +(`:backend:plugin`). The reference is **design-time metadata**: it drives validation, UI warnings, +and the import chooser (§20), while the runtime invocation always derives `pluginId` **and** +version from the resolved configuration's definition — a v1 action key can never be invoked +against a v2 configuration's token. Invariants (`ExternalPluginProcessLinkMapper`): `FIXED` ⇒ +config id required (null only for a dangling import, §20), key/version derived from the +configuration at save time; `BUILDING_BLOCK` ⇒ config id null, key + version required from the +DTO. + +**Runtime resolution (`ExternalPluginServiceTaskStartListener`).** A `BUILDING_BLOCK` reference is +resolved through the same optional `BuildingBlockPluginConfigurationResolver` SPI +(`backend/plugin`) the embedded `PluginService` uses — the building-block module's resolver walks +execution → `BuildingBlockInstance` → root instance → the call-activity's or case-definition +link's `pluginConfigurationMappings`. External entries share that one `Map` under +the namespaced key **`external-plugin:@`**, so embedded and external mappings +coexist without collision and version pinning stays strict (a block referencing two versions of +one plugin simply has two mapping rows). The listener validates the resolved configuration: +exists, definition `pluginId` matches the reference key, and the definition manifest still +declares the action key; a version mismatch proceeds on the resolved configuration's version with +a warning. + +**Required-plugins surface.** `BuildingBlockPluginDefinitionService` / +`GET /api/management/v1/building-block/{key}/version/{versionTag}/plugin` also collect the +external `BUILDING_BLOCK` references from the block's process links (recursively through nested +blocks), returned with a `source: embedded|external` discriminator plus pluginId/version. + +**Admin UX** (`@valtimo/process-link`). In building-block context the plugin picker +(`select-plugin-configuration`) lists external plugin **definitions** alongside embedded ones +(labels via `getExternalPluginDisplayName`, `Name (X.Y.Z)`); saving an external action in that +context writes a `BUILDING_BLOCK` reference (no configuration id). The call-activity / +case-definition mapping step (`configure-building-block-plugins`) renders a row per external +requirement with a dropdown of activated configurations — exact `pluginId@version` matches by +default, other versions of the same plugin selectable behind a non-blocking warning (the §11 +compatibility-warning pattern) — and writes the namespaced key. Delete guards are unaffected: +`ExternalPluginHostUsageResolver` ignores links with a null configuration id, mirroring the +embedded rule that only fixed references block deletion. + +## 20. Case-definition import/export parity ✅ + +Importing a case definition treats external plugin configurations exactly like embedded ones: the +import wizard shows every referenced configuration, the admin maps each to an existing +configuration in the target environment, unmapped references import as dangling and are repaired +later. + +- **Export metadata.** `ExternalPluginProcessLinkExportResponseDto` / + `ExternalPluginTaskFormProcessLinkExportResponseDto` carry `referenceType`, + `pluginDefinitionKey`, and the version alongside the configuration id, so the target environment + can list candidate configurations by `pluginId`. +- **Remap hook.** `ProcessLinkImporter` delegates configuration remapping per link type: each + `ProcessLinkMapper` implements `applyPluginConfigurationMappings(node, mappings)` (the embedded + mapper rewrites `pluginConfigurationId`; both external mappers rewrite + `externalPluginConfigurationId`). One user-facing `Map` covers both + plugin systems — source UUIDs are unambiguous across them. +- **Preview.** `ExternalPluginImportPreviewContributor` (`backend/external-plugin/preview/`) scans + `*.process-link.json` for `external_plugin` / `external_plugin_task_form` links **and** + `*.case-tab.json` for `EXTERNAL_PLUGIN` tabs, emitting entries with `source: external`, + pluginId, version, and an existence check. `PluginConfigurationPreviewDto` carries the + `source: embedded|external` discriminator (default `embedded`). +- **Dangling handling.** An unmapped external link imports with a null configuration id; both + external mappers' `afterImport` publish a `CaseConfigurationIssueDetectedEvent` + (`external-plugin-process-link`). `ExternalPluginConfigurationMappingResolver` registers as an + additional `PluginConfigurationMappingResolver` bean behind the existing + `dangling-plugin-configurations` / `plugin-configuration-mappings` endpoints — no new endpoints. +- **`EXTERNAL_PLUGIN` case-tab import.** `CaseTabImporter` remaps the configuration UUID embedded + in the tab's `contentKey` (`"[:]"`) through the same mappings and creates + the `case_external_plugin_tab` side row for imported tabs (the REST-driven create path derives + the side row from `CaseTabCreatedEvent`, which a repository-level import does not fire — the + importer therefore materialises it itself). +- **Wizard & repair UI** (`@valtimo/case-management`). The upload wizard's PLUGINS step and the + missing-plugin-configurations repair component render external rows with options from + `ExternalPluginService.getConfigurations()`, exact-version matches by default and + version-mismatched candidates behind an explicit warning, labelled `Name (X.Y.Z)`. + +## 21. Action result write-back ✅ + +A plugin action's result can be written declaratively to the case/building-block document or to +process variables through value-resolver expressions — for embedded **and** external plugin +actions, with no hand-coded `resultProcessVariable` action property needed. + +- **Model.** `process_link.action_result_mappings` (json), mapped by both `PluginProcessLink` and + `ExternalPluginProcessLink`; rows are `PluginActionResultMapping(source, target)` where `source` + is an RFC 6901 JSON pointer into the action result (empty pointer = whole result) and `target` + is a writable value-resolver key (`doc:`, `pv:`, `case:` — non-writable prefixes are rejected at + save time by `PluginActionResultMappingValidator` in both mappers). +- **Dispatch (`PluginActionResultHandler`, `backend/plugin`).** Extracts each source pointer, + splits `pv:` targets from document targets, and applies them via + `ValueResolverService.handleValues` — the same pattern the building-block listeners use. Because + `doc:` targets the execution's business-key document, a result written inside a building-block + process lands on the **BB instance document** and BB output mappings carry it to the case as + usual. Pointer misses and a null result with configured mappings log a warning; they never fail + the process. +- **Embedded source.** `PluginService.invoke` (both overloads) captures the `@PluginAction` + method's return value, serialises it, and delegates to the handler, so + every listener (service task, user task, call activity, send/receive/intermediate events) is + covered without listener changes. +- **External source.** The action output's optional `result` field (§7): + `ExternalPluginServiceTaskStartListener` keeps applying `variables` as plain process variables + and additionally feeds `body.result` through the handler. SDK `ActionOutput` declares `result`; + the host passes it through verbatim; the `case-summary` sample action returns one. +- **Admin UX** (`@valtimo/process-link`). `PluginActionResultMappingsComponent` — an "Output + mapping" section in the action-configuration step for both embedded and external actions: + source/target rows with a free-text JSON-pointer input and a `ValuePathSelectorComponent` target + (`doc:`/`case:` browsing with case/BB context; free-text `pv:` fallback for independent + processes). + --- ## Implementation status (§18) From 9ed4286f3e6ee372fece4b28ab0086322b8701ad Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Fri, 24 Jul 2026 09:49:06 +0200 Subject: [PATCH 2/7] Enforce manifest outputs contract on action results and treat null as a value --- .../ExternalPluginServiceTaskStartListener.kt | 50 +++++++++++- ...ernalPluginServiceTaskStartListenerTest.kt | 69 +++++++++++++++++ .../service/PluginActionResultHandler.kt | 11 ++- .../service/PluginActionResultHandlerTest.kt | 4 +- plugin-host/app/package-lock.json | 77 ++++++++++++------- .../app/src/routes/plugin-actions.test.ts | 37 +++++++++ plugin-host/app/src/routes/plugin-actions.ts | 31 ++++++++ plugin-host/plugin-sdk/src/models/types.ts | 9 ++- plugin-host/plugin-sdk/src/runtime.test.ts | 69 +++++++++++++++++ plugin-host/plugin-sdk/src/runtime.ts | 23 +++++- .../sample-plugins/case-summary/README.md | 6 +- .../case-summary/package-lock.json | 5 +- .../sample-plugins/case-summary/src/plugin.ts | 8 +- 13 files changed, 354 insertions(+), 45 deletions(-) create mode 100644 plugin-host/plugin-sdk/src/runtime.test.ts diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt index f232e359b1..b725726706 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt @@ -95,11 +95,59 @@ class ExternalPluginServiceTaskStartListener( ) when { - response.status in 200..299 -> applySuccess(execution, processLink, response.body) + response.status in 200..299 -> { + validateDeclaredOutputs(definition, processLink, response.body) + applySuccess(execution, processLink, response.body) + } + else -> throw actionFailed(response, definition, processLink) } } + /** + * Enforces the manifest's result contract at runtime: when the resolved definition's manifest + * declares `outputs` for the invoked action, the response's `result` must contain every + * declared key. A key may hold JSON null — null is a legitimate value — but an absent key means + * the plugin broke its own contract (typically a value dropped during serialization, e.g. an + * `undefined` in a JS plugin), which would otherwise surface only as a silently skipped result + * mapping. Validated before anything (variables or mappings) is applied, so a contract + * violation fails the invocation without partial writes. + */ + private fun validateDeclaredOutputs( + definition: ExternalPluginDefinition, + processLink: ExternalPluginProcessLink, + body: JsonNode?, + ) { + val declaredOutputs = declaredOutputs(definition, processLink.actionKey) + if (declaredOutputs.isEmpty()) { + return + } + + val result = body?.get("result") + val missingKeys = if (result != null && result.isObject) { + declaredOutputs.filterNot { result.has(it) } + } else { + declaredOutputs + } + if (missingKeys.isNotEmpty()) { + val message = "External plugin '${definition.pluginId}@${definition.version}' action " + + "'${processLink.actionKey}' declares outputs $declaredOutputs in its manifest, but its " + + "result is missing key(s) $missingKeys. Every declared output must be returned; " + + "returning null for a key is allowed." + logger.warn { message } + throw ExternalPluginActionFailedException("RESULT_CONTRACT_VIOLATION", message) + } + } + + private fun declaredOutputs(definition: ExternalPluginDefinition, actionKey: String): List { + val actions = definition.manifestJson?.get("actions") ?: return emptyList() + if (!actions.isArray) return emptyList() + val action = actions.firstOrNull { it.get("key")?.asText() == actionKey } ?: return emptyList() + val outputs = action.get("outputs") ?: return emptyList() + if (!outputs.isArray) return emptyList() + return outputs.mapNotNull { if (it.isTextual) it.asText() else null } + } + /** * `FIXED` links carry the configuration id directly. `BUILDING_BLOCK` links carry a * `pluginId`/version pair (design-time metadata, D1) and are resolved through the shared diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt index e483e124c0..1b51622904 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt @@ -291,6 +291,75 @@ class ExternalPluginServiceTaskStartListenerTest { .hasMessageContaining("does not declare") } + @Test + fun `a result missing a manifest-declared output key fails with RESULT_CONTRACT_VIOLATION`() { + stubDefinitionWithDeclaredOutputs("summary", "title") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 200, + body = objectMapper.readTree("""{"result":{"summary":"a summary"}}"""), + ), + ) + + assertThatThrownBy { listener.notify(globalProcessServiceTaskEvent()) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .hasMessageContaining("title") + .hasMessageContaining("declares outputs") + + val exception = runCatching { listener.notify(globalProcessServiceTaskEvent()) }.exceptionOrNull() + assertThat((exception as ExternalPluginActionFailedException).errorCode) + .isEqualTo("RESULT_CONTRACT_VIOLATION") + verify(pluginActionResultHandler, never()).handle(any(), any(), any()) + } + + @Test + fun `a response without a result object fails when the manifest declares outputs`() { + stubDefinitionWithDeclaredOutputs("summary") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + assertThatThrownBy { listener.notify(globalProcessServiceTaskEvent()) } + .isInstanceOf(ExternalPluginActionFailedException::class.java) + .hasMessageContaining("summary") + } + + @Test + fun `declared output keys returned as null pass validation`() { + stubDefinitionWithDeclaredOutputs("summary", "title") + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse( + status = 200, + body = objectMapper.readTree("""{"result":{"summary":null,"title":null}}"""), + ), + ) + + listener.notify(globalProcessServiceTaskEvent()) + } + + @Test + fun `a definition without declared outputs skips result validation`() { + // setUp's definition has no manifestJson at all — a 200 without any result must pass. + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listener.notify(globalProcessServiceTaskEvent()) + } + + private fun stubDefinitionWithDeclaredOutputs(vararg outputs: String) { + val outputsJson = outputs.joinToString(",") { "\"$it\"" } + val definition = mock { + on { this.hostId } doReturn hostId + on { pluginId } doReturn "case-summary" + on { version } doReturn "0.1.0" + on { manifestJson } doReturn objectMapper.readTree( + """{"actions":[{"key":"case-summary","outputs":[$outputsJson]}]}""", + ) as com.fasterxml.jackson.databind.node.ObjectNode + } + whenever(definitionService.get(definitionId)).thenReturn(definition) + } + @Test fun `BUILDING_BLOCK reference without a resolver bean throws a clear error`() { // listener from setUp() was constructed with a null resolver (the default) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt index 702262b04f..6493eb5953 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/PluginActionResultHandler.kt @@ -63,18 +63,17 @@ class PluginActionResultHandler( val valuesToHandle = mappings.mapNotNull { mapping -> val pointer = mapping.source.ifBlank { "" } val node = result.at(pointer) - if (node.isMissingNode || node.isNull) { - // A null result value is skipped like a missing one: writing JSON null would wipe - // existing data and fails schema validation on non-nullable document fields. + if (node.isMissingNode) { logger.warn { - "Plugin action result mapping source pointer '${mapping.source}' was " + - (if (node.isNull) "null" else "absent") + - " in the action result for activity '${execution.currentActivityId}' " + + "Plugin action result mapping source pointer '${mapping.source}' was absent " + + "in the action result for activity '${execution.currentActivityId}' " + "of process instance '${execution.processInstanceId}' — target " + "'${mapping.target}' was not written." } return@mapNotNull null } + // JSON null is a value, not an absence: the plugin explicitly returned null for this + // key, so it is written through to the target (which may clear an existing value). mapping.target to objectMapper.treeToValue(node, Any::class.java) }.toMap() diff --git a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt index 9ae491b6a4..b40688b230 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/plugin/service/PluginActionResultHandlerTest.kt @@ -125,7 +125,7 @@ class PluginActionResultHandlerTest { } @Test - fun `skips null result values but still writes the non-null ones`() { + fun `writes null result values through to the target instead of skipping them`() { val result = MapperSingleton.get().readTree("""{"remarks": null, "decision": "APPROVED"}""") val businessKey = UUID.randomUUID() whenever(execution.processBusinessKey).thenReturn(businessKey.toString()) @@ -141,7 +141,7 @@ class PluginActionResultHandlerTest { verify(valueResolverService).handleValues( businessKey, - mapOf("doc:/approvalDecision" to "APPROVED") + mapOf("doc:/reviewerRemarks" to null, "doc:/approvalDecision" to "APPROVED") ) } diff --git a/plugin-host/app/package-lock.json b/plugin-host/app/package-lock.json index 0fb48c53d5..7eb004dc9a 100644 --- a/plugin-host/app/package-lock.json +++ b/plugin-host/app/package-lock.json @@ -4136,6 +4136,29 @@ "undici": "^5.29.0" } }, + "node_modules/testcontainers/node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/testcontainers/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -4271,15 +4294,12 @@ } }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "dev": true, - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", "engines": { - "node": ">=14.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -4288,15 +4308,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true }, - "node_modules/undici/node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5137,20 +5148,32 @@ "node": ">=12" } }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=8" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", diff --git a/plugin-host/app/src/routes/plugin-actions.test.ts b/plugin-host/app/src/routes/plugin-actions.test.ts index 42f8929e10..847128aa12 100644 --- a/plugin-host/app/src/routes/plugin-actions.test.ts +++ b/plugin-host/app/src/routes/plugin-actions.test.ts @@ -109,6 +109,43 @@ describe("plugin-actions routes", () => { expect(res.json()).toMatchObject({ status: "error", errorCode: "BOOM" }); }); + it("returns 500 RESULT_CONTRACT_VIOLATION when a declared output key is absent from the result", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary", "title"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ + status: "completed", + result: { summary: "a summary" }, + }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ status: "error", errorCode: "RESULT_CONTRACT_VIOLATION" }); + expect(res.json().errorMessage).toContain("title"); + }); + + it("returns 500 RESULT_CONTRACT_VIOLATION when outputs are declared but there is no result object", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ status: "completed", variables: { done: true } }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ errorCode: "RESULT_CONTRACT_VIOLATION" }); + }); + + it("accepts declared output keys returned as null", async () => { + pluginManager.getManifest.mockReturnValue({ + actions: [{ key: ACTION, outputs: ["summary", "title"] }], + }); + pluginManager.callAction.mockResolvedValueOnce({ + status: "completed", + result: { summary: null, title: null }, + }); + const res = await invokeAction(actionBody()); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ result: { summary: null, title: null } }); + }); + it("returns 500 when callAction throws (host/infrastructure error)", async () => { pluginManager.callAction.mockRejectedValueOnce(new Error("wasm crash")); const res = await invokeAction(actionBody()); diff --git a/plugin-host/app/src/routes/plugin-actions.ts b/plugin-host/app/src/routes/plugin-actions.ts index 9cd05fd808..6954c8106e 100644 --- a/plugin-host/app/src/routes/plugin-actions.ts +++ b/plugin-host/app/src/routes/plugin-actions.ts @@ -165,6 +165,37 @@ export async function pluginActionRoutes( return; } + // Enforce the manifest's result contract: every key the action declares under `outputs` + // must be present on the result object. JSON null is a valid value — only an absent key + // is a violation (typically a value dropped during serialization, e.g. `undefined` in a + // JS plugin), which GZAC would otherwise skip silently when applying result mappings. + const declaredOutputs = actionDef.outputs ?? []; + if (declaredOutputs.length > 0) { + const resultValue = result.result; + const resultObject = + resultValue && typeof resultValue === "object" && !Array.isArray(resultValue) + ? (resultValue as Record) + : undefined; + const missing = resultObject + ? declaredOutputs.filter((key) => !(key in resultObject)) + : declaredOutputs; + if (missing.length > 0) { + request.log.error( + { pluginId, version, actionKey, declaredOutputs, missing }, + "Action result violates the manifest outputs contract" + ); + reply.code(500).send({ + status: "error", + errorCode: "RESULT_CONTRACT_VIOLATION", + errorMessage: + `Action '${actionKey}' declares outputs [${declaredOutputs.join(", ")}] in its ` + + `manifest, but its result is missing: [${missing.join(", ")}]. Every declared ` + + `output must be returned; returning null for a key is allowed.`, + }); + return; + } + } + reply.code(200).send(result); } catch (err) { request.log.error( diff --git a/plugin-host/plugin-sdk/src/models/types.ts b/plugin-host/plugin-sdk/src/models/types.ts index 4f282b30b5..f31657084e 100644 --- a/plugin-host/plugin-sdk/src/models/types.ts +++ b/plugin-host/plugin-sdk/src/models/types.ts @@ -28,7 +28,9 @@ export interface ActionInput { * `variables` is applied as process variables, unchanged from before. `result` is a separate, * optional channel: an arbitrary JSON payload that GZAC's `action_result_mappings` (JSON-pointer → * `doc:`/`pv:`/`case:` target) resolve against, entirely independent of `variables`. Plugins that - * don't set `result` are unaffected — there is nothing to map. + * don't set `result` are unaffected — there is nothing to map. Actions that declare + * {@link ManifestAction.outputs} must return a `result` containing every declared key (`null` + * values are allowed; absent keys fail the invocation). */ export interface ActionOutput { status: "completed" | "error"; @@ -139,6 +141,11 @@ export interface ManifestAction { * admin map these keys to `doc:`/`pv:`/`case:` targets, with the source restricted to this * declared set (a dropdown instead of free-text JSON pointers). Actions without `outputs` (or an * empty array) have no declared shape and cannot use result mapping. + * + * Declaring `outputs` is a runtime contract: a completed action's `result` must contain every + * declared key — the host (and GZAC) reject a result with missing keys — but a key's value may + * be `null`. The runtime serialises `undefined` values on the result object as `null`, so + * `result: {title}` keeps the `title` key even when the lookup found nothing. */ outputs?: string[]; } diff --git a/plugin-host/plugin-sdk/src/runtime.test.ts b/plugin-host/plugin-sdk/src/runtime.test.ts new file mode 100644 index 0000000000..ec9062aa99 --- /dev/null +++ b/plugin-host/plugin-sdk/src/runtime.test.ts @@ -0,0 +1,69 @@ +/* + * 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. + */ + +import {describe, expect, it} from "vitest"; +import {action} from "./actions"; +import {handleAction} from "./runtime"; + +const actionInput = (actionKey: string) => + JSON.stringify({ + actionKey, + configurationId: "cfg-1", + configuration: {}, + processInstanceId: "pi-1", + documentId: "doc-1", + activityId: "act-1", + properties: {}, + }); + +describe("handleAction result serialization", () => { + it("serializes undefined result values as explicit null so declared output keys stay on the wire", () => { + // `JSON.stringify` drops keys whose value is `undefined`; the manifest outputs contract + // requires every declared key to be present (null allowed), so the runtime must normalise. + action("undefined-result-values", () => ({ + status: "completed", + result: { summary: "a summary", title: undefined, amount: null }, + })); + + const output = JSON.parse(handleAction(actionInput("undefined-result-values"))); + + expect(output).toEqual({ + status: "completed", + result: { summary: "a summary", title: null, amount: null }, + }); + }); + + it("leaves variables untouched — only the result channel is normalised", () => { + action("undefined-variable-values", () => ({ + status: "completed", + variables: { kept: 1, dropped: undefined }, + result: { key: undefined }, + })); + + const output = JSON.parse(handleAction(actionInput("undefined-variable-values"))); + + expect(output.variables).toEqual({ kept: 1 }); + expect(output.result).toEqual({ key: null }); + }); + + it("passes non-object results through unchanged", () => { + action("scalar-result", () => ({ status: "completed", result: "just a string" })); + + const output = JSON.parse(handleAction(actionInput("scalar-result"))); + + expect(output).toEqual({ status: "completed", result: "just a string" }); + }); +}); diff --git a/plugin-host/plugin-sdk/src/runtime.ts b/plugin-host/plugin-sdk/src/runtime.ts index fc1a3101ba..ac49f92012 100644 --- a/plugin-host/plugin-sdk/src/runtime.ts +++ b/plugin-host/plugin-sdk/src/runtime.ts @@ -102,10 +102,10 @@ export function handleAction(inputJson: string): string { if (rejected) { throw rejection instanceof Error ? rejection : new Error(String(rejection)); } - return JSON.stringify(resolved); + return serializeActionOutput(resolved as ActionOutput); } - return JSON.stringify(result); + return serializeActionOutput(result as ActionOutput); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); log.error(`Action execution failed: ${message}`); @@ -117,6 +117,25 @@ export function handleAction(inputJson: string): string { } } +/** + * JSON has no `undefined`: `JSON.stringify` silently drops object keys whose value is `undefined`, + * which would turn a returned-but-empty output (e.g. `result: {title}` where `title` is undefined) + * into an absent key and violate the manifest's `outputs` contract — every declared output key must + * be present, though null is allowed. Normalise the top-level `result` channel so a key the author + * put on the object survives serialization as an explicit null. + */ +function serializeActionOutput(output: ActionOutput): string { + const result = output?.result; + if (result && typeof result === "object" && !Array.isArray(result)) { + const normalized: Record = {}; + for (const [key, value] of Object.entries(result as Record)) { + normalized[key] = value === undefined ? null : value; + } + return JSON.stringify({ ...output, result: normalized }); + } + return JSON.stringify(output); +} + /** * Called by the Plugin Host for event delivery. * Input: JSON string with EventInput shape. diff --git a/plugin-host/sample-plugins/case-summary/README.md b/plugin-host/sample-plugins/case-summary/README.md index 50e1886b3c..99b3525863 100644 --- a/plugin-host/sample-plugins/case-summary/README.md +++ b/plugin-host/sample-plugins/case-summary/README.md @@ -16,8 +16,10 @@ Registers a single `case-summary` action that: action declares these keys in `manifest.json` (`actions[].outputs`), which is what makes the dedicated "Output mapping" step appear in the process-link stepper at all — its source field is a dropdown of the declared keys (`summary`, `title`, `amount`, `currency`), not a free-text JSON - pointer. This demonstrates the action-result write-back feature end-to-end; see "Manual Test Path" - below. + pointer. Declaring `outputs` is a runtime contract: every declared key must be present on the + returned `result` (the host and GZAC both reject a result with missing keys), but a key's value + may be `null` — which the mapping writes through to the target. This demonstrates the + action-result write-back feature end-to-end; see "Manual Test Path" below. This is the second sample plugin alongside `say-hello`. Where `say-hello` only uses static configuration and BPMN action properties, `case-summary` exercises the end-to-end host-function callback path. diff --git a/plugin-host/sample-plugins/case-summary/package-lock.json b/plugin-host/sample-plugins/case-summary/package-lock.json index 961b749a26..442593cfc9 100644 --- a/plugin-host/sample-plugins/case-summary/package-lock.json +++ b/plugin-host/sample-plugins/case-summary/package-lock.json @@ -53,7 +53,10 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.4.0" + "@vitest/coverage-v8": "^3.2.0", + "happy-dom": "^15.0.0", + "typescript": "^5.4.0", + "vitest": "^3.2.0" }, "peerDependencies": { "@extism/js-pdk": "^1.1.0" diff --git a/plugin-host/sample-plugins/case-summary/src/plugin.ts b/plugin-host/sample-plugins/case-summary/src/plugin.ts index 8b8525f2ea..2561f3aac7 100644 --- a/plugin-host/sample-plugins/case-summary/src/plugin.ts +++ b/plugin-host/sample-plugins/case-summary/src/plugin.ts @@ -284,11 +284,13 @@ action("case-summary", (input: ActionInput) => { [definitionKeyVariable]: document.definitionId?.name, }, // `result` is a separate channel from `variables`, consumed only by the process link's - // configured `actionResultMappings` (e.g. mapping "/summary" to a `doc:` path). Demonstrates - // the action-result write-back feature independently of the existing process-variable output. + // configured `actionResultMappings` (e.g. mapping "/summary" to a `doc:` path). Every key + // declared under the action's `outputs` in manifest.json must be present here — `?? null` + // keeps a key on the wire when the lookup found nothing (undefined would be dropped by JSON + // serialization and violate the outputs contract). result: { summary, - title, + title: title ?? null, amount: amount ?? null, currency, }, From 215535692d26fc07f087eb81a6300e5f9f7cc453 Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Fri, 24 Jul 2026 10:13:05 +0200 Subject: [PATCH 3/7] fix failing tests --- .../ExternalPluginProcessLinkImportIntTest.kt | 9 +++------ .../src/test/resources/config/application.yml | 9 +++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt index fe06fc89c7..139c4294df 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkImportIntTest.kt @@ -142,10 +142,11 @@ class ExternalPluginProcessLinkImportIntTest @Autowired constructor( } private fun fixture(externalPluginConfigurationId: UUID): String { + // processDefinitionId is intentionally absent — ProcessLinkImporter resolves the latest + // deployed definition for the file's key and sets it on the node itself. return """ [ { - "processDefinitionId": "will-be-overwritten", "activityId": "my-service-task", "activityType": "bpmn:ServiceTask:start", "processLinkType": "external_plugin", @@ -155,11 +156,7 @@ class ExternalPluginProcessLinkImportIntTest @Autowired constructor( "actionResultMappings": [] } ] - """.trimIndent().let { - // processDefinitionId is set by the importer itself when absent from the node; - // drop the placeholder so ProcessLinkImporter fills it in. - it.replace(""""processDefinitionId": "will-be-overwritten",\n """, "") - } + """.trimIndent() } private companion object { diff --git a/backend/external-plugin/src/test/resources/config/application.yml b/backend/external-plugin/src/test/resources/config/application.yml index 2fc83df815..7a4102bdd2 100644 --- a/backend/external-plugin/src/test/resources/config/application.yml +++ b/backend/external-plugin/src/test/resources/config/application.yml @@ -1,4 +1,13 @@ spring: + # Several modules are on the test classpath only so EndpointDescriptionCoverageTest can scan + # their controllers, but their auto-configurations cannot start in this module's IT context: + # mandrill re-registers the mail JPA repositories that MailAutoConfiguration already provides + # (a duplicate the strict, no-override test context rejects), and the Exact plugin requires + # exact.baseUrl/exact.redirectUrl properties this module doesn't define. + autoconfigure: + exclude: + - com.ritense.mail.autoconfigure.MandrillMailAutoConfiguration + - com.ritense.exact.config.ExactPluginAutoConfiguration datasource: type: com.zaxxer.hikari.HikariDataSource liquibase: From f28285a31bfd1149d2289e4a4e945710bbdebd2e Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Fri, 24 Jul 2026 11:11:35 +0200 Subject: [PATCH 4/7] fix import error --- .../ExternalPluginAutoConfiguration.kt | 4 +- .../ExternalPluginImportPreviewContributor.kt | 15 +- ...ernalPluginImportPreviewContributorTest.kt | 63 ++++++- ...ginProcessLinkExportImportRoundTripTest.kt | 171 ++++++++++++++++++ .../case-management-upload.component.ts | 13 +- 5 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt index 6bdf837aab..e4da7df2ac 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -473,7 +473,9 @@ class ExternalPluginAutoConfiguration { fun externalPluginImportPreviewContributor( objectMapper: ObjectMapper, configurationRepository: ExternalPluginConfigurationRepository, - ): ImportPreviewContributor = ExternalPluginImportPreviewContributor(objectMapper, configurationRepository) + definitionRepository: ExternalPluginDefinitionRepository, + ): ImportPreviewContributor = + ExternalPluginImportPreviewContributor(objectMapper, configurationRepository, definitionRepository) @Bean @ConditionalOnMissingBean(ExternalPluginConfigurationMappingResolver::class) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt index bb4ca11448..ed00bce172 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt @@ -19,6 +19,7 @@ package com.ritense.externalplugin.preview import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ArrayNode import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository import com.ritense.valtimo.contract.importer.ImportPreviewContribution import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL import com.ritense.valtimo.contract.importer.ImportPreviewContributor @@ -34,6 +35,7 @@ import java.util.UUID class ExternalPluginImportPreviewContributor( private val objectMapper: ObjectMapper, private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, ) : ImportPreviewContributor { override fun contributePreview(zipEntries: Map): List { @@ -107,16 +109,23 @@ class ExternalPluginImportPreviewContributor( val configId = contentKey.substringBefore(':').toUuidOrNull() ?: continue val tabKey = node.path("key").asText(fileName) + // The tab's contentKey only carries the config id; resolve the plugin key/version + // through the configuration when it exists locally. Without a pluginDefinitionKey the + // import wizard cannot offer the row for mapping at all (it filters key-less rows), so + // an unresolvable configuration stays a key-less "unidentifiable" row as before. + val configuration = configurationRepository.findById(configId).orElse(null) + val definition = configuration?.let { definitionRepository.findById(it.definitionId).orElse(null) } + result.add( ImportPreviewContribution( pluginConfigurationId = configId, - pluginDefinitionKey = null, + pluginDefinitionKey = definition?.pluginId, pluginActionDefinitionKey = "case-tab", processDefinitionKey = fileName, activityId = tabKey, - existsInTargetEnvironment = configurationRepository.existsById(configId), + existsInTargetEnvironment = configuration != null, source = SOURCE_EXTERNAL, - pluginDefinitionVersion = null, + pluginDefinitionVersion = definition?.version, ) ) } diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt index 507f5a64f2..073de2703a 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt @@ -17,24 +17,32 @@ package com.ritense.externalplugin.preview import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository import com.ritense.valtimo.contract.importer.ImportPreviewContribution.Companion.SOURCE_EXTERNAL import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.time.Instant +import java.util.Optional import java.util.UUID class ExternalPluginImportPreviewContributorTest { private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository private lateinit var contributor: ExternalPluginImportPreviewContributor @BeforeEach fun setUp() { configurationRepository = mock() - contributor = ExternalPluginImportPreviewContributor(ObjectMapper(), configurationRepository) + definitionRepository = mock() + contributor = ExternalPluginImportPreviewContributor(ObjectMapper(), configurationRepository, definitionRepository) } @Test @@ -144,9 +152,31 @@ class ExternalPluginImportPreviewContributorTest { } @Test - fun `contributes an entry for an EXTERNAL_PLUGIN case tab and checks existence`() { + fun `contributes an entry for an EXTERNAL_PLUGIN case tab with the resolved plugin key and version`() { val configId = UUID.randomUUID() - whenever(configurationRepository.existsById(configId)).thenReturn(true) + val definitionId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + createdAt = Instant.now(), + ) + ) + ) + whenever(definitionRepository.findById(definitionId)).thenReturn( + Optional.of( + ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + ) val json = """ [ @@ -166,10 +196,37 @@ class ExternalPluginImportPreviewContributorTest { assertThat(result).hasSize(1) val entry = result.single() assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) assertThat(entry.existsInTargetEnvironment).isTrue() } + @Test + fun `an EXTERNAL_PLUGIN case tab whose configuration is unknown stays a key-less entry`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + assertThat(result.single().pluginDefinitionKey).isNull() + assertThat(result.single().existsInTargetEnvironment).isFalse() + } + @Test fun `ignores non-EXTERNAL_PLUGIN case tabs`() { val json = """ diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt new file mode 100644 index 0000000000..800334462e --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginProcessLinkExportImportRoundTripTest.kt @@ -0,0 +1,171 @@ +/* + * 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.externalplugin.processlink + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.node.TextNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.externalplugin.preview.ExternalPluginImportPreviewContributor +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReference +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.processlink.autodeployment.ProcessLinkDeployDto +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.json.MapperSingleton +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.Instant +import java.util.Optional +import java.util.UUID + +/** + * Round-trips a `FIXED` external plugin process link through the same steps the case + * export/import wizard performs — export DTO → JSON → import preview → deploy DTO with a + * configuration mapping → new link → export DTO → JSON → import preview — pinning that a link + * created *by import* keeps surfacing in the next import's configuration-matching step. + */ +class ExternalPluginProcessLinkExportImportRoundTripTest { + + private val objectMapper = MapperSingleton.get() + private lateinit var configurationRepository: ExternalPluginConfigurationRepository + private lateinit var definitionRepository: ExternalPluginDefinitionRepository + private lateinit var mapper: ExternalPluginProcessLinkMapper + private lateinit var contributor: ExternalPluginImportPreviewContributor + + private val sourceConfigId = UUID.randomUUID() + private val targetConfigId = UUID.randomUUID() + private val definitionId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + configurationRepository = mock() + definitionRepository = mock() + mapper = ExternalPluginProcessLinkMapper( + objectMapper, + configurationRepository, + definitionRepository, + mock(), + ) + contributor = ExternalPluginImportPreviewContributor(objectMapper, configurationRepository, definitionRepository) + + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + val targetConfiguration = ExternalPluginConfiguration( + id = targetConfigId, + definitionId = definitionId, + title = "Target configuration", + createdAt = Instant.now(), + ) + whenever(configurationRepository.findById(targetConfigId)).thenReturn(Optional.of(targetConfiguration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + whenever(configurationRepository.existsById(any())).thenReturn(true) + } + + @Test + fun `a link created by import with a remapped configuration surfaces in the next import preview`() { + // A link as configured through the UI (export #1's source). + val originalLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-v1", + activityId = "my-service-task", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = sourceConfigId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val export1 = export(originalLink) + val preview1 = preview("config/case/bezwaar/1-0-1/process-link/proc.process-link.json", export1) + assertThat(preview1).hasSize(1) + assertThat(preview1.single().pluginConfigurationId).isEqualTo(sourceConfigId) + + // Import export #1 the way ProcessLinkImporter does, with the wizard mapping the source + // configuration to the target configuration. + val importedLink = importLink(export1, mapOf(sourceConfigId to targetConfigId)) + assertThat(importedLink.externalPluginConfigurationId).isEqualTo(targetConfigId) + + // Export the imported case (#2) and run the next import's preview over it. + val export2 = export(importedLink) + val preview2 = preview("config/case/bezwaar-2/1-0-1/process-link/proc.process-link.json", export2) + assertThat(preview2).hasSize(1) + assertThat(preview2.single().pluginConfigurationId).isEqualTo(targetConfigId) + } + + @Test + fun `a link imported dangling (no mapping chosen) is absent from the next import preview`() { + val originalLink = ExternalPluginProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "process-def-v1", + activityId = "my-service-task", + activityType = ActivityTypeWithEventName.SERVICE_TASK_START, + externalPluginConfigurationId = sourceConfigId, + actionKey = "case-summary", + pluginConfigurationReference = PluginConfigurationReference( + type = PluginConfigurationReferenceType.FIXED, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val importedLink = importLink(export(originalLink), mapOf(sourceConfigId to null)) + assertThat(importedLink.externalPluginConfigurationId).isNull() + + // A dangling link has no configuration id to match, so the preview cannot offer a row. + val preview = preview("config/case/bezwaar-2/1-0-1/process-link/proc.process-link.json", export(importedLink)) + assertThat(preview).isEmpty() + } + + private fun export(link: ExternalPluginProcessLink): ByteArray = + objectMapper.writeValueAsBytes(listOf(mapper.toProcessLinkExportResponseDto(link))) + + private fun preview(fileName: String, content: ByteArray) = + contributor.contributePreview(mapOf(fileName to content)) + + /** The per-node steps of `ProcessLinkImporter.import`. */ + private fun importLink(exportedJson: ByteArray, mappings: Map): ExternalPluginProcessLink { + val tree = objectMapper.readTree(exportedJson.toString(Charsets.UTF_8)) as ArrayNode + val node = tree[0] as ObjectNode + if (!node.has("processDefinitionId")) { + node.set("processDefinitionId", TextNode.valueOf("process-def-v2")) + } + mapper.applyPluginConfigurationMappings(node, mappings) + val deployDto = objectMapper.treeToValue(node, ProcessLinkDeployDto::class.java) + val createDto = mapper.toProcessLinkCreateRequestDto(deployDto, null) + return mapper.toNewProcessLink(createDto, null) as ExternalPluginProcessLink + } +} diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts index bf58cdc39f..e0cfa8f95c 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-upload/case-management-upload.component.ts @@ -322,7 +322,11 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private loadPluginMappingRows(pluginConfigs: PluginConfigurationPreview[]): void { const uniqueById = new Map(); for (const config of pluginConfigs) { - if (!uniqueById.has(config.pluginConfigurationId)) { + // One row per configuration id, but never let a key-less contribution (e.g. an external + // plugin case tab) shadow a mappable one (a process link) for the same configuration — + // key-less rows are filtered out below, which would hide the mapping choice entirely. + const existing = uniqueById.get(config.pluginConfigurationId); + if (!existing || (existing.pluginDefinitionKey === null && config.pluginDefinitionKey !== null)) { uniqueById.set(config.pluginConfigurationId, config); } } @@ -627,11 +631,14 @@ export class CaseManagementUploadComponent implements OnInit, OnDestroy { private buildPluginConfigurationMappings(): Record { const mappings: Record = {}; for (const row of this.pluginMappingRows$.value) { + // Only rows the user could actually act on are sent. An explicit null tells the importer + // to clear the configuration id (a deliberate dangling import); omitting the key keeps the + // original id. Rows without a dropdown (plugin not installed / no configurations — possibly + // a transient lookup failure) must not silently clear ids: a cleared id disappears from the + // next export's import preview, making the mapping unrecoverable through the wizard. if (row.status === 'available') { const control = this.pluginMappingForm.get(row.sourcePluginConfigurationId); mappings[row.sourcePluginConfigurationId] = control?.value ?? null; - } else { - mappings[row.sourcePluginConfigurationId] = null; } } return mappings; From ebeb67a288071719fe5dc32a4ff304fdac11c9e7 Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Fri, 24 Jul 2026 13:02:14 +0200 Subject: [PATCH 5/7] fix stepper title --- .../src/lib/services/plugin-state.service.ts | 38 +++++++++++++------ .../lib/services/process-link-step.service.ts | 6 +++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts index eb3628aede..b127db008e 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/plugin-state.service.ts @@ -19,6 +19,7 @@ import {BehaviorSubject, combineLatest, Observable, of, Subject, switchMap} from import {catchError, map, take} from 'rxjs/operators'; import { ExternalPluginService, + getExternalPluginDisplayName, PluginConfiguration, PluginDefinition, PluginFunction, @@ -26,6 +27,7 @@ import { PluginService, toExternalPluginKey, } from '@valtimo/plugin'; +import {TranslateService} from '@ngx-translate/core'; import {ProcessLink} from '../models'; @Injectable({ @@ -43,7 +45,8 @@ export class PluginStateService { constructor( private readonly pluginManagementService: PluginManagementService, private readonly pluginService: PluginService, - private readonly externalPluginService: ExternalPluginService + private readonly externalPluginService: ExternalPluginService, + private readonly translateService: TranslateService ) {} get selectedPluginDefinition$(): Observable { @@ -222,35 +225,46 @@ export class PluginStateService { (!processLink.pluginVersion || d.version === processLink.pluginVersion) ); if (definition) { + // The manifest-translated display name rides along on the synthetic definition so the + // stepper can label the step — external plugins have no plugin-translation bundle. this._selectedPluginDefinition$.next({ key: toExternalPluginKey(definition.id), + title: getExternalPluginDisplayName(definition, this.translateService.currentLang), } as PluginDefinition); } }); return; } - // Fetch all external configurations to find the one matching this process link - this.externalPluginService - .getConfigurations() - .pipe( - take(1), - catchError(() => of([])) - ) - .subscribe(configs => { + // Fetch all external configurations and definitions to find the ones matching this process link + combineLatest([ + this.externalPluginService.getConfigurations().pipe(catchError(() => of([]))), + this.externalPluginService.getDefinitions().pipe(catchError(() => of([]))), + ]) + .pipe(take(1)) + .subscribe(([configs, definitions]) => { const config = configs.find(c => c.id === configId); if (!config) return; const definitionId = config.definitionId; const externalKey = toExternalPluginKey(definitionId); + const definition = definitions.find(d => d.id === definitionId); + const definitionTitle = definition + ? getExternalPluginDisplayName(definition, this.translateService.currentLang) + : undefined; - // Set synthetic plugin definition with the external: prefix key - this._selectedPluginDefinition$.next({key: externalKey} as PluginDefinition); + // Set synthetic plugin definition with the external: prefix key; the manifest-translated + // display name rides along so the stepper can label the step without a translation bundle. + this._selectedPluginDefinition$.next({ + key: externalKey, + title: definitionTitle, + } as PluginDefinition); // Set synthetic plugin configuration with the external config ID this._selectedPluginConfiguration$.next({ id: configId, - pluginDefinition: {key: externalKey}, + title: config.title, + pluginDefinition: {key: externalKey, title: definitionTitle}, } as PluginConfiguration); }); } diff --git a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts index a125b43b0f..80766bf051 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts @@ -544,6 +544,12 @@ export class ProcessLinkStepService { ): string { if (this._context === 'buildingBlock') { const definitionKey = selectedDefinition?.key || selectedConfiguration?.pluginDefinition?.key; + // External definitions have no plugin-translation bundle — looking one up would render the + // raw `external:.title` key. Their localized display name comes from the manifest and + // is carried on the (synthetic) definition's title. + if (isExternalPluginKey(definitionKey)) { + return selectedDefinition?.title || selectedConfiguration?.pluginDefinition?.title || ''; + } return definitionKey ? this.pluginTranslateService.instant('title', definitionKey) : ''; } return selectedConfiguration?.title || ''; From f2930ce75439fa3f5b8bf5d208d626caee6cc6ef Mon Sep 17 00:00:00 2001 From: Maxim Buur Date: Fri, 24 Jul 2026 16:03:11 +0200 Subject: [PATCH 6/7] final pr feedback --- .../BuildingBlockAutoConfiguration.kt | 14 ++ ...ltBuildingBlockPluginMappingUsageFinder.kt | 68 +++++++ .../BuildingBlockProcessLinkRepository.kt | 23 +++ ...ildingBlockPluginMappingUsageFinderTest.kt | 119 +++++++++++ .../ExternalPluginAutoConfiguration.kt | 2 + .../ExternalPluginProcessLinkRepository.kt | 21 ++ .../ExternalPluginHostUsageResolver.kt | 119 ++++++++++- .../ExternalPluginHostUsageResolverTest.kt | 184 +++++++++++++++++- .../BuildingBlockPluginMappingUsageFinder.kt | 51 +++++ .../plugin/web/rest/dto/PluginUsageDto.kt | 10 +- .../plugin-usage-modal.component.html | 14 +- .../plugin-usage-modal.component.ts | 9 +- .../src/lib/models/external-plugin.model.ts | 3 + ...gure-building-block-plugins.component.html | 23 ++- ...gure-building-block-plugins.component.scss | 12 +- .../lib/services/process-link-step.service.ts | 42 ++-- .../valtimo/shared/assets/core/en.json | 9 +- .../valtimo/shared/assets/core/nl.json | 9 +- 18 files changed, 676 insertions(+), 56 deletions(-) create mode 100644 backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt create mode 100644 backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt create mode 100644 backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt index cb71c9afe0..cad483793d 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/configuration/BuildingBlockAutoConfiguration.kt @@ -28,6 +28,8 @@ import com.ritense.buildingblock.processlink.mapper.BuildingBlockProcessLinkMapp import com.ritense.buildingblock.processlink.service.BuildingBlockCallActivityListener import com.ritense.buildingblock.processlink.service.BuildingBlockSupportedProcessLinksHandler import com.ritense.buildingblock.processlink.service.DefaultBuildingBlockPluginConfigurationResolver +import com.ritense.buildingblock.processlink.service.DefaultBuildingBlockPluginMappingUsageFinder +import com.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository import com.ritense.buildingblock.repository.BuildingBlockDefinitionArtworkRepository import com.ritense.buildingblock.repository.BuildingBlockDefinitionRepository import com.ritense.buildingblock.repository.BuildingBlockInstanceRepository @@ -98,6 +100,7 @@ import com.ritense.formflow.service.FormFlowService import com.ritense.importer.ImportService import com.ritense.importer.ValtimoImportService import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder import com.ritense.plugin.service.PluginService import com.ritense.processdocument.service.BuildingBlockProcessLookup import com.ritense.processdocument.service.ProcessDocumentAssociationService @@ -476,6 +479,17 @@ class BuildingBlockAutoConfiguration { documentService, ) + @Bean + @ConditionalOnMissingBean(BuildingBlockPluginMappingUsageFinder::class) + fun buildingBlockPluginMappingUsageFinder( + buildingBlockProcessLinkRepository: BuildingBlockProcessLinkRepository, + caseDefinitionBuildingBlockLinkRepository: CaseDefinitionBuildingBlockLinkRepository, + ): BuildingBlockPluginMappingUsageFinder = + DefaultBuildingBlockPluginMappingUsageFinder( + buildingBlockProcessLinkRepository, + caseDefinitionBuildingBlockLinkRepository, + ) + @Bean @ConditionalOnMissingBean(BuildingBlockCaseAssigneeListener::class) fun buildingBlockCaseAssigneeListener( diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt new file mode 100644 index 0000000000..f086333289 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinder.kt @@ -0,0 +1,68 @@ +/* + * 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.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.plugin.service.BuildingBlockPluginMappingUsage +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Component +@SkipComponentScan +@Transactional(readOnly = true) +class DefaultBuildingBlockPluginMappingUsageFinder( + private val processLinkRepository: BuildingBlockProcessLinkRepository, + private val caseLinkRepository: CaseDefinitionBuildingBlockLinkRepository, +) : BuildingBlockPluginMappingUsageFinder { + + /** + * The mappings live in JSON columns, so matching happens in memory — the platform supports + * both PostgreSQL and MySQL, whose JSON query dialects differ. Both tables hold + * configuration-time data (bounded by the number of linked activities / case-BB pairs), not + * runtime data, so a full scan stays small. + */ + override fun findUsages(configurationId: UUID): List { + val processLinkUsages = processLinkRepository.findAll().flatMap { link -> + link.pluginConfigurationMappings.filterValues { it == configurationId }.keys.map { mappingKey -> + BuildingBlockPluginMappingUsage( + mappingKey = mappingKey, + buildingBlockDefinitionKey = link.buildingBlockDefinitionId.key, + processLinkId = link.id, + processDefinitionId = link.processDefinitionId, + activityId = link.activityId, + ) + } + } + + val caseLinkUsages = caseLinkRepository.findAll().flatMap { caseLink -> + caseLink.pluginConfigurationMappings.filterValues { it == configurationId }.keys.map { mappingKey -> + BuildingBlockPluginMappingUsage( + mappingKey = mappingKey, + buildingBlockDefinitionKey = caseLink.buildingBlockDefinitionId.key, + caseDefinitionKey = caseLink.caseDefinitionId.key, + caseDefinitionVersionTag = caseLink.caseDefinitionId.versionTag.toString(), + ) + } + } + + return processLinkUsages + caseLinkUsages + } +} diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt new file mode 100644 index 0000000000..4446a6c844 --- /dev/null +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/repository/BuildingBlockProcessLinkRepository.kt @@ -0,0 +1,23 @@ +/* + * 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.repository + +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface BuildingBlockProcessLinkRepository : JpaRepository diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt new file mode 100644 index 0000000000..df9c59b763 --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginMappingUsageFinderTest.kt @@ -0,0 +1,119 @@ +/* + * 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.ritense.buildingblock.domain.CaseDefinitionBuildingBlockLink +import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink +import com.ritense.buildingblock.repository.BuildingBlockProcessLinkRepository +import com.ritense.buildingblock.repository.CaseDefinitionBuildingBlockLinkRepository +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.UUID + +class DefaultBuildingBlockPluginMappingUsageFinderTest { + + private lateinit var processLinkRepository: BuildingBlockProcessLinkRepository + private lateinit var caseLinkRepository: CaseDefinitionBuildingBlockLinkRepository + private lateinit var finder: DefaultBuildingBlockPluginMappingUsageFinder + + private val configurationId = UUID.randomUUID() + private val otherConfigurationId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + processLinkRepository = mock() + caseLinkRepository = mock() + whenever(processLinkRepository.findAll()).thenReturn(emptyList()) + whenever(caseLinkRepository.findAll()).thenReturn(emptyList()) + finder = DefaultBuildingBlockPluginMappingUsageFinder(processLinkRepository, caseLinkRepository) + } + + @Test + fun `reports a call-activity process link whose mappings reference the configuration`() { + val link = processLink( + mappings = mapOf( + "external-plugin:case-summary@0.1.0" to configurationId, + "zakenapi" to otherConfigurationId, + ) + ) + whenever(processLinkRepository.findAll()).thenReturn(listOf(link)) + + val usages = finder.findUsages(configurationId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.mappingKey).isEqualTo("external-plugin:case-summary@0.1.0") + assertThat(usage.buildingBlockDefinitionKey).isEqualTo("send-notification") + assertThat(usage.processLinkId).isEqualTo(link.id) + assertThat(usage.processDefinitionId).isEqualTo("bezwaar:3:abc") + assertThat(usage.activityId).isEqualTo("CallSendNotification") + assertThat(usage.caseDefinitionKey).isNull() + } + + @Test + fun `reports a case-definition link whose mappings reference the configuration`() { + val caseLink = CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId("bezwaar", "1.0.1"), + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mapOf("external-plugin:case-summary@0.1.0" to configurationId), + ) + whenever(caseLinkRepository.findAll()).thenReturn(listOf(caseLink)) + + val usages = finder.findUsages(configurationId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.mappingKey).isEqualTo("external-plugin:case-summary@0.1.0") + assertThat(usage.buildingBlockDefinitionKey).isEqualTo("send-notification") + assertThat(usage.caseDefinitionKey).isEqualTo("bezwaar") + assertThat(usage.caseDefinitionVersionTag).isEqualTo("1.0.1") + assertThat(usage.processDefinitionId).isNull() + } + + @Test + fun `mappings referencing other configurations are not reported`() { + whenever(processLinkRepository.findAll()).thenReturn( + listOf(processLink(mappings = mapOf("zakenapi" to otherConfigurationId))) + ) + whenever(caseLinkRepository.findAll()).thenReturn( + listOf( + CaseDefinitionBuildingBlockLink( + caseDefinitionId = CaseDefinitionId("bezwaar", "1.0.1"), + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mapOf("zakenapi" to otherConfigurationId), + ) + ) + ) + + assertThat(finder.findUsages(configurationId)).isEmpty() + } + + private fun processLink(mappings: Map): BuildingBlockProcessLink = BuildingBlockProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "bezwaar:3:abc", + activityId = "CallSendNotification", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = BuildingBlockDefinitionId("send-notification", "2.0.0"), + pluginConfigurationMappings = mappings, + ) +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt index e4da7df2ac..48e6a3c3ca 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -123,6 +123,7 @@ class ExternalPluginAutoConfiguration { operatonRepositoryService: OperatonRepositoryService, bpmnRepositoryService: RepositoryService, caseExternalPluginTabService: java.util.Optional, + buildingBlockMappingUsageFinder: java.util.Optional, ) = ExternalPluginHostUsageResolver( definitionRepository, configurationRepository, @@ -131,6 +132,7 @@ class ExternalPluginAutoConfiguration { operatonRepositoryService, bpmnRepositoryService, caseExternalPluginTabService, + buildingBlockMappingUsageFinder, ) @Bean diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt index 48d94432ad..521b270c55 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/repository/ExternalPluginProcessLinkRepository.kt @@ -17,8 +17,11 @@ package com.ritense.externalplugin.repository import com.ritense.externalplugin.domain.ExternalPluginProcessLink +import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.processlink.domain.ActivityTypeWithEventName import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.util.UUID interface ExternalPluginProcessLinkRepository : JpaRepository { @@ -34,4 +37,22 @@ interface ExternalPluginProcessLinkRepository : JpaRepository, ): List + + /** + * Links whose (design-time) reference pins one of the given plugin definition keys with the + * given reference type — used by the host delete guard to find `BUILDING_BLOCK` references, + * which carry no configuration id and are therefore invisible to the configuration-based + * usage queries above. + */ + @Query( + """ + select link from ExternalPluginProcessLink link + where link.pluginConfigurationReference.type = :referenceType + and link.pluginConfigurationReference.pluginDefinitionKey in :pluginDefinitionKeys + """ + ) + fun findAllByReferenceTypeAndPluginDefinitionKeyIn( + @Param("referenceType") referenceType: PluginConfigurationReferenceType, + @Param("pluginDefinitionKeys") pluginDefinitionKeys: Collection, + ): List } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt index 5d09b337b7..2bb76ba4e2 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt @@ -18,10 +18,13 @@ package com.ritense.externalplugin.service import com.ritense.case_.service.CaseExternalPluginTabService import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository +import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder import com.ritense.plugin.web.rest.dto.PluginUsageDto import com.ritense.plugin.web.rest.dto.PluginUsageParentType import com.ritense.valtimo.contract.annotation.SkipComponentScan @@ -52,17 +55,29 @@ class ExternalPluginHostUsageResolver( * normal GZAC deployment it is always present (external-plugin depends on case). */ private val caseExternalPluginTabService: Optional, + /** + * Optional for the same reason: implemented by the building-block module, which external-plugin + * cannot depend on directly (the SPI lives in `:backend:plugin`). Without it, building-block + * mapping usages simply don't block deletion — matching a deployment without building blocks. + */ + private val buildingBlockMappingUsageFinder: Optional = Optional.empty(), ) { fun findUsagesForHost(hostId: UUID): List { - val configurations = collectConfigurationsForHost(hostId) - return buildUsageDtos(configurations) + buildCaseTabUsageDtos(configurations) + val definitions = definitionRepository.findAllByHostId(hostId) + val configurations = collectConfigurations(definitions) + return buildUsageDtos(configurations) + + buildCaseTabUsageDtos(configurations) + + buildBuildingBlockMappingUsageDtos(configurations) + + buildDefinitionReferenceUsageDtos(definitions) } fun findUsagesForConfiguration(configurationId: UUID): List { val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return emptyList() - return buildUsageDtos(listOf(configuration)) + buildCaseTabUsageDtos(listOf(configuration)) + return buildUsageDtos(listOf(configuration)) + + buildCaseTabUsageDtos(listOf(configuration)) + + buildBuildingBlockMappingUsageDtos(listOf(configuration)) } /** @@ -133,12 +148,106 @@ class ExternalPluginHostUsageResolver( return actionLinks + taskFormLinks } - private fun collectConfigurationsForHost(hostId: UUID): List { - val definitions = definitionRepository.findAllByHostId(hostId) + private fun collectConfigurations(definitions: List): List { if (definitions.isEmpty()) return emptyList() return definitions.flatMap { configurationRepository.findAllByDefinitionId(it.id) } } + /** + * A building block references a configuration through its `pluginConfigurationMappings` (on the + * call-activity process link or the case-definition ↔ BB link) rather than through a process + * link's own configuration id, so those mappings must equally block deletion — otherwise the + * mapping would silently dangle and every run of the building block would fail to resolve its + * plugin. + */ + private fun buildBuildingBlockMappingUsageDtos( + configurations: List, + ): List { + val finder = buildingBlockMappingUsageFinder.orElse(null) ?: return emptyList() + if (configurations.isEmpty()) return emptyList() + + val metaCache = mutableMapOf() + return configurations.flatMap { configuration -> + finder.findUsages(configuration.id).map { usage -> + val processDefinitionId = usage.processDefinitionId + if (processDefinitionId != null) { + val meta = metaCache.getOrPut(processDefinitionId) { + resolveProcessDefinitionMeta(processDefinitionId) + } + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = usage.activityId, + activityName = usage.activityId?.let { resolveActivityName(it, meta) }, + processLinkId = usage.processLinkId, + buildingBlockKey = usage.buildingBlockDefinitionKey, + ) + } else { + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = PluginUsageParentType.CASE, + parentKey = usage.caseDefinitionKey, + parentVersionTag = usage.caseDefinitionVersionTag, + buildingBlockKey = usage.buildingBlockDefinitionKey, + ) + } + } + } + } + + /** + * A `BUILDING_BLOCK`-reference process link pins a plugin *definition* (`pluginId@version`), + * not a configuration — it never blocks deleting an individual configuration, but it must block + * deleting the host that serves the definition: without the host, the reference can never + * resolve again, even when no case definition uses the building block yet. The shared + * [PluginUsageDto] requires a configuration identity, so the definition stands in: + * `configurationId` carries the definition id and `configurationTitle` the `pluginId@version` + * pair the link is pinned to. + */ + private fun buildDefinitionReferenceUsageDtos( + definitions: List, + ): List { + if (definitions.isEmpty()) return emptyList() + + val links = processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + definitions.map { it.pluginId }.toSet(), + ) + if (links.isEmpty()) return emptyList() + + val definitionByKeyAndVersion = definitions.associateBy { it.pluginId to it.version } + val metaCache = mutableMapOf() + + return links.mapNotNull { link -> + val reference = link.pluginConfigurationReference + val definition = definitionByKeyAndVersion[reference.pluginDefinitionKey to reference.pluginDefinitionVersion] + ?: return@mapNotNull null + val meta = metaCache.getOrPut(link.processDefinitionId) { + resolveProcessDefinitionMeta(link.processDefinitionId) + } + PluginUsageDto( + configurationId = definition.id, + configurationTitle = "${definition.pluginId}@${definition.version}", + parentType = meta.parentType, + parentKey = meta.parentKey, + parentVersionTag = meta.parentVersionTag, + processDefinitionId = link.processDefinitionId, + processDefinitionKey = meta.processDefinitionKey, + processDefinitionName = meta.processDefinitionName, + activityId = link.activityId, + activityName = resolveActivityName(link.activityId, meta), + processLinkId = link.id, + ) + } + } + private fun resolveProcessDefinitionMeta(processDefinitionId: String): ProcessDefinitionMeta { val processDefinition: OperatonProcessDefinition? = runCatching { operatonRepositoryService.findProcessDefinitionById(processDefinitionId) diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt index f678da161f..0237b44570 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt @@ -27,6 +27,8 @@ import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.plugin.domain.PluginConfigurationReferenceType +import com.ritense.plugin.service.BuildingBlockPluginMappingUsage +import com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder import com.ritense.plugin.web.rest.dto.PluginUsageParentType import com.ritense.processlink.domain.ActivityTypeWithEventName import com.ritense.valtimo.operaton.domain.OperatonProcessDefinition @@ -63,6 +65,8 @@ class ExternalPluginHostUsageResolverTest { taskFormProcessLinkRepository = mock() operatonRepositoryService = mock() bpmnRepositoryService = mock() + whenever(processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn(any(), any())) + .thenReturn(emptyList()) resolver = ExternalPluginHostUsageResolver( definitionRepository, configurationRepository, @@ -115,10 +119,13 @@ class ExternalPluginHostUsageResolverTest { /** * A `BUILDING_BLOCK`-reference `ExternalPluginProcessLink` carries a `null` - * `externalPluginConfigurationId` (Phase 2) — it must never block deletion of a configuration, - * mirroring the embedded system's `PluginConfigurationUsageResolver` (`BUILDING_BLOCK` - * references there are likewise ignored by the delete guard). The repository query itself - * (`IN (...)` over non-null ids) already excludes such rows; this exercises the resolver's own + * `externalPluginConfigurationId` (Phase 2) — it never counts as a *configuration* usage (the + * link pins no configuration; the mapping does, covered by + * [BuildingBlockPluginMappingUsageFinder][com.ritense.plugin.service.BuildingBlockPluginMappingUsageFinder]). + * It blocks *host* deletion only through the definition-reference guard, which matches on the + * pinned `pluginId@version` — here the link pins `plugin`, not the host's `test-plugin`, so no + * definition usage surfaces either. The repository query itself (`IN (...)` over non-null ids) + * already excludes such rows from configuration usage; this exercises the resolver's own * `mapNotNull` defense so the guarantee holds even if a caller ever passes a broader result set. */ @Test @@ -484,6 +491,169 @@ class ExternalPluginHostUsageResolverTest { .containsExactlyInAnyOrder("SendLetter", "ReviewTask") } + @Test + fun `configuration referenced only by building-block mappings still blocks deletion`() { + val configuration = configuration(definitionId = UUID.randomUUID(), title = "Mapped CRM") + val mappingUsageFinder = mock() + val resolverWithFinder = resolverWith(mappingUsageFinder) + val processDefId = "bezwaar:3:abc" + + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(mappingUsageFinder.findUsages(configuration.id)).thenReturn( + listOf( + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + processLinkId = UUID.randomUUID(), + processDefinitionId = processDefId, + activityId = "CallSendNotification", + ), + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + ), + ) + ) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "bezwaar", + name = "Bezwaarprocedure", + versionTag = "CD:bezwaar:1.0.1", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(mock()) + + val usages = resolverWithFinder.findUsagesForConfiguration(configuration.id) + + assertThat(usages).hasSize(2) + val processLinkUsage = usages.single { it.processDefinitionId != null } + assertThat(processLinkUsage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(processLinkUsage.activityId).isEqualTo("CallSendNotification") + val caseLinkUsage = usages.single { it.processDefinitionId == null } + assertThat(caseLinkUsage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(caseLinkUsage.parentKey).isEqualTo("bezwaar") + assertThat(caseLinkUsage.parentVersionTag).isEqualTo("1.0.1") + assertThat(usages).allSatisfy { + assertThat(it.configurationId).isEqualTo(configuration.id) + assertThat(it.configurationTitle).isEqualTo("Mapped CRM") + assertThat(it.buildingBlockKey).isEqualTo("send-notification") + } + } + + @Test + fun `host deletion is blocked by a BUILDING_BLOCK reference pinning one of its definitions`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) // test-plugin@1.0.0 + val processDefId = "send-notification:2:bb-hash" + val referenceLink = buildingBlockReferenceProcessLink( + processDefinitionId = processDefId, + activityId = "PostMessage", + pluginDefinitionKey = definition.pluginId, + pluginVersion = definition.version, + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + whenever( + processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + setOf(definition.pluginId), + ) + ).thenReturn(listOf(referenceLink)) + whenever(operatonRepositoryService.findProcessDefinitionById(processDefId)).thenReturn( + operatonProcessDefinition( + id = processDefId, + key = "send-notification", + name = "Send notification", + versionTag = "BB:send-notification:2.0.0", + ) + ) + whenever(bpmnRepositoryService.getBpmnModelInstance(processDefId)).thenReturn(mock()) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + val usage = usages[0] + assertThat(usage.configurationId).isEqualTo(definition.id) + assertThat(usage.configurationTitle).isEqualTo("test-plugin@1.0.0") + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.BUILDING_BLOCK) + assertThat(usage.parentKey).isEqualTo("send-notification") + assertThat(usage.processLinkId).isEqualTo(referenceLink.id) + } + + @Test + fun `a BUILDING_BLOCK reference pinned to a version the host does not serve does not block it`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) // test-plugin@1.0.0 + val referenceLink = buildingBlockReferenceProcessLink( + processDefinitionId = "send-notification:2:bb-hash", + activityId = "PostMessage", + pluginDefinitionKey = definition.pluginId, + pluginVersion = "9.9.9", + ) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(emptyList()) + whenever( + processLinkRepository.findAllByReferenceTypeAndPluginDefinitionKeyIn( + PluginConfigurationReferenceType.BUILDING_BLOCK, + setOf(definition.pluginId), + ) + ).thenReturn(listOf(referenceLink)) + + val usages = resolver.findUsagesForHost(hostId) + + assertThat(usages).isEmpty() + } + + @Test + fun `host deletion is blocked by building-block mappings referencing its configurations`() { + val hostId = UUID.randomUUID() + val definition = definition(hostId = hostId) + val configuration = configuration(definitionId = definition.id, title = "Mapped CRM") + val mappingUsageFinder = mock() + val resolverWithFinder = resolverWith(mappingUsageFinder) + + whenever(definitionRepository.findAllByHostId(hostId)).thenReturn(listOf(definition)) + whenever(configurationRepository.findAllByDefinitionId(definition.id)).thenReturn(listOf(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(mappingUsageFinder.findUsages(configuration.id)).thenReturn( + listOf( + BuildingBlockPluginMappingUsage( + mappingKey = "external-plugin:test-plugin@1.0.0", + buildingBlockDefinitionKey = "send-notification", + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + ), + ) + ) + + val usages = resolverWithFinder.findUsagesForHost(hostId) + + assertThat(usages).hasSize(1) + assertThat(usages[0].configurationId).isEqualTo(configuration.id) + assertThat(usages[0].parentKey).isEqualTo("bezwaar") + } + + private fun resolverWith(finder: BuildingBlockPluginMappingUsageFinder): ExternalPluginHostUsageResolver = + ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + operatonRepositoryService, + bpmnRepositoryService, + java.util.Optional.empty(), + java.util.Optional.of(finder), + ) + private fun definition(hostId: UUID): ExternalPluginDefinition = ExternalPluginDefinition( id = UUID.randomUUID(), pluginId = "test-plugin", @@ -521,6 +691,8 @@ class ExternalPluginHostUsageResolverTest { private fun buildingBlockReferenceProcessLink( processDefinitionId: String, activityId: String, + pluginDefinitionKey: String = "plugin", + pluginVersion: String = "1.0.0", ): ExternalPluginProcessLink = ExternalPluginProcessLink( id = UUID.randomUUID(), processDefinitionId = processDefinitionId, @@ -530,8 +702,8 @@ class ExternalPluginHostUsageResolverTest { actionKey = "action", pluginConfigurationReference = PluginConfigurationReference( type = PluginConfigurationReferenceType.BUILDING_BLOCK, - pluginDefinitionKey = "plugin", - pluginDefinitionVersion = "1.0.0", + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginVersion, ), ) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt new file mode 100644 index 0000000000..8d3d1c95c5 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginMappingUsageFinder.kt @@ -0,0 +1,51 @@ +/* + * 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.plugin.service + +import java.util.UUID + +/** + * SPI reporting where building-block surfaces reference a plugin configuration through their + * `pluginConfigurationMappings` — the call-activity process link and the case-definition ↔ + * building-block link. Mapping values are configuration ids of either plugin system (embedded or + * external), so the finder is system-agnostic. Implemented by the building-block module (this + * interface lives here for the same reason as [BuildingBlockPluginConfigurationResolver]: both + * plugin systems depend on `:backend:plugin`, neither may depend on `:backend:building-block`); + * plugin systems consult it from their delete guards so a configuration referenced only by a + * building block cannot be deleted out from under it. + */ +interface BuildingBlockPluginMappingUsageFinder { + + fun findUsages(configurationId: UUID): List +} + +/** + * One `pluginConfigurationMappings` entry referencing the configuration, in one of two shapes: + * - a building-block **call-activity process link**: [processLinkId], [processDefinitionId] and + * [activityId] are set, the case fields are null; + * - a **case-definition ↔ building-block link**: [caseDefinitionKey] and [caseDefinitionVersionTag] + * are set, the process-link fields are null. + */ +data class BuildingBlockPluginMappingUsage( + val mappingKey: String, + val buildingBlockDefinitionKey: String, + val processLinkId: UUID? = null, + val processDefinitionId: String? = null, + val activityId: String? = null, + val caseDefinitionKey: String? = null, + val caseDefinitionVersionTag: String? = null, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt index c8b7be881e..d1d7491b2b 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt @@ -34,12 +34,19 @@ enum class PluginUsageParentType { * One usage of a plugin configuration that blocks its deletion. Used by the "configuration in use" * and "host in use" guards on both the embedded and external plugin paths. * - * Two shapes share this DTO: + * Several shapes share this DTO: * - **Process-link usage** (embedded + external): the BPMN-activity fields are populated and * [tabKey] is null. * - **External-plugin case-tab usage**: [tabKey]/[tabName] are populated, [parentType] is `CASE`, * and the process-link fields are null. (A `case-tab` of an external plugin references the * configuration but has no process link.) + * - **Building-block mapping usage**: a building block's `pluginConfigurationMappings` reference + * the configuration; [buildingBlockKey] names the building block. On a call-activity link the + * process-link fields are populated too; on a case-definition ↔ BB link only [parentKey]/ + * [parentVersionTag] (the case) are. + * - **Definition-reference usage** (host deletion only): a `BUILDING_BLOCK`-reference process link + * pins a plugin *definition* rather than a configuration — [configurationId] then carries the + * definition id and [configurationTitle] the pinned `pluginId@version` pair. */ data class PluginUsageDto( val configurationId: UUID, @@ -55,4 +62,5 @@ data class PluginUsageDto( val processLinkId: UUID? = null, val tabKey: String? = null, val tabName: String? = null, + val buildingBlockKey: String? = null, ) diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html index 176be87a66..8172cc8d9b 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.html @@ -57,7 +57,13 @@

>
- {{ usage.activityName || usage.activityId || usage.tabName || usage.tabKey }} + {{ + usage.activityName || + usage.activityId || + usage.tabName || + usage.tabKey || + usage.buildingBlockKey + }}
{{ usage.activityId }} @@ -65,6 +71,12 @@

{{ 'pluginManagement.usageModal.caseTab' | translate }}
+
+ {{ 'pluginManagement.usageModal.buildingBlockMapping' | translate }} +
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts index 361a2e6285..a6574ba07c 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts @@ -65,7 +65,14 @@ export class PluginUsageModalComponent { } public trackByUsage(_index: number, usage: ExternalPluginHostUsage): string { - return usage.processLinkId ?? `tab:${usage.parentKey}:${usage.tabKey}`; + if (usage.processLinkId) { + return usage.processLinkId; + } + if (usage.tabKey) { + return `tab:${usage.parentKey}:${usage.tabKey}`; + } + // Building-block mapping usage on a case-definition ↔ BB link (no process link, no tab). + return `bb:${usage.parentKey}:${usage.buildingBlockKey}:${usage.configurationId}`; } public parentTypeTagColor(parentType: ExternalPluginHostUsageParentType): string { diff --git a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts index a7e59c4d2d..0ee2e0fa64 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts @@ -265,6 +265,9 @@ interface ExternalPluginHostUsage { // Populated only for an external-plugin case-tab usage. tabKey?: string | null; tabName?: string | null; + // Populated only for a building-block mapping usage (the BB's pluginConfigurationMappings + // reference the configuration); names the building block holding the mapping. + buildingBlockKey?: string | null; } const EXTERNAL_PLUGIN_KEY_PREFIX = 'external:'; diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html index 35341bab1e..01adca853a 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.html @@ -93,14 +93,21 @@ @if (plugin.selectedConfigurationVersion) {
- {{ - 'processLinkConfiguration.buildingBlock.externalPluginVersionMismatchWarning' - | translate - : { - selectedVersion: plugin.selectedConfigurationVersion, - requiredVersion: plugin.pluginDefinitionVersion - } - }} +
}

diff --git a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss index df3e8be6d6..67a0a65fcd 100644 --- a/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss +++ b/frontend/projects/valtimo/process-link/src/lib/components/configure-building-block-plugins/configure-building-block-plugins.component.scss @@ -34,9 +34,15 @@ .plugin-configuration-select { &__warning { - margin-top: var(--cds-spacing-02); - color: var(--cds-support-warning); - font-size: var(--cds-label-01-font-size); + display: flex; + width: 100%; + margin-top: var(--cds-spacing-03); + + ::ng-deep cds-inline-notification { + width: 100%; + max-inline-size: unset; + margin: 0; + } } } diff --git a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts index 80766bf051..353ace4516 100644 --- a/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts +++ b/frontend/projects/valtimo/process-link/src/lib/services/process-link-step.service.ts @@ -134,8 +134,7 @@ export class ProcessLinkStepService { } public setChoosePluginConfigurationSteps(): void { - const selectionLabel = - this._context === 'buildingBlock' ? 'choosePluginDefinition' : 'choosePluginConfiguration'; + const selectionLabel = this.selectionStepLabel; this._steps$.next([ {label: 'chooseProcessLinkType', secondaryLabel: 'processLinkType.plugin'}, {label: selectionLabel}, @@ -146,8 +145,7 @@ export class ProcessLinkStepService { } public setSingleChoosePluginConfigurationSteps(): void { - const selectionLabel = - this._context === 'buildingBlock' ? 'choosePluginDefinition' : 'choosePluginConfiguration'; + const selectionLabel = this.selectionStepLabel; this._steps$.next([ {label: selectionLabel}, {label: 'choosePluginAction', disabled: true}, @@ -164,10 +162,7 @@ export class ProcessLinkStepService { ]) .pipe(take(1)) .subscribe(([hasOneType, selectedConfiguration, selectedDefinition]) => { - const selectionLabel = - this._context === 'buildingBlock' - ? 'choosePluginDefinition' - : 'choosePluginConfiguration'; + const selectionLabel = this.selectionStepLabel; const selectedPluginLabel = this.getSelectedPluginLabel( selectedConfiguration, selectedDefinition @@ -220,10 +215,7 @@ export class ProcessLinkStepService { : pluginKey ? this.pluginTranslateService.instant(selectedFunction.key, pluginKey) : selectedFunction.key; - const selectionLabel = - this._context === 'buildingBlock' - ? 'choosePluginDefinition' - : 'choosePluginConfiguration'; + const selectionLabel = this.selectionStepLabel; const selectedPluginLabel = this.getSelectedPluginLabel( selectedConfiguration, selectedDefinition @@ -283,10 +275,7 @@ export class ProcessLinkStepService { : pluginKey ? this.pluginTranslateService.instant(selectedFunction.key, pluginKey) : selectedFunction.key; - const selectionLabel = - this._context === 'buildingBlock' - ? 'choosePluginDefinition' - : 'choosePluginConfiguration'; + const selectionLabel = this.selectionStepLabel; const selectedPluginLabel = this.getSelectedPluginLabel( selectedConfiguration, selectedDefinition @@ -538,6 +527,17 @@ export class ProcessLinkStepService { this._currentStepIndex$.next(0); } + /** + * Step label for the plugin selection step. In the building-block context the admin picks a + * plugin *definition* (the configuration is resolved at runtime from the BB's mappings), so the + * step must be titled accordingly — everywhere else a concrete configuration is chosen. + */ + private get selectionStepLabel(): string { + return this._context === 'buildingBlock' + ? 'choosePluginDefinition' + : 'choosePluginConfiguration'; + } + private getSelectedPluginLabel( selectedConfiguration: PluginConfiguration | undefined, selectedDefinition: PluginDefinition | undefined @@ -581,13 +581,9 @@ export class ProcessLinkStepService { this._currentStepIndex$.next(0); break; case 'plugin': { - const selectionLabel = - this._context === 'buildingBlock' - ? 'choosePluginDefinition' - : 'choosePluginConfiguration'; // Plugin has 3 config steps: select config, select action, configure action this._steps$.next([ - {label: selectionLabel}, + {label: this.selectionStepLabel}, {label: 'choosePluginAction'}, {label: 'configurePluginAction'}, ]); @@ -611,7 +607,7 @@ export class ProcessLinkStepService { // configure. A task-form's configure step has nothing to fill in (see the action-config // component), but the flow and step layout are identical. this._steps$.next([ - {label: 'choosePluginConfiguration'}, + {label: this.selectionStepLabel}, {label: 'choosePluginAction'}, {label: 'configurePluginAction'}, ]); @@ -638,7 +634,7 @@ export class ProcessLinkStepService { */ public initializeEditModeResultMappingsSteps(): void { this._steps$.next([ - {label: 'choosePluginConfiguration'}, + {label: this.selectionStepLabel}, {label: 'choosePluginAction'}, {label: 'configurePluginAction'}, {label: 'configurePluginActionResultMappings'}, diff --git a/frontend/projects/valtimo/shared/assets/core/en.json b/frontend/projects/valtimo/shared/assets/core/en.json index 8da3872883..bcf0cbb478 100644 --- a/frontend/projects/valtimo/shared/assets/core/en.json +++ b/frontend/projects/valtimo/shared/assets/core/en.json @@ -2331,12 +2331,12 @@ "hostInUseModal": { "title": "Plugin host is in use", "thisHost": "this plugin host", - "description": "{{ entityName }} cannot be deleted because the following BPMN activities still use a configuration from this host. Remove these process links first." + "description": "{{ entityName }} cannot be deleted because the following process links, case tabs or building blocks still use a plugin or configuration from this host. Remove these usages first." }, "configurationInUseModal": { "title": "Plugin configuration is in use", "thisConfiguration": "this plugin configuration", - "description": "{{ entityName }} cannot be deleted because the following BPMN activities still reference it. Remove these process links first." + "description": "{{ entityName }} cannot be deleted because the following process links, case tabs or building blocks still reference it. Remove these usages first." }, "usageModal": { "headers": { @@ -2351,7 +2351,8 @@ "BUILDING_BLOCK": "Building block", "GLOBAL": "Global" }, - "caseTab": "Case tab" + "caseTab": "Case tab", + "buildingBlockMapping": "Building block plugin mapping" }, "compatibility": { "incompatibleTag": "Incompatible", @@ -2685,7 +2686,7 @@ "zaakInstanceLink": "This building block may need a zaak instance link to be configured for the case definition under which this building block is used, in order to function correctly.", "zaakTypeLink": "This building block may need a zaak type link to be configured for the case definition under which this building block is used, in order to function correctly." }, - "externalPluginVersionMismatchWarning": "The selected configuration's plugin version does not match the version ({{version}}) this building block was linked against. It may not support this action." + "externalPluginVersionMismatchWarning": "The selected configuration runs plugin version {{selectedVersion}}, but this building block was linked against version {{requiredVersion}}. It may not support this action." } }, "processLinkType": { diff --git a/frontend/projects/valtimo/shared/assets/core/nl.json b/frontend/projects/valtimo/shared/assets/core/nl.json index 1d37f91cf3..0b4ccc6907 100644 --- a/frontend/projects/valtimo/shared/assets/core/nl.json +++ b/frontend/projects/valtimo/shared/assets/core/nl.json @@ -2358,12 +2358,12 @@ "hostInUseModal": { "title": "Plugin-host is in gebruik", "thisHost": "deze plugin-host", - "description": "{{ entityName }} kan niet worden verwijderd omdat de volgende BPMN-activiteiten nog gebruikmaken van een configuratie van deze host. Verwijder eerst deze proceskoppelingen." + "description": "{{ entityName }} kan niet worden verwijderd omdat de volgende proceskoppelingen, zaaktabbladen of bouwblokken nog gebruikmaken van een plugin of configuratie van deze host. Verwijder eerst deze verwijzingen." }, "configurationInUseModal": { "title": "Plugin-configuratie is in gebruik", "thisConfiguration": "deze plugin-configuratie", - "description": "{{ entityName }} kan niet worden verwijderd omdat de volgende BPMN-activiteiten er nog naar verwijzen. Verwijder eerst deze proceskoppelingen." + "description": "{{ entityName }} kan niet worden verwijderd omdat de volgende proceskoppelingen, zaaktabbladen of bouwblokken er nog naar verwijzen. Verwijder eerst deze verwijzingen." }, "usageModal": { "headers": { @@ -2378,7 +2378,8 @@ "BUILDING_BLOCK": "Bouwblok", "GLOBAL": "Globaal" }, - "caseTab": "Zaaktabblad" + "caseTab": "Zaaktabblad", + "buildingBlockMapping": "Plugin-koppeling van bouwblok" }, "compatibility": { "incompatibleTag": "Incompatibel", @@ -2718,7 +2719,7 @@ "zaakInstanceLink": "Dit bouwblok heeft mogelijk een zaakinstantiekoppeling nodig, die is geconfigureerd voor de dossierdefinitie waaronder dit bouwblok wordt geconfigureerd, om correct te functioneren.", "zaakTypeLink": "Dit bouwblok heeft mogelijk een zaaktypekoppeling nodig, die is geconfigureerd voor de dossierdefinitie waaronder dit bouwblok is geconfigureerd, om correct te functioneren." }, - "externalPluginVersionMismatchWarning": "De pluginversie van de geselecteerde configuratie komt niet overeen met de versie ({{version}}) waaraan dit bouwblok is gekoppeld. Deze actie wordt mogelijk niet ondersteund." + "externalPluginVersionMismatchWarning": "De geselecteerde configuratie gebruikt pluginversie {{selectedVersion}}, maar dit bouwblok is gekoppeld aan versie {{requiredVersion}}. Deze actie wordt mogelijk niet ondersteund." } }, "processLinkType": { From 564d8f6deaa6ab0d463bb816110c8b887401b84a Mon Sep 17 00:00:00 2001 From: Ivo Zaal Date: Tue, 28 Jul 2026 16:56:58 +0200 Subject: [PATCH 7/7] Fixed import issues --- ...-block-test.case-building-block-links.json | 4 +- .../energy-subsidy-request.process-link.json | 4 +- .../domain/BuildingBlockProcessLink.kt | 7 + ...uildingBlockPluginConfigurationResolver.kt | 42 +++--- .../BuildingBlockProcessLinkPersistenceIT.kt | 120 ++++++++++++++++++ ...ldingBlockPluginConfigurationResolverIT.kt | 37 ++++++ .../configuration/CaseAutoConfiguration.kt | 15 ++- .../com/ritense/case/deployment/CaseTabDto.kt | 14 ++ .../case/service/CaseDefinitionCheckerImpl.kt | 14 +- .../ritense/case/service/CaseTabExporter.kt | 36 +++++- .../ritense/case/service/CaseTabImporter.kt | 27 +++- .../case_/domain/tab/CaseExternalPluginTab.kt | 12 ++ .../service/CaseExternalPluginTabService.kt | 18 ++- .../service/ExternalPluginCaseTabResolver.kt | 19 +++ .../service/event/CaseTabCreatedEvent.kt | 13 +- .../case/service/CaseTabImporterTest.kt | 25 +++- .../contract/case_/CaseDefinitionChecker.kt | 10 ++ .../PluginConfigurationMappingResolver.kt | 9 ++ .../liquibase/13-32-0/13-32-0-master.xml | 1 + ...-external-plugin-tab-plugin-definition.xml | 35 +++++ .../ExternalPluginAutoConfiguration.kt | 8 +- .../ExternalPluginImportPreviewContributor.kt | 15 ++- .../ExternalPluginServiceTaskStartListener.kt | 14 ++ ...ExternalPluginTaskFormProcessLinkMapper.kt | 19 ++- .../ExternalPluginCaseTabResolverImpl.kt | 14 +- ...ernalPluginConfigurationMappingResolver.kt | 91 +++++++++---- ...ernalPluginImportPreviewContributorTest.kt | 32 +++++ ...ernalPluginServiceTaskStartListenerTest.kt | 39 +++++- .../ExternalPluginCaseTabResolverImplTest.kt | 23 +++- ...lPluginConfigurationMappingResolverTest.kt | 84 ++++++++++-- ...uildingBlockPluginConfigurationResolver.kt | 10 ++ .../case-management-detail.component.ts | 14 +- ...missing-plugin-configurations.component.ts | 4 + 33 files changed, 734 insertions(+), 95 deletions(-) create mode 100644 backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt create mode 100644 backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml diff --git a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json index 88018bad81..1af6a9ecd5 100644 --- a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json +++ b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/building-block-link/building-block-test.case-building-block-links.json @@ -119,11 +119,11 @@ "target": "doc:/zgwActionSummary" }, { - "source": "doc:/reviewerRemarks", + "source": "doc:/submission/reviewerRemarks", "target": "doc:/reviewerRemarks" }, { - "source": "doc:/approvalDecision", + "source": "doc:/submission/approvalDecision", "target": "doc:/approvalDecision" } ] diff --git a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json index fbc8ee2c50..431774023c 100644 --- a/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json +++ b/backend/apps/dev/src/main/resources/config/case/energy-subsidy-request/1-0-0/process-link/energy-subsidy-request.process-link.json @@ -74,12 +74,12 @@ "syncTiming": "END" }, { - "source": "/reviewerRemarks", + "source": "/submission/reviewerRemarks", "target": "doc:/reviewerRemarks", "syncTiming": "END" }, { - "source": "/approvalDecision", + "source": "/submission/approvalDecision", "target": "doc:/approvalDecision", "syncTiming": "END" } diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLink.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLink.kt index 8fa7e67b1e..92b379d08e 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLink.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLink.kt @@ -30,6 +30,7 @@ import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.PrimaryKeyJoinColumn import jakarta.persistence.SecondaryTable +import org.hibernate.annotations.SecondaryRow import org.hibernate.annotations.Type import java.util.UUID @@ -38,6 +39,12 @@ import java.util.UUID name = SECONDARY_TABLE_NAME, pkJoinColumns = [PrimaryKeyJoinColumn(name = "process_link_id")] ) +// A building-block process link ALWAYS has its secondary row. Declaring it non-optional stops +// Hibernate from writing the row through the optional-secondary-table upsert `MERGE`, which on +// Hibernate 6.6 mis-binds the `jsonb` mapping columns as `integer` +// ("column input_mappings is of type jsonb but expression is of type integer") — a plain +// INSERT/UPDATE binds them correctly through the JsonType binder. +@SecondaryRow(table = SECONDARY_TABLE_NAME, optional = false) @DiscriminatorValue(PROCESS_LINK_TYPE) class BuildingBlockProcessLink( id: UUID, diff --git a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt index 439cf6bbba..8537c9943e 100644 --- a/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt +++ b/backend/building-block/src/main/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolver.kt @@ -38,18 +38,31 @@ class DefaultBuildingBlockPluginConfigurationResolver( private val documentService: DocumentService, ) : BuildingBlockPluginConfigurationResolver { - override fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? { - val instance = findInstance(execution) ?: return null - val root = findRootInstance(instance) - - return findCallActivityMapping(root, pluginDefinitionKey) - ?: findCaseLinkMapping(root, pluginDefinitionKey) - } + override fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? = + resolveMapping(execution) { it[pluginDefinitionKey] } override fun resolve(task: DelegateTask, pluginDefinitionKey: String): UUID? { return resolve(task.execution, pluginDefinitionKey) } + override fun resolveByKeyPrefix(execution: DelegateExecution, keyPrefix: String): UUID? = + resolveMapping(execution) { mappings -> + mappings.entries.firstOrNull { it.key.startsWith(keyPrefix) }?.value + } + + /** + * Selects a configuration id from the call-activity process link's mappings first, then the + * case-definition ↔ building-block link's mappings — the original resolution order — returning + * the first non-null. + */ + private fun resolveMapping(execution: DelegateExecution, select: (Map) -> UUID?): UUID? { + val instance = findInstance(execution) ?: return null + val root = findRootInstance(instance) + + return callActivityMappings(root)?.let(select) + ?: caseLinkMappings(root)?.let(select) + } + /** * Prefer the BB document id (Valtimo convention: business key == document id, propagated through * `` on call activities). This makes the resolver work from any @@ -78,10 +91,10 @@ class DefaultBuildingBlockPluginConfigurationResolver( } /** - * Resolves plugin configuration from the BuildingBlockProcessLink on the call activity - * that started the root building block. + * The plugin configuration mappings from the BuildingBlockProcessLink on the call activity that + * started the root building block. */ - private fun findCallActivityMapping(instance: BuildingBlockInstance, pluginDefinitionKey: String): UUID? { + private fun callActivityMappings(instance: BuildingBlockInstance): Map? { val activityId = instance.activityId ?: return null val callerProcessDefinitionId = instance.callerProcessDefinitionId ?: return null @@ -89,19 +102,16 @@ class DefaultBuildingBlockPluginConfigurationResolver( .filterIsInstance() .firstOrNull() ?.pluginConfigurationMappings - ?.get(pluginDefinitionKey) } - private fun findCaseLinkMapping(instance: BuildingBlockInstance, pluginDefinitionKey: String): UUID? { + private fun caseLinkMappings(instance: BuildingBlockInstance): Map? { val caseDocumentId = instance.caseDocumentId ?: return null val caseDocument = documentService.get(caseDocumentId.toString()) val caseDefinitionId = caseDocument.definitionId().caseDefinitionId() - val link = linkRepository.findByCaseDefinitionIdAndBuildingBlockDefinitionId( + return linkRepository.findByCaseDefinitionIdAndBuildingBlockDefinitionId( caseDefinitionId, instance.definition.id - ) ?: return null - - return link.pluginConfigurationMappings[pluginDefinitionKey] + )?.pluginConfigurationMappings } } diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt new file mode 100644 index 0000000000..d97fb9510b --- /dev/null +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/domain/BuildingBlockProcessLinkPersistenceIT.kt @@ -0,0 +1,120 @@ +/* + * 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.domain + +import com.ritense.buildingblock.BaseIntegrationTest +import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.processlink.repository.ProcessLinkRepository +import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * Round-trips a [BuildingBlockProcessLink] through the database — insert, update, and reload — for + * both a link that carries a plugin configuration mapping and one that does not. It guards the + * `@SecondaryTable` `building_block_process_link` row whose `input_mappings` / `output_mappings` / + * `plugin_configuration_mappings` are `jsonb`; that row is what a case-definition import using a + * plugin configuration in a building-block mapping persists. + * + * Context: on Hibernate 6.6 the row was written through the optional-secondary-table upsert `MERGE`, + * which mis-bound the json columns as `integer` ("column input_mappings is of type jsonb but + * expression is of type integer"). [BuildingBlockProcessLink] now declares `@SecondaryRow` + * (optional = false) so the row is always written with a plain INSERT/UPDATE. + * + * NB: this test exercises the same columns/entity but does not by itself reproduce that MERGE — the + * failing statement is only emitted by the static-update autoflush the full import/deploy path + * triggers (see BuildingBlockProcessLink's `@SecondaryRow` note); repository save/merge here binds + * the json correctly regardless of the flag. It is a persistence round-trip guard, not a strict + * regression test for the Hibernate binding. + */ +@Transactional +class BuildingBlockProcessLinkPersistenceIT @Autowired constructor( + private val processLinkRepository: ProcessLinkRepository, +) : BaseIntegrationTest() { + + @PersistenceContext + lateinit var entityManager: EntityManager + + @Test + fun `inserts and updates a building-block process link that has a plugin configuration mapping`() { + assertInsertAndUpdateRoundTrips( + initialMappings = mapOf("external-plugin:case-summary@0.1.0" to UUID.randomUUID()), + updatedMappings = mapOf("external-plugin:case-summary@0.1.0" to UUID.randomUUID()), + ) + } + + @Test + fun `inserts and updates a building-block process link without a plugin configuration mapping`() { + assertInsertAndUpdateRoundTrips( + initialMappings = emptyMap(), + updatedMappings = emptyMap(), + ) + } + + private fun assertInsertAndUpdateRoundTrips( + initialMappings: Map, + updatedMappings: Map, + ) { + val id = UUID.randomUUID() + val processDefinitionId = "energy-subsidy-request:1:${UUID.randomUUID()}" + val buildingBlockDefinitionId = BuildingBlockDefinitionId.of("subsidy-calculator", "1.0.0") + + // INSERT the secondary row. + processLinkRepository.saveAndFlush( + buildingBlockProcessLink(id, processDefinitionId, buildingBlockDefinitionId, initialMappings, "doc:/before") + ) + + // UPDATE the secondary row (the mappings change), then reload and assert the json round-trips. + processLinkRepository.saveAndFlush( + buildingBlockProcessLink(id, processDefinitionId, buildingBlockDefinitionId, updatedMappings, "doc:/after") + ) + entityManager.clear() + + val reloaded = processLinkRepository.findById(id).orElseThrow() as BuildingBlockProcessLink + assertThat(reloaded.pluginConfigurationMappings).isEqualTo(updatedMappings) + assertThat(reloaded.inputMappings).containsExactly( + BuildingBlockInputMapping(source = "doc:/after", target = "target") + ) + assertThat(reloaded.outputMappings).containsExactly( + BuildingBlockOutputMapping(source = "result", target = "doc:/result", syncTiming = BuildingBlockSyncTiming.END) + ) + } + + private fun buildingBlockProcessLink( + id: UUID, + processDefinitionId: String, + buildingBlockDefinitionId: BuildingBlockDefinitionId, + pluginConfigurationMappings: Map, + inputSource: String, + ) = BuildingBlockProcessLink( + id = id, + processDefinitionId = processDefinitionId, + activityId = "callActivity", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = buildingBlockDefinitionId, + pluginConfigurationMappings = pluginConfigurationMappings, + inputMappings = listOf(BuildingBlockInputMapping(source = inputSource, target = "target")), + outputMappings = listOf( + BuildingBlockOutputMapping(source = "result", target = "doc:/result", syncTiming = BuildingBlockSyncTiming.END) + ), + ) +} diff --git a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt index 5e4ab02797..e7c9063eb8 100644 --- a/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt +++ b/backend/building-block/src/test/kotlin/com/ritense/buildingblock/processlink/service/DefaultBuildingBlockPluginConfigurationResolverIT.kt @@ -250,6 +250,43 @@ class DefaultBuildingBlockPluginConfigurationResolverIT @Autowired constructor( assertThat(resolvedConfigId).isEqualTo(pluginConfigurationId) } + @Test + fun `resolveByKeyPrefix matches a mapping made for a different version of the plugin`() { + // The building block's mapping was made for case-summary@0.1.0... + val bb1ProcessLink = BuildingBlockProcessLink( + id = UUID.randomUUID(), + processDefinitionId = "case-process", + activityId = "callBB1", + activityType = ActivityTypeWithEventName.CALL_ACTIVITY_START, + buildingBlockDefinitionId = bb1DefinitionId, + pluginConfigurationMappings = mapOf("external-plugin:case-summary@0.1.0" to pluginConfigurationId), + inputMappings = emptyList() + ) + + whenever(processLinkService.getProcessLinks("case-process", "callBB1")).thenReturn(listOf(bb1ProcessLink)) + + val bb1Execution = createMockExecution( + processDefinitionId = "case-process", + activityId = "callBB1", + businessKey = caseDocumentId.toString() + ) + runWithoutAuthorization { + listener.onCallActivityStart(OperatonExecutionEvent(bb1Execution)) + } + val bb1Instance = buildingBlockInstanceRepository.findAll().first() + bb1Instance.processInstanceId = "bb1-process-instance" + buildingBlockInstanceService.save(bb1Instance) + + val bb1ProcessExecution = mock { + on { processInstanceId } doReturn "bb1-process-instance" + } + + // ...so an exact lookup for 0.2.0 finds nothing, but the version-agnostic prefix does. + assertThat(resolver.resolve(bb1ProcessExecution, "external-plugin:case-summary@0.2.0")).isNull() + assertThat(resolver.resolveByKeyPrefix(bb1ProcessExecution, "external-plugin:case-summary@")) + .isEqualTo(pluginConfigurationId) + } + private fun createMockExecution( processDefinitionId: String, activityId: String, diff --git a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt index abab9c5cc7..63e0bef1cc 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/configuration/CaseAutoConfiguration.kt @@ -27,9 +27,9 @@ import com.ritense.case.repository.CaseDefinitionListColumnRepository import com.ritense.case.repository.CaseTabDocumentDefinitionMapper import com.ritense.case.repository.CaseTabRepository import com.ritense.case.repository.CaseTabSpecificationFactory +import com.ritense.case.repository.HiddenTaskListColumnRepository import com.ritense.case.repository.QuickSearchRepository import com.ritense.case.repository.StartableItemRepository -import com.ritense.case.repository.HiddenTaskListColumnRepository import com.ritense.case.repository.TaskListColumnRepository import com.ritense.case.security.config.CaseHttpSecurityConfigurer import com.ritense.case.service.CaseDefinitionCheckerImpl @@ -48,9 +48,9 @@ import com.ritense.case.service.CaseTabImporter import com.ritense.case.service.CaseTabService import com.ritense.case.service.CaseTaskListExporter import com.ritense.case.service.CaseTaskListImporter +import com.ritense.case.service.ConfigurationIssueCaseDefinitionFinalizationChecker import com.ritense.case.service.StartableItemExporter import com.ritense.case.service.StartableItemImporter -import com.ritense.case.service.ConfigurationIssueCaseDefinitionFinalizationChecker import com.ritense.case.service.StartableItemManagementService import com.ritense.case.service.StartableItemProvider import com.ritense.case.service.StartableItemService @@ -67,6 +67,7 @@ import com.ritense.case_.authorization.CaseDefinitionSpecificationFactory import com.ritense.case_.repository.CaseDefinitionRepository import com.ritense.case_.repository.HiddenCaseListColumnRepository import com.ritense.case_.service.ActiveCaseDefinitionService +import com.ritense.case_.service.ExternalPluginCaseTabResolver import com.ritense.document.service.DocumentDefinitionService import com.ritense.document.service.DocumentSearchService import com.ritense.document.service.DocumentService @@ -96,6 +97,7 @@ import org.springframework.core.io.ResourceLoader import org.springframework.core.io.support.PathMatchingResourcePatternResolver import org.springframework.core.io.support.ResourcePatternResolver import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import java.util.Optional @AutoConfiguration @EnableJpaRepositories( @@ -331,9 +333,11 @@ class CaseAutoConfiguration { fun caseTabExporter( objectMapper: ObjectMapper, caseTabService: CaseTabService, + externalPluginCaseTabResolver: Optional, ) = CaseTabExporter( objectMapper, - caseTabService + caseTabService, + externalPluginCaseTabResolver ) @Bean @@ -359,8 +363,9 @@ class CaseAutoConfiguration { fun caseTabImporter( objectMapper: ObjectMapper, caseTabRepository: CaseTabRepository, - applicationEventPublisher: ApplicationEventPublisher - ) = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher) + applicationEventPublisher: ApplicationEventPublisher, + pluginConfigurationMappingResolvers: List + ) = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, pluginConfigurationMappingResolvers) @Bean @ConditionalOnMissingBean(CaseTaskListExporter::class) diff --git a/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt b/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt index f7c4a043ed..9ed7afe495 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/deployment/CaseTabDto.kt @@ -16,6 +16,7 @@ package com.ritense.case.deployment +import com.fasterxml.jackson.annotation.JsonInclude import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabType @@ -25,6 +26,19 @@ data class CaseTabDto( val type: CaseTabType, val contentKey: String, val showTasks: Boolean = false, + + /** + * Design-time plugin identity for `EXTERNAL_PLUGIN` tabs only, populated by the exporter so the + * export is self-describing (the `contentKey` alone carries just the configuration id). Left + * `null`/absent for every other tab type and for exports produced before this field existed; + * the import preview falls back to resolving the configuration when these are absent. Serialized + * only when set so unaffected tab types export byte-for-byte as before. + */ + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionKey: String? = null, + + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginVersion: String? = null, ) { companion object { fun of(caseTab: CaseTab) = CaseTabDto( diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt index 4c875914fe..01adae55bd 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseDefinitionCheckerImpl.kt @@ -83,17 +83,23 @@ class CaseDefinitionCheckerImpl( } override fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationType: String) { + assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, listOf(configurationType)) + } + + override fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationTypes: Collection) { assertCanUpdateGlobalConfiguration() val caseDefinition = caseDefinitionRepository.findById(caseDefinitionId).orElse(null) ?: error("CaseDefinition $caseDefinitionId does not exist.") if (!caseDefinition.final) { return } - val hasUnresolvedIssue = configurationIssueRepository.findUnresolvedByCaseDefinitionIdAndIssueType( - caseDefinitionId, configurationType - ) != null + val hasUnresolvedIssue = configurationTypes.any { configurationType -> + configurationIssueRepository.findUnresolvedByCaseDefinitionIdAndIssueType( + caseDefinitionId, configurationType + ) != null + } require(hasUnresolvedIssue) { - "Failed to update CaseDefinition $caseDefinitionId. This case definition is final and has no unresolved configuration issues of type '$configurationType'." + "Failed to update CaseDefinition $caseDefinitionId. This case definition is final and has no unresolved configuration issues of type(s) '${configurationTypes.joinToString()}'." } } diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt index 663632cb33..5c5b79fec5 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabExporter.kt @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.ritense.case.deployment.CaseTabDto import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabType +import com.ritense.case_.service.ExternalPluginCaseTabResolver import com.ritense.exporter.ExportFile import com.ritense.exporter.ExportPrettyPrinter import com.ritense.exporter.ExportResult @@ -29,11 +30,14 @@ import com.ritense.exporter.request.ExportRequest import com.ritense.exporter.request.FormDefinitionExportRequest import com.ritense.valtimo.contract.case_.CaseDefinitionId import org.springframework.transaction.annotation.Transactional +import java.util.Optional +import java.util.UUID @Transactional(readOnly = true) class CaseTabExporter( private val objectMapper: ObjectMapper, - private val caseTabService: CaseTabService + private val caseTabService: CaseTabService, + private val externalPluginCaseTabResolver: Optional = Optional.empty(), ) : Exporter { override fun supports() = DocumentDefinitionExportRequest::class.java @@ -52,7 +56,7 @@ class CaseTabExporter( val caseTabExport = ExportFile( PATH.format(caseDefinitionKey, formattedCaseDefinitionVersion, caseDefinitionKey), - objectMapper.writer(ExportPrettyPrinter()).writeValueAsBytes(caseTabs.map(CaseTabDto::of)) + objectMapper.writer(ExportPrettyPrinter()).writeValueAsBytes(caseTabs.map(::toExportDto)) ) return ExportResult( @@ -61,6 +65,34 @@ class CaseTabExporter( ) } + /** + * Maps a tab to its export DTO, enriching `EXTERNAL_PLUGIN` tabs with their plugin definition + * (`pluginId`/version) so the export is self-describing — the import preview can then identify + * the plugin even when the referenced configuration was deleted in the target, matching how a + * process link's export already carries its plugin key/version. A tab whose configuration can no + * longer be resolved here (or when external-plugin isn't on the classpath) exports as before, + * without the plugin key. + */ + private fun toExportDto(caseTab: CaseTab): CaseTabDto { + val dto = CaseTabDto.of(caseTab) + if (caseTab.type != CaseTabType.EXTERNAL_PLUGIN) { + return dto + } + val resolver = externalPluginCaseTabResolver.orElse(null) ?: return dto + val configurationId = caseTab.contentKey.substringBefore(':').toUuidOrNull() ?: return dto + val definition = resolver.resolvePluginDefinition(configurationId) ?: return dto + return dto.copy( + pluginDefinitionKey = definition.pluginDefinitionKey, + pluginVersion = definition.pluginDefinitionVersion, + ) + } + + private fun String.toUuidOrNull(): UUID? = try { + UUID.fromString(this) + } catch (_: IllegalArgumentException) { + null + } + private fun createFormDefininitionExportRequests(caseTabs: List, caseDefinitionId: CaseDefinitionId): Set { return caseTabs.filter { it.type == CaseTabType.FORMIO diff --git a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt index 8d3ec5ae73..82841bb2c3 100644 --- a/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case/service/CaseTabImporter.kt @@ -28,7 +28,7 @@ import com.ritense.importer.ImportRequest import com.ritense.importer.Importer import com.ritense.importer.ValtimoImportTypes.Companion.CASE_TAB import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION -import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import org.springframework.context.ApplicationEventPublisher import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -38,6 +38,7 @@ class CaseTabImporter( private val objectMapper: ObjectMapper, private val caseTabRepository: CaseTabRepository, private val applicationEventPublisher: ApplicationEventPublisher, + private val pluginConfigurationMappingResolvers: List = emptyList(), ) : Importer { override fun type() = CASE_TAB @@ -49,6 +50,16 @@ class CaseTabImporter( deploy(request) } + /** + * A case tab is not a process link, so it gets no detection from the process-link importer. + * Trigger an in-transaction recheck here — for external-plugin tabs this is what raises the + * configuration issue when the tab references a plugin configuration missing in this environment. + */ + override fun afterImport(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId ?: return + pluginConfigurationMappingResolvers.forEach { it.recheckIssuesForCaseDefinition(caseDefinitionId) } + } + private fun deploy(request: ImportRequest) { val caseDefinitionId = request.caseDefinitionId!! val fileContent = request.content.toString(Charsets.UTF_8) @@ -65,7 +76,7 @@ class CaseTabImporter( tab.contentKey } - caseTabRepository.save( + val saved = caseTabRepository.save( CaseTab( id = CaseTabId(caseDefinitionId, tab.key), name = tab.name, @@ -75,11 +86,19 @@ class CaseTabImporter( showTasks = tab.showTasks ) ) + saved to tab } + // Carry the export's plugin identity onto the event so the EXTERNAL_PLUGIN side row can + // persist it — this is what keeps a tab dangling on import (its configuration missing here) + // identifiable in the repair panel afterwards. savedTabs - .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } - .forEach { applicationEventPublisher.publishEvent(CaseTabCreatedEvent(it)) } + .filter { (saved, _) -> saved.type == CaseTabType.EXTERNAL_PLUGIN } + .forEach { (saved, dto) -> + applicationEventPublisher.publishEvent( + CaseTabCreatedEvent(saved, dto.pluginDefinitionKey, dto.pluginVersion) + ) + } } /** diff --git a/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt b/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt index 537a08b4b8..e6219a594c 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/domain/tab/CaseExternalPluginTab.kt @@ -28,6 +28,12 @@ import java.util.UUID * the tab and (optionally) which `case-tab` bundle to render when the plugin ships more than one. * Shares the `case_tab` composite key and is removed `ON DELETE CASCADE` when the parent tab is * deleted (mirrors [CaseWidgetTab]). + * + * [pluginDefinitionKey]/[pluginDefinitionVersion] are design-time plugin identity — the same + * metadata `process_link` persists for a plugin process link. They let the "missing plugin + * configurations" repair panel identify the plugin even when [externalPluginConfigurationId] no + * longer resolves in this environment (the panel reads the database, not the export). `null` for + * rows where the plugin could not be resolved/carried at creation. */ @Entity @Table(name = "case_external_plugin_tab") @@ -40,4 +46,10 @@ data class CaseExternalPluginTab( @Column(name = "bundle_key") val bundleKey: String? = null, + + @Column(name = "plugin_definition_key") + val pluginDefinitionKey: String? = null, + + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, ) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt index ad1c96d48d..d5174185f5 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginTabService.kt @@ -70,7 +70,7 @@ class CaseExternalPluginTabService( @EventListener(CaseTabCreatedEvent::class) fun handleCaseTabCreatedEvent(event: CaseTabCreatedEvent) { if (event.tab.type != CaseTabType.EXTERNAL_PLUGIN) return - upsertSideRow(event.tab) + upsertSideRow(event.tab, event.pluginDefinitionKey, event.pluginDefinitionVersion) } /** @@ -88,13 +88,27 @@ class CaseExternalPluginTabService( } } - private fun upsertSideRow(tab: CaseTab) { + /** + * Persists the side row, capturing the tab's plugin identity. Prefers resolving it from the + * (present) configuration — authoritative, and how the management-API create/update path works — + * and falls back to the [importedPluginDefinitionKey]/[importedPluginDefinitionVersion] carried + * from the export when the configuration is missing here (a dangling import). Without either, the + * key stays `null` and the repair panel can't identify the plugin (unchanged legacy behaviour). + */ + private fun upsertSideRow( + tab: CaseTab, + importedPluginDefinitionKey: String? = null, + importedPluginDefinitionVersion: String? = null, + ) { val (configurationId, bundleKey) = parseContentKey(tab.contentKey) + val resolved = resolver.orElse(null)?.resolvePluginDefinition(configurationId) caseExternalPluginTabRepository.save( CaseExternalPluginTab( id = tab.id, externalPluginConfigurationId = configurationId, bundleKey = bundleKey, + pluginDefinitionKey = resolved?.pluginDefinitionKey ?: importedPluginDefinitionKey, + pluginDefinitionVersion = resolved?.pluginDefinitionVersion ?: importedPluginDefinitionVersion, ) ) } diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt index 5e207045f1..d54bef8e15 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseTabResolver.kt @@ -38,4 +38,23 @@ interface ExternalPluginCaseTabResolver { * selects the sole `case-tab` bundle */ fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? + + /** + * Resolves the plugin definition (`pluginId` + version) backing the configuration, or `null` + * when the configuration/definition can no longer be found. Used at export time so a `case-tab` + * export is self-describing: unlike a process link (whose export already carries its plugin + * key/version), a tab's `contentKey` holds only the configuration id. Embedding the resolved + * definition lets the import preview identify the plugin even when the referenced configuration + * was deleted in the target environment (otherwise the tab is an unidentifiable, unmappable row). + */ + fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? } + +/** + * Design-time plugin identity of an `EXTERNAL_PLUGIN` case tab, embedded in the tab export so the + * import preview can identify the plugin without resolving the (possibly-deleted) configuration. + */ +data class ExternalPluginTabDefinition( + val pluginDefinitionKey: String, + val pluginDefinitionVersion: String, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt index 10f9df7a11..3fe2e3d2c3 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/event/CaseTabCreatedEvent.kt @@ -18,4 +18,15 @@ package com.ritense.case_.service.event import com.ritense.case.domain.CaseTab -data class CaseTabCreatedEvent(val tab: CaseTab) \ No newline at end of file +/** + * [pluginDefinitionKey]/[pluginDefinitionVersion] carry the design-time plugin identity for an + * `EXTERNAL_PLUGIN` tab so its side row can persist it — populated by the importer from the + * self-describing export, so a tab dangling on import (its configuration missing here) stays + * identifiable. `null` for other tab types and for callers that resolve the plugin from the + * (present) configuration instead. + */ +data class CaseTabCreatedEvent( + val tab: CaseTab, + val pluginDefinitionKey: String? = null, + val pluginDefinitionVersion: String? = null, +) \ No newline at end of file diff --git a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt index 24d9555e86..ae167eaaa5 100644 --- a/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt +++ b/backend/case/src/test/kotlin/com/ritense/case/service/CaseTabImporterTest.kt @@ -17,15 +17,14 @@ package com.ritense.case.service import com.ritense.case.domain.CaseTab -import com.ritense.case.domain.CaseTabId import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository import com.ritense.case_.service.event.CaseTabCreatedEvent import com.ritense.importer.ImportRequest import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION -import com.ritense.importer.ValtimoImportTypes.Companion.FORM import com.ritense.valtimo.contract.case_.CaseDefinitionId import com.ritense.valtimo.contract.json.MapperSingleton +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -190,6 +189,28 @@ class CaseTabImporterTest( verify(applicationEventPublisher, never()).publishEvent(any()) } + @Test + fun `afterImport rechecks configuration issues for the case definition (in-transaction tab detection)`() { + val resolver = mock() + val importerWithResolver = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, listOf(resolver)) + + importerWithResolver.afterImport( + ImportRequest(fileName = FILENAME, content = "[]".toByteArray(Charsets.UTF_8), caseDefinitionId = CASE_DEFINITION_ID) + ) + + verify(resolver).recheckIssuesForCaseDefinition(CASE_DEFINITION_ID) + } + + @Test + fun `afterImport does nothing without a case definition id`() { + val resolver = mock() + val importerWithResolver = CaseTabImporter(objectMapper, caseTabRepository, applicationEventPublisher, listOf(resolver)) + + importerWithResolver.afterImport(ImportRequest(fileName = FILENAME, content = "[]".toByteArray(Charsets.UTF_8))) + + verify(resolver, never()).recheckIssuesForCaseDefinition(any()) + } + private companion object { const val FILENAME = "/case/tab/my-doc-def.case-tab.json" val CASE_DEFINITION_ID: CaseDefinitionId = CaseDefinitionId.of("my-doc-def", "1.0.0") diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt index cdb0583a6d..bf92b16ec4 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/case_/CaseDefinitionChecker.kt @@ -49,4 +49,14 @@ interface CaseDefinitionChecker { fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationType: String) { assertCanUpdateCaseDefinition(caseDefinitionId) } + + /** + * Multi-type variant for callers that repair several configuration-issue types in one operation + * (e.g. an external-plugin resolve that fixes service-task links, task-form links and case tabs + * together): a final case definition may be updated when *any* of the given types has an + * unresolved issue. + */ + fun assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId: CaseDefinitionId, configurationTypes: Collection) { + assertCanUpdateCaseDefinition(caseDefinitionId) + } } \ No newline at end of file diff --git a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt index 815918d9d1..f9728e51f6 100644 --- a/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt +++ b/backend/contract/src/main/kotlin/com/ritense/valtimo/contract/plugin/PluginConfigurationMappingResolver.kt @@ -35,4 +35,13 @@ interface PluginConfigurationMappingResolver { fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) fun getDanglingPluginConfigurations(caseDefinitionId: CaseDefinitionId): List fun recheckIssuesForProcessDefinition(processDefinitionId: String) + + /** + * Re-evaluates and (re)publishes this resolver's configuration issues for a whole case + * definition, independent of any process-link change. Lets surfaces that are not process links — + * e.g. external-plugin case tabs — get reliable, in-transaction issue detection at import time + * (triggered from their own importer) rather than depending on an incidental process-link event. + * Default no-op for resolvers with nothing to recheck at case-definition granularity. + */ + fun recheckIssuesForCaseDefinition(caseDefinitionId: CaseDefinitionId) {} } diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml index 852d7b8f01..cd704a3ff1 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml @@ -30,5 +30,6 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liqui + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml new file mode 100644 index 0000000000..3d3dcbcf12 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260728-add-case-external-plugin-tab-plugin-definition.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt index 48e6a3c3ca..f9b728206c 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -17,6 +17,8 @@ package com.ritense.externalplugin.autoconfigure import com.fasterxml.jackson.databind.ObjectMapper +import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.repository.CaseExternalPluginTabRepository import com.ritense.externalplugin.client.ExternalPluginHostClient import com.ritense.externalplugin.compatibility.DefaultGzacVersionProvider import com.ritense.externalplugin.compatibility.GzacCompatibilityChecker @@ -65,8 +67,6 @@ import com.ritense.externalplugin.service.PluginPropertyEncryptor import com.ritense.externalplugin.web.rest.ExternalPluginManagementResource import com.ritense.externalplugin.web.rest.ExternalPluginMenuPageResource import com.ritense.externalplugin.web.rest.ExternalPluginUserTokenResource -import com.ritense.case.repository.CaseTabRepository -import com.ritense.case_.repository.CaseExternalPluginTabRepository import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver import com.ritense.plugin.service.EncryptionService import com.ritense.plugin.service.PluginActionResultHandler @@ -175,7 +175,9 @@ class ExternalPluginAutoConfiguration { @ConditionalOnMissingBean(ExternalPluginCaseTabResolverImpl::class) fun externalPluginCaseTabResolver( bundleUrlResolver: ExternalPluginBundleUrlResolver, - ) = ExternalPluginCaseTabResolverImpl(bundleUrlResolver) + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ) = ExternalPluginCaseTabResolverImpl(bundleUrlResolver, configurationRepository, definitionRepository) @Bean @ConditionalOnMissingBean(ExternalPluginMenuPageService::class) diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt index ed00bce172..135f0ee4e5 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt @@ -109,23 +109,26 @@ class ExternalPluginImportPreviewContributor( val configId = contentKey.substringBefore(':').toUuidOrNull() ?: continue val tabKey = node.path("key").asText(fileName) - // The tab's contentKey only carries the config id; resolve the plugin key/version - // through the configuration when it exists locally. Without a pluginDefinitionKey the - // import wizard cannot offer the row for mapping at all (it filters key-less rows), so - // an unresolvable configuration stays a key-less "unidentifiable" row as before. + // Self-describing exports carry the tab's plugin key/version directly (like a process + // link), so the plugin stays identifiable even when the referenced configuration was + // deleted in the target. Fall back to resolving through the configuration for exports + // produced before that field existed; without a pluginDefinitionKey the import wizard + // filters the row out as "unidentifiable" (unmappable), as before. val configuration = configurationRepository.findById(configId).orElse(null) val definition = configuration?.let { definitionRepository.findById(it.definitionId).orElse(null) } + val pluginDefinitionKey = node.path("pluginDefinitionKey").asText(null) ?: definition?.pluginId + val pluginDefinitionVersion = node.path("pluginVersion").asText(null) ?: definition?.version result.add( ImportPreviewContribution( pluginConfigurationId = configId, - pluginDefinitionKey = definition?.pluginId, + pluginDefinitionKey = pluginDefinitionKey, pluginActionDefinitionKey = "case-tab", processDefinitionKey = fileName, activityId = tabKey, existsInTargetEnvironment = configuration != null, source = SOURCE_EXTERNAL, - pluginDefinitionVersion = definition?.version, + pluginDefinitionVersion = pluginDefinitionVersion, ) ) } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt index b725726706..66cd553174 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListener.kt @@ -181,6 +181,17 @@ class ExternalPluginServiceTaskStartListener( val mappingKey = buildingBlockMappingKey(pluginId, version) resolver.resolve(execution, mappingKey) + ?: resolver.resolveByKeyPrefix(execution, buildingBlockMappingKeyPrefix(pluginId))?.also { + // Version-tolerant fallback: no mapping for the exact pinned version, but one + // exists for another version of the same plugin. The resolved configuration's + // version wins at runtime (D1), mirroring how a mismatched version is accepted + // for FIXED links; validateResolvedDefinition surfaces the mismatch as a warning. + logger.warn { + "No building-block plugin configuration mapping for '$mappingKey' (process " + + "link '${processLink.id}'); using a mapping for a different version of " + + "external plugin '$pluginId'." + } + } ?: throw IllegalStateException( "No plugin configuration mapping provided for external plugin '$mappingKey' " + "(process link '${processLink.id}')" @@ -233,6 +244,9 @@ class ExternalPluginServiceTaskStartListener( private fun buildingBlockMappingKey(pluginId: String, version: String) = "external-plugin:$pluginId@$version" + /** Version-agnostic prefix of [buildingBlockMappingKey]: matches a mapping for any version of the plugin. */ + private fun buildingBlockMappingKeyPrefix(pluginId: String) = "external-plugin:$pluginId@" + private fun resolveActionProperties(execution: DelegateExecution, processLink: ExternalPluginProcessLink): ObjectNode { val rawProperties = processLink.actionProperties ?: objectMapper.createObjectNode() val keysToResolve = mutableListOf() diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt index c8a8685da0..08677a9e0a 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/processlink/ExternalPluginTaskFormProcessLinkMapper.kt @@ -170,10 +170,13 @@ class ExternalPluginTaskFormProcessLinkMapper( } /** - * Mirrors [ExternalPluginProcessLinkMapper.afterImport]. Unlike the service-task link, the - * task-form link's `externalPluginConfigurationId` is never nullable — it always references a - * `FIXED` configuration — so "dangling" here only means the referenced configuration no longer - * exists in the target environment. + * Mirrors [ExternalPluginProcessLinkMapper.afterImport], but publishes under its **own** + * [ISSUE_TYPE]. The two link kinds inspect disjoint repositories, so sharing a single issue type + * let one mapper's "resolved" verdict clobber the other's "detected" verdict (last writer in the + * mapper loop wins). Each owning its own type removes that coupling entirely. Unlike the + * service-task link, the task-form link's `externalPluginConfigurationId` is never nullable — it + * always references a `FIXED` configuration — so "dangling" here only means the referenced + * configuration no longer exists in the target environment. */ override fun afterImport( caseDefinitionId: CaseDefinitionId, @@ -186,11 +189,11 @@ class ExternalPluginTaskFormProcessLinkMapper( if (hasIssue) { applicationEventPublisher.publishEvent( - CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginProcessLinkMapper.ISSUE_TYPE) + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE) ) } else { applicationEventPublisher.publishEvent( - CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginProcessLinkMapper.ISSUE_TYPE) + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE) ) } } @@ -213,4 +216,8 @@ class ExternalPluginTaskFormProcessLinkMapper( pluginDefinitionVersion = definition?.version ?: pluginVersion, ) } + + companion object { + const val ISSUE_TYPE = "external-plugin-task-form" + } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt index ee6f0c737d..6a33038654 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImpl.kt @@ -17,6 +17,9 @@ package com.ritense.externalplugin.service import com.ritense.case_.service.ExternalPluginCaseTabResolver +import com.ritense.case_.service.ExternalPluginTabDefinition +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository import com.ritense.valtimo.contract.annotation.SkipComponentScan import org.springframework.stereotype.Service import java.util.UUID @@ -24,17 +27,26 @@ import java.util.UUID /** * external-plugin's implementation of the case-module [ExternalPluginCaseTabResolver] SPI. Resolves * a plugin configuration's `case-tab` bundle to its absolute URL by delegating to the shared - * [ExternalPluginBundleUrlResolver] with the `case-tab` bundle type (behaviour-preserving). + * [ExternalPluginBundleUrlResolver] with the `case-tab` bundle type (behaviour-preserving), and the + * configuration's plugin definition (`pluginId`/version) for the self-describing tab export. */ @Service @SkipComponentScan class ExternalPluginCaseTabResolverImpl( private val bundleUrlResolver: ExternalPluginBundleUrlResolver, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, ) : ExternalPluginCaseTabResolver { override fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? = bundleUrlResolver.resolve(configurationId, CASE_TAB_TYPE, bundleKey) + override fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? { + val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return null + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) ?: return null + return ExternalPluginTabDefinition(definition.pluginId, definition.version) + } + companion object { private const val CASE_TAB_TYPE = "case-tab" } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt index c9b57239eb..faa82a4281 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt @@ -24,7 +24,8 @@ import com.ritense.case_.domain.tab.CaseExternalPluginTab import com.ritense.case_.repository.CaseExternalPluginTabRepository import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink -import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper.Companion.ISSUE_TYPE +import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper +import com.ritense.externalplugin.processlink.ExternalPluginTaskFormProcessLinkMapper import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository @@ -40,6 +41,7 @@ import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto import com.ritense.valtimo.contract.plugin.DanglingPluginConfigurationDto.Companion.SOURCE_EXTERNAL import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -63,7 +65,7 @@ open class ExternalPluginConfigurationMappingResolver( ) : PluginConfigurationMappingResolver { override fun resolve(caseDefinitionId: CaseDefinitionId, mappings: Map) { - caseDefinitionChecker.assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, ISSUE_TYPE) + caseDefinitionChecker.assertCanUpdateCaseDefinitionConfiguration(caseDefinitionId, ALL_ISSUE_TYPES) val processDefinitionIds = processDefinitionIdsFor(caseDefinitionId) @@ -121,6 +123,17 @@ open class ExternalPluginConfigurationMappingResolver( checkForRemainingIssues(caseDefinitionId, processDefinitionIds) } + /** + * In-transaction recheck for a whole case definition, triggered from `CaseTabImporter.afterImport` + * so a dangling `EXTERNAL_PLUGIN` case tab is detected reliably at import time. (The tab surface + * has no process link, so it would otherwise depend on an incidental process-link recheck, which + * fires only AFTER_COMMIT and does not persist during import.) Re-publishes all three per-surface + * verdicts; idempotent when a surface is already correct. + */ + override fun recheckIssuesForCaseDefinition(caseDefinitionId: CaseDefinitionId) { + checkForRemainingIssues(caseDefinitionId, processDefinitionIdsFor(caseDefinitionId)) + } + private fun resolveProcessLinks(processDefinitionIds: List, mappings: Map) { val links = allLinks(processDefinitionIds).filter { it.pluginConfigurationReference.type == FIXED } for (link in links) { @@ -168,51 +181,75 @@ open class ExternalPluginConfigurationMappingResolver( val newContentKey = if (bundlePart.isEmpty()) mappedId.toString() else "$mappedId:$bundlePart" val updatedTab = tab.copy(contentKey = newContentKey) + val existingSideRow = caseExternalPluginTabRepository.findByIdOrNull(tab.id) caseTabRepository.save(updatedTab) caseExternalPluginTabRepository.save( CaseExternalPluginTab( id = updatedTab.id, externalPluginConfigurationId = mappedId, bundleKey = bundlePart.ifEmpty { null }, + // The chooser maps to a configuration of the same plugin, so the plugin identity + // is unchanged — preserve it rather than re-deriving it. + pluginDefinitionKey = existingSideRow?.pluginDefinitionKey, + pluginDefinitionVersion = existingSideRow?.pluginDefinitionVersion, ) ) } } private fun danglingTabsFor(caseDefinitionId: CaseDefinitionId): List { - val danglingConfigIds = caseTabsFor(caseDefinitionId) + val danglingTabs = caseTabsFor(caseDefinitionId) .filter { it.type == CaseTabType.EXTERNAL_PLUGIN } - .mapNotNull { it.contentKey.substringBefore(':').toUuidOrNull() } - .filter { !configurationRepository.existsById(it) } - .toSet() - - if (danglingConfigIds.isEmpty()) return emptyList() - - return listOf( - DanglingPluginConfigurationDto( - pluginDefinitionKey = null, - sourcePluginConfigurationIds = danglingConfigIds, - source = SOURCE_EXTERNAL, - pluginDefinitionVersion = null, - ) - ) + .mapNotNull { caseExternalPluginTabRepository.findByIdOrNull(it.id) } + .filter { !configurationRepository.existsById(it.externalPluginConfigurationId) } + + if (danglingTabs.isEmpty()) return emptyList() + + // Group by the persisted plugin identity so the repair panel can offer a per-plugin chooser, + // exactly like a dangling process link — instead of the old single key-less "unidentifiable" + // entry that forced a manual reconfigure. + return danglingTabs + .groupBy { it.pluginDefinitionKey to it.pluginDefinitionVersion } + .map { (keyAndVersion, sideRows) -> + val (pluginDefinitionKey, pluginDefinitionVersion) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = pluginDefinitionKey, + sourcePluginConfigurationIds = sideRows.map { it.externalPluginConfigurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + } } + /** + * Each external surface owns its own issue type and is judged independently, so one surface being + * clean can never clear another surface's issue (the cross-surface clobber that a single shared + * type suffered). Mirrors the per-surface `afterImport` detection on the mappers. + */ private fun checkForRemainingIssues(caseDefinitionId: CaseDefinitionId, processDefinitionIds: List) { - val hasProcessLinkIssue = allLinks(processDefinitionIds).any { link -> + publishIssue(caseDefinitionId, PROCESS_LINK_ISSUE_TYPE, hasProcessLinkIssue(processDefinitionIds)) + publishIssue(caseDefinitionId, TASK_FORM_ISSUE_TYPE, hasTaskFormIssue(processDefinitionIds)) + publishIssue(caseDefinitionId, CASE_TAB_ISSUE_TYPE, danglingTabsFor(caseDefinitionId).isNotEmpty()) + } + + private fun hasProcessLinkIssue(processDefinitionIds: List) = + allLinks(processDefinitionIds).any { link -> link.pluginConfigurationReference.type == FIXED && (link.externalPluginConfigurationId == null || !configurationRepository.existsById(link.externalPluginConfigurationId)) } - val hasTaskFormIssue = allTaskFormLinks(processDefinitionIds).any { link -> + + private fun hasTaskFormIssue(processDefinitionIds: List) = + allTaskFormLinks(processDefinitionIds).any { link -> !configurationRepository.existsById(link.externalPluginConfigurationId) } - val hasTabIssue = danglingTabsFor(caseDefinitionId).isNotEmpty() - if (hasProcessLinkIssue || hasTaskFormIssue || hasTabIssue) { - applicationEventPublisher.publishEvent(CaseConfigurationIssueDetectedEvent(caseDefinitionId, ISSUE_TYPE)) + private fun publishIssue(caseDefinitionId: CaseDefinitionId, issueType: String, hasIssue: Boolean) { + val event = if (hasIssue) { + CaseConfigurationIssueDetectedEvent(caseDefinitionId, issueType) } else { - applicationEventPublisher.publishEvent(CaseConfigurationIssueResolvedEvent(caseDefinitionId, ISSUE_TYPE)) + CaseConfigurationIssueResolvedEvent(caseDefinitionId, issueType) } + applicationEventPublisher.publishEvent(event) } private fun processDefinitionIdsFor(caseDefinitionId: CaseDefinitionId): List = @@ -234,4 +271,12 @@ open class ExternalPluginConfigurationMappingResolver( } catch (_: IllegalArgumentException) { null } + + companion object { + val PROCESS_LINK_ISSUE_TYPE = ExternalPluginProcessLinkMapper.ISSUE_TYPE + val TASK_FORM_ISSUE_TYPE = ExternalPluginTaskFormProcessLinkMapper.ISSUE_TYPE + const val CASE_TAB_ISSUE_TYPE = "external-plugin-case-tab" + + private val ALL_ISSUE_TYPES = listOf(PROCESS_LINK_ISSUE_TYPE, TASK_FORM_ISSUE_TYPE, CASE_TAB_ISSUE_TYPE) + } } diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt index 073de2703a..aafe974966 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt @@ -227,6 +227,38 @@ class ExternalPluginImportPreviewContributorTest { assertThat(result.single().existsInTargetEnvironment).isFalse() } + @Test + fun `a self-describing EXTERNAL_PLUGIN case tab stays identifiable when its configuration was deleted`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "summary", + "name": "Summary", + "type": "external_plugin", + "contentKey": "$configId:bundle-key", + "pluginDefinitionKey": "case-summary", + "pluginVersion": "0.1.0" + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("case/tab/my-doc.case-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + // Read from the export, not from the (deleted) configuration — so the row is mappable. + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.existsInTargetEnvironment).isFalse() + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + @Test fun `ignores non-EXTERNAL_PLUGIN case tabs`() { val json = """ diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt index 1b51622904..d4b0643a76 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/processlink/ExternalPluginServiceTaskStartListenerTest.kt @@ -27,7 +27,6 @@ import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService import com.ritense.externalplugin.service.ExternalPluginHostService -import com.ritense.plugin.domain.PluginActionResultMapping import com.ritense.plugin.domain.PluginConfigurationReference import com.ritense.plugin.domain.PluginConfigurationReferenceType import com.ritense.plugin.service.BuildingBlockPluginConfigurationResolver @@ -248,6 +247,44 @@ class ExternalPluginServiceTaskStartListenerTest { ) } + @Test + fun `BUILDING_BLOCK reference falls back to a mapping made for a different version of the same plugin`() { + val resolver = mock() + val listenerWithResolver = ExternalPluginServiceTaskStartListener( + processLinkRepository, + configurationService, + definitionService, + hostService, + hostClient, + valueResolverService, + objectMapper, + pluginActionResultHandler, + resolver, + ) + + // The reference pins 0.2.0, but only a mapping for another version (the wired-up 0.1.0) exists. + val processLink = buildingBlockProcessLink(pluginId = "case-summary", version = "0.2.0") + val execution = executionFor(processLink) + + whenever(resolver.resolve(execution, "external-plugin:case-summary@0.2.0")).thenReturn(null) + whenever(resolver.resolveByKeyPrefix(execution, "external-plugin:case-summary@")).thenReturn(configurationId) + whenever(hostClient.invokeAction(any(), any(), any(), any(), any(), any())).thenReturn( + ExternalPluginHostClient.ActionResponse(status = 200, body = objectMapper.createObjectNode()), + ) + + listenerWithResolver.notify(OperatonExecutionEvent(execution, "start")) + + verify(resolver).resolveByKeyPrefix(execution, "external-plugin:case-summary@") + verify(hostClient).invokeAction( + baseUrl = any(), + pluginId = eq("case-summary"), + version = eq("0.1.0"), + actionKey = any(), + payload = any(), + hostSecret = any(), + ) + } + @Test fun `BUILDING_BLOCK reference to a definition whose manifest no longer declares the action key throws a clear error`() { val resolver = mock() diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt index 380a035206..1512904263 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseTabResolverImplTest.kt @@ -35,7 +35,9 @@ class ExternalPluginCaseTabResolverImplTest { private val configurationRepository = mock() private val definitionRepository = mock() private val resolver = ExternalPluginCaseTabResolverImpl( - ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository) + ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository), + configurationRepository, + definitionRepository, ) @Test @@ -77,6 +79,25 @@ class ExternalPluginCaseTabResolverImplTest { assertThat(resolver.resolveBundleUrl(configId, null)).isNull() } + @Test + fun `resolvePluginDefinition returns the plugin id and version of the configuration's definition`() { + val (configId, _) = stub("""[ { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""") + + val definition = resolver.resolvePluginDefinition(configId) + + assertThat(definition).isNotNull + assertThat(definition!!.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(definition.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `resolvePluginDefinition returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolvePluginDefinition(configId)).isNull() + } + private fun stub(bundlesJson: String): Pair { val configId = UUID.randomUUID() val definitionId = UUID.randomUUID() diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt index e5d94a9352..80a9e23d83 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt @@ -20,10 +20,10 @@ import com.ritense.case.domain.CaseTab import com.ritense.case.domain.CaseTabId import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository +import com.ritense.case_.domain.tab.CaseExternalPluginTab import com.ritense.case_.repository.CaseExternalPluginTabRepository import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink -import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository import com.ritense.externalplugin.repository.ExternalPluginProcessLinkRepository import com.ritense.externalplugin.repository.ExternalPluginTaskFormProcessLinkRepository @@ -44,8 +44,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension import org.mockito.Mockito.lenient +import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq @@ -54,6 +54,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.context.ApplicationEventPublisher import org.springframework.data.jpa.domain.Specification +import java.util.Optional import java.util.UUID @ExtendWith(MockitoExtension::class) @@ -112,7 +113,11 @@ class ExternalPluginConfigurationMappingResolverTest { verify(caseDefinitionChecker).assertCanUpdateCaseDefinitionConfiguration( caseDefinitionId, - ExternalPluginProcessLinkMapper.ISSUE_TYPE, + listOf( + ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.TASK_FORM_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.CASE_TAB_ISSUE_TYPE, + ), ) } @@ -162,7 +167,6 @@ class ExternalPluginConfigurationMappingResolverTest { ) stubProcessDefinitions() whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) - whenever(configurationRepository.existsById(any())).thenReturn(true) resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) @@ -170,8 +174,9 @@ class ExternalPluginConfigurationMappingResolverTest { verify(caseTabRepository).save(tabCaptor.capture()) assertThat(tabCaptor.firstValue.contentKey).isEqualTo("$targetId:bundle-key") + // No prior side row is stubbed, so the preserved plugin identity is null here. verify(caseExternalPluginTabRepository).save( - com.ritense.case_.domain.tab.CaseExternalPluginTab( + CaseExternalPluginTab( id = tab.id, externalPluginConfigurationId = targetId, bundleKey = "bundle-key", @@ -201,11 +206,15 @@ class ExternalPluginConfigurationMappingResolverTest { resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) - verify(applicationEventPublisher).publishEvent(any()) + // Each surface is judged independently now; the process-link surface is clean. + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + verify(applicationEventPublisher, never()).publishEvent(any()) } @Test - fun `resolve emits detected event when dangling links remain`() { + fun `resolve emits detected event for the process-link surface when a dangling link remains`() { val danglingLink = processLink(externalPluginConfigurationId = UUID.randomUUID()) stubProcessDefinitions("pd-1") whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(listOf(danglingLink)) @@ -213,8 +222,14 @@ class ExternalPluginConfigurationMappingResolverTest { resolver.resolve(caseDefinitionId, emptyMap()) - verify(applicationEventPublisher).publishEvent(any()) - verify(applicationEventPublisher, never()).publishEvent(any()) + // The dangling service-task link raises the process-link issue; the clean task-form and tab + // surfaces are resolved independently (no cross-surface clobber). + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.TASK_FORM_ISSUE_TYPE) + ) } @Test @@ -269,6 +284,17 @@ class ExternalPluginConfigurationMappingResolverTest { stubProcessDefinitions("pd-1") whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn( + Optional.of( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = danglingConfigId, + bundleKey = null, + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + ) + ) whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) @@ -276,6 +302,9 @@ class ExternalPluginConfigurationMappingResolverTest { assertThat(result).hasSize(1) assertThat(result.single().sourcePluginConfigurationIds).containsExactly(danglingConfigId) assertThat(result.single().source).isEqualTo(SOURCE_EXTERNAL) + // The persisted plugin identity now makes the dangling tab identifiable (not key-less). + assertThat(result.single().pluginDefinitionKey).isEqualTo("case-summary") + assertThat(result.single().pluginDefinitionVersion).isEqualTo("0.1.0") } @Test @@ -289,7 +318,42 @@ class ExternalPluginConfigurationMappingResolverTest { resolver.recheckIssuesForProcessDefinition("pd-1") - verify(applicationEventPublisher).publishEvent(any()) + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueResolvedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE) + ) + } + + @Test + fun `recheckIssuesForCaseDefinition emits detected event for the case-tab surface when a tab is dangling`() { + val danglingConfigId = UUID.randomUUID() + val tab = CaseTab( + id = CaseTabId(caseDefinitionId, "summary"), + name = "Summary", + tabOrder = 0, + type = CaseTabType.EXTERNAL_PLUGIN, + contentKey = "$danglingConfigId:summary", + ) + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseTabRepository.findAll(any>())).thenReturn(listOf(tab)) + whenever(caseExternalPluginTabRepository.findById(tab.id)).thenReturn( + Optional.of( + CaseExternalPluginTab( + id = tab.id, + externalPluginConfigurationId = danglingConfigId, + bundleKey = "summary", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + ) + ) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + resolver.recheckIssuesForCaseDefinition(caseDefinitionId) + + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.CASE_TAB_ISSUE_TYPE) + ) } private fun stubProcessDefinitions(vararg processDefinitionIds: String) { diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt index 3419c375c8..61b81e926d 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/service/BuildingBlockPluginConfigurationResolver.kt @@ -23,4 +23,14 @@ import java.util.UUID interface BuildingBlockPluginConfigurationResolver { fun resolve(execution: DelegateExecution, pluginDefinitionKey: String): UUID? fun resolve(task: DelegateTask, pluginDefinitionKey: String): UUID? + + /** + * Resolves the configuration id for the first `pluginConfigurationMappings` key that starts with + * [keyPrefix], or `null` when none matches. Lets a caller resolve version-tolerantly: the + * external-plugin system keys building-block mappings as `external-plugin:@`, + * so a prefix of `external-plugin:@` matches a mapping made for a *different* version of + * the same plugin (the resolved configuration's version then applies at runtime — D1). Callers try + * the exact key first and fall back to this. Default no-op for resolvers that don't support it. + */ + fun resolveByKeyPrefix(execution: DelegateExecution, keyPrefix: String): UUID? = null } diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts index a93acdac83..e6e5008242 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/case-management-detail.component.ts @@ -74,10 +74,14 @@ export class CaseManagementDetailComponent implements OnInit, OnDestroy { map(params => params?.caseDefinitionKey ?? '') ); - public readonly caseListColumn$ = - this.configService.getFeatureToggleObservable('caseListColumn', true); - public readonly tabManagementEnabled$ = - this.configService.getFeatureToggleObservable('enableTabManagement', true); + public readonly caseListColumn$ = this.configService.getFeatureToggleObservable( + 'caseListColumn', + true + ); + public readonly tabManagementEnabled$ = this.configService.getFeatureToggleObservable( + 'enableTabManagement', + true + ); public _activeTab: TabEnum | string; public pendingTab: TabEnum | null | string; @@ -107,6 +111,8 @@ export class CaseManagementDetailComponent implements OnInit, OnDestroy { this.configurationIssueService.hasAnyOfIssues$([ 'plugin-process-link', 'external-plugin-process-link', + 'external-plugin-task-form', + 'external-plugin-case-tab', ]); private readonly _tabIssueCache = new Map>(); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts index 322d3b9055..8504dcfedd 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts @@ -65,6 +65,8 @@ import { const EMBEDDED_ISSUE_TYPE = 'plugin-process-link'; const EXTERNAL_ISSUE_TYPE = 'external-plugin-process-link'; +const EXTERNAL_TASK_FORM_ISSUE_TYPE = 'external-plugin-task-form'; +const EXTERNAL_CASE_TAB_ISSUE_TYPE = 'external-plugin-case-tab'; @Component({ selector: 'valtimo-case-management-missing-plugin-configurations', @@ -85,6 +87,8 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni public readonly hasIssue$ = this.configurationIssueService.hasAnyOfIssues$([ EMBEDDED_ISSUE_TYPE, EXTERNAL_ISSUE_TYPE, + EXTERNAL_TASK_FORM_ISSUE_TYPE, + EXTERNAL_CASE_TAB_ISSUE_TYPE, ]); public readonly mappingRows$ = new BehaviorSubject([]); public readonly hasUnknownPluginConfigurations$ = new BehaviorSubject(false);