diff --git a/backend/apps/dev/src/main/resources/config/pbac/process-timer.permission.json b/backend/apps/dev/src/main/resources/config/pbac/process-timer.permission.json new file mode 100644 index 0000000000..10dd0e405b --- /dev/null +++ b/backend/apps/dev/src/main/resources/config/pbac/process-timer.permission.json @@ -0,0 +1,10 @@ +{ + "changesetId": "process-timer-admin-v1", + "permissions": [ + { + "resourceType": "com.ritense.valtimo.camunda.domain.CamundaTimer", + "action": "complete", + "roleKey": "ROLE_ADMIN" + } + ] +} diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ValtimoCamundaAutoConfiguration.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ValtimoCamundaAutoConfiguration.kt index 3119355ce3..ad2e8f6a5c 100644 --- a/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ValtimoCamundaAutoConfiguration.kt +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/autoconfiguration/ValtimoCamundaAutoConfiguration.kt @@ -25,6 +25,8 @@ import com.ritense.valtimo.camunda.authorization.CamundaExecutionSpecificationFa import com.ritense.valtimo.camunda.authorization.CamundaIdentityLinkSpecificationFactory import com.ritense.valtimo.camunda.authorization.CamundaProcessDefinitionSpecificationFactory import com.ritense.valtimo.camunda.authorization.CamundaTaskSpecificationFactory +import com.ritense.valtimo.camunda.authorization.CamundaTimerExecutionMapper +import com.ritense.valtimo.camunda.authorization.CamundaTimerSpecificationFactory import com.ritense.valtimo.camunda.repository.CamundaBytearrayRepository import com.ritense.valtimo.camunda.repository.CamundaExecutionRepository import com.ritense.valtimo.camunda.repository.CamundaHistoricProcessInstanceRepository @@ -157,6 +159,13 @@ class ValtimoCamundaAutoConfiguration { return CamundaExecutionSpecificationFactory(repository, queryDialectHelper) } + @Bean + @ConditionalOnMissingBean(CamundaTimerSpecificationFactory::class) + @ConditionalOnBean(AuthorizationService::class) + fun camundaTimerSpecificationFactory(): CamundaTimerSpecificationFactory { + return CamundaTimerSpecificationFactory() + } + @Bean @ConditionalOnMissingBean(CamundaProcessDefinitionSpecificationFactory::class) @ConditionalOnBean(AuthorizationService::class) @@ -172,6 +181,14 @@ class ValtimoCamundaAutoConfiguration { @ConditionalOnBean(AuthorizationService::class) fun camundaExecutionProcessDefinitionMapper() = CamundaExecutionProcessDefinitionMapper() + @Bean + @ConditionalOnMissingBean(CamundaTimerExecutionMapper::class) + @ConditionalOnBean(AuthorizationService::class) + fun camundaTimerExecutionMapper( + camundaExecutionRepository: CamundaExecutionRepository + ): CamundaTimerExecutionMapper { + return CamundaTimerExecutionMapper(camundaExecutionRepository) + } @Bean @ConditionalOnMissingBean(CamundaTaskIdentityLinkMapper::class) diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerActionProvider.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerActionProvider.kt new file mode 100644 index 0000000000..6f07861cb0 --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerActionProvider.kt @@ -0,0 +1,36 @@ +/* + * 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.camunda.authorization + +import com.ritense.authorization.Action +import com.ritense.authorization.ResourceActionProvider +import com.ritense.valtimo.camunda.domain.CamundaTimer + +class CamundaTimerActionProvider : ResourceActionProvider { + override fun getAvailableActions(): List> { + return listOf(COMPLETE) + } + + companion object { + /** + * Completing a timer means firing it ahead of its due date, so the process continues as if + * the timer had elapsed. + */ + @JvmField + val COMPLETE = Action(Action.COMPLETE) + } +} diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapper.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapper.kt new file mode 100644 index 0000000000..be5c13efa8 --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapper.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.valtimo.camunda.authorization + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.authorization.AuthorizationEntityMapper +import com.ritense.authorization.AuthorizationEntityMapperResult +import com.ritense.valtimo.camunda.domain.CamundaExecution +import com.ritense.valtimo.camunda.domain.CamundaTimer +import com.ritense.valtimo.camunda.repository.CamundaExecutionRepository +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.Root + +/** + * Maps a timer onto the process instance it belongs to, so conditions on a timer permission can + * refer to the execution and, through the mappers on the execution, to the case it is part of. + */ +class CamundaTimerExecutionMapper( + private val camundaExecutionRepository: CamundaExecutionRepository, +) : AuthorizationEntityMapper { + + override fun mapRelated(entity: CamundaTimer): List { + // The process-instance-level execution row has the process instance id as its own id. + return runWithoutAuthorization { + entity.processInstanceId + ?.let { camundaExecutionRepository.findById(it).orElse(null) } + ?.let { listOf(it) } + ?: emptyList() + } + } + + override fun mapQuery( + root: Root, + query: AbstractQuery<*>, + criteriaBuilder: CriteriaBuilder + ): AuthorizationEntityMapperResult { + throw UnsupportedOperationException("CamundaTimer is not a JPA entity and cannot be queried") + } + + override fun supports(fromClass: Class<*>, toClass: Class<*>): Boolean { + return fromClass == CamundaTimer::class.java && toClass == CamundaExecution::class.java + } +} diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecification.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecification.kt new file mode 100644 index 0000000000..74afe69b96 --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecification.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.valtimo.camunda.authorization + +import com.ritense.authorization.permission.Permission +import com.ritense.authorization.request.AuthorizationRequest +import com.ritense.authorization.specification.AuthorizationSpecification +import com.ritense.valtimo.camunda.domain.CamundaTimer +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.Predicate +import jakarta.persistence.criteria.Root + +class CamundaTimerSpecification( + authRequest: AuthorizationRequest, + permissionSupplier: () -> List, +) : AuthorizationSpecification(authRequest, permissionSupplier) { + + override fun toPredicate( + root: Root, + query: AbstractQuery<*>, + criteriaBuilder: CriteriaBuilder + ): Predicate { + throw NotImplementedError("CamundaTimer is not a JPA entity") + } + + override fun identifierToEntity(identifier: String): CamundaTimer { + throw NotImplementedError("CamundaTimer is not a JPA entity") + } +} diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecificationFactory.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecificationFactory.kt new file mode 100644 index 0000000000..cca4c34c3a --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerSpecificationFactory.kt @@ -0,0 +1,37 @@ +/* + * 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.camunda.authorization + +import com.ritense.authorization.permission.Permission +import com.ritense.authorization.request.AuthorizationRequest +import com.ritense.authorization.specification.AuthorizationSpecification +import com.ritense.authorization.specification.AuthorizationSpecificationFactory +import com.ritense.valtimo.camunda.domain.CamundaTimer + +class CamundaTimerSpecificationFactory : AuthorizationSpecificationFactory { + + override fun create( + request: AuthorizationRequest, + permissionSupplier: () -> List + ): AuthorizationSpecification { + return CamundaTimerSpecification(request, permissionSupplier) + } + + override fun canCreate(request: AuthorizationRequest<*>, permissionSupplier: () -> List): Boolean { + return CamundaTimer::class.java == request.resourceType + } +} diff --git a/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/domain/CamundaTimer.kt b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/domain/CamundaTimer.kt new file mode 100644 index 0000000000..fd1a0bd074 --- /dev/null +++ b/backend/core/src/main/kotlin/com/ritense/valtimo/camunda/domain/CamundaTimer.kt @@ -0,0 +1,53 @@ +/* + * 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.camunda.domain + +import org.camunda.bpm.engine.management.JobDefinition +import org.camunda.bpm.engine.runtime.Job +import java.util.Date + +/** + * A timer of a running process instance. + * + * Timers are a resource of their own so that permissions on them are independent of permissions on + * the process execution they belong to. Camunda stores all runtime jobs in a single table and only + * exposes them through its own services, so a timer is read from the engine and mapped onto this + * class to evaluate permissions against. Conditions can still refer to the case a timer belongs to + * through the mapper to [CamundaExecution]. + */ +class CamundaTimer( + val id: String, + val processInstanceId: String? = null, + val processDefinitionId: String? = null, + val processDefinitionKey: String? = null, + val activityId: String? = null, + val dueDate: Date? = null, + val suspended: Boolean = false, +) { + companion object { + @JvmStatic + fun from(job: Job, jobDefinition: JobDefinition?) = CamundaTimer( + id = job.id, + processInstanceId = job.processInstanceId, + processDefinitionId = job.processDefinitionId, + processDefinitionKey = job.processDefinitionKey, + activityId = jobDefinition?.activityId, + dueDate = job.duedate, + suspended = job.isSuspended, + ) + } +} diff --git a/backend/core/src/test/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapperTest.kt b/backend/core/src/test/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapperTest.kt new file mode 100644 index 0000000000..31722f32dd --- /dev/null +++ b/backend/core/src/test/kotlin/com/ritense/valtimo/camunda/authorization/CamundaTimerExecutionMapperTest.kt @@ -0,0 +1,71 @@ +/* + * 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.camunda.authorization + +import com.ritense.valtimo.camunda.domain.CamundaExecution +import com.ritense.valtimo.camunda.domain.CamundaProcessDefinition +import com.ritense.valtimo.camunda.domain.CamundaTimer +import com.ritense.valtimo.camunda.repository.CamundaExecutionRepository +import java.util.Optional +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CamundaTimerExecutionMapperTest { + + private val camundaExecutionRepository: CamundaExecutionRepository = mock() + + private val mapper = CamundaTimerExecutionMapper(camundaExecutionRepository) + + @Test + fun `should map timer to the execution of its process instance`() { + val execution = mock() + whenever(camundaExecutionRepository.findById("process-instance-id")) + .thenReturn(Optional.of(execution)) + + val related = mapper.mapRelated(CamundaTimer(id = "job-1", processInstanceId = "process-instance-id")) + + assertEquals(listOf(execution), related) + } + + @Test + fun `should map to nothing when the execution no longer exists`() { + whenever(camundaExecutionRepository.findById("process-instance-id")) + .thenReturn(Optional.empty()) + + val related = mapper.mapRelated(CamundaTimer(id = "job-1", processInstanceId = "process-instance-id")) + + assertTrue(related.isEmpty()) + } + + @Test + fun `should map to nothing when the timer has no process instance`() { + val related = mapper.mapRelated(CamundaTimer(id = "job-1")) + + assertTrue(related.isEmpty()) + } + + @Test + fun `should only support mapping a timer to an execution`() { + assertTrue(mapper.supports(CamundaTimer::class.java, CamundaExecution::class.java)) + assertFalse(mapper.supports(CamundaTimer::class.java, CamundaProcessDefinition::class.java)) + assertFalse(mapper.supports(CamundaExecution::class.java, CamundaExecution::class.java)) + } +} diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/autoconfigure/ProcessDocumentAutoConfiguration.java b/backend/process-document/src/main/java/com/ritense/processdocument/autoconfigure/ProcessDocumentAutoConfiguration.java index 8326fff7e0..d07dd832d3 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/autoconfigure/ProcessDocumentAutoConfiguration.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/autoconfigure/ProcessDocumentAutoConfiguration.java @@ -39,17 +39,20 @@ import com.ritense.processdocument.service.ProcessDocumentAssociationService; import com.ritense.processdocument.service.ProcessDocumentDeploymentService; import com.ritense.processdocument.service.ProcessDocumentService; +import com.ritense.processdocument.service.ProcessInstanceCaseAccessService; import com.ritense.processdocument.service.impl.CamundaProcessJsonSchemaDocumentAssociationService; import com.ritense.processdocument.service.impl.CamundaProcessJsonSchemaDocumentDeploymentService; import com.ritense.processdocument.service.impl.CamundaProcessJsonSchemaDocumentService; import com.ritense.processdocument.service.impl.DocumentDefinitionProcessLinkServiceImpl; import com.ritense.processdocument.web.rest.ProcessDocumentResource; +import com.ritense.processdocument.web.rest.ProcessTimerResource; import com.ritense.valtimo.camunda.service.CamundaRepositoryService; import com.ritense.valtimo.contract.authentication.UserManagementService; import com.ritense.valtimo.service.CamundaProcessService; import com.ritense.valtimo.service.CamundaTaskService; import com.ritense.valueresolver.ValueResolverFactory; import org.camunda.bpm.engine.HistoryService; +import org.camunda.bpm.engine.ManagementService; import org.camunda.bpm.engine.RuntimeService; import org.camunda.bpm.extension.reactor.spring.EnableCamundaEventBus; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -176,6 +179,32 @@ public ProcessDocumentResource processDocumentResource( return new ProcessDocumentResource(processDocumentService, processDocumentAssociationService, documentDefinitionProcessLinkService); } + @Bean + @ConditionalOnMissingBean(ProcessInstanceCaseAccessService.class) + public ProcessInstanceCaseAccessService processInstanceCaseAccessService( + ProcessDocumentAssociationService processDocumentAssociationService + ) { + return new ProcessInstanceCaseAccessService( + processDocumentAssociationService + ); + } + + @Bean + @ConditionalOnMissingBean(ProcessTimerResource.class) + public ProcessTimerResource processTimerResource( + ProcessInstanceCaseAccessService processInstanceCaseAccessService, + AuthorizationService authorizationService, + ManagementService managementService, + ApplicationEventPublisher eventPublisher + ) { + return new ProcessTimerResource( + processInstanceCaseAccessService, + authorizationService, + managementService, + eventPublisher + ); + } + @Bean @ConditionalOnMissingBean(ProcessDocumentDeploymentService.class) public ProcessDocumentDeploymentService processDocumentDeploymentService( diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/security/config/ProcessDocumentHttpSecurityConfigurer.java b/backend/process-document/src/main/java/com/ritense/processdocument/security/config/ProcessDocumentHttpSecurityConfigurer.java index 0a4a11a4bd..ee8231fe80 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/security/config/ProcessDocumentHttpSecurityConfigurer.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/security/config/ProcessDocumentHttpSecurityConfigurer.java @@ -68,6 +68,16 @@ public void configure(HttpSecurity http) { .requestMatchers(antMatcher( GET, "/api/v1/process-document/instance/document/{document-id}/audit")) .authenticated() + .requestMatchers(antMatcher( + GET, + "/api/v1/process-document/case/{caseId}/process-instance/{processInstanceId}/timers" + )) + .authenticated() + .requestMatchers(antMatcher( + POST, + "/api/v1/process-document/case/{caseId}/process-instance/{processInstanceId}/timer/{jobId}/skip" + )) + .authenticated() .requestMatchers(antMatcher( POST, "/api/v1/process-document/operation/new-document-and-start-process")) .authenticated() diff --git a/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditService.java b/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditService.java index 13d46b3a1e..d1cc48aef9 100644 --- a/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditService.java +++ b/backend/process-document/src/main/java/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditService.java @@ -31,6 +31,7 @@ import com.ritense.document.event.DocumentUnassignedEvent; import com.ritense.document.service.impl.JsonSchemaDocumentService; import com.ritense.processdocument.event.BesluitAddedEvent; +import com.ritense.processdocument.event.ProcessTimerSkippedEvent; import com.ritense.processdocument.service.ProcessDocumentAuditService; import com.ritense.valtimo.camunda.processaudit.ProcessEndedEvent; import com.ritense.valtimo.camunda.processaudit.ProcessStartedEvent; @@ -83,7 +84,8 @@ public Page getAuditLog( BesluitAddedEvent.class, DocumentAssigneeChangedEvent.class, DocumentUnassignedEvent.class, - TaskDueDateSetEvent.class + TaskDueDateSetEvent.class, + ProcessTimerSkippedEvent.class ); final var document = documentService.getDocumentBy(id); diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEvent.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEvent.kt new file mode 100644 index 0000000000..d3f3928999 --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEvent.kt @@ -0,0 +1,55 @@ +/* + * 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.processdocument.event + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonView +import com.ritense.valtimo.contract.audit.AuditEvent +import com.ritense.valtimo.contract.audit.AuditMetaData +import com.ritense.valtimo.contract.audit.view.AuditView +import java.time.LocalDateTime +import java.util.UUID + +class ProcessTimerSkippedEvent @JsonCreator constructor( + id: UUID, + origin: String, + occurredOn: LocalDateTime, + user: String, + private val documentId: UUID, + private val processInstanceId: String, + private val jobId: String, + private val activityId: String?, +) : AuditMetaData(id, origin, occurredOn, user), AuditEvent { + + @JsonView(AuditView.Internal::class) + @JsonIgnore(false) + override fun getDocumentId(): UUID = documentId + + @JsonProperty + @JsonView(AuditView.Public::class) + fun getProcessInstanceId(): String = processInstanceId + + @JsonProperty + @JsonView(AuditView.Public::class) + fun getJobId(): String = jobId + + @JsonProperty + @JsonView(AuditView.Public::class) + fun getActivityId(): String? = activityId +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessService.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessService.kt new file mode 100644 index 0000000000..09ee1585e5 --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessService.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.processdocument.service + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import java.util.UUID +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +/** + * Shared case-scoped access checks for endpoints that operate on the runtime state + * of a process instance belonging to a specific case. + * + * Verifies that a process instance actually belongs to the case it is addressed through, so a + * caller cannot reach an unrelated process instance through a case they do have access to. Used by + * [com.ritense.processdocument.web.rest.ProcessTimerResource]. + */ +open class ProcessInstanceCaseAccessService( + private val processDocumentAssociationService: ProcessDocumentAssociationService, +) { + + open fun requireBelongsToCase(caseId: UUID, processInstanceId: String) { + val belongs = runWithoutAuthorization { + processDocumentAssociationService.findProcessDocumentInstanceDtos( + JsonSchemaDocumentId.existingId(caseId) + ) + }.any { it.processDocumentInstanceId().processInstanceId().toString() == processInstanceId } + + if (!belongs) { + throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Process instance $processInstanceId is not associated with case $caseId" + ) + } + } +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt new file mode 100644 index 0000000000..321ae8b8cb --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResource.kt @@ -0,0 +1,168 @@ +/* + * 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.processdocument.web.rest + +import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization +import com.ritense.authorization.AuthorizationService +import com.ritense.authorization.request.EntityAuthorizationRequest +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.logging.LoggableResource +import com.ritense.processdocument.event.ProcessTimerSkippedEvent +import com.ritense.processdocument.service.ProcessInstanceCaseAccessService +import com.ritense.processdocument.web.rest.dto.JobInspectionDto +import com.ritense.processdocument.web.rest.dto.JobType +import com.ritense.valtimo.camunda.authorization.CamundaTimerActionProvider +import com.ritense.valtimo.camunda.domain.CamundaTimer +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import com.ritense.valtimo.contract.audit.utils.AuditHelper +import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE +import com.ritense.valtimo.contract.utils.RequestHelper +import org.camunda.bpm.engine.ManagementService +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDateTime +import java.util.UUID + +@RestController +@SkipComponentScan +@RequestMapping("/api/v1/process-document", produces = [APPLICATION_JSON_UTF8_VALUE]) +class ProcessTimerResource( + private val caseAccessService: ProcessInstanceCaseAccessService, + private val authorizationService: AuthorizationService, + private val managementService: ManagementService, + private val eventPublisher: ApplicationEventPublisher, +) { + + @Transactional(readOnly = true) + @GetMapping("/case/{caseId}/process-instance/{processInstanceId}/timers") + fun getSkippableTimers( + @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, + @PathVariable processInstanceId: String, + ): ResponseEntity> { + caseAccessService.requireBelongsToCase(caseId, processInstanceId) + + val timers = getTimerJobs(processInstanceId) + .filter { hasCompletePermission(it.timer) } + .map { it.dto } + + return ResponseEntity.ok(timers) + } + + @Transactional + @PostMapping("/case/{caseId}/process-instance/{processInstanceId}/timer/{jobId}/skip") + fun skipTimer( + @LoggableResource(resourceType = JsonSchemaDocument::class) @PathVariable caseId: UUID, + @PathVariable processInstanceId: String, + @PathVariable jobId: String, + ): ResponseEntity { + caseAccessService.requireBelongsToCase(caseId, processInstanceId) + + val timer = getTimerJobs(processInstanceId).find { it.dto.id == jobId } + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Timer job $jobId is not a skippable timer of process instance $processInstanceId" + ) + + requireCompletePermission(timer.timer) + + runWithoutAuthorization { + managementService.executeJob(jobId) + } + + publishTimerSkippedEvent( + caseId = caseId, + processInstanceId = processInstanceId, + jobId = jobId, + activityId = timer.dto.activityId, + ) + + return ResponseEntity.noContent().build() + } + + private fun hasCompletePermission(timer: CamundaTimer) = + authorizationService.hasPermission(completePermissionRequest(timer)) + + private fun requireCompletePermission(timer: CamundaTimer) = + authorizationService.requirePermission(completePermissionRequest(timer)) + + private fun completePermissionRequest(timer: CamundaTimer) = EntityAuthorizationRequest( + CamundaTimer::class.java, + CamundaTimerActionProvider.COMPLETE, + timer, + ) + + /** + * Reads the active timers of a process instance from the engine. A process instance that is no + * longer active has no jobs, so it simply yields an empty list. + */ + private fun getTimerJobs(processInstanceId: String): List = runWithoutAuthorization { + val rawJobs = managementService.createJobQuery() + .processInstanceId(processInstanceId) + .list() + val definitionsById = rawJobs.mapNotNull { it.jobDefinitionId } + .distinct() + .mapNotNull { jobDefinitionId -> + managementService.createJobDefinitionQuery() + .jobDefinitionId(jobDefinitionId) + .singleResult() + } + .associateBy { it.id } + rawJobs.map { job -> + val definition = definitionsById[job.jobDefinitionId] + TimerJob( + dto = JobInspectionDto.from(job, definition), + timer = CamundaTimer.from(job, definition), + ) + }.filter { it.dto.jobType == JobType.TIMER } + } + + private fun publishTimerSkippedEvent( + caseId: UUID, + processInstanceId: String, + jobId: String, + activityId: String?, + ) { + eventPublisher.publishEvent( + ProcessTimerSkippedEvent( + UUID.randomUUID(), + RequestHelper.getOrigin(), + LocalDateTime.now(), + AuditHelper.getActor(), + caseId, + processInstanceId, + jobId, + activityId, + ) + ) + } + + /** + * Pairs the API representation of a timer with the authorization resource for the same timer. + */ + private data class TimerJob( + val dto: JobInspectionDto, + val timer: CamundaTimer, + ) +} diff --git a/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/dto/JobInspectionDto.kt b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/dto/JobInspectionDto.kt new file mode 100644 index 0000000000..c9ed69371c --- /dev/null +++ b/backend/process-document/src/main/kotlin/com/ritense/processdocument/web/rest/dto/JobInspectionDto.kt @@ -0,0 +1,89 @@ +/* + * 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.processdocument.web.rest.dto + +import org.camunda.bpm.engine.management.JobDefinition +import org.camunda.bpm.engine.runtime.Job +import java.util.Date + +/** + * Inspection-friendly view of a Camunda runtime job. + * + * The engine's internal job-type discriminators (`async-continuation`, + * `timer-intermediate-transition`, etc.) are mapped onto a small set of + * stable categories on [JobType]; the BPMN activity the job belongs to is + * looked up via the job's [JobDefinition] so the inspector can show which + * node a job is associated with. + */ +data class JobInspectionDto( + val id: String, + val jobDefinitionId: String?, + val executionId: String?, + val activityId: String?, + val jobType: JobType, + val retries: Int, + val exceptionMessage: String?, + val dueDate: Date?, + val suspended: Boolean, +) { + companion object { + fun from(job: Job, definition: JobDefinition?): JobInspectionDto = JobInspectionDto( + id = job.id, + jobDefinitionId = job.jobDefinitionId, + executionId = job.executionId, + activityId = definition?.activityId, + jobType = JobType.classify(definition?.jobType), + retries = job.retries, + exceptionMessage = job.exceptionMessage, + dueDate = job.duedate, + suspended = job.isSuspended, + ) + } +} + +/** + * Stable, frontend-translatable categorisation of Camunda's many internal + * job-type strings. Keep small — the inspector only needs to communicate + * "is this a timer, an async continuation, or something else". + */ +enum class JobType { + /** Timer events (start, intermediate, boundary). */ + TIMER, + + /** Asynchronous continuation queued via `asyncBefore` / `asyncAfter`. */ + ASYNC_CONTINUATION, + + /** Message-correlation that has been deferred to the job executor. */ + MESSAGE, + + /** Batch operations seeded by the engine. */ + BATCH, + + /** Anything we don't have a dedicated bucket for. */ + OTHER; + + companion object { + fun classify(rawJobType: String?): JobType = when { + rawJobType == null -> OTHER + rawJobType == "timer" || rawJobType.startsWith("timer-") -> TIMER + rawJobType == "async-continuation" -> ASYNC_CONTINUATION + rawJobType == "message" || rawJobType.startsWith("message-") -> MESSAGE + rawJobType.startsWith("batch-") -> BATCH + else -> OTHER + } + } +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEventTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEventTest.kt new file mode 100644 index 0000000000..5b586ccaf4 --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/event/ProcessTimerSkippedEventTest.kt @@ -0,0 +1,81 @@ +/* + * 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.processdocument.event + +import com.ritense.valtimo.contract.audit.AuditEvent +import com.ritense.valtimo.contract.json.MapperSingleton +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProcessTimerSkippedEventTest { + + private val objectMapper = MapperSingleton.get() + + @Test + fun `should round-trip through the audit object mapper preserving all fields`() { + val id = UUID.randomUUID() + val documentId = UUID.randomUUID() + val event: AuditEvent = ProcessTimerSkippedEvent( + id, + "127.0.0.1", + LocalDateTime.parse("2026-07-21T12:00:00"), + "admin@example.com", + documentId, + "process-instance-1", + "job-1", + "Event_timer", + ) + + val json = objectMapper.writeValueAsString(event) + // documentId must be serialized, otherwise deserialization of the non-null + // creator parameter fails (regression guard for the audit round-trip). + assertTrue(json.contains("documentId"), "documentId should be serialized: $json") + + val restored = objectMapper.readValue(json, AuditEvent::class.java) + + assertTrue(restored is ProcessTimerSkippedEvent) + restored as ProcessTimerSkippedEvent + assertEquals(documentId, restored.getDocumentId()) + assertEquals(id, restored.id) + assertEquals("admin@example.com", restored.user) + assertEquals("process-instance-1", restored.getProcessInstanceId()) + assertEquals("job-1", restored.getJobId()) + assertEquals("Event_timer", restored.getActivityId()) + } + + @Test + fun `should round-trip when activityId is null`() { + val event: AuditEvent = ProcessTimerSkippedEvent( + UUID.randomUUID(), + "127.0.0.1", + LocalDateTime.parse("2026-07-21T12:00:00"), + "admin@example.com", + UUID.randomUUID(), + "process-instance-1", + "job-1", + null, + ) + + val json = objectMapper.writeValueAsString(event) + val restored = objectMapper.readValue(json, AuditEvent::class.java) as ProcessTimerSkippedEvent + + assertEquals(null, restored.getActivityId()) + } +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessServiceTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessServiceTest.kt new file mode 100644 index 0000000000..d4388ccaaa --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/ProcessInstanceCaseAccessServiceTest.kt @@ -0,0 +1,78 @@ +/* + * 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.processdocument.service + +import com.ritense.document.domain.Document +import com.ritense.processdocument.domain.ProcessDocumentInstanceId +import com.ritense.processdocument.domain.ProcessInstanceId +import com.ritense.processdocument.domain.impl.ProcessDocumentInstanceDto +import java.util.UUID +import kotlin.test.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.web.server.ResponseStatusException + +class ProcessInstanceCaseAccessServiceTest { + + private lateinit var processDocumentAssociationService: ProcessDocumentAssociationService + + private lateinit var service: ProcessInstanceCaseAccessService + + private val caseId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp() { + processDocumentAssociationService = mock() + + service = ProcessInstanceCaseAccessService( + processDocumentAssociationService + ) + } + + @Test + fun `requireBelongsToCase passes when the process instance is associated with the case`() { + val processInstanceId = UUID.randomUUID().toString() + val dto = instanceDto(processInstanceId) + whenever(processDocumentAssociationService.findProcessDocumentInstanceDtos(any())) + .thenReturn(listOf(dto)) + + service.requireBelongsToCase(caseId, processInstanceId) + } + + @Test + fun `requireBelongsToCase throws 404 when the process instance is not associated with the case`() { + whenever(processDocumentAssociationService.findProcessDocumentInstanceDtos(any())) + .thenReturn(emptyList()) + + val ex = assertThrows { + service.requireBelongsToCase(caseId, "unknown-pid") + } + assertEquals(404, ex.statusCode.value()) + } + + private fun instanceDto(processInstanceId: String): ProcessDocumentInstanceDto { + val pInstanceId = mock() + whenever(pInstanceId.toString()).thenReturn(processInstanceId) + val id = mock() + whenever(id.processInstanceId()).thenReturn(pInstanceId) + return ProcessDocumentInstanceDto(id, "p", true, 1, 1, null, null) + } +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditServiceTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditServiceTest.kt new file mode 100644 index 0000000000..5208aa9c51 --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/service/impl/CamundaProcessJsonSchemaDocumentAuditServiceTest.kt @@ -0,0 +1,82 @@ +/* + * 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.processdocument.service.impl + +import com.ritense.audit.service.AuditService +import com.ritense.authorization.AuthorizationService +import com.ritense.authorization.request.EntityAuthorizationRequest +import com.ritense.document.domain.impl.JsonSchemaDocument +import com.ritense.document.domain.impl.JsonSchemaDocumentId +import com.ritense.document.service.JsonSchemaDocumentActionProvider +import com.ritense.document.service.impl.JsonSchemaDocumentService +import com.ritense.processdocument.event.ProcessTimerSkippedEvent +import com.ritense.valtimo.contract.audit.AuditEvent +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CamundaProcessJsonSchemaDocumentAuditServiceTest { + + private lateinit var auditService: AuditService + private lateinit var documentService: JsonSchemaDocumentService + private lateinit var authorizationService: AuthorizationService + + private lateinit var service: CamundaProcessJsonSchemaDocumentAuditService + + @BeforeEach + fun setUp() { + auditService = mock() + documentService = mock() + authorizationService = mock() + + service = CamundaProcessJsonSchemaDocumentAuditService( + auditService, + documentService, + authorizationService, + ) + } + + @Test + fun `getAuditLog requires VIEW permission and includes ProcessTimerSkippedEvent in the queried event types`() { + val documentId = UUID.randomUUID() + val id = JsonSchemaDocumentId.existingId(documentId) + whenever(documentService.getDocumentBy(any())).thenReturn(mock()) + whenever(auditService.findByEventAndDocumentId(any(), any(), any())).thenReturn(Page.empty()) + + service.getAuditLog(id, PageRequest.of(0, 10)) + + val permissionCaptor = argumentCaptor>() + verify(authorizationService).requirePermission(permissionCaptor.capture()) + assertEquals(JsonSchemaDocumentActionProvider.VIEW, permissionCaptor.firstValue.action) + + val eventTypesCaptor = argumentCaptor>>() + verify(auditService).findByEventAndDocumentId(eventTypesCaptor.capture(), any(), any()) + assertTrue( + eventTypesCaptor.firstValue.contains(ProcessTimerSkippedEvent::class.java), + "ProcessTimerSkippedEvent should be part of the audited event types" + ) + } +} diff --git a/backend/process-document/src/test/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResourceTest.kt b/backend/process-document/src/test/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResourceTest.kt new file mode 100644 index 0000000000..d241e2b01e --- /dev/null +++ b/backend/process-document/src/test/kotlin/com/ritense/processdocument/web/rest/ProcessTimerResourceTest.kt @@ -0,0 +1,225 @@ +/* + * 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.processdocument.web.rest + +import com.ritense.authorization.AuthorizationService +import com.ritense.authorization.request.EntityAuthorizationRequest +import com.ritense.document.domain.Document +import com.ritense.processdocument.domain.ProcessDocumentInstanceId +import com.ritense.processdocument.domain.ProcessInstanceId +import com.ritense.processdocument.domain.impl.ProcessDocumentInstanceDto +import com.ritense.processdocument.event.ProcessTimerSkippedEvent +import com.ritense.processdocument.service.ProcessDocumentAssociationService +import com.ritense.processdocument.service.ProcessInstanceCaseAccessService +import com.ritense.valtimo.camunda.authorization.CamundaTimerActionProvider +import com.ritense.valtimo.camunda.domain.CamundaTimer +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.camunda.bpm.engine.ManagementService +import org.camunda.bpm.engine.management.JobDefinition +import org.camunda.bpm.engine.runtime.Job +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +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 org.springframework.web.server.ResponseStatusException + +class ProcessTimerResourceTest { + + private lateinit var authorizationService: AuthorizationService + private lateinit var processDocumentAssociationService: ProcessDocumentAssociationService + private lateinit var managementService: ManagementService + private lateinit var eventPublisher: ApplicationEventPublisher + + private lateinit var resource: ProcessTimerResource + + private val caseId: UUID = UUID.randomUUID() + + @BeforeEach + fun setUp() { + authorizationService = mock() + processDocumentAssociationService = mock() + managementService = mock(defaultAnswer = RETURNS_DEEP_STUBS) + eventPublisher = mock() + + val caseAccessService = ProcessInstanceCaseAccessService( + processDocumentAssociationService + ) + + resource = ProcessTimerResource( + caseAccessService = caseAccessService, + authorizationService = authorizationService, + managementService = managementService, + eventPublisher = eventPublisher, + ) + } + + @Test + fun `skip should require COMPLETE permission on the timer`() { + val processInstanceId = associateInstance() + stubJobsForInstance(processInstanceId, Triple("job-1", "timer", "timerBoundary")) + + resource.skipTimer(caseId, processInstanceId, "job-1") + + verify(authorizationService).requirePermission( + argThat> { + resourceType == CamundaTimer::class.java && + action == CamundaTimerActionProvider.COMPLETE && + entities.single().id == "job-1" + } + ) + } + + @Test + fun `skip should execute the timer job and publish event`() { + val processInstanceId = associateInstance() + stubJobsForInstance(processInstanceId, Triple("job-1", "timer", "timerBoundary")) + + val response = resource.skipTimer(caseId, processInstanceId, "job-1") + + assertEquals(204, response.statusCode.value()) + verify(managementService).executeJob("job-1") + + val eventCaptor = argumentCaptor() + verify(eventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.firstValue + assertEquals("job-1", event.getJobId()) + assertEquals("timerBoundary", event.getActivityId()) + assertEquals(processInstanceId, event.getProcessInstanceId()) + assertEquals(caseId, event.documentId) + } + + @Test + fun `skip should return 404 when job is not a skippable timer of the instance`() { + val processInstanceId = associateInstance() + stubJobsForInstance(processInstanceId, Triple("async-1", "async-continuation", "someTask")) + + val ex = assertThrows { + resource.skipTimer(caseId, processInstanceId, "async-1") + } + assertEquals(404, ex.statusCode.value()) + verify(managementService, never()).executeJob(any()) + verify(eventPublisher, never()).publishEvent(any()) + } + + @Test + fun `skip should return 404 when process instance does not belong to case`() { + val processInstanceId = UUID.randomUUID().toString() + whenever(processDocumentAssociationService.findProcessDocumentInstanceDtos(any())).thenReturn(emptyList()) + + val ex = assertThrows { + resource.skipTimer(caseId, processInstanceId, "job-1") + } + assertEquals(404, ex.statusCode.value()) + verify(managementService, never()).executeJob(any()) + } + + @Test + fun `skip should return 404 when process instance has no jobs`() { + val processInstanceId = associateInstance() + stubJobsForInstance(processInstanceId) + + val ex = assertThrows { + resource.skipTimer(caseId, processInstanceId, "job-1") + } + assertEquals(404, ex.statusCode.value()) + verify(managementService, never()).executeJob(any()) + } + + @Test + fun `getSkippableTimers should only return timer jobs the user may complete`() { + val processInstanceId = associateInstance() + stubJobsForInstance( + processInstanceId, + Triple("job-1", "timer", "timerBoundary"), + Triple("async-1", "async-continuation", "someTask"), + ) + whenever(authorizationService.hasPermission(any>())).thenReturn(true) + + val response = resource.getSkippableTimers(caseId, processInstanceId) + + assertEquals(200, response.statusCode.value()) + assertEquals(1, response.body!!.size) + assertEquals("job-1", response.body!!.single().id) + + verify(authorizationService).hasPermission( + argThat> { + resourceType == CamundaTimer::class.java && + action == CamundaTimerActionProvider.COMPLETE && + entities.single().id == "job-1" + } + ) + } + + @Test + fun `getSkippableTimers should not return timers the user may not complete`() { + val processInstanceId = associateInstance() + stubJobsForInstance(processInstanceId, Triple("job-1", "timer", "timerBoundary")) + whenever(authorizationService.hasPermission(any>())).thenReturn(false) + + val response = resource.getSkippableTimers(caseId, processInstanceId) + + assertEquals(200, response.statusCode.value()) + assertTrue(response.body!!.isEmpty()) + } + + private fun associateInstance(): String { + val processInstanceId = UUID.randomUUID().toString() + val pInstanceId = mock() + whenever(pInstanceId.toString()).thenReturn(processInstanceId) + val id = mock() + whenever(id.processInstanceId()).thenReturn(pInstanceId) + val instance = ProcessDocumentInstanceDto(id, "p", true, 1, 1, null, null) + whenever(processDocumentAssociationService.findProcessDocumentInstanceDtos(any())) + .thenReturn(listOf(instance)) + return processInstanceId + } + + private fun stubJobsForInstance(processInstanceId: String, vararg specs: Triple) { + val jobs = specs.map { (jobId, jobType, activityId) -> + val jobDefinitionId = "jobdef-$jobId" + val job = mock() + whenever(job.id).thenReturn(jobId) + whenever(job.jobDefinitionId).thenReturn(jobDefinitionId) + whenever(job.processInstanceId).thenReturn(processInstanceId) + val jobDefinition = mock() + whenever(jobDefinition.id).thenReturn(jobDefinitionId) + whenever(jobDefinition.activityId).thenReturn(activityId) + whenever(jobDefinition.jobType).thenReturn(jobType) + whenever( + managementService.createJobDefinitionQuery() + .jobDefinitionId(jobDefinitionId) + .singleResult() + ).thenReturn(jobDefinition) + job + } + whenever( + managementService.createJobQuery() + .processInstanceId(processInstanceId) + .list() + ).thenReturn(jobs) + } +} diff --git a/documentation/features/access-control/configurable-elements.md b/documentation/features/access-control/configurable-elements.md index 2a2df2133c..da1728370b 100644 --- a/documentation/features/access-control/configurable-elements.md +++ b/documentation/features/access-control/configurable-elements.md @@ -10,7 +10,7 @@ Each configurable element offers a set of actions that can be adjusted to fit yo Below the full list of elements within Valtimo that can and need to be configured in Access Control. Per element a list of configurable actions is documented and an example of the configuration is added. The available actions per element define what can be configured for that element. -
FeatureResource nameResource typeModule
CaseDocumentcom.ritense.document.domain.impl.JsonSchemaDocumentDocument
Document definitioncom.ritense.document.domain.impl.JsonSchemaDocumentDefinitionDocument
Document snapshotcom.ritense.document.domain.impl.snapshot.JsonSchemaDocumentSnapshotDocument
Notescom.ritense.note.domain.NoteNotes
Search fieldscom.ritense.document.domain.impl.searchfield.SearchFieldDocument
Tabscom.ritense.case.domain.CaseTabCaseTab
Widgetscom.ritense.case_.domain.tab.CaseWidget
Dashboardcom.ritense.dashboard.domain.Dashboard
ProcessExecutioncom.ritense.valtimo.camunda.domain.CamundaExecutionCore
Definitioncom.ritense.valtimo.camunda.domain.CamundaProcessDefinitionCore
TasksTaskscom.ritense.valtimo.camunda.domain.CamundaTaskCore
Identity linkscom.ritense.valtimo.camunda.domain.CamundaIdentityLinkCore
ZGWZaakcom.ritense.zakenapi.security.ZaakZaken API
Documentscom.ritense.resource.authorization.ResourcePermissionResource
+
FeatureResource nameResource typeModule
CaseDocumentcom.ritense.document.domain.impl.JsonSchemaDocumentDocument
Document definitioncom.ritense.document.domain.impl.JsonSchemaDocumentDefinitionDocument
Document snapshotcom.ritense.document.domain.impl.snapshot.JsonSchemaDocumentSnapshotDocument
Notescom.ritense.note.domain.NoteNotes
Search fieldscom.ritense.document.domain.impl.searchfield.SearchFieldDocument
Tabscom.ritense.case.domain.CaseTabCaseTab
Widgetscom.ritense.case_.domain.tab.CaseWidget
Dashboardcom.ritense.dashboard.domain.Dashboard
ProcessExecutioncom.ritense.valtimo.camunda.domain.CamundaExecutionCore
Definitioncom.ritense.valtimo.camunda.domain.CamundaProcessDefinitionCore
Timercom.ritense.valtimo.camunda.domain.CamundaTimerCore
TasksTaskscom.ritense.valtimo.camunda.domain.CamundaTaskCore
Identity linkscom.ritense.valtimo.camunda.domain.CamundaIdentityLinkCore
ZGWZaakcom.ritense.zakenapi.security.ZaakZaken API
Documentscom.ritense.resource.authorization.ResourcePermissionResource
## Actions configurable with Access control diff --git a/documentation/features/case/tabs/README.md b/documentation/features/case/tabs/README.md index c0f8b6912f..51b69bf1bb 100644 --- a/documentation/features/case/tabs/README.md +++ b/documentation/features/case/tabs/README.md @@ -113,7 +113,7 @@ The following tabs are created by default for each new case in Valtimo. These ca ### Valtimo standard tabs -
TabDescription
SummaryDisplays case specific data from the case JSON document or external data sources. This page links to a specific Form.io form with the name <caseDefinitionKey>.summary
ProgressShows the current state of any active process and the history of all processes that have been executed while handling the case
AuditShows a log of all performed case actions. Information on who did what and when was that action done is logged and displayed on this tab.
DocumentsDisplays all files that where generated or uploaded while handling the case.
NotesAllows case handlers to leave case specific comments for internal use.
+
TabDescription
SummaryDisplays case specific data from the case JSON document or external data sources. This page links to a specific Form.io form with the name <caseDefinitionKey>.summary
ProgressShows the current state of any active process and the history of all processes that have been executed while handling the case. A waiting timer can be skipped from this tab by users with the required permission.
AuditShows a log of all performed case actions. Information on who did what and when was that action done is logged and displayed on this tab.
DocumentsDisplays all files that where generated or uploaded while handling the case.
NotesAllows case handlers to leave case specific comments for internal use.
### GZAC edition additional tabs diff --git a/documentation/features/process/README.md b/documentation/features/process/README.md index 3ba435dd18..d77d427243 100644 --- a/documentation/features/process/README.md +++ b/documentation/features/process/README.md @@ -14,7 +14,7 @@ Access to the processes can be configured through access control. More informati ### Resources and actions -
Resource typeActionEffect
com.ritense.valtimo.camunda.domain.CamundaExecutioncreateAllows creating an execution for a process definition.
com.ritense.valtimo.camunda.domain.CamundaProcessDefinition--
+
Resource typeActionEffect
com.ritense.valtimo.camunda.domain.CamundaExecutioncreateAllows creating an execution for a process definition.
com.ritense.valtimo.camunda.domain.CamundaTimercompleteAllows skipping a waiting timer of a running process, so the process continues as if the timer had elapsed.
com.ritense.valtimo.camunda.domain.CamundaProcessDefinition--
Process definitions have no actions currently. As a result, they can only be used as part of container conditions. See the example [here](./#permission-to-start-a-process-for-one-specific-process-definition) on how to use this. @@ -45,3 +45,38 @@ Process definitions have no actions currently. As a result, they can only be use + +
+ +Permission to skip a waiting timer of one specific process definition + +A timer can be scoped through the process instance it belongs to, so the same container conditions that are available on +`CamundaExecution` can be used for timers as well. + +
{
+    "resourceType": "com.ritense.valtimo.camunda.domain.CamundaTimer",
+    "action": "complete",
+    "conditions": [
+        {
+            "type": "container",
+            "resourceType": "com.ritense.valtimo.camunda.domain.CamundaExecution",
+            "conditions": [
+                {
+                    "type": "container",
+                    "resourceType": "com.ritense.valtimo.camunda.domain.CamundaProcessDefinition",
+                    "conditions": [
+                        {
+                            "type": "field",
+                            "field": "key",
+                            "operator": "==",
+                            "value": "evenementenvergunning"
+                        }
+                    ]
+                }
+            ]
+        }
+    ]
+}
+
+ +
diff --git a/documentation/release-notes/12.x.x/12.42.0/README.md b/documentation/release-notes/12.x.x/12.42.0/README.md index d088e0c3b9..c178b90f25 100644 --- a/documentation/release-notes/12.x.x/12.42.0/README.md +++ b/documentation/release-notes/12.x.x/12.42.0/README.md @@ -2,6 +2,13 @@ ## New Features +* **Skip a waiting timer from the case Progress tab** + + When a process is waiting on a timer, users can now skip that timer directly from the **Progress** tab of a case. A + skip button appears on the waiting timer in the process diagram; after confirming, the process continues immediately + as if the timer had elapsed. The option is only available to users who have the `complete` permission on the timer + (`CamundaTimer`) through Access Control (PBAC), and every skip is recorded in the case's audit trail. + * **New feature title** New feature explanation. diff --git a/frontend/projects/valtimo/config/assets/core/en.json b/frontend/projects/valtimo/config/assets/core/en.json index f3b60bc973..f04681822c 100644 --- a/frontend/projects/valtimo/config/assets/core/en.json +++ b/frontend/projects/valtimo/config/assets/core/en.json @@ -12,7 +12,8 @@ "BesluitAddedEvent": "Decision added > {{identificatie}}", "DocumentAssigneeChangedEvent": "The assignee has been changed to {{assigneeName}}", "DocumentUnassignedEvent": "The assignee has been unassigned", - "TaskDueDateSetEvent": "Task due date set > {{taskName}}" + "TaskDueDateSetEvent": "Task due date set > {{taskName}}", + "ProcessTimerSkippedEvent": "Timer skipped > {{activityId}}" }, "dossier": { "exportButtonTooltip": "Export current case list", @@ -294,7 +295,14 @@ "startedBy": "Started by", "startedOn": "Started on", "system": "System", - "of": "of" + "of": "of", + "skipTimer": { + "action": "Skip timer", + "confirmTitle": "Skip timer", + "confirmContent": "Are you sure you want to skip this timer? The process will continue as if the timer had elapsed. This cannot be undone.", + "confirmButton": "Skip", + "successToast": "Timer skipped" + } }, "dashboard": { "openTasks": { diff --git a/frontend/projects/valtimo/config/assets/core/nl.json b/frontend/projects/valtimo/config/assets/core/nl.json index 0f7cfb5afc..eb28754392 100644 --- a/frontend/projects/valtimo/config/assets/core/nl.json +++ b/frontend/projects/valtimo/config/assets/core/nl.json @@ -12,7 +12,8 @@ "BesluitAddedEvent": "Besluit toegevoegd > {{identificatie}}", "DocumentAssigneeChangedEvent": "De behandelaar is gewijzigd naar {{assigneeName}}", "DocumentUnassignedEvent": "De behandelaar is van het dossier afgehaald", - "TaskDueDateSetEvent": "Taak einddatum ingesteld > {{taskName}}" + "TaskDueDateSetEvent": "Taak einddatum ingesteld > {{taskName}}", + "ProcessTimerSkippedEvent": "Timer overgeslagen > {{activityId}}" }, "dossier": { "exportButtonTooltip": "Exporteer huidige dossier lijst", @@ -294,7 +295,14 @@ "startedBy": "Gestart door", "startedOn": "Gestart op", "system": "Systeem", - "of": "van" + "of": "van", + "skipTimer": { + "action": "Timer overslaan", + "confirmTitle": "Timer overslaan", + "confirmContent": "Weet je zeker dat je deze timer wilt overslaan? Het proces gaat verder alsof de timer is verlopen. Dit kan niet ongedaan worden gemaakt.", + "confirmButton": "Overslaan", + "successToast": "Timer overgeslagen" + } }, "dashboard": { "openTasks": { diff --git a/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.html b/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.html index 472d05ba7e..ba09f38f37 100644 --- a/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.html +++ b/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.html @@ -19,6 +19,9 @@ *ngIf="{ processInstanceItems: processInstanceItems$ | async, selectedProcessInstance: selectedProcessInstance$ | async, + canSkipTimer: canSkipTimer$ | async, + skippableTimers: skippableTimers$ | async, + diagramReloadToken: diagramReloadToken$ | async, } as obs" > {{ @@ -72,7 +75,21 @@
+ + diff --git a/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.ts b/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.ts index 53804269bc..841ca60ffd 100644 --- a/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.ts +++ b/frontend/projects/valtimo/dossier/src/lib/components/dossier-detail/tab/progress/progress.component.ts @@ -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. @@ -14,21 +14,50 @@ * limitations under the License. */ -import {Component} from '@angular/core'; +import {Component, DestroyRef} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {ActivatedRoute, ParamMap} from '@angular/router'; +import {TranslateService} from '@ngx-translate/core'; +import {CARBON_CONSTANTS} from '@valtimo/components'; import {DocumentService, LoadedValue, ProcessDocumentInstance} from '@valtimo/document'; -import {BehaviorSubject, combineLatest, map, Observable, startWith, switchMap, tap} from 'rxjs'; +import {SkippableTimer} from '@valtimo/process'; +import {NotificationService} from 'carbon-components-angular'; import {ListItem} from 'carbon-components-angular/dropdown'; +import { + BehaviorSubject, + catchError, + combineLatest, + map, + Observable, + of, + shareReplay, + startWith, + switchMap, + take, + tap, +} from 'rxjs'; +import {PendingSkip} from '../../../../models/pending-skip.model'; +import {DossierProcessTimerService} from '../../../../services'; @Component({ selector: 'valtimo-dossier-detail-tab-progress', templateUrl: './progress.component.html', styleUrls: ['./progress.component.css'], + providers: [NotificationService], }) export class DossierDetailTabProgressComponent { + private readonly _documentId$: Observable = this.route.paramMap.pipe( + map((params: ParamMap) => params.get('documentId')), + shareReplay({bufferSize: 1, refCount: true}) + ); + + private readonly _reloadProcessInstances$ = new BehaviorSubject(undefined); + + public readonly selectedProcessInstanceId$ = new BehaviorSubject(null); + private readonly processDocumentInstances$: Observable> = - this.route.paramMap.pipe( - switchMap((params: ParamMap) => + combineLatest([this.route.paramMap, this._reloadProcessInstances$]).pipe( + switchMap(([params]: [ParamMap, void]) => this.documentService.findProcessDocumentInstances(params.get('documentId')) ), map(processDocumentInstances => @@ -43,29 +72,40 @@ export class DossierDetailTabProgressComponent { ) ), tap(processDocumentInstances => { - if (processDocumentInstances.length > 0) { + if (processDocumentInstances.length === 0) { + return; + } + + // Reloading keeps the selected process instance, as long as it is still part of the case. + const stillPresent = processDocumentInstances.some( + processDocumentInstance => + processDocumentInstance.id.processInstanceId === this.selectedProcessInstanceId$.value + ); + + if (!stillPresent) { this.selectedProcessInstanceId$.next(processDocumentInstances[0].id.processInstanceId); } }) ); - public readonly processInstanceItems$: Observable>> = - this.processDocumentInstances$.pipe( - map(processDocumentInstances => - processDocumentInstances.map((processDocumentInstance, index) => ({ - processInstanceId: processDocumentInstance.id.processInstanceId, - content: processDocumentInstance.processName || '-', - selected: index === 0, - })) - ), - map(processInstanceItems => ({ - value: processInstanceItems, - isLoading: false, - })), - startWith({isLoading: true}) - ); + public readonly processInstanceItems$: Observable>> = combineLatest([ + this.processDocumentInstances$, + this.selectedProcessInstanceId$, + ]).pipe( + map(([processDocumentInstances, selectedProcessInstanceId]) => + processDocumentInstances.map(processDocumentInstance => ({ + processInstanceId: processDocumentInstance.id.processInstanceId, + content: processDocumentInstance.processName || '-', + selected: processDocumentInstance.id.processInstanceId === selectedProcessInstanceId, + })) + ), + map(processInstanceItems => ({ + value: processInstanceItems, + isLoading: false, + })), + startWith({isLoading: true}) + ); - public readonly selectedProcessInstanceId$ = new BehaviorSubject(null); public readonly selectedProcessInstance$: Observable> = combineLatest([this.processDocumentInstances$, this.selectedProcessInstanceId$]).pipe( map(([processDocumentInstances, selectedProcessInstanceId]) => @@ -80,14 +120,106 @@ export class DossierDetailTabProgressComponent { startWith({isLoading: true}) ); + public readonly diagramReloadToken$ = new BehaviorSubject(0); + public readonly showSkipConfirm$ = new BehaviorSubject(false); + + private readonly _reloadTimers$ = new BehaviorSubject(undefined); + + /** + * The endpoint only returns the timers the user is allowed to complete, so no separate permission + * check is needed here. Checking the `complete` permission on `CamundaTimer` up front would not + * work for permissions that are conditional on the timer or the case it belongs to. + */ + public readonly skippableTimers$: Observable> = combineLatest([ + this._documentId$, + this.selectedProcessInstanceId$, + this._reloadTimers$, + ]).pipe( + switchMap(([documentId, processInstanceId]) => { + if (!documentId || !processInstanceId) { + return of>([]); + } + return this.dossierProcessTimerService.getSkippableTimers(documentId, processInstanceId).pipe( + map(jobs => jobs.map(job => ({jobId: job.id, activityId: job.activityId}))), + catchError(() => of>([])) + ); + }), + startWith>([]), + shareReplay({bufferSize: 1, refCount: true}) + ); + + public readonly canSkipTimer$: Observable = this.skippableTimers$.pipe( + map(timers => timers.length > 0) + ); + + private readonly _pendingSkip$ = new BehaviorSubject(null); + constructor( private readonly route: ActivatedRoute, - private readonly documentService: DocumentService + private readonly documentService: DocumentService, + private readonly dossierProcessTimerService: DossierProcessTimerService, + private readonly notificationService: NotificationService, + private readonly translateService: TranslateService, + private readonly destroyRef: DestroyRef ) {} - public loadProcessInstance(processInstanceId: string) { + public loadProcessInstance(processInstanceId: string): void { if (!!processInstanceId) { this.selectedProcessInstanceId$.next(processInstanceId); } } + + /** + * The case and process instance the timer belongs to are captured here, so confirming skips the + * timer in the context it was requested from, even when the selection or the route changed in the + * meantime. + */ + public onRequestSkipTimer(timer: SkippableTimer): void { + const processInstanceId = this.selectedProcessInstanceId$.value; + + this._documentId$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(documentId => { + if (!documentId || !processInstanceId) { + return; + } + + this._pendingSkip$.next({timer, documentId, processInstanceId}); + this.showSkipConfirm$.next(true); + }); + } + + public onConfirmSkipTimer(): void { + this.showSkipConfirm$.next(false); + const pendingSkip = this._pendingSkip$.value; + + if (!pendingSkip) { + return; + } + + /* + The pending skip is cleared before the request is sent, so a repeated confirm cannot submit the + same timer twice and a completing request cannot clear a newer pending skip. + */ + this._pendingSkip$.next(null); + + this.dossierProcessTimerService + .skipTimer(pendingSkip.documentId, pendingSkip.processInstanceId, pendingSkip.timer.jobId) + // HTTP failures are surfaced globally by HttpErrorInterceptor, so only success is handled here. + .subscribe(() => { + this.notificationService.showToast({ + type: 'success', + title: this.translateService.instant('progress.skipTimer.successToast'), + duration: CARBON_CONSTANTS.notificationDuration, + showClose: true, + }); + // Skipping a timer can complete the process, so the process instance data is stale too. + this._reloadProcessInstances$.next(); + this._reloadTimers$.next(); + this.diagramReloadToken$.next(this.diagramReloadToken$.value + 1); + }); + } + + public onCancelSkipTimer(): void { + this.showSkipConfirm$.next(false); + this._pendingSkip$.next(null); + } } diff --git a/frontend/projects/valtimo/dossier/src/lib/models/index.ts b/frontend/projects/valtimo/dossier/src/lib/models/index.ts index 8895720dc6..e7ad4f85dd 100644 --- a/frontend/projects/valtimo/dossier/src/lib/models/index.ts +++ b/frontend/projects/valtimo/dossier/src/lib/models/index.ts @@ -20,6 +20,8 @@ export * from './case-widget-display.model'; export * from './case-widget.model'; export * from './dossier-detail-tab.model'; export * from './dossier-parameters.model'; +export * from './pending-skip.model'; +export * from './process-job.model'; export * from './search.model'; export * from './tab-api.model'; export * from './tabs.model'; diff --git a/frontend/projects/valtimo/dossier/src/lib/models/pending-skip.model.ts b/frontend/projects/valtimo/dossier/src/lib/models/pending-skip.model.ts new file mode 100644 index 0000000000..89f3db7e42 --- /dev/null +++ b/frontend/projects/valtimo/dossier/src/lib/models/pending-skip.model.ts @@ -0,0 +1,25 @@ +/* + * 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 {SkippableTimer} from '@valtimo/process'; + +interface PendingSkip { + timer: SkippableTimer; + documentId: string; + processInstanceId: string; +} + +export {PendingSkip}; diff --git a/frontend/projects/valtimo/dossier/src/lib/models/process-job.model.ts b/frontend/projects/valtimo/dossier/src/lib/models/process-job.model.ts new file mode 100644 index 0000000000..3a47bbf60d --- /dev/null +++ b/frontend/projects/valtimo/dossier/src/lib/models/process-job.model.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +type ProcessJobType = 'TIMER' | 'ASYNC_CONTINUATION' | 'MESSAGE' | 'BATCH' | 'OTHER'; + +interface ProcessJob { + id: string; + jobDefinitionId: string | null; + executionId: string | null; + activityId: string | null; + jobType: ProcessJobType; + retries: number; + exceptionMessage: string | null; + dueDate: string | null; + suspended: boolean; +} + +export {ProcessJob, ProcessJobType}; diff --git a/frontend/projects/valtimo/dossier/src/lib/services/dossier-process-timer.service.ts b/frontend/projects/valtimo/dossier/src/lib/services/dossier-process-timer.service.ts new file mode 100644 index 0000000000..60e96d01b3 --- /dev/null +++ b/frontend/projects/valtimo/dossier/src/lib/services/dossier-process-timer.service.ts @@ -0,0 +1,46 @@ +/* + * 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 {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {ConfigService} from '@valtimo/config'; +import {Observable} from 'rxjs'; +import {ProcessJob} from '../models'; + +@Injectable({providedIn: 'root'}) +export class DossierProcessTimerService { + private readonly _baseUrl: string; + + constructor( + private readonly http: HttpClient, + private readonly configService: ConfigService + ) { + this._baseUrl = this.configService.config.valtimoApi.endpointUri; + } + + public getSkippableTimers(caseId: string, processInstanceId: string): Observable { + return this.http.get( + `${this._baseUrl}v1/process-document/case/${caseId}/process-instance/${processInstanceId}/timers` + ); + } + + public skipTimer(caseId: string, processInstanceId: string, jobId: string): Observable { + return this.http.post( + `${this._baseUrl}v1/process-document/case/${caseId}/process-instance/${processInstanceId}/timer/${jobId}/skip`, + {} + ); + } +} diff --git a/frontend/projects/valtimo/dossier/src/lib/services/index.ts b/frontend/projects/valtimo/dossier/src/lib/services/index.ts index 6efb1ec92d..fb6996f158 100644 --- a/frontend/projects/valtimo/dossier/src/lib/services/index.ts +++ b/frontend/projects/valtimo/dossier/src/lib/services/index.ts @@ -31,3 +31,4 @@ export * from './dossier-widgets-api.service'; export * from './dossier-widgets-layout.service'; export * from './dossier-detail-layout.service'; export * from './dossier-list-case-tag.service'; +export * from './dossier-process-timer.service'; diff --git a/frontend/projects/valtimo/process/src/lib/models/process.model.ts b/frontend/projects/valtimo/process/src/lib/models/process.model.ts index 22ddef0dba..1b730e6621 100644 --- a/frontend/projects/valtimo/process/src/lib/models/process.model.ts +++ b/frontend/projects/valtimo/process/src/lib/models/process.model.ts @@ -135,3 +135,8 @@ export interface IdentityLink { groupId: string; type: string; } + +export interface SkippableTimer { + jobId: string; + activityId: string | null; +} diff --git a/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.scss b/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.scss index 5ce3d38145..60a05d1fac 100644 --- a/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.scss +++ b/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.scss @@ -55,3 +55,27 @@ background-color: #b2e0ff; } } + +.valtimo-skip-timer-overlay { + align-items: center; + background-color: var(--cds-button-primary); + border: none; + border-radius: 50%; + box-shadow: 0 1px 3px var(--cds-shadow, rgba(0, 0, 0, 0.3)); + color: var(--cds-text-on-color); + cursor: pointer; + display: inline-flex; + height: 24px; + justify-content: center; + padding: 0; + width: 24px; + + &:hover { + background-color: var(--cds-button-primary-hover); + } + + &:focus { + outline: 2px solid var(--cds-focus); + outline-offset: 1px; + } +} diff --git a/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.ts b/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.ts index 703866749b..0fcb4e5d5b 100644 --- a/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.ts +++ b/frontend/projects/valtimo/process/src/lib/process-diagram/process-diagram.component.ts @@ -23,10 +23,12 @@ import { OnDestroy, OnInit, Output, + SimpleChanges, ViewChild, ViewEncapsulation, } from '@angular/core'; import {ProcessService} from '../process.service'; +import {SkippableTimer} from '../models'; import BpmnJS from 'bpmn-js/dist/bpmn-navigated-viewer.production.min.js'; import heatmap from 'heatmap.js-fixed/build/heatmap.js'; @@ -40,11 +42,18 @@ import heatmap from 'heatmap.js-fixed/build/heatmap.js'; export class ProcessDiagramComponent implements OnInit, OnDestroy, OnChanges { private bpmnJS: BpmnJS; private heatMapInstance: any; + private _imported = false; + private _skipOverlayIds: string[] = []; @ViewChild('ref', {static: true}) public el: ElementRef; @Output() public importDone: EventEmitter = new EventEmitter(); + @Output() public skipTimerEvent: EventEmitter = new EventEmitter(); @Input() public processDefinitionKey?: string; @Input() public processInstanceId?: string; + @Input() public skippableTimers: SkippableTimer[] = []; + @Input() public canSkipTimer = false; + @Input() public skipTimerLabel = 'Skip timer'; + @Input() public reloadToken?: number; public processDiagram: any; public processDefinition: any; @@ -88,6 +97,9 @@ export class ProcessDiagramComponent implements OnInit, OnDestroy, OnChanges { }); } + this._imported = true; + this.renderSkipTimerOverlays(); + canvas.zoom('fit-viewport', 'auto'); if (this.processDefinitionVersions) { eventBus.on('canvas.init', () => { @@ -109,15 +121,25 @@ export class ProcessDiagramComponent implements OnInit, OnDestroy, OnChanges { }); } - ngOnChanges(): void { - if (this.processDefinitionKey) { + public ngOnChanges(changes: SimpleChanges): void { + if (changes['processDefinitionKey'] && this.processDefinitionKey) { this.loadProcessDefinitionFromKey(this.processDefinitionKey); - } else if (this.processInstanceId) { + } else if (changes['processInstanceId'] && this.processInstanceId) { + this.loadProcessInstanceXml(this.processInstanceId); + } else if ( + changes['reloadToken'] && + !changes['reloadToken'].firstChange && + this.processInstanceId + ) { this.loadProcessInstanceXml(this.processInstanceId); } + + if (changes['skippableTimers'] || changes['canSkipTimer']) { + this.renderSkipTimerOverlays(); + } } - ngOnDestroy() { + public ngOnDestroy() { if (this.bpmnJS) { this.bpmnJS.destroy(); } @@ -151,6 +173,7 @@ export class ProcessDiagramComponent implements OnInit, OnDestroy, OnChanges { } private loadProcessInstanceXml(processInstanceId) { + this._imported = false; this.processService.getProcessXml(processInstanceId).subscribe(response => { this.processDiagram = response; this.bpmnJS.importXML(this.processDiagram.bpmn20Xml); @@ -158,6 +181,56 @@ export class ProcessDiagramComponent implements OnInit, OnDestroy, OnChanges { }); } + private renderSkipTimerOverlays(): void { + if (!this.bpmnJS || !this._imported) { + return; + } + + const overlays = this.bpmnJS.get('overlays') as any; + this._skipOverlayIds.forEach(overlayId => { + try { + overlays.remove(overlayId); + } catch { + // overlay already gone (e.g. after a diagram re-import); ignore + } + }); + this._skipOverlayIds = []; + + if (!this.canSkipTimer || !this.skippableTimers?.length) { + return; + } + + this.skippableTimers.forEach(timer => { + if (!timer.activityId) { + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'valtimo-skip-timer-overlay'; + button.title = this.skipTimerLabel; + button.setAttribute('aria-label', this.skipTimerLabel); + button.innerHTML = + ''; + button.addEventListener('click', (event: MouseEvent) => { + event.stopPropagation(); + this.skipTimerEvent.emit(timer); + }); + + try { + const overlayId = overlays.add(timer.activityId, { + position: {top: -14, right: 14}, + show: {minZoom: 0, maxZoom: 5.0}, + html: button, + }); + this._skipOverlayIds.push(overlayId); + } catch { + // activity not present in the rendered diagram; nothing to attach to + } + }); + } + public loadProcessDefinitionHeatmapCount(processDefinition) { this.processService.getProcessHeatmapCount(processDefinition).subscribe(response => { this.inputData = response;