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 |