Commit 7b2ab34de3cba85ab387e3a9ec974b79dc7bdde8

Authored by zichun
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.
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 {