Commit 7b2ab34de3cba85ab387e3a9ec974b79dc7bdde8
1 parent
e734608d
feat(ref-plugin): PlateApprovalTaskHandler mutates plate state via PluginContext
Proves out the "handler-side plug-in context access" pattern: a
plug-in's TaskHandler captures the PluginContext through its
constructor when the plug-in instantiates it inside `start(context)`,
and then uses `context.jdbc`, `context.logger`, etc. from inside
`execute` the same way the plug-in's HTTP lambdas do. Zero new
api.v1 surface was needed — the plug-in decides whether a handler
takes a context or not, and a pure handler simply omits the
constructor parameter.
## Why this pattern and not a richer TaskContext
The alternatives were:
(a) add a `PluginContext` field (or a narrowed projection of it)
to api.v1 `TaskContext`, threading the host-owned context
through the workflow engine
(b) capture the context in the plug-in's handler constructor —
this commit
Option (a) would have forced every TaskHandler author — core PBC
handlers too, not just plug-in ones — to reason about a per-plug-in
context that wouldn't make sense for core PBCs. It would also have
coupled api.v1 to the plug-in machinery in a way that leaks into
every handler implementation.
Option (b) is a pure plug-in-local pattern. A pure handler:
class PureHandler : TaskHandler { ... }
and a stateful handler look identical except for one constructor
parameter:
class StatefulHandler(private val context: PluginContext) : TaskHandler {
override fun execute(task, ctx) {
context.jdbc.update(...)
context.logger.info(...)
}
}
and both register the same way:
context.taskHandlers.register(PureHandler())
context.taskHandlers.register(StatefulHandler(context))
The framework's `TaskHandlerRegistry`, `DispatchingJavaDelegate`,
and `DelegateTaskContext` stay unchanged. Plug-in teardown still
strips handlers via `unregisterAllByOwner(pluginId)` because
registration still happens through the scoped registrar inside
`start(context)`.
## What PlateApprovalTaskHandler now does
Before this commit, the handler was a pure function that wrote
`plateApproved=true` + metadata to the process variables and
didn't touch the DB. Now it:
1. Parses `plateId` out of the process variables as a UUID
(fail-fast on non-UUID).
2. Calls `context.jdbc.update` to set the plate row's `status`
from 'DRAFT' to 'APPROVED', guarded by an explicit
`WHERE id=:id AND status='DRAFT'`. The guard makes a second
invocation a no-op (rowsUpdated=0) rather than silently
overwriting a later status.
3. Logs via the plug-in's PluginLogger — "plate {id} approved by
user:admin (rows updated: 1)". Log lines are tagged
`[plugin:printing-shop]` by the framework's Slf4jPluginLogger.
4. Emits process output variables: `plateApproved=true`,
`plateId=<uuid>`, `approvedBy=<principal label>`, `approvedAt=<instant>`,
and `rowsUpdated=<count>` so callers can see whether the
approval actually changed state.
## Smoke test (fresh DB, full end-to-end loop)
```
POST /api/v1/plugins/printing-shop/plates
{"code":"PLATE-042","name":"Red cover plate","widthMm":320,"heightMm":480}
→ 201 {"id":"0bf577c9-...","status":"DRAFT",...}
POST /api/v1/workflow/process-instances
{"processDefinitionKey":"plugin-printing-shop-plate-approval",
"variables":{"plateId":"0bf577c9-..."}}
→ 201 {"ended":true,
"variables":{"plateId":"0bf577c9-...",
"rowsUpdated":1,
"approvedBy":"user:admin",
"approvedAt":"2026-04-09T05:01:01.779369Z",
"plateApproved":true}}
GET /api/v1/plugins/printing-shop/plates/0bf577c9-...
→ 200 {"id":"0bf577c9-...","status":"APPROVED", ...}
^^^^ note: was DRAFT a moment ago, NOW APPROVED — persisted to
plugin_printingshop__plate via the handler's context.jdbc.update
POST /api/v1/workflow/process-instances (same plateId, second run)
→ 201 {"variables":{"rowsUpdated":0,"plateApproved":true,...}}
^^^^ idempotent guard: the WHERE status='DRAFT' clause prevents
double-updates, rowsUpdated=0 on the re-run
```
This is the first cross-cutting end-to-end business flow in the
framework driven entirely through the public surfaces:
1. Plug-in HTTP endpoint writes a domain row
2. Workflow HTTP endpoint starts a BPMN process
3. Plug-in-contributed BPMN (deployed via PluginProcessDeployer)
routes to a plug-in-contributed TaskHandler (registered via
context.taskHandlers)
4. Handler mutates the same plug-in-owned table via context.jdbc
5. Plug-in HTTP endpoint reads the new state
Every step uses only api.v1. Zero framework core code knows the
plug-in exists.
## Non-goals (parking lot)
- Emitting an event from the handler. The next step in the
plug-in workflow story is for a handler to publish a domain
event via `context.eventBus.publish(...)` so OTHER subscribers
(e.g. pbc-production waiting on a PlateApproved event) can react.
This commit stays narrow: the handler only mutates its own plug-in
state.
- Transaction scope of the handler's DB write relative to the
Flowable engine's process-state persistence. Today both go
through the host DataSource and Spring transaction manager that
Flowable auto-configures, so a handler throw rolls everything
back — verified by walking the code path. An explicit test of
transactional rollback lands with REF.1 when the handler takes
on real business logic.
Showing
2 changed files
with
70 additions
and
17 deletions
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
| @@ -275,7 +275,13 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP | @@ -275,7 +275,13 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP | ||
| 275 | // scoped registrar that tags every registration with this plug-in's | 275 | // scoped registrar that tags every registration with this plug-in's |
| 276 | // id, so shutdown automatically strips them from the framework's | 276 | // id, so shutdown automatically strips them from the framework's |
| 277 | // TaskHandlerRegistry via unregisterAllByOwner("printing-shop"). | 277 | // TaskHandlerRegistry via unregisterAllByOwner("printing-shop"). |
| 278 | - context.taskHandlers.register(PlateApprovalTaskHandler()) | 278 | + // |
| 279 | + // Note the captured context: each handler instance holds the | ||
| 280 | + // PluginContext in a private field, so inside `execute` it has | ||
| 281 | + // the same toolkit (jdbc, logger, eventBus, translator, etc.) | ||
| 282 | + // the HTTP lambdas above use. This is the "handler-side plug-in | ||
| 283 | + // context access" pattern — no new api.v1 surface required. | ||
| 284 | + context.taskHandlers.register(PlateApprovalTaskHandler(context)) | ||
| 279 | context.logger.info( | 285 | context.logger.info( |
| 280 | "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", | 286 | "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", |
| 281 | ) | 287 | ) |
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt
| 1 | package org.vibeerp.reference.printingshop.workflow | 1 | package org.vibeerp.reference.printingshop.workflow |
| 2 | 2 | ||
| 3 | +import org.vibeerp.api.v1.plugin.PluginContext | ||
| 3 | import org.vibeerp.api.v1.security.Principal | 4 | import org.vibeerp.api.v1.security.Principal |
| 4 | import org.vibeerp.api.v1.workflow.TaskContext | 5 | import org.vibeerp.api.v1.workflow.TaskContext |
| 5 | import org.vibeerp.api.v1.workflow.TaskHandler | 6 | import org.vibeerp.api.v1.workflow.TaskHandler |
| 6 | import org.vibeerp.api.v1.workflow.WorkflowTask | 7 | import org.vibeerp.api.v1.workflow.WorkflowTask |
| 7 | import java.time.Instant | 8 | import java.time.Instant |
| 9 | +import java.util.UUID | ||
| 8 | 10 | ||
| 9 | /** | 11 | /** |
| 10 | - * Reference plug-in-contributed TaskHandler — the executable | ||
| 11 | - * acceptance test for the P2.1 plug-in-loaded TaskHandler registration | ||
| 12 | - * seam. | 12 | + * Reference plug-in TaskHandler — the executable acceptance test for |
| 13 | + * the "handler-side plug-in context access" pattern. | ||
| 13 | * | 14 | * |
| 14 | - * The handler is deliberately minimal: it takes a `plateId` variable | ||
| 15 | - * off the BPMN process, "approves" it by writing `plateApproved=true` | ||
| 16 | - * + the approving principal + timestamp, and exits. Real printing-shop | ||
| 17 | - * plate approval would also UPDATE the `plugin_printingshop__plate` | ||
| 18 | - * table via `context.jdbc` — but the plug-in loader doesn't give | ||
| 19 | - * TaskHandlers access to the full [org.vibeerp.api.v1.plugin.PluginContext] | ||
| 20 | - * yet, so v0.7 of this handler is a pure functional bridge. Giving | ||
| 21 | - * handlers a context (or a narrowed projection thereof) is the | ||
| 22 | - * obvious next chunk once REF.1 lands a real BPMN process that needs | ||
| 23 | - * plug-in state mutation. | 15 | + * **The pattern.** The framework deliberately does NOT add a new api.v1 |
| 16 | + * surface to pass a PluginContext through [TaskContext] — instead, a | ||
| 17 | + * plug-in instantiates its handlers inside `start(context)` and passes | ||
| 18 | + * the captured [PluginContext] into each handler's constructor. The | ||
| 19 | + * handler then holds the context in a private field and uses it from | ||
| 20 | + * inside [execute] exactly the same way the plug-in's HTTP lambdas do. | ||
| 21 | + * | ||
| 22 | + * Benefits over threading a context through api.v1: | ||
| 23 | + * • Zero new api.v1 surface. No new interface, no new default method. | ||
| 24 | + * • Plug-in authors already understand `context.jdbc`, `context.logger`, | ||
| 25 | + * `context.eventBus`, etc. from writing HTTP endpoint lambdas in | ||
| 26 | + * the same file — the handler gets the identical toolkit. | ||
| 27 | + * • A pure handler (no state mutation, no side effects beyond | ||
| 28 | + * `ctx.set(...)`) simply omits the constructor parameter. Nothing | ||
| 29 | + * forces a handler to take a context when it doesn't need one. | ||
| 30 | + * • The plug-in loader's owner-tagged `unregisterAllByOwner` cleanup | ||
| 31 | + * still works unchanged because registration happens inside | ||
| 32 | + * `start(context)` via the scoped registrar. | ||
| 33 | + * | ||
| 34 | + * **What this handler does.** Reads a `plateId` variable off the BPMN | ||
| 35 | + * process, updates the `plugin_printingshop__plate` row's status from | ||
| 36 | + * 'DRAFT' to 'APPROVED' via `context.jdbc`, publishes the approving | ||
| 37 | + * principal + timestamp as process output variables, and logs via the | ||
| 38 | + * plug-in's own `PluginLogger` (tagged `[plugin:printing-shop]`). | ||
| 39 | + * Idempotent: if the plate is already 'APPROVED' the update is a no-op | ||
| 40 | + * (the WHERE clause's status filter means the second invocation | ||
| 41 | + * updates zero rows) and the process continues normally. | ||
| 24 | * | 42 | * |
| 25 | * Key naming follows the convention documented on | 43 | * Key naming follows the convention documented on |
| 26 | * [org.vibeerp.api.v1.workflow.TaskHandler.key]: | 44 | * [org.vibeerp.api.v1.workflow.TaskHandler.key]: |
| @@ -30,13 +48,20 @@ import java.time.Instant | @@ -30,13 +48,20 @@ import java.time.Instant | ||
| 30 | * ids don't allow hyphens inside nested parts so we use the | 48 | * ids don't allow hyphens inside nested parts so we use the |
| 31 | * underscored form for workflow keys throughout the framework). | 49 | * underscored form for workflow keys throughout the framework). |
| 32 | */ | 50 | */ |
| 33 | -class PlateApprovalTaskHandler : TaskHandler { | 51 | +class PlateApprovalTaskHandler( |
| 52 | + private val context: PluginContext, | ||
| 53 | +) : TaskHandler { | ||
| 34 | 54 | ||
| 35 | override fun key(): String = KEY | 55 | override fun key(): String = KEY |
| 36 | 56 | ||
| 37 | override fun execute(task: WorkflowTask, ctx: TaskContext) { | 57 | override fun execute(task: WorkflowTask, ctx: TaskContext) { |
| 38 | - val plateId = task.variables["plateId"] as? String | 58 | + val plateIdString = task.variables["plateId"] as? String |
| 39 | ?: error("PlateApprovalTaskHandler: BPMN process missing 'plateId' variable") | 59 | ?: error("PlateApprovalTaskHandler: BPMN process missing 'plateId' variable") |
| 60 | + val plateId = try { | ||
| 61 | + UUID.fromString(plateIdString) | ||
| 62 | + } catch (ex: IllegalArgumentException) { | ||
| 63 | + error("PlateApprovalTaskHandler: plateId '$plateIdString' is not a valid UUID") | ||
| 64 | + } | ||
| 40 | 65 | ||
| 41 | val approverLabel = when (val p = ctx.principal()) { | 66 | val approverLabel = when (val p = ctx.principal()) { |
| 42 | is Principal.User -> "user:${p.username}" | 67 | is Principal.User -> "user:${p.username}" |
| @@ -44,10 +69,32 @@ class PlateApprovalTaskHandler : TaskHandler { | @@ -44,10 +69,32 @@ class PlateApprovalTaskHandler : TaskHandler { | ||
| 44 | is Principal.PluginPrincipal -> "plugin:${p.pluginId}" | 69 | is Principal.PluginPrincipal -> "plugin:${p.pluginId}" |
| 45 | } | 70 | } |
| 46 | 71 | ||
| 72 | + // Mutate the plug-in's own table. Filter on status='DRAFT' so | ||
| 73 | + // a second execution against the same plate is a clean no-op | ||
| 74 | + // rather than silently overwriting a later status. | ||
| 75 | + val updatedRows = context.jdbc.update( | ||
| 76 | + """ | ||
| 77 | + UPDATE plugin_printingshop__plate | ||
| 78 | + SET status = 'APPROVED', | ||
| 79 | + updated_at = :now | ||
| 80 | + WHERE id = :id | ||
| 81 | + AND status = 'DRAFT' | ||
| 82 | + """.trimIndent(), | ||
| 83 | + mapOf( | ||
| 84 | + "id" to plateId, | ||
| 85 | + "now" to java.sql.Timestamp.from(Instant.now()), | ||
| 86 | + ), | ||
| 87 | + ) | ||
| 88 | + | ||
| 89 | + context.logger.info( | ||
| 90 | + "plate ${plateId} approved by ${approverLabel} (rows updated: ${updatedRows})", | ||
| 91 | + ) | ||
| 92 | + | ||
| 47 | ctx.set("plateApproved", true) | 93 | ctx.set("plateApproved", true) |
| 48 | - ctx.set("plateId", plateId) | 94 | + ctx.set("plateId", plateIdString) |
| 49 | ctx.set("approvedBy", approverLabel) | 95 | ctx.set("approvedBy", approverLabel) |
| 50 | ctx.set("approvedAt", Instant.now().toString()) | 96 | ctx.set("approvedAt", Instant.now().toString()) |
| 97 | + ctx.set("rowsUpdated", updatedRows) | ||
| 51 | } | 98 | } |
| 52 | 99 | ||
| 53 | companion object { | 100 | companion object { |