diff --git a/backend/core/src/main/java/com/ritense/valtimo/operaton/repository/CustomRepositoryServiceImpl.java b/backend/core/src/main/java/com/ritense/valtimo/operaton/repository/CustomRepositoryServiceImpl.java
index a7ab887f66..47cfc5e3a1 100644
--- a/backend/core/src/main/java/com/ritense/valtimo/operaton/repository/CustomRepositoryServiceImpl.java
+++ b/backend/core/src/main/java/com/ritense/valtimo/operaton/repository/CustomRepositoryServiceImpl.java
@@ -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,9 +16,13 @@
package com.ritense.valtimo.operaton.repository;
+import com.ritense.valtimo.contract.BlueprintId;
import com.ritense.valtimo.contract.audit.utils.AuditHelper;
+import com.ritense.valtimo.contract.buildingblock.BuildingBlockDefinitionId;
+import com.ritense.valtimo.contract.case_.CaseDefinitionId;
import com.ritense.valtimo.contract.utils.RequestHelper;
import com.ritense.valtimo.domain.process.event.ProcessDefinitionDeletedEvent;
+import com.ritense.valtimo.event.ProcessDefinitionDeleted;
import java.time.LocalDateTime;
import java.util.UUID;
import org.operaton.bpm.engine.impl.RepositoryServiceImpl;
@@ -46,6 +50,20 @@ public void deleteProcessDefinition(String processDefinitionId, boolean cascade,
processDefinition.getKey()
)
);
+ applicationEventPublisher.publishEvent(
+ new ProcessDefinitionDeleted(
+ processDefinitionId,
+ getBlueprintId(processDefinition.getVersionTag())
+ )
+ );
+ }
+
+ private BlueprintId getBlueprintId(String versionTag) {
+ CaseDefinitionId caseDefinitionId = CaseDefinitionId.fromProcessVersionTag(versionTag);
+ if (caseDefinitionId != null) {
+ return caseDefinitionId;
+ }
+ return BuildingBlockDefinitionId.Companion.fromProcessVersionTag(versionTag);
}
}
\ No newline at end of file
diff --git a/backend/core/src/main/resources/config/liquibase/13-42-0/13-42-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-42-0/13-42-0-master.xml
new file mode 100644
index 0000000000..796bbee74a
--- /dev/null
+++ b/backend/core/src/main/resources/config/liquibase/13-42-0/13-42-0-master.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/backend/core/src/main/resources/config/liquibase/13-42-0/20260813-delete-orphaned-process-definition-case-definition.xml b/backend/core/src/main/resources/config/liquibase/13-42-0/20260813-delete-orphaned-process-definition-case-definition.xml
new file mode 100644
index 0000000000..6883c3dacd
--- /dev/null
+++ b/backend/core/src/main/resources/config/liquibase/13-42-0/20260813-delete-orphaned-process-definition-case-definition.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+ process_definition_id NOT IN (SELECT id_ FROM act_re_procdef)
+
+
+
+
+
+ process_definition_id NOT IN (SELECT id_ FROM act_re_procdef)
+
+
+
+
diff --git a/backend/core/src/main/resources/config/liquibase/changelog-master.xml b/backend/core/src/main/resources/config/liquibase/changelog-master.xml
index 68ca8fe1a3..502f3ed8c2 100644
--- a/backend/core/src/main/resources/config/liquibase/changelog-master.xml
+++ b/backend/core/src/main/resources/config/liquibase/changelog-master.xml
@@ -34,5 +34,6 @@
+
diff --git a/backend/core/src/test/kotlin/com/ritense/valtimo/liquibase/DeleteOrphanedProcessDefinitionLinksIntTest.kt b/backend/core/src/test/kotlin/com/ritense/valtimo/liquibase/DeleteOrphanedProcessDefinitionLinksIntTest.kt
new file mode 100644
index 0000000000..e8e88ef94c
--- /dev/null
+++ b/backend/core/src/test/kotlin/com/ritense/valtimo/liquibase/DeleteOrphanedProcessDefinitionLinksIntTest.kt
@@ -0,0 +1,307 @@
+/*
+ * 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.liquibase
+
+import com.ritense.valtimo.BaseIntegrationTest
+import com.ritense.valtimo.contract.config.LiquibaseRunner
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.operaton.bpm.engine.RepositoryService
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.jdbc.core.JdbcTemplate
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.transaction.support.TransactionTemplate
+import java.util.UUID
+
+class DeleteOrphanedProcessDefinitionLinksIntTest : BaseIntegrationTest() {
+
+ @Autowired
+ lateinit var jdbcTemplate: JdbcTemplate
+
+ @Autowired
+ lateinit var repositoryService: RepositoryService
+
+ @Autowired
+ lateinit var liquibaseRunner: LiquibaseRunner
+
+ @Autowired
+ lateinit var transactionManager: PlatformTransactionManager
+
+ companion object {
+ const val CHANGESET_FILENAME = "13-42-0/20260813-delete-orphaned-process-definition-case-definition.xml"
+ }
+
+ @AfterEach
+ fun cleanup() {
+ TransactionTemplate(transactionManager).execute {
+ jdbcTemplate.update("DELETE FROM process_definition_case_definition WHERE case_definition_key IN ('valid-case', 'orphan-case')")
+ jdbcTemplate.update("DELETE FROM building_block_process_link WHERE building_block_definition_key = 'test-building-block'")
+ jdbcTemplate.update("DELETE FROM process_link WHERE activity_id IN ('TestServiceTask', 'SomeTask', 'OrphanTask')")
+ }
+ }
+
+ @Test
+ fun `changeset should delete orphaned process_definition_case_definition rows`() {
+ val txTemplate = TransactionTemplate(transactionManager)
+
+ val validProcDefId = txTemplate.execute {
+ repositoryService.createDeployment()
+ .addClasspathResource("config/global/bpmn/test-process.bpmn")
+ .deployWithResult()
+ .deployedProcessDefinitions
+ .first()
+ .id
+ }!!
+
+ txTemplate.execute {
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_definition_case_definition
+ (process_definition_id, case_definition_key, case_definition_version_tag, can_initialize_document, startable_by_user)
+ VALUES (?, 'valid-case', '1.0.0', false, true)
+ """.trimIndent(),
+ validProcDefId
+ )
+
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_definition_case_definition
+ (process_definition_id, case_definition_key, case_definition_version_tag, can_initialize_document, startable_by_user)
+ VALUES ('non-existent:1:12345', 'orphan-case', '1.0.0', false, true)
+ """
+ )
+ }
+
+ val totalCountBefore = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_definition_case_definition",
+ Int::class.java
+ )
+
+ val orphanCountBefore = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_definition_case_definition WHERE case_definition_key = 'orphan-case'",
+ Int::class.java
+ )
+ assertThat(orphanCountBefore).isEqualTo(1)
+
+ txTemplate.execute {
+ jdbcTemplate.update(
+ """
+ DELETE FROM DATABASECHANGELOG
+ WHERE FILENAME LIKE '%20260813-delete-orphaned%' AND AUTHOR = 'Ritense' AND ID = '1'
+ """.trimIndent()
+ )
+ }
+
+ liquibaseRunner.run()
+
+ val validCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_definition_case_definition WHERE case_definition_key = 'valid-case'",
+ Int::class.java
+ )
+ assertThat(validCountAfter).isEqualTo(1)
+
+ val orphanCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_definition_case_definition WHERE case_definition_key = 'orphan-case'",
+ Int::class.java
+ )
+ assertThat(orphanCountAfter).isEqualTo(0)
+
+ val totalCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_definition_case_definition",
+ Int::class.java
+ )
+ assertThat(totalCountAfter).isEqualTo(totalCountBefore!! - 1)
+ }
+
+ @Test
+ fun `changeset should delete orphaned process_link rows`() {
+ val txTemplate = TransactionTemplate(transactionManager)
+
+ val validProcDefId = txTemplate.execute {
+ repositoryService.createDeployment()
+ .addClasspathResource("config/global/bpmn/test-process.bpmn")
+ .deployWithResult()
+ .deployedProcessDefinitions
+ .first()
+ .id
+ }!!
+
+ val validLinkId = UUID.randomUUID()
+ val orphanLinkId = UUID.randomUUID()
+
+ txTemplate.execute {
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_link
+ (id, process_definition_id, activity_id, activity_type, process_link_type)
+ VALUES (?, ?, 'TestServiceTask', 'bpmn:ServiceTask:start', 'test')
+ """.trimIndent(),
+ validLinkId,
+ validProcDefId
+ )
+
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_link
+ (id, process_definition_id, activity_id, activity_type, process_link_type)
+ VALUES (?, 'non-existent:1:12345', 'SomeTask', 'bpmn:ServiceTask:start', 'test')
+ """.trimIndent(),
+ orphanLinkId
+ )
+ }
+
+ val totalCountBefore = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_link",
+ Int::class.java
+ )
+
+ val orphanCountBefore = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_link WHERE id = ?",
+ Int::class.java,
+ orphanLinkId
+ )
+ assertThat(orphanCountBefore).isEqualTo(1)
+
+ txTemplate.execute {
+ jdbcTemplate.update(
+ """
+ DELETE FROM DATABASECHANGELOG
+ WHERE FILENAME LIKE '%20260813-delete-orphaned%' AND AUTHOR = 'Ritense' AND ID = '2'
+ """.trimIndent()
+ )
+ }
+
+ liquibaseRunner.run()
+
+ val validCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_link WHERE id = ?",
+ Int::class.java,
+ validLinkId
+ )
+ assertThat(validCountAfter).isEqualTo(1)
+
+ val orphanCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_link WHERE id = ?",
+ Int::class.java,
+ orphanLinkId
+ )
+ assertThat(orphanCountAfter).isEqualTo(0)
+
+ val totalCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM process_link",
+ Int::class.java
+ )
+ assertThat(totalCountAfter).isEqualTo(totalCountBefore!! - 1)
+ }
+
+ @Test
+ fun `changeset should cascade delete building_block_process_link when process_link is deleted`() {
+ val txTemplate = TransactionTemplate(transactionManager)
+
+ val validProcDefId = txTemplate.execute {
+ repositoryService.createDeployment()
+ .addClasspathResource("config/global/bpmn/test-process.bpmn")
+ .deployWithResult()
+ .deployedProcessDefinitions
+ .first()
+ .id
+ }!!
+
+ val validLinkId = UUID.randomUUID()
+ val orphanLinkId = UUID.randomUUID()
+
+ txTemplate.execute {
+ // Valid process_link with building_block_process_link child
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_link
+ (id, process_definition_id, activity_id, activity_type, process_link_type)
+ VALUES (?, ?, 'TestServiceTask', 'bpmn:ServiceTask:start', 'test')
+ """.trimIndent(),
+ validLinkId,
+ validProcDefId
+ )
+ jdbcTemplate.update(
+ """
+ INSERT INTO building_block_process_link
+ (process_link_id, building_block_definition_key, building_block_definition_version_tag, plugin_configuration_mappings)
+ VALUES (?, 'test-building-block', '1.0.0', '{}')
+ """.trimIndent(),
+ validLinkId
+ )
+
+ // Orphan process_link with building_block_process_link child
+ jdbcTemplate.update(
+ """
+ INSERT INTO process_link
+ (id, process_definition_id, activity_id, activity_type, process_link_type)
+ VALUES (?, 'non-existent:1:12345', 'OrphanTask', 'bpmn:ServiceTask:start', 'test')
+ """.trimIndent(),
+ orphanLinkId
+ )
+ jdbcTemplate.update(
+ """
+ INSERT INTO building_block_process_link
+ (process_link_id, building_block_definition_key, building_block_definition_version_tag, plugin_configuration_mappings)
+ VALUES (?, 'test-building-block', '2.0.0', '{}')
+ """.trimIndent(),
+ orphanLinkId
+ )
+ }
+
+ val bbLinkCountBefore = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM building_block_process_link WHERE building_block_definition_key = 'test-building-block'",
+ Int::class.java
+ )
+ assertThat(bbLinkCountBefore).isEqualTo(2)
+
+ txTemplate.execute {
+ jdbcTemplate.update(
+ """
+ DELETE FROM DATABASECHANGELOG
+ WHERE FILENAME LIKE '%20260813-delete-orphaned%' AND AUTHOR = 'Ritense' AND ID = '2'
+ """.trimIndent()
+ )
+ }
+
+ liquibaseRunner.run()
+
+ // Verify valid building_block_process_link remains
+ val validBbLinkCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM building_block_process_link WHERE process_link_id = ?",
+ Int::class.java,
+ validLinkId
+ )
+ assertThat(validBbLinkCount).isEqualTo(1)
+
+ // Verify orphan building_block_process_link was cascade-deleted
+ val orphanBbLinkCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM building_block_process_link WHERE process_link_id = ?",
+ Int::class.java,
+ orphanLinkId
+ )
+ assertThat(orphanBbLinkCount).isEqualTo(0)
+
+ // Verify total count decreased by 1
+ val bbLinkCountAfter = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM building_block_process_link WHERE building_block_definition_key = 'test-building-block'",
+ Int::class.java
+ )
+ assertThat(bbLinkCountAfter).isEqualTo(1)
+ }
+}
diff --git a/documentation/release-notes/13.x.x/13.42.0/README.md b/documentation/release-notes/13.x.x/13.42.0/README.md
index b63498c993..b80cee2b5c 100644
--- a/documentation/release-notes/13.x.x/13.42.0/README.md
+++ b/documentation/release-notes/13.x.x/13.42.0/README.md
@@ -18,6 +18,11 @@
## Bugfixes
+* **Deleting a process linked to a case now cleans up properly**
+
+ When a process that was linked to a case definition was deleted, the link remained in the database.
+ This could cause errors when viewing or exporting the case definition. Existing orphaned
+ links from earlier versions are automatically cleaned up during upgrade.
* **Form flow steps with a colon in their expressions work again after import**