Commit 618a9781012fd910c10e2b3e0ad01dc4fcb676fa
1 parent
0e14f616
docs(plan): rules engine + user-task forms implementation plan (P3.5/P2.3)
Showing
1 changed file
with
446 additions
and
0 deletions
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** | ... | ... |