Commit 618a9781012fd910c10e2b3e0ad01dc4fcb676fa

Authored by zichun
1 parent 0e14f616

docs(plan): rules engine + user-task forms implementation plan (P3.5/P2.3)

docs/superpowers/plans/2026-04-10-rules-engine-usertask-forms.md 0 → 100644
  1 +# Rules Engine + User-Task Forms — Implementation Plan
  2 +
  3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  4 +
  5 +**Goal:** Ship P3.5 (event-condition-action rules engine) and P2.3 (user-task form rendering in the SPA) to complete the v1.0 Tier 1 customization story.
  6 +
  7 +**Architecture:** Rules are stored as `metadata__rule` rows (JSONB payload). A `RuleEngine` service loads enabled rules at boot, subscribes to the event bus by topic, evaluates conditions against event properties, and executes actions. User-task forms are rendered by the existing MetadataFormRenderer, fed by new task-list/complete endpoints on WorkflowController that wrap Flowable's TaskService.
  8 +
  9 +**Tech Stack:** Kotlin/Spring Boot (backend), Flowable TaskService (user-tasks), Jackson (event-to-map serialization for condition eval), React + TypeScript (SPA pages)
  10 +
  11 +**Spec:** `docs/superpowers/specs/2026-04-10-rules-engine-usertask-forms-design.md`
  12 +
  13 +---
  14 +
  15 +## File Map
  16 +
  17 +### New files (Backend)
  18 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt`
  19 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt`
  20 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt`
  21 +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt`
  22 +
  23 +### New files (Frontend)
  24 +- `web/src/pages/UserTasksPage.tsx`
  25 +- `web/src/pages/TaskDetailPage.tsx`
  26 +
  27 +### Modified files
  28 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` — add RuleYaml
  29 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` — process rules
  30 +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt` — add task endpoints
  31 +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt` — add task methods
  32 +- `web/src/pages/MetadataAdminPage.tsx` — add Rules tab
  33 +- `web/src/api/client.ts` — add workflow task + rule API functions
  34 +- `web/src/types/api.ts` — add UserTask + Rule types
  35 +- `web/src/i18n/messages.ts` — add keys
  36 +- `web/src/App.tsx` — add routes
  37 +- `web/src/layout/AppLayout.tsx` — add nav entry
  38 +- `platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt` — add workflow routes
  39 +
  40 +---
  41 +
  42 +## Task 1: Add user-task endpoints to WorkflowController
  43 +
  44 +**Files:**
  45 +- Modify: `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt`
  46 +- Modify: `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt`
  47 +
  48 +- [ ] **Step 1: Add task methods to WorkflowService**
  49 +
  50 +Add three methods using Flowable's `TaskService`:
  51 +
  52 +```kotlin
  53 +fun listPendingTasks(): List<UserTaskSummary> {
  54 + return taskService.createTaskQuery()
  55 + .active()
  56 + .orderByTaskCreateTime().desc()
  57 + .list()
  58 + .map { task ->
  59 + UserTaskSummary(
  60 + taskId = task.id,
  61 + taskName = task.name ?: task.taskDefinitionKey,
  62 + formKey = task.formKey,
  63 + processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key,
  64 + processInstanceId = task.processInstanceId,
  65 + createTime = task.createTime.toInstant(),
  66 + assignee = task.assignee,
  67 + )
  68 + }
  69 +}
  70 +
  71 +fun getTaskDetail(taskId: String): UserTaskDetail {
  72 + val task = taskService.createTaskQuery().taskId(taskId).singleResult()
  73 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
  74 + val vars = runtimeService.getVariables(task.executionId)
  75 + .filterKeys { !it.startsWith("__vibeerp_") }
  76 + return UserTaskDetail(
  77 + taskId = task.id,
  78 + taskName = task.name ?: task.taskDefinitionKey,
  79 + formKey = task.formKey,
  80 + variables = vars,
  81 + processDefinitionKey = repositoryService.getProcessDefinition(task.processDefinitionId).key,
  82 + processInstanceId = task.processInstanceId,
  83 + createTime = task.createTime.toInstant(),
  84 + )
  85 +}
  86 +
  87 +fun completeTask(taskId: String, variables: Map<String, Any?>) {
  88 + val task = taskService.createTaskQuery().taskId(taskId).singleResult()
  89 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
  90 + taskService.complete(taskId, variables.filterValues { it != null })
  91 +}
  92 +```
  93 +
  94 +Data classes:
  95 +```kotlin
  96 +data class UserTaskSummary(
  97 + val taskId: String,
  98 + val taskName: String,
  99 + val formKey: String?,
  100 + val processDefinitionKey: String,
  101 + val processInstanceId: String,
  102 + val createTime: Instant,
  103 + val assignee: String?,
  104 +)
  105 +
  106 +data class UserTaskDetail(
  107 + val taskId: String,
  108 + val taskName: String,
  109 + val formKey: String?,
  110 + val variables: Map<String, Any?>,
  111 + val processDefinitionKey: String,
  112 + val processInstanceId: String,
  113 + val createTime: Instant,
  114 +)
  115 +```
  116 +
  117 +- [ ] **Step 2: Add endpoints to WorkflowController**
  118 +
  119 +```kotlin
  120 +@GetMapping("/tasks")
  121 +@RequirePermission("workflow.task.read")
  122 +fun listTasks(): List<UserTaskSummary> = workflowService.listPendingTasks()
  123 +
  124 +@GetMapping("/tasks/{taskId}")
  125 +@RequirePermission("workflow.task.read")
  126 +fun getTask(@PathVariable taskId: String): UserTaskDetail = workflowService.getTaskDetail(taskId)
  127 +
  128 +@PostMapping("/tasks/{taskId}/complete")
  129 +@RequirePermission("workflow.task.complete")
  130 +@ResponseStatus(HttpStatus.NO_CONTENT)
  131 +fun completeTask(@PathVariable taskId: String, @RequestBody variables: Map<String, Any?>) {
  132 + workflowService.completeTask(taskId, variables)
  133 +}
  134 +```
  135 +
  136 +- [ ] **Step 3: Add workflow.task.read and workflow.task.complete permissions**
  137 +
  138 +Add to the existing `platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml` (or create it if it doesn't exist):
  139 +
  140 +```yaml
  141 +permissions:
  142 + - key: workflow.task.read
  143 + description: View pending user tasks
  144 + - key: workflow.task.complete
  145 + description: Complete a pending user task
  146 +```
  147 +
  148 +- [ ] **Step 4: Build, test, commit**
  149 +
  150 +```bash
  151 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
  152 +git commit -m "feat(workflow): user-task list/detail/complete endpoints"
  153 +```
  154 +
  155 +---
  156 +
  157 +## Task 2: Add RuleYaml + MetadataLoader extension for rules
  158 +
  159 +**Files:**
  160 +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt`
  161 +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt`
  162 +
  163 +- [ ] **Step 1: Add RuleYaml data classes to MetadataYaml.kt**
  164 +
  165 +```kotlin
  166 +@JsonIgnoreProperties(ignoreUnknown = true)
  167 +data class RuleYaml(
  168 + val slug: String = "",
  169 + val name: String = "",
  170 + val description: String? = null,
  171 + val enabled: Boolean = true,
  172 + val triggerEvent: String = "",
  173 + val conditions: List<RuleConditionYaml> = emptyList(),
  174 + val conditionLogic: String = "AND",
  175 + val actions: List<RuleActionYaml> = emptyList(),
  176 + val version: Int = 1,
  177 +)
  178 +
  179 +@JsonIgnoreProperties(ignoreUnknown = true)
  180 +data class RuleConditionYaml(
  181 + val field: String = "",
  182 + val operator: String = "eq",
  183 + val value: String = "",
  184 +)
  185 +
  186 +@JsonIgnoreProperties(ignoreUnknown = true)
  187 +data class RuleActionYaml(
  188 + val type: String = "log",
  189 + val config: Map<String, String> = emptyMap(),
  190 +)
  191 +```
  192 +
  193 +Add `rules: List<RuleYaml> = emptyList()` to `MetadataYamlFile`.
  194 +
  195 +- [ ] **Step 2: Extend MetadataLoader**
  196 +
  197 +Add `DELETE FROM metadata__rule WHERE source = :source` to `wipeBySource()`.
  198 +Add `insertRules()` method following the same pattern.
  199 +Call from `doLoad()`. Add `ruleCount` to `LoadResult`.
  200 +
  201 +- [ ] **Step 3: Add tests, build, commit**
  202 +
  203 +Add YAML parse test for rules section. Run tests.
  204 +
  205 +```bash
  206 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
  207 +git commit -m "feat(metadata): extend YAML schema + loader for rules"
  208 +```
  209 +
  210 +---
  211 +
  212 +## Task 3: Build RuleEvaluator + RuleEngine + RuleController
  213 +
  214 +**Files:**
  215 +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt`
  216 +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt`
  217 +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt`
  218 +- Create: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt`
  219 +
  220 +- [ ] **Step 1: Create RuleEvaluator (pure function, no dependencies)**
  221 +
  222 +```kotlin
  223 +package org.vibeerp.platform.metadata.rules
  224 +
  225 +object RuleEvaluator {
  226 + fun evaluate(eventMap: Map<String, Any?>, conditions: List<Condition>, logic: String): Boolean {
  227 + if (conditions.isEmpty()) return true
  228 + return if (logic == "OR") {
  229 + conditions.any { matches(eventMap, it) }
  230 + } else {
  231 + conditions.all { matches(eventMap, it) }
  232 + }
  233 + }
  234 +
  235 + private fun matches(eventMap: Map<String, Any?>, c: Condition): Boolean {
  236 + val actual = eventMap[c.field] ?: return c.operator == "eq" && c.value.isBlank()
  237 + val av = actual.toString()
  238 + return when (c.operator) {
  239 + "eq" -> av == c.value
  240 + "neq" -> av != c.value
  241 + "gt" -> av.toDoubleOrNull()?.let { it > c.value.toDouble() } ?: false
  242 + "gte" -> av.toDoubleOrNull()?.let { it >= c.value.toDouble() } ?: false
  243 + "lt" -> av.toDoubleOrNull()?.let { it < c.value.toDouble() } ?: false
  244 + "lte" -> av.toDoubleOrNull()?.let { it <= c.value.toDouble() } ?: false
  245 + "contains" -> av.contains(c.value, ignoreCase = true)
  246 + "in" -> c.value.split(",").map { it.trim() }.contains(av)
  247 + else -> false
  248 + }
  249 + }
  250 +
  251 + data class Condition(val field: String, val operator: String, val value: String)
  252 +}
  253 +```
  254 +
  255 +- [ ] **Step 2: Write RuleEvaluator tests**
  256 +
  257 +Test cases: eq match/mismatch, numeric gt/lt/gte/lte, contains, in, AND logic, OR logic, empty conditions returns true, null field handling.
  258 +
  259 +- [ ] **Step 3: Create RuleEngine**
  260 +
  261 +```kotlin
  262 +@Component
  263 +class RuleEngine(
  264 + private val eventBus: EventBus,
  265 + private val jdbc: NamedParameterJdbcTemplate,
  266 + private val objectMapper: ObjectMapper,
  267 +) {
  268 + private val subscriptions = mutableListOf<EventBus.Subscription>()
  269 + private val log = LoggerFactory.getLogger(RuleEngine::class.java)
  270 +
  271 + @PostConstruct
  272 + fun loadAndSubscribe() { refresh() }
  273 +
  274 + fun refresh() {
  275 + subscriptions.forEach { it.close() }
  276 + subscriptions.clear()
  277 + val rules = loadEnabledRules()
  278 + val byTopic = rules.groupBy { it.triggerTopic }
  279 + byTopic.forEach { (topic, topicRules) ->
  280 + val sub = eventBus.subscribe(topic) { event ->
  281 + val eventMap = objectMapper.convertValue(event, Map::class.java) as Map<String, Any?>
  282 + topicRules.forEach { rule ->
  283 + val conditions = rule.conditions.map { RuleEvaluator.Condition(it.field, it.operator, it.value) }
  284 + if (RuleEvaluator.evaluate(eventMap, conditions, rule.conditionLogic)) {
  285 + executeActions(rule, event, eventMap)
  286 + }
  287 + }
  288 + }
  289 + subscriptions.add(sub)
  290 + }
  291 + log.info("RuleEngine: loaded {} enabled rules across {} topics", rules.size, byTopic.size)
  292 + }
  293 +
  294 + private fun executeActions(rule: LoadedRule, event: DomainEvent, eventMap: Map<String, Any?>) {
  295 + rule.actions.forEach { action ->
  296 + when (action.type) {
  297 + "log" -> {
  298 + val msg = action.config["message"] ?: "Rule '${rule.name}' fired"
  299 + log.info("[RULE:{}] {}", rule.slug, msg)
  300 + }
  301 + else -> log.warn("[RULE:{}] unknown action type '{}'", rule.slug, action.type)
  302 + }
  303 + }
  304 + }
  305 +
  306 + private fun loadEnabledRules(): List<LoadedRule> { /* read from metadata__rule, filter enabled */ }
  307 +}
  308 +```
  309 +
  310 +- [ ] **Step 4: Create RuleController (CRUD with source='user' enforcement)**
  311 +
  312 +Same pattern as FormDefinitionController — PUT/DELETE on `metadata__rule` table with source='user' enforcement. GET endpoints for listing and by-slug lookup. After every write, call `ruleEngine.refresh()`.
  313 +
  314 +Add read endpoints to MetadataController: `GET /rules`, `GET /rules/{slug}`.
  315 +Add `"rules"` to the `all()` response map.
  316 +
  317 +- [ ] **Step 5: Run tests, build, commit**
  318 +
  319 +```bash
  320 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
  321 +git commit -m "feat(metadata): rules engine — event-condition-action automation"
  322 +```
  323 +
  324 +---
  325 +
  326 +## Task 4: Build SPA pages for user-tasks and rules
  327 +
  328 +**Files:**
  329 +- Create: `web/src/pages/UserTasksPage.tsx`
  330 +- Create: `web/src/pages/TaskDetailPage.tsx`
  331 +- Modify: `web/src/pages/MetadataAdminPage.tsx` — add Rules tab
  332 +- Modify: `web/src/api/client.ts` — add API functions
  333 +- Modify: `web/src/types/api.ts` — add types
  334 +- Modify: `web/src/i18n/messages.ts` — add keys
  335 +- Modify: `web/src/App.tsx` — add routes
  336 +- Modify: `web/src/layout/AppLayout.tsx` — add nav entry
  337 +- Modify: `platform/platform-bootstrap/.../SpaController.kt` — add workflow route prefixes
  338 +
  339 +- [ ] **Step 1: Add types to api.ts**
  340 +
  341 +```typescript
  342 +export interface UserTaskSummary {
  343 + taskId: string
  344 + taskName: string
  345 + formKey: string | null
  346 + processDefinitionKey: string
  347 + processInstanceId: string
  348 + createTime: string
  349 + assignee: string | null
  350 +}
  351 +
  352 +export interface UserTaskDetail extends UserTaskSummary {
  353 + variables: Record<string, unknown>
  354 +}
  355 +
  356 +export interface RuleCondition {
  357 + field: string
  358 + operator: string
  359 + value: string
  360 +}
  361 +
  362 +export interface RuleAction {
  363 + type: string
  364 + config: Record<string, string>
  365 +}
  366 +
  367 +export interface RuleDefinition {
  368 + slug: string
  369 + name: string
  370 + description?: string
  371 + enabled: boolean
  372 + triggerEvent: string
  373 + conditions: RuleCondition[]
  374 + conditionLogic: 'AND' | 'OR'
  375 + actions: RuleAction[]
  376 + version: number
  377 + source?: string
  378 +}
  379 +```
  380 +
  381 +- [ ] **Step 2: Add API client functions**
  382 +
  383 +```typescript
  384 +export const workflow = {
  385 + listTasks: () => apiFetch<UserTaskSummary[]>('/api/v1/workflow/tasks'),
  386 + getTask: (taskId: string) => apiFetch<UserTaskDetail>(`/api/v1/workflow/tasks/${taskId}`),
  387 + completeTask: (taskId: string, variables: Record<string, unknown>) =>
  388 + apiFetch<void>(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false),
  389 +}
  390 +
  391 +// Add to metadata namespace:
  392 +listRules: () => apiFetch<RuleDefinition[]>('/api/v1/_meta/metadata/rules'),
  393 +getRule: (slug: string) => apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`),
  394 +saveRule: (slug: string, body: Omit<RuleDefinition, 'source'>) =>
  395 + apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  396 +deleteRule: (slug: string) =>
  397 + apiFetch<void>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false),
  398 +```
  399 +
  400 +- [ ] **Step 3: Add i18n keys**
  401 +
  402 +English + Chinese keys for: nav.tasks, page.tasks.title, tab.rules, label.triggerEvent, label.conditions, label.actions, label.enabled, action.complete, action.newRule, etc.
  403 +
  404 +- [ ] **Step 4: Create UserTasksPage**
  405 +
  406 +DataTable listing pending tasks. Columns: Task Name, Process, Created, Assignee. Click row navigates to `/workflow/tasks/{taskId}`.
  407 +
  408 +- [ ] **Step 5: Create TaskDetailPage**
  409 +
  410 +Fetches task detail. If `formKey` starts with `vibe:`, strips prefix and renders `<MetadataFormRenderer slug={slug} initialValues={variables} onSubmit={completeTask} />`. If no formKey, shows a simple "Complete Task" button.
  411 +
  412 +- [ ] **Step 6: Add Rules tab to MetadataAdminPage**
  413 +
  414 +7th tab showing rules in a DataTable (Name, Trigger Event, Enabled toggle, Conditions count, Actions count, Source badge). Inline form for creating/editing rules with: name, trigger event dropdown, conditions builder (field/operator/value rows), condition logic toggle (AND/OR), actions builder (type/config rows), enabled checkbox.
  415 +
  416 +Known event types for the dropdown: SalesOrderConfirmedEvent, SalesOrderShippedEvent, SalesOrderCancelledEvent, PurchaseOrderConfirmedEvent, PurchaseOrderReceivedEvent, PurchaseOrderCancelledEvent, WorkOrderCreatedEvent, WorkOrderStartedEvent, WorkOrderCompletedEvent, WorkOrderCancelledEvent, InspectionRecordedEvent.
  417 +
  418 +- [ ] **Step 7: Add routes, nav, SpaController**
  419 +
  420 +Routes: `/workflow/tasks`, `/workflow/tasks/:taskId`
  421 +Nav: "Tasks" under a "Workflow" section
  422 +SpaController: add `/workflow/**` prefix
  423 +
  424 +- [ ] **Step 8: Build and commit**
  425 +
  426 +```bash
  427 +cd web && npm run build && cd ..
  428 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
  429 +git commit -m "feat(web): user-task pages + rules tab in metadata admin"
  430 +```
  431 +
  432 +---
  433 +
  434 +## Task 5: Smoke test + version bump + push
  435 +
  436 +- [ ] **Step 1: Full build**
  437 +- [ ] **Step 2: Boot and smoke test**
  438 + - Verify Tasks page loads (empty if no running processes)
  439 + - Start a plate-approval process, verify the user-task appears
  440 + - Complete the task with the form, verify it disappears
  441 + - Create a rule via Metadata Admin → Rules tab
  442 + - Trigger the rule's event, verify log output
  443 +- [ ] **Step 3: Bump version to v0.34.0-SNAPSHOT**
  444 +- [ ] **Step 4: Update PROGRESS.md (P2.3 + P3.5 done)**
  445 +- [ ] **Step 5: Update CLAUDE.md test count**
  446 +- [ ] **Step 6: Commit, push, verify CI**