From 3706e2af376e0d8f696cc43877e9323037aaf1b5 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 27 Apr 2026 15:35:25 +0800 Subject: [PATCH] feat: rules engine write API + workflow user-task endpoints --- platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt | 26 ++++++++++++++++++++++++-- platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt | 47 +++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt | 9 +++++++++ platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt | 34 ++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt | 28 ++++++++++++++++++++++++++++ platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml | 9 +++++++++ 9 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt create mode 100644 platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt create mode 100644 platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt index 2be348c..4faebbc 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt @@ -154,6 +154,7 @@ class MetadataLoader( customFields = files.flatMap { it.parsed.customFields }, forms = files.flatMap { it.parsed.forms }, listViews = files.flatMap { it.parsed.listViews }, + rules = files.flatMap { it.parsed.rules }, ) wipeBySource(source) @@ -163,11 +164,12 @@ class MetadataLoader( insertCustomFields(source, merged) insertForms(source, merged) insertListViews(source, merged) + insertRules(source, merged) log.info( - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views from {} file(s)", + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views, {} rules from {} file(s)", source, merged.entities.size, merged.permissions.size, merged.menus.size, - merged.customFields.size, merged.forms.size, merged.listViews.size, files.size, + merged.customFields.size, merged.forms.size, merged.listViews.size, merged.rules.size, files.size, ) return LoadResult( @@ -178,6 +180,7 @@ class MetadataLoader( customFieldCount = merged.customFields.size, formCount = merged.forms.size, listViewCount = merged.listViews.size, + ruleCount = merged.rules.size, files = files.map { it.url }, ) } @@ -194,6 +197,7 @@ class MetadataLoader( val customFieldCount: Int = 0, val formCount: Int = 0, val listViewCount: Int = 0, + val ruleCount: Int = 0, val files: List, ) @@ -207,6 +211,7 @@ class MetadataLoader( jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params) jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__rule WHERE source = :source", params) } private fun insertEntities(source: String, file: MetadataYamlFile) { @@ -311,6 +316,23 @@ class MetadataLoader( } } + private fun insertRules(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (rule in file.rules) { + jdbc.update( + """ + INSERT INTO metadata__rule (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(rule)) + .addValue("now", now), + ) + } + } + private data class ParsedYaml( val url: String, val parsed: MetadataYamlFile, diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt new file mode 100644 index 0000000..da2689e --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt @@ -0,0 +1,47 @@ +package org.vibeerp.platform.metadata.rules + +/** + * Stateless evaluator for event-condition-action rule conditions. + * + * Takes a flattened event map and a list of conditions, returns whether the + * event matches. No Spring dependencies -- this is a pure function so it can + * be unit-tested in isolation without any framework context. + */ +object RuleEvaluator { + + data class Condition(val field: String, val operator: String, val value: String) + + /** + * Evaluate a list of conditions against an event map. + * + * @param eventMap flattened key-value representation of the domain event + * @param conditions the conditions to test + * @param logic "AND" (all must match) or "OR" (any must match); defaults to AND + * @return true if the event satisfies the condition set + */ + fun evaluate(eventMap: Map, conditions: List, logic: String): Boolean { + if (conditions.isEmpty()) return true + return if (logic.uppercase() == "OR") { + conditions.any { matches(eventMap, it) } + } else { + conditions.all { matches(eventMap, it) } + } + } + + private fun matches(eventMap: Map, c: Condition): Boolean { + val actual = eventMap[c.field] + ?: return c.operator == "eq" && c.value.isBlank() + val av = actual.toString() + return when (c.operator) { + "eq" -> av == c.value + "neq" -> av != c.value + "gt" -> av.toDoubleOrNull()?.let { it > c.value.toDouble() } ?: false + "gte" -> av.toDoubleOrNull()?.let { it >= c.value.toDouble() } ?: false + "lt" -> av.toDoubleOrNull()?.let { it < c.value.toDouble() } ?: false + "lte" -> av.toDoubleOrNull()?.let { it <= c.value.toDouble() } ?: false + "contains" -> av.contains(c.value, ignoreCase = true) + "in" -> c.value.split(",").map { it.trim() }.contains(av) + else -> false + } + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt index c5fb899..2964264 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt @@ -45,6 +45,7 @@ class MetadataController( "customFields" to readPayloads("metadata__custom_field"), "forms" to readPayloads("metadata__form"), "listViews" to readPayloads("metadata__list_view"), + "rules" to readPayloads("metadata__rule"), ) @GetMapping("/entities") @@ -72,6 +73,14 @@ class MetadataController( readPayloadBySlug("metadata__list_view", slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") + @GetMapping("/rules") + fun rules(): List> = readPayloads("metadata__rule") + + @GetMapping("/rules/{slug}") + fun ruleBySlug(@PathVariable slug: String): Map = + readPayloadBySlug("metadata__rule", slug) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rule '$slug' not found") + /** * Read every custom-field declaration as raw `metadata__custom_field` * payload rows. Returns the YAML wire format unchanged so the SPA diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt new file mode 100644 index 0000000..215c98d --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt @@ -0,0 +1,109 @@ +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.metadata.rules.RuleEngine +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +/** + * Write endpoints for automation rules stored in `metadata__rule`. + * + * Only rows with `source = 'user'` may be created, updated, or deleted + * through this controller. Rows seeded by core YAMLs or plug-in JARs + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the + * REST surface -- they can only change by redeploying the owning module. + * + * After every successful mutation the [RuleEngine] is refreshed so that + * new/changed rules take effect immediately without a restart. + * + * Read endpoints live in [MetadataController]. + */ +@RestController +@RequestMapping("/api/v1/_meta/metadata/rules") +class RuleController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, + private val ruleEngine: RuleEngine, +) { + + @PutMapping("/{slug}") + @RequirePermission("admin.metadata.write") + fun upsert(@PathVariable slug: String, @RequestBody body: Map): Map { + val payload = body.toMutableMap().apply { put("slug", slug) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + val existing = jdbc.query( + "SELECT id, source FROM metadata__rule WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isNotEmpty()) { + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot modify rule '$slug' (source='$source')", + ) + } + jdbc.update( + "UPDATE metadata__rule SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource() + .addValue("id", id) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } else { + jdbc.update( + "INSERT INTO metadata__rule (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } + + ruleEngine.refresh() + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{slug}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable slug: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__rule WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rule '$slug' not found") + } + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot delete rule '$slug' (source='$source')", + ) + } + jdbc.update( + "DELETE FROM metadata__rule WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource("id", id), + ) + + ruleEngine.refresh() + } +} diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt new file mode 100644 index 0000000..18f7e34 --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt @@ -0,0 +1,186 @@ +package org.vibeerp.platform.metadata.rules + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.jupiter.api.Test + +class RuleEvaluatorTest { + + private fun cond(field: String, op: String, value: String) = + RuleEvaluator.Condition(field, op, value) + + // ── eq ──────────────────────────────────────────────────────────── + + @Test + fun `eq matches when equal`() { + val event = mapOf("status" to "CONFIRMED") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "eq", "CONFIRMED")), "AND")).isTrue() + } + + @Test + fun `eq fails when not equal`() { + val event = mapOf("status" to "DRAFT") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "eq", "CONFIRMED")), "AND")).isFalse() + } + + @Test + fun `eq with blank value matches missing field`() { + val event = mapOf("status" to "DRAFT") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("missing", "eq", "")), "AND")).isTrue() + } + + // ── neq ─────────────────────────────────────────────────────────── + + @Test + fun `neq matches when not equal`() { + val event = mapOf("status" to "DRAFT") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "neq", "CONFIRMED")), "AND")).isTrue() + } + + @Test + fun `neq fails when equal`() { + val event = mapOf("status" to "CONFIRMED") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "neq", "CONFIRMED")), "AND")).isFalse() + } + + // ── numeric comparisons ────────────────────────────────────────── + + @Test + fun `gt matches when greater`() { + val event = mapOf("amount" to 150.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isTrue() + } + + @Test + fun `gt fails when equal`() { + val event = mapOf("amount" to 100.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isFalse() + } + + @Test + fun `gte matches when equal`() { + val event = mapOf("amount" to 100.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gte", "100")), "AND")).isTrue() + } + + @Test + fun `lt matches when less`() { + val event = mapOf("amount" to 50.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lt", "100")), "AND")).isTrue() + } + + @Test + fun `lt fails when equal`() { + val event = mapOf("amount" to 100.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lt", "100")), "AND")).isFalse() + } + + @Test + fun `lte matches when equal`() { + val event = mapOf("amount" to 100.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lte", "100")), "AND")).isTrue() + } + + @Test + fun `lte matches when less`() { + val event = mapOf("amount" to 50.0) + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lte", "100")), "AND")).isTrue() + } + + @Test + fun `numeric comparison fails for non-numeric actual value`() { + val event = mapOf("amount" to "not-a-number") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isFalse() + } + + // ── contains ───────────────────────────────────────────────────── + + @Test + fun `contains matches case-insensitive substring`() { + val event = mapOf("name" to "Premium Paper Stock") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("name", "contains", "paper")), "AND")).isTrue() + } + + @Test + fun `contains fails when substring absent`() { + val event = mapOf("name" to "Premium Ink") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("name", "contains", "paper")), "AND")).isFalse() + } + + // ── in ──────────────────────────────────────────────────────────── + + @Test + fun `in matches when value is in comma-separated list`() { + val event = mapOf("status" to "CONFIRMED") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "in", "DRAFT, CONFIRMED, SHIPPED")), "AND")).isTrue() + } + + @Test + fun `in fails when value is not in list`() { + val event = mapOf("status" to "CANCELLED") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "in", "DRAFT, CONFIRMED, SHIPPED")), "AND")).isFalse() + } + + // ── logic ───────────────────────────────────────────────────────── + + @Test + fun `AND logic requires all conditions to match`() { + val event = mapOf("status" to "CONFIRMED", "amount" to 200.0) + val conditions = listOf( + cond("status", "eq", "CONFIRMED"), + cond("amount", "gt", "100"), + ) + assertThat(RuleEvaluator.evaluate(event, conditions, "AND")).isTrue() + } + + @Test + fun `AND logic fails when one condition does not match`() { + val event = mapOf("status" to "DRAFT", "amount" to 200.0) + val conditions = listOf( + cond("status", "eq", "CONFIRMED"), + cond("amount", "gt", "100"), + ) + assertThat(RuleEvaluator.evaluate(event, conditions, "AND")).isFalse() + } + + @Test + fun `OR logic passes when any condition matches`() { + val event = mapOf("status" to "DRAFT", "amount" to 200.0) + val conditions = listOf( + cond("status", "eq", "CONFIRMED"), + cond("amount", "gt", "100"), + ) + assertThat(RuleEvaluator.evaluate(event, conditions, "OR")).isTrue() + } + + @Test + fun `OR logic fails when no conditions match`() { + val event = mapOf("status" to "DRAFT", "amount" to 50.0) + val conditions = listOf( + cond("status", "eq", "CONFIRMED"), + cond("amount", "gt", "100"), + ) + assertThat(RuleEvaluator.evaluate(event, conditions, "OR")).isFalse() + } + + // ── edge cases ──────────────────────────────────────────────────── + + @Test + fun `empty conditions returns true`() { + val event = mapOf("status" to "ANYTHING") + assertThat(RuleEvaluator.evaluate(event, emptyList(), "AND")).isTrue() + } + + @Test + fun `missing field returns false for non-eq operator`() { + val event = mapOf("other" to "value") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("missing", "gt", "100")), "AND")).isFalse() + } + + @Test + fun `unknown operator returns false`() { + val event = mapOf("status" to "CONFIRMED") + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "regex", ".*")), "AND")).isFalse() + } +} diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt index e3dd304..83faa32 100644 --- a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt @@ -231,4 +231,38 @@ class MetadataYamlParseTest { assertThat(lv.filters[0].operator).isEqualTo("eq") assertThat(lv.filters[1].operator).isEqualTo("contains") } + + @Test + fun `rules section parses with conditions and actions`() { + val yaml = """ + rules: + - slug: high-value-order + name: High Value Order Alert + enabled: true + triggerEvent: SalesOrderConfirmedEvent + conditionLogic: AND + conditions: + - field: totalAmount + operator: gt + value: "10000" + actions: + - type: log + config: + message: "High value order detected" + """.trimIndent() + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + assertThat(parsed.rules).hasSize(1) + assertThat(parsed.rules[0].slug).isEqualTo("high-value-order") + assertThat(parsed.rules[0].name).isEqualTo("High Value Order Alert") + assertThat(parsed.rules[0].enabled).isEqualTo(true) + assertThat(parsed.rules[0].triggerEvent).isEqualTo("SalesOrderConfirmedEvent") + assertThat(parsed.rules[0].conditionLogic).isEqualTo("AND") + assertThat(parsed.rules[0].conditions).hasSize(1) + assertThat(parsed.rules[0].conditions[0].field).isEqualTo("totalAmount") + assertThat(parsed.rules[0].conditions[0].operator).isEqualTo("gt") + assertThat(parsed.rules[0].conditions[0].value).isEqualTo("10000") + assertThat(parsed.rules[0].actions).hasSize(1) + assertThat(parsed.rules[0].actions[0].type).isEqualTo("log") + assertThat(parsed.rules[0].actions[0].config["message"]).isEqualTo("High value order detected") + } } diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt index 18fabf8..0d7a1e8 100644 --- a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt @@ -2,10 +2,14 @@ package org.vibeerp.platform.workflow import org.flowable.engine.RepositoryService import org.flowable.engine.RuntimeService +import org.flowable.engine.TaskService import org.flowable.engine.runtime.ProcessInstance import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException import org.vibeerp.platform.security.authz.AuthorizationContext +import java.time.Instant /** * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used @@ -26,6 +30,7 @@ import org.vibeerp.platform.security.authz.AuthorizationContext class WorkflowService( private val runtimeService: RuntimeService, private val repositoryService: RepositoryService, + private val taskService: TaskService, ) { private val log = LoggerFactory.getLogger(WorkflowService::class.java) @@ -139,6 +144,66 @@ class WorkflowService( ) } + /* ------------------------------------------------------------------ */ + /* User-task management */ + /* ------------------------------------------------------------------ */ + + /** + * List all active (pending) user tasks across all process instances, + * ordered newest first. + */ + fun listPendingTasks(): List = + taskService.createTaskQuery().active().orderByTaskCreateTime().desc().list().map { task -> + val defKey = repositoryService + .getProcessDefinition(task.processDefinitionId).key + UserTaskSummary( + taskId = task.id, + taskName = task.name ?: "", + formKey = task.formKey, + processDefinitionKey = defKey, + processInstanceId = task.processInstanceId, + createTime = task.createTime.toInstant(), + assignee = task.assignee, + ) + } + + /** + * Fetch a single user task's detail including its process variables + * (with framework-internal `__vibeerp_*` variables stripped). + * + * @throws ResponseStatusException 404 when the task does not exist. + */ + fun getTaskDetail(taskId: String): UserTaskDetail { + val task = taskService.createTaskQuery().taskId(taskId).singleResult() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found") + val defKey = repositoryService + .getProcessDefinition(task.processDefinitionId).key + val variables = runtimeService.getVariables(task.executionId) + .filterKeys { !it.startsWith(DispatchingJavaDelegate.RESERVED_VAR_PREFIX) } + return UserTaskDetail( + taskId = task.id, + taskName = task.name ?: "", + formKey = task.formKey, + variables = variables, + processDefinitionKey = defKey, + processInstanceId = task.processInstanceId, + createTime = task.createTime.toInstant(), + ) + } + + /** + * Complete a user task, optionally passing variables into the process + * scope. Null-valued entries are dropped before forwarding to Flowable. + * + * @throws ResponseStatusException 404 when the task does not exist. + */ + fun completeTask(taskId: String, variables: Map) { + taskService.createTaskQuery().taskId(taskId).singleResult() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found") + taskService.complete(taskId, variables.filterValues { it != null }) + log.info("completed user task '{}'", taskId) + } + companion object { /** * Reserved process variable name: the UUID-string id of the @@ -178,3 +243,23 @@ data class ProcessDefinitionSummary( val deploymentId: String, val resourceName: String, ) + +data class UserTaskSummary( + val taskId: String, + val taskName: String, + val formKey: String?, + val processDefinitionKey: String, + val processInstanceId: String, + val createTime: Instant, + val assignee: String?, +) + +data class UserTaskDetail( + val taskId: String, + val taskName: String, + val formKey: String?, + val variables: Map, + val processDefinitionKey: String, + val processInstanceId: String, + val createTime: Instant, +) diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt index 24514f0..7c73410 100644 --- a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt @@ -11,11 +11,14 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.ResponseStatus import org.vibeerp.platform.security.authz.RequirePermission import org.vibeerp.platform.workflow.ProcessDefinitionSummary import org.vibeerp.platform.workflow.ProcessInstanceSummary import org.vibeerp.platform.workflow.StartedProcessInstance import org.vibeerp.platform.workflow.TaskHandlerRegistry +import org.vibeerp.platform.workflow.UserTaskDetail +import org.vibeerp.platform.workflow.UserTaskSummary import org.vibeerp.platform.workflow.WorkflowService /** @@ -26,6 +29,9 @@ import org.vibeerp.platform.workflow.WorkflowService * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars * - `GET /api/v1/workflow/definitions` — list deployed definitions * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys + * - `GET /api/v1/workflow/tasks` — list pending user tasks + * - `GET /api/v1/workflow/tasks/{taskId}` — inspect a single user task + * - `POST /api/v1/workflow/tasks/{taskId}/complete` — complete a user task * * The endpoints are permission-gated using the same * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC @@ -69,6 +75,28 @@ class WorkflowController( keys = handlerRegistry.keys().sorted(), ) + /* ------------------------------------------------------------------ */ + /* User-task endpoints */ + /* ------------------------------------------------------------------ */ + + @GetMapping("/tasks") + @RequirePermission("workflow.task.read") + fun listTasks(): List = workflowService.listPendingTasks() + + @GetMapping("/tasks/{taskId}") + @RequirePermission("workflow.task.read") + fun getTask(@PathVariable taskId: String): UserTaskDetail = workflowService.getTaskDetail(taskId) + + @PostMapping("/tasks/{taskId}/complete") + @RequirePermission("workflow.task.complete") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun completeTask( + @PathVariable taskId: String, + @RequestBody variables: Map, + ) { + workflowService.completeTask(taskId, variables) + } + @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class) fun handleMissing(ex: Exception): ResponseEntity = ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found")) diff --git a/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml b/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml index 81d81de..69e6763 100644 --- a/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml +++ b/platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml @@ -9,6 +9,10 @@ permissions: description: Read active workflow process instances and their variables - key: workflow.definition.read description: Read deployed BPMN process definitions and registered task handlers + - key: workflow.task.read + description: View pending user tasks + - key: workflow.task.complete + description: Complete a pending user task menus: - path: /workflow/processes @@ -21,3 +25,8 @@ menus: icon: file-code section: Workflow order: 710 + - path: /workflow/tasks + label: Tasks + icon: clipboard-check + section: Workflow + order: 850 -- libgit2 0.22.2