Commit 0e14f616e336b37360b51c29e5de69b8abec2245
1 parent
a989f134
docs(spec): rules engine (P3.5) + user-task forms (P2.3) design
P3.5: event-condition-action rules stored as metadata rows, evaluated when events fire. v1.0 actions: log, set-field, publish-event. P2.3: SPA pages for listing and completing Flowable user-tasks using MetadataFormRenderer. P2.2 BPMN designer deferred to v1.1.
Showing
1 changed file
with
231 additions
and
0 deletions
docs/superpowers/specs/2026-04-10-rules-engine-usertask-forms-design.md
0 → 100644
| 1 | +# Rules Engine + User-Task Form Rendering — Design Spec | |
| 2 | + | |
| 3 | +> Sub-projects B+C of the v1.0 remaining work. | |
| 4 | +> Covers P3.5 (rules engine) and P2.3 (user-task form rendering). | |
| 5 | +> P2.2 (BPMN designer) is deferred to v1.1. | |
| 6 | + | |
| 7 | +## 1. Context | |
| 8 | + | |
| 9 | +With P3.2/P3.3/P3.6/R3 shipped, the framework has metadata-driven forms, a | |
| 10 | +form designer, and a metadata admin UI. Two pieces remain for v1.0: | |
| 11 | + | |
| 12 | +1. **P2.3 — User-task form rendering**: Flowable user-tasks need a SPA page | |
| 13 | + where operators can see pending tasks and complete them by filling out | |
| 14 | + metadata-driven forms (using the MetadataFormRenderer we just built). | |
| 15 | + | |
| 16 | +2. **P3.5 — Rules engine**: Key users need to create "if event X happens and | |
| 17 | + condition Y is met, then do action Z" rules through the metadata admin UI, | |
| 18 | + without writing code. Rules are stored as `metadata__rule` rows. | |
| 19 | + | |
| 20 | +## 2. P2.3 — User-Task Form Rendering | |
| 21 | + | |
| 22 | +### New backend endpoints | |
| 23 | + | |
| 24 | +Add to `WorkflowController`: | |
| 25 | + | |
| 26 | +``` | |
| 27 | +GET /api/v1/workflow/tasks | |
| 28 | + → List pending user-tasks for the current user (or all if admin) | |
| 29 | + Returns: List<UserTaskSummary> with taskId, taskName, formKey, | |
| 30 | + processDefinitionKey, processInstanceId, createTime, assignee | |
| 31 | + | |
| 32 | +GET /api/v1/workflow/tasks/{taskId} | |
| 33 | + → Get task details + variables | |
| 34 | + Returns: UserTaskDetail with taskId, taskName, formKey, variables, | |
| 35 | + processDefinitionKey, processInstanceId, createTime | |
| 36 | + | |
| 37 | +POST /api/v1/workflow/tasks/{taskId}/complete | |
| 38 | + → Complete the task with form values | |
| 39 | + Request body: Map<String, Any?> (the submitted form data) | |
| 40 | + Returns: 204 No Content | |
| 41 | +``` | |
| 42 | + | |
| 43 | +These use Flowable's `TaskService.createTaskQuery()` and | |
| 44 | +`TaskService.complete(taskId, variables)`. | |
| 45 | + | |
| 46 | +The `formKey` on the user-task (set in BPMN XML as `flowable:formKey="vibe:plate-approval-task"`) | |
| 47 | +maps to a form definition slug in `metadata__form`. The SPA strips the `vibe:` | |
| 48 | +prefix and fetches the form via the existing | |
| 49 | +`GET /api/v1/_meta/metadata/forms/{slug}` endpoint. | |
| 50 | + | |
| 51 | +### New SPA page: UserTasksPage | |
| 52 | + | |
| 53 | +Route: `/workflow/tasks` | |
| 54 | + | |
| 55 | +- Lists pending user-tasks in a DataTable (task name, process, created time, assignee) | |
| 56 | +- Click a task → expands inline or navigates to `/workflow/tasks/{taskId}` | |
| 57 | +- TaskDetailPage renders the form using `<MetadataFormRenderer slug={formKey} />` | |
| 58 | + with `initialValues` from the task variables | |
| 59 | +- Submit button calls `POST /api/v1/workflow/tasks/{taskId}/complete` | |
| 60 | +- On success, navigates back to the task list | |
| 61 | + | |
| 62 | +### SPA route: TaskDetailPage | |
| 63 | + | |
| 64 | +Route: `/workflow/tasks/:taskId` | |
| 65 | + | |
| 66 | +- Fetches task detail (variables + formKey) | |
| 67 | +- Strips `vibe:` prefix from formKey → slug | |
| 68 | +- Renders `<MetadataFormRenderer slug={slug} initialValues={variables} onSubmit={completeTask} />` | |
| 69 | +- If no formKey, shows a simple "Complete" button with no form | |
| 70 | + | |
| 71 | +## 3. P3.5 — Rules Engine | |
| 72 | + | |
| 73 | +### Rule definition schema (stored in `metadata__rule.payload`) | |
| 74 | + | |
| 75 | +```typescript | |
| 76 | +interface RuleDefinition { | |
| 77 | + slug: string // unique identifier | |
| 78 | + name: string // display name | |
| 79 | + description?: string | |
| 80 | + enabled: boolean // toggle on/off without deleting | |
| 81 | + triggerEvent: string // event class simple name: "SalesOrderConfirmedEvent" | |
| 82 | + conditions: Array<{ | |
| 83 | + field: string // event property path: "totalAmount" | |
| 84 | + operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'in' | |
| 85 | + value: string // compared value (coerced to field type at eval time) | |
| 86 | + }> | |
| 87 | + conditionLogic: 'AND' | 'OR' // how to combine multiple conditions | |
| 88 | + actions: Array<{ | |
| 89 | + type: 'log' | 'set-field' | 'publish-event' | |
| 90 | + config: Record<string, string> | |
| 91 | + }> | |
| 92 | + version: number | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +### Built-in action types (v1.0) | |
| 97 | + | |
| 98 | +| Action type | What it does | Config | | |
| 99 | +|---|---|---| | |
| 100 | +| `log` | Writes an INFO log line with a template message | `{ message: "Order {orderCode} exceeded threshold" }` | | |
| 101 | +| `set-field` | Sets a field on the event's aggregate via a REST call or ext update | `{ entity: "SalesOrder", field: "ext.priority", value: "HIGH" }` | | |
| 102 | +| `publish-event` | Publishes a synthetic event to the event bus | `{ eventType: "RuleTriggeredEvent", data: "{...}" }` | | |
| 103 | + | |
| 104 | +v1.0 keeps actions simple. Complex actions (send email, call webhook, create entity) are v1.1. | |
| 105 | + | |
| 106 | +### Backend: RuleEngine service | |
| 107 | + | |
| 108 | +New class in `platform-metadata` (or a new `platform-rules` module — but YAGNI, | |
| 109 | +put it in `platform-metadata` since rules are metadata): | |
| 110 | + | |
| 111 | +```kotlin | |
| 112 | +@Component | |
| 113 | +class RuleEngine( | |
| 114 | + private val eventBus: EventBus, | |
| 115 | + private val jdbc: NamedParameterJdbcTemplate, | |
| 116 | + private val objectMapper: ObjectMapper, | |
| 117 | +) { | |
| 118 | + @PostConstruct | |
| 119 | + fun loadAndSubscribe() { | |
| 120 | + // 1. Read all enabled rules from metadata__rule | |
| 121 | + // 2. Group by triggerEvent | |
| 122 | + // 3. Subscribe to each distinct event type via eventBus.subscribe(topic, listener) | |
| 123 | + // using the aggregateType as the topic string | |
| 124 | + // 4. When an event fires, evaluate conditions against event properties | |
| 125 | + // 5. If conditions match, execute actions | |
| 126 | + } | |
| 127 | + | |
| 128 | + fun refresh() { | |
| 129 | + // Unsubscribe all, reload, resubscribe | |
| 130 | + // Called after rule CRUD operations | |
| 131 | + } | |
| 132 | +} | |
| 133 | +``` | |
| 134 | + | |
| 135 | +### Condition evaluation | |
| 136 | + | |
| 137 | +Events are domain objects with typed properties. The rule engine serializes the | |
| 138 | +event to a `Map<String, Any?>` via Jackson, then evaluates each condition: | |
| 139 | + | |
| 140 | +```kotlin | |
| 141 | +fun evaluateCondition(eventMap: Map<String, Any?>, condition: Condition): Boolean { | |
| 142 | + val actual = eventMap[condition.field] ?: return false | |
| 143 | + val expected = condition.value | |
| 144 | + return when (condition.operator) { | |
| 145 | + "eq" -> actual.toString() == expected | |
| 146 | + "neq" -> actual.toString() != expected | |
| 147 | + "gt" -> (actual as? Number)?.toDouble()?.let { it > expected.toDouble() } ?: false | |
| 148 | + "gte" -> (actual as? Number)?.toDouble()?.let { it >= expected.toDouble() } ?: false | |
| 149 | + "lt" -> (actual as? Number)?.toDouble()?.let { it < expected.toDouble() } ?: false | |
| 150 | + "lte" -> (actual as? Number)?.toDouble()?.let { it <= expected.toDouble() } ?: false | |
| 151 | + "contains" -> actual.toString().contains(expected) | |
| 152 | + "in" -> expected.split(",").map { it.trim() }.contains(actual.toString()) | |
| 153 | + else -> false | |
| 154 | + } | |
| 155 | +} | |
| 156 | +``` | |
| 157 | + | |
| 158 | +### Backend endpoints (in MetadataController or new RuleController) | |
| 159 | + | |
| 160 | +``` | |
| 161 | +GET /api/v1/_meta/metadata/rules → List<RuleDefinition> | |
| 162 | +GET /api/v1/_meta/metadata/rules/{slug} → RuleDefinition | |
| 163 | +PUT /api/v1/_meta/metadata/rules/{slug} → RuleDefinition (upsert, source='user') | |
| 164 | +DELETE /api/v1/_meta/metadata/rules/{slug} → 204 | |
| 165 | +``` | |
| 166 | + | |
| 167 | +Same source='user' enforcement as forms/list-views. | |
| 168 | +After every write, call `ruleEngine.refresh()`. | |
| 169 | + | |
| 170 | +### SPA: Rules tab in MetadataAdmin | |
| 171 | + | |
| 172 | +Add a 7th tab "Rules" to MetadataAdminPage: | |
| 173 | + | |
| 174 | +- DataTable showing: Name, Trigger Event, Enabled (toggle), Conditions (count), Actions (count), Source badge | |
| 175 | +- "New Rule" button opens an inline form: | |
| 176 | + - Name, description | |
| 177 | + - Trigger event dropdown (populated from known event types) | |
| 178 | + - Conditions builder: field, operator, value (add/remove rows) | |
| 179 | + - Condition logic toggle (AND/OR) | |
| 180 | + - Actions builder: type dropdown, config key-value inputs (add/remove rows) | |
| 181 | + - Enabled checkbox | |
| 182 | + - Save/Cancel | |
| 183 | +- Edit button per source='user' row | |
| 184 | +- Delete button per source='user' row | |
| 185 | + | |
| 186 | +### MetadataYaml extension | |
| 187 | + | |
| 188 | +Add `rules: List<RuleYaml>` to `MetadataYamlFile` so core PBCs and plug-ins | |
| 189 | +can ship default rules in their metadata YAML. Same loader/wipe pattern. | |
| 190 | + | |
| 191 | +## 4. Scope Decisions | |
| 192 | + | |
| 193 | +| Decision | Choice | | |
| 194 | +|---|---| | |
| 195 | +| BPMN designer (P2.2) | Deferred to v1.1 — customers use Camunda Modeler or hand-edit BPMN XML | | |
| 196 | +| Rule action types | v1.0: log, set-field, publish-event. v1.1: email, webhook, create entity | | |
| 197 | +| Rule condition complexity | v1.0: flat field conditions with AND/OR. v1.1: nested groups, expressions | | |
| 198 | +| User-task assignment | v1.0: all tasks visible to all authenticated users. v1.1: role-based assignment | | |
| 199 | + | |
| 200 | +## 5. File Inventory | |
| 201 | + | |
| 202 | +### New files (Backend) | |
| 203 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEngine.kt` | |
| 204 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt` | |
| 205 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt` | |
| 206 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/RuleYaml.kt` | |
| 207 | +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt` | |
| 208 | + | |
| 209 | +### New files (Frontend) | |
| 210 | +- `web/src/pages/UserTasksPage.tsx` | |
| 211 | +- `web/src/pages/TaskDetailPage.tsx` | |
| 212 | + | |
| 213 | +### Modified files | |
| 214 | +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt` — add task endpoints | |
| 215 | +- `platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt` — add task methods | |
| 216 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` — add RuleYaml | |
| 217 | +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` — process rules | |
| 218 | +- `web/src/pages/MetadataAdminPage.tsx` — add Rules tab | |
| 219 | +- `web/src/api/client.ts` — add workflow task + rule API functions | |
| 220 | +- `web/src/types/api.ts` — add UserTask + Rule types | |
| 221 | +- `web/src/i18n/messages.ts` — add keys | |
| 222 | +- `web/src/App.tsx` — add routes | |
| 223 | +- `web/src/layout/AppLayout.tsx` — add nav entry | |
| 224 | +- `platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt` — add workflow routes | |
| 225 | + | |
| 226 | +## 6. Testing | |
| 227 | + | |
| 228 | +- `RuleEvaluatorTest.kt` — unit tests for condition evaluation (eq, gt, lt, contains, in, AND/OR logic) | |
| 229 | +- `RuleEngine` — integration: load rules from metadata, verify event subscription and action execution | |
| 230 | +- Manual smoke: create a rule via UI, trigger the event, verify action fires | |
| 231 | +- Manual smoke: start a BPMN process with a user-task, see it in the tasks page, complete it with the form | ... | ... |