@pxp-nd/flow 1.1.27 → 1.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ # BPM + Automation Platform — Design Proposal
2
+
3
+ **Status:** Draft
4
+ **Base:** Current Tempo Flows (`twf_*`, `EventHq`, SQS worker) — see [FLOWS.md](./FLOWS.md)
5
+ **Stack lean (v1):** NestJS + React + PostgreSQL + SQS/BullMQ
6
+
7
+ ---
8
+
9
+ ## 1. Summary — what we want to build
10
+
11
+ We want a **new process platform** that keeps everything that already works in Tempo Flows (visual automation, action catalog, event + queue, multi-tenant templates) and extends it into a real **BPM** product.
12
+
13
+ **In short:**
14
+
15
+ | Today (Flows) | Tomorrow (BPM + Automation) |
16
+ |---------------|-----------------------------|
17
+ | Event → delay → email/push/controller | Same automations, cleaner engine |
18
+ | Mostly linear graphs | Gateways (branch / parallel) with clear UI |
19
+ | Little human workflow | Forms, approvals, assignees, task inbox |
20
+ | No first-class documents | Require / upload / generate documents |
21
+ | Internal controllers mainly | HTTP / webhooks / external connectors |
22
+ | Soft versioning | Immutable process versions |
23
+ | Magic vendor `55` | Explicit **SYSTEM / GROUP / TENANT** catalog scopes |
24
+
25
+ **Product shape (same engine, two modes):**
26
+
27
+ 1. **Automation** — fire-and-forget graphs (billing alerts, COS push, funnels).
28
+ 2. **BPM** — long-running cases (form → approve → documents → notify → close).
29
+
30
+ **Builder UX (keep):** left = action palette · center = canvas · right = node form (`schema_json`).
31
+
32
+ **Migration:** strangler pattern — keep `twf_*` running; migrate process-by-process (e.g. Safe Families first); do not big-bang rewrite.
33
+
34
+ ---
35
+
36
+ ## 2. ER diagram (color legend)
37
+
38
+ | Color | Meaning |
39
+ |-------|---------|
40
+ | **Amber** | Exists today as `twf_*` → **renamed / evolved** |
41
+ | **Green** | **Completely new** (no direct `twf_*` equivalent) |
42
+
43
+ ```mermaid
44
+ flowchart TB
45
+ classDef renamed fill:#FFF3CD,stroke:#B8860B,color:#000000,stroke-width:2px
46
+ classDef newtbl fill:#D1E7DD,stroke:#0F5132,color:#000000,stroke-width:2px
47
+ classDef legend fill:#F8F9FA,stroke:#6C757D,color:#000000
48
+
49
+ subgraph LEGEND["Legend"]
50
+ L1["AMBER = existing twf_* → renamed/evolved"]
51
+ L2["GREEN = completely new"]
52
+ L1:::legend
53
+ L2:::legend
54
+ end
55
+
56
+ subgraph TENANCY["Tenancy"]
57
+ TG["bpm_tenant_group"]:::newtbl
58
+ TGM["bpm_tenant_group_member"]:::newtbl
59
+ AG["bpm_action_group"]:::newtbl
60
+ end
61
+
62
+ subgraph DESIGN["Design-time"]
63
+ AT["bpm_action_type<br/>← twf_action_type"]:::renamed
64
+ A["bpm_action<br/>← twf_action"]:::renamed
65
+ PD["bpm_process_definition<br/>← twf_flow"]:::renamed
66
+ PV["bpm_process_version<br/>← split from twf_flow"]:::renamed
67
+ N["bpm_node<br/>← twf_node"]:::renamed
68
+ E["bpm_edge<br/>← twf_node_connection"]:::renamed
69
+ FD["bpm_form_definition"]:::newtbl
70
+ FV["bpm_form_version"]:::newtbl
71
+ DT["bpm_document_type"]:::newtbl
72
+ AP["bpm_approval_policy"]:::newtbl
73
+ end
74
+
75
+ subgraph RUNTIME["Runtime"]
76
+ PI["bpm_process_instance<br/>← twf_flow_instance"]:::renamed
77
+ AI["bpm_activity_instance<br/>← twf_node_instance"]:::renamed
78
+ TK["bpm_token"]:::newtbl
79
+ T["bpm_task"]:::newtbl
80
+ TA["bpm_task_action"]:::newtbl
81
+ DOC["bpm_document"]:::newtbl
82
+ MSG["bpm_message<br/>← twf_event"]:::renamed
83
+ JOB["bpm_job"]:::newtbl
84
+ AUD["bpm_audit_log<br/>← twf_flow_log"]:::renamed
85
+ end
86
+
87
+ TG --> TGM
88
+ A --> AG
89
+ TG --> AG
90
+ AT --> A
91
+ A --> N
92
+ PD --> PV
93
+ PV --> N
94
+ PV --> E
95
+ FD --> FV
96
+ FV -.-> N
97
+ AP -.-> N
98
+ DT -.-> N
99
+ PV --> PI
100
+ PI --> AI
101
+ PI --> TK
102
+ N --> AI
103
+ AI --> T
104
+ T --> TA
105
+ PI --> DOC
106
+ DT --> DOC
107
+ MSG --> PI
108
+ PI --> JOB
109
+ PI --> AUD
110
+ ```
111
+
112
+ ```mermaid
113
+ erDiagram
114
+ bpm_tenant_group ||--o{ bpm_tenant_group_member : has
115
+ bpm_tenant_group ||--o{ bpm_action_group : scopes
116
+ bpm_action_type ||--o{ bpm_action : categorizes
117
+ bpm_action ||--o{ bpm_action_group : visible_in
118
+ bpm_action ||--o{ bpm_node : used_by
119
+ bpm_process_definition ||--o{ bpm_process_version : has
120
+ bpm_process_version ||--o{ bpm_node : contains
121
+ bpm_process_version ||--o{ bpm_edge : contains
122
+ bpm_form_definition ||--o{ bpm_form_version : has
123
+ bpm_form_version ||--o{ bpm_node : bound_to
124
+ bpm_approval_policy ||--o{ bpm_node : configures
125
+ bpm_document_type ||--o{ bpm_node : required_by
126
+ bpm_process_version ||--o{ bpm_process_instance : instantiates
127
+ bpm_process_instance ||--o{ bpm_activity_instance : has
128
+ bpm_process_instance ||--o{ bpm_token : active_at
129
+ bpm_node ||--o{ bpm_activity_instance : executes_as
130
+ bpm_activity_instance ||--o{ bpm_task : creates
131
+ bpm_task ||--o{ bpm_task_action : records
132
+ bpm_process_instance ||--o{ bpm_document : owns
133
+ bpm_document_type ||--o{ bpm_document : typed_as
134
+ bpm_message ||--o{ bpm_process_instance : starts_or_signals
135
+ bpm_process_instance ||--o{ bpm_job : schedules
136
+ bpm_process_instance ||--o{ bpm_audit_log : audited_by
137
+ ```
138
+
139
+ **Color checklist**
140
+
141
+ | Amber — renamed / evolved | Green — new |
142
+ |---------------------------|-------------|
143
+ | `bpm_action_type` ← `twf_action_type` | `bpm_tenant_group` |
144
+ | `bpm_action` ← `twf_action` | `bpm_tenant_group_member` |
145
+ | `bpm_process_definition` ← `twf_flow` | `bpm_action_group` |
146
+ | `bpm_process_version` ← split from `twf_flow` | `bpm_form_definition` / `bpm_form_version` |
147
+ | `bpm_node` ← `twf_node` | `bpm_document_type` |
148
+ | `bpm_edge` ← `twf_node_connection` | `bpm_approval_policy` |
149
+ | `bpm_process_instance` ← `twf_flow_instance` | `bpm_token` |
150
+ | `bpm_activity_instance` ← `twf_node_instance` | `bpm_task` / `bpm_task_action` |
151
+ | `bpm_message` ← `twf_event` | `bpm_document` |
152
+ | `bpm_audit_log` ← `twf_flow_log` | `bpm_job` |
153
+
154
+ > Tenant identity maps to today’s `vendor_id`. Optional bridge tables `twf_origin_name` / `twf_field_map` can remain for legacy view-based automations; BPM case data lives in `bpm_process_instance.variables`.
155
+
156
+ ---
157
+
158
+ ## 3. Data dictionary
159
+
160
+ Conventions used below:
161
+
162
+ - `tenant_id` = vendor id (unless noted).
163
+ - JSON examples are illustrative.
164
+ - `visibility_scope`: `SYSTEM` | `GROUP` | `TENANT`.
165
+
166
+ ---
167
+
168
+ ### 3.1 Tenancy
169
+
170
+ #### `bpm_tenant_group` *(new)*
171
+
172
+ Groups of tenants that share a catalog slice (e.g. business type).
173
+
174
+ | Column | Type | Description | Example |
175
+ |--------|------|-------------|---------|
176
+ | `id` | PK | Group id | `1` |
177
+ | `code` | varchar | Stable code | `SF` |
178
+ | `name` | varchar | Display name | `Safe Families` |
179
+ | `is_active` | bool | Soft enable | `true` |
180
+
181
+ #### `bpm_tenant_group_member` *(new)*
182
+
183
+ | Column | Type | Description | Example |
184
+ |--------|------|-------------|---------|
185
+ | `id` | PK | Row id | `10` |
186
+ | `tenant_id` | int | Vendor id | `674` |
187
+ | `tenant_group_id` | FK | Group | `1` (`SF`) |
188
+
189
+ #### `bpm_action_group` *(new)*
190
+
191
+ Links a GROUP-scoped action to one or more tenant groups.
192
+
193
+ | Column | Type | Description | Example |
194
+ |--------|------|-------------|---------|
195
+ | `action_id` | FK | Action | `111` |
196
+ | `tenant_group_id` | FK | Group that may use it | `1` (`SF`) |
197
+
198
+ ---
199
+
200
+ ### 3.2 Catalog (palette)
201
+
202
+ #### `bpm_action_type` *(← `twf_action_type`)*
203
+
204
+ Category in the left palette (Delay, Email, Controller, HTTP, Approval, …).
205
+
206
+ | Column | Type | Description | Example |
207
+ |--------|------|-------------|---------|
208
+ | `id` | PK | Type id | `28` |
209
+ | `code` | varchar | Stable type code | `CONTROLLER_EXECUTION` |
210
+ | `name` | varchar | Palette group label | `CONTROLLER EXECUTION` |
211
+ | `schema_json` | json | Shared UI schema for the type | `{ "timeout": { ... } }` |
212
+ | `is_start` | char(1) | Can be a start/trigger type | `N` |
213
+ | `is_delay` | char(1) | Timer-like behavior | `N` |
214
+ | `on_duplicate` | varchar | Hook when process is cloned | `duplicateEmailTemplate` |
215
+ | `is_active` | bool | Visible in palette | `true` |
216
+
217
+ #### `bpm_action` *(← `twf_action`)*
218
+
219
+ Reusable step/event definition (stable contract via `code`).
220
+
221
+ | Column | Type | Description | Example |
222
+ |--------|------|-------------|---------|
223
+ | `id` | PK | Action id | `111` |
224
+ | `action_type_id` | FK | Category | `28` |
225
+ | `code` | varchar | **Stable API key** (never hardcode numeric id in callers) | `SF SEND COS PUSH` |
226
+ | `name` | varchar | Builder label | `Send Push Notification to COS members` |
227
+ | `description` | text | Help text in palette | `Sends a push when a COS message is posted` |
228
+ | `schema_json` | json | Right-panel form for this action | `{ "subject": {...}, "message": {...} }` |
229
+ | `config_json_template` | json | Defaults / `{{ FIELD }}` placeholders | `{ "subject": "{{ FAMILY_NAME }}" }` |
230
+ | `controller_function` | varchar | Internal handler or connector key | `hq-nd/BusinessTypeSF/sendCircleOfSupportNotification` |
231
+ | `visibility_scope` | varchar | Who can use it | `GROUP` |
232
+ | `owner_tenant_id` | int null | Set when scope = `TENANT` | `null` |
233
+ | `is_enabled` | bool | Kill switch | `true` |
234
+ | `icon` | varchar | Palette icon | `mdi:bell-ring-outline` |
235
+
236
+ ---
237
+
238
+ ### 3.3 Process definition (design-time graph)
239
+
240
+ #### `bpm_process_definition` *(← `twf_flow` metadata)*
241
+
242
+ Logical process (many versions).
243
+
244
+ | Column | Type | Description | Example |
245
+ |--------|------|-------------|---------|
246
+ | `id` | PK | Definition id | `100` |
247
+ | `key` | varchar | Stable process key | `sf-new-cos-message` |
248
+ | `name` | varchar | Display name | `New COS Message` |
249
+ | `category` | varchar | `automation` or `bpm` | `automation` |
250
+ | `owner_tenant_id` | int null | Owning vendor; null for platform template | `674` |
251
+ | `visibility_scope` | varchar | Who may clone/subscribe | `GROUP` |
252
+ | `icon` | varchar | UI icon | `chatbubbles-outline` |
253
+ | `is_active` | bool | Soft delete | `true` |
254
+
255
+ #### `bpm_process_version` *(split from `twf_flow`)*
256
+
257
+ Immutable published graph. Running instances pin a version.
258
+
259
+ | Column | Type | Description | Example |
260
+ |--------|------|-------------|---------|
261
+ | `id` | PK | Version id | `45` |
262
+ | `definition_id` | FK | Parent definition | `100` |
263
+ | `version` | int | Monotonic version number | `3` |
264
+ | `status` | varchar | `draft` \| `published` \| `retired` | `published` |
265
+ | `published_at` | timestamp null | Publish time | `2026-07-15T12:00:00Z` |
266
+ | `graph_checksum` | varchar | Optional integrity hash | `sha256:…` |
267
+
268
+ #### `bpm_node` *(← `twf_node`)*
269
+
270
+ One element on the canvas.
271
+
272
+ | Column | Type | Description | Example |
273
+ |--------|------|-------------|---------|
274
+ | `id` | PK | Node id | `18200` |
275
+ | `version_id` | FK | Process version | `45` |
276
+ | `action_id` | FK null | Catalog action (null for pure gateways/start/end) | `111` |
277
+ | `element_type` | varchar | BPMN-ish type | `serviceTask` / `userTask` / `exclusiveGateway` / `startEvent` |
278
+ | `name` | varchar | Label on canvas | `Send Push Notification to COS members` |
279
+ | `is_init` | bool | Entry / trigger node | `false` |
280
+ | `config_json` | json | Values from right-panel form (`action_config_json` today) | `{ "subject": "{{ REGISTERED_BY_NAME }}", "message": "{{ MESSAGE }}" }` |
281
+ | `form_version_id` | FK null | Bound form (user tasks) | `12` |
282
+ | `approval_policy_id` | FK null | Approval rules | `3` |
283
+ | `document_type_id` | FK null | Required doc type | `null` |
284
+ | `position_x` / `position_y` | int | Canvas coordinates | `120`, `340` |
285
+
286
+ #### `bpm_edge` *(← `twf_node_connection`)*
287
+
288
+ Directed connection; may carry a condition (gateway branch).
289
+
290
+ | Column | Type | Description | Example |
291
+ |--------|------|-------------|---------|
292
+ | `id` | PK | Edge id | `900` |
293
+ | `version_id` | FK | Process version | `45` |
294
+ | `from_node_id` | FK null | Source; null = link into start | `18199` |
295
+ | `to_node_id` | FK | Target | `18200` |
296
+ | `name` | varchar null | Branch label | `approved` |
297
+ | `condition_expr` | varchar null | Expression for XOR/default | `variables.decision == "approved"` |
298
+ | `is_default` | bool | Default flow on gateway | `false` |
299
+
300
+ ---
301
+
302
+ ### 3.4 Forms, approvals, documents (design-time)
303
+
304
+ #### `bpm_form_definition` *(new)*
305
+
306
+ | Column | Type | Description | Example |
307
+ |--------|------|-------------|---------|
308
+ | `id` | PK | Form id | `5` |
309
+ | `key` | varchar | Stable key | `sf-need-review` |
310
+ | `name` | varchar | Display name | `Need review form` |
311
+ | `visibility_scope` | varchar | Catalog scope | `GROUP` |
312
+ | `owner_tenant_id` | int null | If `TENANT` | `null` |
313
+
314
+ #### `bpm_form_version` *(new)*
315
+
316
+ | Column | Type | Description | Example |
317
+ |--------|------|-------------|---------|
318
+ | `id` | PK | Form version id | `12` |
319
+ | `form_id` | FK | Parent form | `5` |
320
+ | `version` | int | Version number | `2` |
321
+ | `schema_json` | json | JSON Schema of fields | `{ "type":"object", "properties": { "notes": { "type":"string" } } }` |
322
+ | `ui_schema` | json | UI layout hints | `{ "notes": { "ui:widget":"textarea" } }` |
323
+ | `status` | varchar | `draft` \| `published` | `published` |
324
+
325
+ #### `bpm_approval_policy` *(new)*
326
+
327
+ | Column | Type | Description | Example |
328
+ |--------|------|-------------|---------|
329
+ | `id` | PK | Policy id | `3` |
330
+ | `code` | varchar | Stable code | `SF_AREA_ANY` |
331
+ | `policy_type` | varchar | `ANY` \| `ALL` \| `N_OF_M` | `ANY` |
332
+ | `quorum_n` | int null | Required when `N_OF_M` | `null` |
333
+ | `assignee_resolver` | json | How to resolve approvers | `{ "type":"expression", "resolver":"sfAreaApprovers", "args": { "familyAreaId": "{{ variables.familyAreaId }}" } }` |
334
+ | `outcomes` | json | Allowed outcomes | `["approved","rejected","changes_requested"]` |
335
+
336
+ #### `bpm_document_type` *(new)*
337
+
338
+ | Column | Type | Description | Example |
339
+ |--------|------|-------------|---------|
340
+ | `id` | PK | Doc type id | `7` |
341
+ | `code` | varchar | Stable code | `HOST_AGREEMENT` |
342
+ | `name` | varchar | Label | `Host agreement PDF` |
343
+ | `mime_allowlist` | json | Allowed MIME types | `["application/pdf"]` |
344
+ | `max_size_mb` | int | Upload limit | `10` |
345
+ | `visibility_scope` | varchar | Catalog scope | `GROUP` |
346
+
347
+ ---
348
+
349
+ ### 3.5 Runtime
350
+
351
+ #### `bpm_process_instance` *(← `twf_flow_instance`)*
352
+
353
+ One running (or completed) case.
354
+
355
+ | Column | Type | Description | Example |
356
+ |--------|------|-------------|---------|
357
+ | `id` | PK | Instance id | `5001` |
358
+ | `version_id` | FK | Pinned process version | `45` |
359
+ | `tenant_id` | int | Owning vendor | `674` |
360
+ | `business_key` | varchar | External correlation id | `cos-message:8891` |
361
+ | `status` | varchar | `running` \| `waiting` \| `completed` \| `failed` \| `cancelled` | `running` |
362
+ | `variables` | json | Case data (replaces heavy reliance on SQL views) | `{ "referralId": 321, "familyName": "The Smith Family", "familyInitials": "SM", "message": "Can someone cover Friday?" }` |
363
+ | `started_at` / `ended_at` | timestamp | Lifecycle | `2026-07-16T14:01:00Z` / `null` |
364
+
365
+ #### `bpm_activity_instance` *(← `twf_node_instance`)*
366
+
367
+ One execution of a node inside an instance.
368
+
369
+ | Column | Type | Description | Example |
370
+ |--------|------|-------------|---------|
371
+ | `id` | PK | Activity id | `80001` |
372
+ | `instance_id` | FK | Process instance | `5001` |
373
+ | `node_id` | FK | Definition node | `18200` |
374
+ | `status` | varchar | `pending` \| `active` \| `completed` \| `failed` \| `skipped` | `completed` |
375
+ | `input_json` | json | Snapshot of inputs | `{ "subject": "Jane Doe", "message": "…" }` |
376
+ | `output_json` | json | Snapshot of outputs | `{ "sent": 3, "skipped": 1 }` |
377
+ | `error_message` | text null | Failure detail | `null` |
378
+ | `started_at` / `completed_at` | timestamp | Timing | … |
379
+
380
+ #### `bpm_token` *(new)*
381
+
382
+ Active position(s) in the graph (required for parallel gateways).
383
+
384
+ | Column | Type | Description | Example |
385
+ |--------|------|-------------|---------|
386
+ | `id` | PK | Token id | `60` |
387
+ | `instance_id` | FK | Process instance | `5001` |
388
+ | `node_id` | FK | Current node | `18200` |
389
+ | `status` | varchar | `active` \| `consumed` | `active` |
390
+ | `parent_token_id` | FK null | For parallel forks | `null` |
391
+
392
+ #### `bpm_task` *(new)*
393
+
394
+ Human inbox item (form / approval).
395
+
396
+ | Column | Type | Description | Example |
397
+ |--------|------|-------------|---------|
398
+ | `id` | PK | Task id | `900` |
399
+ | `activity_id` | FK | Related activity | `80010` |
400
+ | `instance_id` | FK | Process instance (denormalized for queries) | `5001` |
401
+ | `tenant_id` | int | Tenant | `674` |
402
+ | `task_type` | varchar | `FORM` \| `APPROVAL` | `APPROVAL` |
403
+ | `title` | varchar | Inbox title | `Approve posted need #441` |
404
+ | `assignee` | varchar null | Claimed user id/email | `approver@sf.org` |
405
+ | `candidate_users` | json | Potential assignees | `["a@sf.org","b@sf.org"]` |
406
+ | `candidate_groups` | json | Role/group keys | `["SF_AREA_12"]` |
407
+ | `due_at` | timestamp null | SLA due | `2026-07-17T18:00:00Z` |
408
+ | `status` | varchar | `open` \| `completed` \| `cancelled` | `open` |
409
+ | `form_version_id` | FK null | Form to render | `12` |
410
+ | `payload_json` | json | Task-specific data | `{ "needId": 441, "kind": "post" }` |
411
+
412
+ #### `bpm_task_action` *(new)*
413
+
414
+ Audit of human actions on a task.
415
+
416
+ | Column | Type | Description | Example |
417
+ |--------|------|-------------|---------|
418
+ | `id` | PK | Action id | `1200` |
419
+ | `task_id` | FK | Task | `900` |
420
+ | `actor` | varchar | Who acted | `a@sf.org` |
421
+ | `action` | varchar | `approve` \| `reject` \| `comment` \| `reassign` \| `complete` | `approve` |
422
+ | `comment` | text null | Optional note | `Looks good` |
423
+ | `data_json` | json null | Submitted form fields | `{ "notes": "OK" }` |
424
+ | `created_at` | timestamp | When | `2026-07-16T15:00:00Z` |
425
+
426
+ #### `bpm_document` *(new)*
427
+
428
+ Uploaded or generated file for an instance.
429
+
430
+ | Column | Type | Description | Example |
431
+ |--------|------|-------------|---------|
432
+ | `id` | PK | Document id | `70` |
433
+ | `instance_id` | FK | Process instance | `5001` |
434
+ | `document_type_id` | FK | Type | `7` |
435
+ | `tenant_id` | int | Tenant | `674` |
436
+ | `file_name` | varchar | Original name | `host-agreement.pdf` |
437
+ | `s3_key` | varchar | Storage key | `bpm/674/5001/host-agreement.pdf` |
438
+ | `mime_type` | varchar | MIME | `application/pdf` |
439
+ | `status` | varchar | `required` \| `uploaded` \| `approved` \| `rejected` | `uploaded` |
440
+ | `uploaded_by` | varchar null | Actor | `coach@sf.org` |
441
+
442
+ #### `bpm_message` *(← `twf_event`)*
443
+
444
+ Inbound event / signal that starts or resumes a process.
445
+
446
+ | Column | Type | Description | Example |
447
+ |--------|------|-------------|---------|
448
+ | `id` | PK | Message id | `56953` |
449
+ | `action_code` | varchar | Trigger action code (or process key) | `SF NEW COS MESSAGE` |
450
+ | `tenant_id` | int null | Known tenant if resolved | `674` |
451
+ | `business_key` | varchar null | Correlation | `cos-message:8891` |
452
+ | `payload_json` | json | Event body / origin row snapshot | `{ "id": 8891, "referralId": 321, "familyName": "The Smith Family", "familyInitials": "SM" }` |
453
+ | `desc_job_name` | varchar | Caller context (like today) | `CircleOfSupport/createMessage` |
454
+ | `status` | varchar | `pending` \| `processed` \| `failed` | `processed` |
455
+ | `process_instance_id` | FK null | Instance created/signaled | `5001` |
456
+
457
+ #### `bpm_job` *(new)*
458
+
459
+ Async work: timers, HTTP retries, delayed continuations (worker queue rows).
460
+
461
+ | Column | Type | Description | Example |
462
+ |--------|------|-------------|---------|
463
+ | `id` | PK | Job id | `300` |
464
+ | `instance_id` | FK null | Related instance | `5001` |
465
+ | `activity_id` | FK null | Related activity | `80001` |
466
+ | `job_type` | varchar | `TIMER` \| `HTTP` \| `EMAIL` \| `PUSH` \| `CONTINUE` | `PUSH` |
467
+ | `run_at` | timestamp | When eligible | `2026-07-16T14:01:05Z` |
468
+ | `attempts` | int | Retry count | `1` |
469
+ | `max_attempts` | int | Cap | `4` |
470
+ | `payload_json` | json | Worker payload | `{ "actionCode": "SF SEND COS PUSH", "config": { ... } }` |
471
+ | `status` | varchar | `scheduled` \| `running` \| `done` \| `failed` | `done` |
472
+ | `last_error` | text null | Last failure | `null` |
473
+
474
+ #### `bpm_audit_log` *(← `twf_flow_log`)*
475
+
476
+ Immutable trail for support and compliance.
477
+
478
+ | Column | Type | Description | Example |
479
+ |--------|------|-------------|---------|
480
+ | `id` | PK | Log id | `10001` |
481
+ | `instance_id` | FK null | Process instance | `5001` |
482
+ | `tenant_id` | int null | Tenant | `674` |
483
+ | `event_type` | varchar | `INSTANCE_STARTED` \| `TASK_COMPLETED` \| `NODE_FAILED` \| … | `NODE_FAILED` |
484
+ | `message` | text | Human-readable summary | `Push connector timeout` |
485
+ | `details_json` | json | Structured detail / stack excerpt | `{ "activityId": 80001, "error": "ETIMEDOUT" }` |
486
+ | `created_at` | timestamp | When | `2026-07-16T14:02:00Z` |
487
+
488
+ ---
489
+
490
+ ## 4. Multi-tenancy (catalog visibility) — short
491
+
492
+ | Scope | Who sees the action/form/template |
493
+ |-------|-----------------------------------|
494
+ | `SYSTEM` | All tenants |
495
+ | `GROUP` | Tenants in linked `bpm_tenant_group` (via `bpm_action_group`) |
496
+ | `TENANT` | Only `owner_tenant_id` |
497
+
498
+ Builder palette = `SYSTEM ∪ GROUP(member) ∪ TENANT(owner)`.
499
+ **Always re-validate on publish and execute** (never trust UI alone).
500
+ Instance/task/document data is always hard-scoped by `tenant_id`.
501
+
502
+ Replace today’s “vendor 55 runs for everyone” with **platform/group templates + tenant clone or subscription**.
503
+
504
+ ---
505
+
506
+ ## Appendix A — Keep / improve from current Flows
507
+
508
+ **Keep:** stable action `code`, action catalog + `schema_json`, node graph, event + SQS worker, template duplicate, instance history, 3-pane builder.
509
+
510
+ **Improve:** human tasks, approvals, forms, documents, gateway UX, case variables, HTTP connectors, immutable versions, observability.
511
+
512
+ Details of the current engine: [FLOWS.md](./FLOWS.md).
513
+
514
+ ---
515
+
516
+ ## Appendix B — Target capabilities (MVP → later)
517
+
518
+ | Capability | Approach |
519
+ |------------|----------|
520
+ | Forms | `bpm_form_*` + User Task node |
521
+ | Approvals | `bpm_approval_policy` + `bpm_task` |
522
+ | Branching | `exclusiveGateway` / `parallelGateway` + `bpm_edge.condition_expr` + `bpm_token` |
523
+ | Documents | `bpm_document_type` + `bpm_document` + S3 |
524
+ | External APIs | Action types `HTTP_REQUEST`, `WEBHOOK_WAIT`, connectors |
525
+ | Automations | Same engine; category=`automation`; service tasks only |
526
+
527
+ ---
528
+
529
+ ## Appendix C — Stack & migration
530
+
531
+ | Layer | Choice |
532
+ |-------|--------|
533
+ | API + engine v1 | **NestJS** modular monolith |
534
+ | Workers | NestJS + SQS/BullMQ |
535
+ | UI | React (evolve Harmony builder + task inbox) |
536
+ | DB | PostgreSQL preferred |
537
+ | Later | Optional Go workers if metrics demand |
538
+
539
+ **Migration:** keep `twf_*` live → adapter from `EventHq.add` → migrate SF processes first → retire legacy write path when ready.
540
+
541
+ ---
542
+
543
+ ## Appendix D — Open decisions
544
+
545
+ 1. Expression language: JSONLogic vs CEL vs JMESPath
546
+ 2. Form tech: RJSF vs Formily vs custom
547
+ 3. Postgres vs MySQL for the new DB
548
+ 4. Inbox inside Harmony vs separate app
549
+ 5. BPMN XML export in v1 or JSON-only
550
+ 6. Tenant groups: reuse `business_type` vs dedicated packs
551
+ 7. Global automations: subscription (recommended) vs fan-out listener
552
+
553
+ ---
554
+
555
+ ## Appendix E — Example process (Safe Families)
556
+
557
+ ```text
558
+ Start (SF NEW COS MESSAGE / SF NEW POSTED NEED)
559
+ → Service Task: notify (push)
560
+ → [BPM] User/Approval Task (optional)
561
+ → Exclusive Gateway (approved / rejected)
562
+ → Service Task: decision notification
563
+ → End
564
+ ```
565
+
566
+ Same trigger model as today; richer graph when human steps are needed.
package/README.md CHANGED
@@ -1 +1,437 @@
1
- # flow-nd
1
+ # Tempo Flows (Workflow Engine)
2
+
3
+ This document describes how automation **flows** work in Tempo: database structure, runtime pipeline, and how to trigger or assemble them.
4
+
5
+ Flows are a **node-graph automation engine**. Definitions and runtime state live in MySQL tables prefixed `twf_*`. Core entities and graph execution come from **`@pxp-nd/flow`**. Tempo adds business orchestration via `EventHq`, `FlowHQ`, and an SQS worker.
6
+
7
+ ---
8
+
9
+ ## High-level architecture
10
+
11
+ ```text
12
+ Business code (controllers / PHP jobs)
13
+ │
14
+ ▼
15
+ EventHq.add({ actionId, descJobName, dataId })
16
+ │
17
+ ├─► twf_event (status: pending)
18
+ └─► SQS FIFO (FLOW_QUEUE_URL)
19
+ │
20
+ ▼
21
+ worker.ts → EventHq.processSingleEvent({ eventId })
22
+ │
23
+ ├─► Find active twf_node rows for actionId
24
+ ├─► Query origin view (origin_name + origin_key = dataId)
25
+ ├─► Vendor match + event_config conditions
26
+ ├─► Create twf_flow_instance
27
+ └─► NodeInstanceController.RecursiveInstance() walks the DAG
28
+ ```
29
+
30
+ ### Key Tempo files
31
+
32
+ | File | Role |
33
+ |------|------|
34
+ | `src/modules/hq-nd/controllers/EventHq.ts` | Enqueue events to SQS; process a single event into flow instances |
35
+ | `src/modules/hq-nd/controllers/Flow.ts` (`FlowHQ`) | Duplicate templates, chained flows, funnel lists/statistics |
36
+ | `src/worker.ts` | SQS consumer (started when `INIT_FLOW_WORKER=Y`) |
37
+ | `src/config.ts` / `ormconfig.js` | Registers `flow-nd` module and merges flow entities |
38
+
39
+ Entities such as `Flow`, `Node`, `Action`, `Event`, `FlowInstance`, `NodeInstance` are **not** redefined in Tempo; they come from `@pxp-nd/flow`.
40
+
41
+ ---
42
+
43
+ ## How execution works (summary)
44
+
45
+ 1. **Fire an event**
46
+ Application code looks up `twf_action` by stable `code`, then calls `EventHq.add` with:
47
+ - `actionId` — PK of the action
48
+ - `dataId` — PK value in the action’s origin view (`origin_key`)
49
+ - `descJobName` — caller context for debugging
50
+
51
+ 2. **Enqueue**
52
+ `EventHq.add` inserts a `twf_event` (`status = pending`) and sends a message to SQS containing `eventId`.
53
+
54
+ 3. **Worker**
55
+ `pollQueue()` in `src/worker.ts` receives the message, deletes it from the queue, then calls `processSingleEvent` with retries (1s / 5s / 10s / 20s). Failures after retries go to Slack (`SLACK_TEMPO_ERROR`).
56
+
57
+ 4. **Match flows**
58
+ For every active `twf_node` with that `action_id`:
59
+ - Load one row: `SELECT * FROM {origin_name} WHERE {origin_key} = {dataId}`
60
+ - Load parent `twf_flow` — must be `is_active = 1` and **`status = 'on'`**
61
+ - Vendor filter: `flow.vendor_id === view.vendor_id` **OR** `flow.vendor_id === 55` (global Qorus flows)
62
+ - Optional `event_config` filters on the action/node
63
+
64
+ 5. **Run the graph**
65
+ Create `twf_flow_instance`, then walk `twf_node` / `twf_node_connection` via `RecursiveInstance()`. Each step creates `twf_node_instance` rows. Errors are stored in `twf_flow_log`.
66
+
67
+ ---
68
+
69
+ ## Database ER diagram
70
+
71
+ ```mermaid
72
+ erDiagram
73
+ twf_action_type ||--o{ twf_action : "categorizes"
74
+ twf_action ||--o{ twf_node : "used by"
75
+ twf_action ||--o{ twf_event : "triggered as"
76
+ twf_action ||--o{ twf_flow_instance : "started by"
77
+ twf_flow ||--o{ twf_node : "contains"
78
+ twf_flow ||--o{ twf_flow_instance : "instantiates"
79
+ twf_node ||--o{ twf_node_connection : "master"
80
+ twf_node ||--o{ twf_node_connection : "child"
81
+ twf_node ||--o{ twf_node_instance : "executes as"
82
+ twf_flow_instance ||--o{ twf_node_instance : "has steps"
83
+ twf_origin_name ||--o{ twf_field_map : "maps fields"
84
+
85
+ twf_action_type {
86
+ int action_type_id PK
87
+ varchar name
88
+ varchar schema_json
89
+ varchar is_start
90
+ varchar is_delay
91
+ varchar controller
92
+ varchar on_duplicate
93
+ tinyint is_active
94
+ }
95
+
96
+ twf_action {
97
+ int action_id PK
98
+ int action_type_id FK
99
+ varchar code "stable contract"
100
+ varchar name
101
+ varchar origin_name "DB view"
102
+ varchar origin_key "PK column in view"
103
+ varchar controller_function
104
+ varchar schema_json
105
+ varchar event_config
106
+ varchar config_json_template
107
+ tinyint is_active
108
+ }
109
+
110
+ twf_flow {
111
+ int flow_id PK
112
+ int vendor_id "0/55 template or vendor"
113
+ varchar code
114
+ varchar name
115
+ varchar type "template|custom"
116
+ varchar template_type "funnel|flow|null"
117
+ varchar status "must be on to run"
118
+ varchar enabled
119
+ tinyint is_active
120
+ varchar publish
121
+ varchar flow_type
122
+ }
123
+
124
+ twf_node {
125
+ int node_id PK
126
+ int flow_id FK
127
+ int action_id FK
128
+ varchar is_init "Y = entry/trigger"
129
+ varchar is_end
130
+ varchar action_config_json "runtime values"
131
+ varchar approval_config
132
+ tinyint is_active
133
+ }
134
+
135
+ twf_node_connection {
136
+ int node_connection_id PK
137
+ int node_id_master "NULL = init edge"
138
+ int node_id_child FK
139
+ varchar condition
140
+ tinyint is_active
141
+ }
142
+
143
+ twf_event {
144
+ int event_id PK
145
+ int action_id FK
146
+ int data_id "origin PK value"
147
+ varchar desc_job_name
148
+ varchar status "pending|processed"
149
+ tinyint is_active
150
+ }
151
+
152
+ twf_flow_instance {
153
+ int flow_instance_id PK
154
+ int flow_id FK
155
+ int data_id
156
+ int action_id
157
+ varchar origin_name
158
+ varchar origin_key
159
+ varchar status
160
+ varchar flag_name
161
+ int flag_value
162
+ tinyint is_active
163
+ }
164
+
165
+ twf_node_instance {
166
+ int node_instance_id PK
167
+ int node_id FK
168
+ int flow_instance_id FK
169
+ timestamp run_time
170
+ timestamp schedule
171
+ varchar status
172
+ json extra_args
173
+ tinyint is_active
174
+ }
175
+
176
+ twf_origin_name {
177
+ int origin_name_id PK
178
+ varchar name "view name"
179
+ varchar originKey
180
+ tinyint is_active
181
+ }
182
+
183
+ twf_field_map {
184
+ int field_map_id PK
185
+ int origin_name_id FK
186
+ varchar name
187
+ varchar alias
188
+ varchar type
189
+ tinyint is_active
190
+ }
191
+
192
+ twf_flow_log {
193
+ int flow_log_id PK
194
+ varchar error_custom_message
195
+ varchar error_message
196
+ text stack_trace
197
+ tinyint is_active
198
+ }
199
+ ```
200
+
201
+ > Note: Some FK relationships are logical (enforced in application / TypeORM) rather than hard MySQL foreign keys on every column.
202
+
203
+ ---
204
+
205
+ ## Runtime pipeline diagram
206
+
207
+ ```mermaid
208
+ flowchart LR
209
+ A[Business code] -->|Action.findOne by code| B[EventHq.add]
210
+ B --> C[(twf_event pending)]
211
+ B --> D[SQS FLOW_QUEUE_URL]
212
+ D --> E[worker.ts pollQueue]
213
+ E --> F[EventHq.processSingleEvent]
214
+ F --> G{Active nodes for actionId}
215
+ G --> H[Query origin view by dataId]
216
+ H --> I{flow status=on<br/>vendor match or vendor 55}
217
+ I -->|yes| J[(twf_flow_instance)]
218
+ J --> K[RecursiveInstance DAG walk]
219
+ K --> L[(twf_node_instance)]
220
+ I -->|no| M[Skip node]
221
+ F -.->|errors| N[(twf_flow_log)]
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Table reference
227
+
228
+ | Table | Role |
229
+ |-------|------|
230
+ | `twf_action_type` | Category of step (EVENT, EMAIL, DELAY, PAGE, CHAINED FLOW, CONTROLLER EXECUTION, …) |
231
+ | `twf_action` | Reusable action / event definition; stable API via `code` |
232
+ | `twf_flow` | Flow definition (template or vendor custom instance) |
233
+ | `twf_node` | One step in a flow; points at one action; holds `action_config_json` |
234
+ | `twf_node_connection` | Directed edges (DAG). Init connection uses `node_id_master = NULL` |
235
+ | `twf_event` | Async event queue row |
236
+ | `twf_flow_instance` | One execution of a flow for a given `dataId` |
237
+ | `twf_node_instance` | Per-step execution record |
238
+ | `twf_origin_name` | Registry of DB views used as event data sources |
239
+ | `twf_field_map` | Column aliases for Harmony builder / templating |
240
+ | `twf_flow_log` | Execution / processing errors |
241
+
242
+ ### Important `twf_flow` fields
243
+
244
+ | Field | Meaning |
245
+ |-------|---------|
246
+ | `type` | `template` (blueprint) or `custom` (vendor instance) |
247
+ | `template_type` | `funnel`, `flow`, or null |
248
+ | `status` | Must be **`on`** for `EventHq` to execute the flow |
249
+ | `vendor_id` | `0` / `55` for global templates; vendor id for instances. **Vendor 55** matches any vendor’s events |
250
+ | `code` | Optional stable identifier (e.g. `NEW_EMAIL_LIST`) |
251
+
252
+ ### Important `twf_action` fields
253
+
254
+ | Field | Meaning |
255
+ |-------|---------|
256
+ | `code` | Unique string — always resolve with `Action.findOne({ code })` |
257
+ | `origin_name` / `origin_key` | View + PK used when resolving `dataId` |
258
+ | `schema_json` | UI form for Harmony flow builder |
259
+ | `event_config` | Optional filters: `{ "filters": [{ "originField", "nodeField" }] }` |
260
+ | `controller_function` | Tempo controller path for CONTROLLER EXECUTION (e.g. `hq-nd/BusinessTypeSF/sendApprovalNotification`) |
261
+
262
+ ### Common `action_type_id` values (active)
263
+
264
+ | ID | Name | Notes |
265
+ |----|------|--------|
266
+ | 9 | EVENT | Trigger / listener node |
267
+ | 10 | EMAIL NOTIFICATION | Send email |
268
+ | 11 | DELAY | Wait step |
269
+ | 16 | PAGE | Opt-in / bridge pages |
270
+ | 24 | CHAINED FLOW | Invoke another flow |
271
+ | 26 | PUSH NOTIFICATION | Mobile push |
272
+ | 28 | CONTROLLER EXECUTION | Call a Tempo controller method |
273
+
274
+ `on_duplicate` on the action type drives post-processing when duplicating a flow (`duplicateEmailTemplate`, `duplicatePageTemplate`, `duplicateChainedFlow`).
275
+
276
+ ---
277
+
278
+ ## JSON configuration layers
279
+
280
+ 1. **`twf_action_type.schema_json`** — shared UI schema for the type
281
+ 2. **`twf_action.schema_json`** — per-action UI overrides
282
+ 3. **`twf_node.action_config_json`** — **actual values at runtime** for that node
283
+
284
+ Examples of `action_config_json`:
285
+
286
+ | Type | Typical keys |
287
+ |------|----------------|
288
+ | PAGE | `templateBuilderId`, `emailListId`, `smsListId`, `title` |
289
+ | EMAIL | `to`, `subject`, `templateId` |
290
+ | DELAY | `quantity`, `type` (`hours` / `minutes` / `days`) |
291
+ | CHAINED / EMAIL_JOURNEY | `flowId` |
292
+ | CONTROLLER / push (SF) | `subject`, `message`, `mobileActionId`, `notificationType` |
293
+
294
+ Templates may use `{{ FIELD }}` placeholders resolved from the origin view row (`config_json_template` / node config).
295
+
296
+ ---
297
+
298
+ ## Triggering a flow from TypeScript
299
+
300
+ ```typescript
301
+ import { Action, Event } from '@pxp-nd/flow';
302
+ import EventHq from './EventHq';
303
+
304
+ const action = await Action.findOne({ code: 'ADD EMAIL LIST ADDRESS' });
305
+ if (action) {
306
+ const eventInstance = new EventHq('hq-nd/EventHq', Event);
307
+ await eventInstance.add({
308
+ actionId: action.actionId,
309
+ descJobName: 'formLegato', // caller context
310
+ dataId: resInsert.emailListAddrId, // PK in action.origin_name
311
+ }, manager);
312
+ }
313
+ ```
314
+
315
+ Rules:
316
+
317
+ - Prefer **`EventHq.add()`** in Tempo (enqueues SQS). Do not bypass the queue for production event firing.
318
+ - Never hardcode `action_id`; always look up by `code`.
319
+ - `dataId` must exist in the action’s origin view.
320
+ - Flow must be `status = 'on'` and `is_active = 1`.
321
+
322
+ Events may also be fired from legacy PHP (e.g. billing jobs calling `hq-nd/EventHq/add`).
323
+
324
+ ---
325
+
326
+ ## Assembling / creating flows
327
+
328
+ ### A. Duplicate a template (preferred for vendor instances)
329
+
330
+ ```typescript
331
+ const flowHq = new FlowHQ('hq-nd/FlowHQ');
332
+ const result = await flowHq.duplicateFlow(
333
+ { flowId: templateFlowId, vendorId, name: 'Bridge Funnel' },
334
+ manager
335
+ );
336
+ // Activate if needed:
337
+ await manager.createQueryBuilder().update('twf_flow')
338
+ .set({ status: 'on' })
339
+ .where('flowId = :flowId', { flowId })
340
+ .execute();
341
+ ```
342
+
343
+ Template IDs often come from env:
344
+
345
+ - `TEMPLATE_BRIDGE_FUNNEL_FLOW_ID` (default `149`)
346
+ - `TEMPLATE_QORD_BRIDGE_FUNNEL_FLOW_ID` (default `3419`)
347
+
348
+ ### B. Patch init node after duplicate
349
+
350
+ ```typescript
351
+ await manager.createQueryBuilder().update('twf_node')
352
+ .set({ actionConfigJson: JSON.stringify({ emailListId }) })
353
+ .where('flowId = :flowId AND isInit = :isInit', { flowId, isInit: 'Y' })
354
+ .execute();
355
+ ```
356
+
357
+ ### C. Chained funnel → email journey
358
+
359
+ Use `FlowHQ.addChainedFlow()` (duplicates child flow, copies `emailListId`, activates).
360
+
361
+ ### D. SQL registration
362
+
363
+ Typical order:
364
+
365
+ 1. Optional `twf_action_type`
366
+ 2. `twf_action` (EVENT + step actions) with `WHERE NOT EXISTS (... code ...)`
367
+ 3. If new origin: `twf_origin_name` + `twf_field_map`
368
+ 4. Optional full graph: `twf_flow` → init `twf_node` → connections → step nodes
369
+ 5. Register in `src/modules/hq-nd/database/jaime-scripts.ts` as `JRR-HQ-[STORY]-[SEQ]`
370
+
371
+ Modern SF-style DML often registers **actions only** and lets the Harmony UI assemble the graph.
372
+
373
+ ---
374
+
375
+ ## Vendor matching (important)
376
+
377
+ When processing an event, Tempo runs a flow if:
378
+
379
+ ```text
380
+ flow.vendorId === viewVendorId OR flow.vendorId === 55
381
+ ```
382
+
383
+ So:
384
+
385
+ - A **vendor-specific** flow only runs for that vendor’s data.
386
+ - A **vendor 55 (Qorus)** flow runs for **any** vendor that fires the matching event (global notifications).
387
+
388
+ ---
389
+
390
+ ## Environment variables
391
+
392
+ | Variable | Purpose |
393
+ |----------|---------|
394
+ | `FLOW_QUEUE_URL` | SQS FIFO queue for async events |
395
+ | `INIT_FLOW_WORKER` | `Y` starts `pollQueue()` from `server.ts` |
396
+ | `TEMPLATE_BRIDGE_FUNNEL_FLOW_ID` | Default bridge funnel template |
397
+ | `TEMPLATE_QORD_BRIDGE_FUNNEL_FLOW_ID` | Qord bridge funnel template |
398
+ | `SLACK_TEMPO_ERROR` | Channel for worker failures after retries |
399
+
400
+ ---
401
+
402
+ ## Naming conventions
403
+
404
+ - Action `code`: UPPER CASE with spaces — e.g. `NEW POSTED NEED`, `BILLING NOT PROCESSED`, `SF SEND COS PUSH`
405
+ - Funnel templates: `type = 'template'`, `template_type = 'funnel'`
406
+ - Email / automation templates: `type = 'template'`, `template_type = 'flow'` (or null)
407
+ - Vendor copies after `duplicateFlow`: `type = 'custom'`
408
+
409
+ ---
410
+
411
+ ## Do / Don’t
412
+
413
+ **Do**
414
+
415
+ - Find actions by `code`
416
+ - Duplicate templates for vendor flows; keep them `status = 'on'` when they should run
417
+ - Register new origins in `twf_origin_name` (+ field maps) before using them in actions
418
+ - Use `EventHq.add` so processing goes through SQS
419
+
420
+ **Don’t**
421
+
422
+ - Hardcode `action_id`
423
+ - Auto-sync `twf_*` schema (`synchronize` stays false)
424
+ - Skip SQS for production event firing
425
+ - Hand-build full vendor graphs in TypeScript when a template + `duplicateFlow` exists
426
+
427
+ ---
428
+
429
+ ## Controllers that commonly fire events
430
+
431
+ Examples in Tempo: `Site`, `Vendor`, `Member`, `Package`, `MemberAccounts`, `EmailList`, `PageTemplate`, `Extra`, `CloudFrontFlow`, mobile `Subscriptions`, `helpers/Site.ts`. Legacy Harmony PHP jobs may also call `EventHq/add` (e.g. billing).
432
+
433
+ ---
434
+
435
+ ## Related Cursor rules
436
+
437
+ Project conventions for flows are also summarized in `.cursorrules` under **Flows (Workflow Engine)**. Keep this document and those rules aligned when changing the event pipeline or `twf_*` contracts.
@@ -719,7 +719,7 @@ let Flow = class Flow extends core_1.Controller {
719
719
  //valid controllerValidation
720
720
  const { action: { actionType } } = node;
721
721
  const { validationController } = actionType;
722
- const nodeController = new Node_2.default('flow-nd');
722
+ const nodeController = new Node_2.default('flow-nd', Node_1.default);
723
723
  await nodeController.executeValidationController({ validationController, actionConfigJson: values, showException: true });
724
724
  //end valid controller
725
725
  const missingRequired = [];
@@ -13,6 +13,7 @@
13
13
  * Last modified :
14
14
  * ISSUE DATE NAME DESCRIPTION
15
15
  * 9141239986 16-May-2025 Mercedes Zambrana Save flagName and FlagValue
16
+ * EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
16
17
  */
17
18
  import { EntityManager } from 'typeorm';
18
19
  import { Controller } from '@pxp-nd/core';
@@ -14,6 +14,7 @@
14
14
  * Last modified :
15
15
  * ISSUE DATE NAME DESCRIPTION
16
16
  * 9141239986 16-May-2025 Mercedes Zambrana Save flagName and FlagValue
17
+ * EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
17
18
  */
18
19
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
20
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -39,16 +40,11 @@ const Event_1 = __importDefault(require("../entity/Event"));
39
40
  const Flow_1 = __importDefault(require("../entity/Flow"));
40
41
  const Action_1 = __importDefault(require("../entity/Action"));
41
42
  const FlowLog_1 = __importDefault(require("../entity/FlowLog"));
43
+ const dbLock_1 = require("../helpers/dbLock");
42
44
  class FlowInstance extends core_1.Controller {
43
45
  async processPending(params, manager) {
44
- const flag = await common_1.GlobalData.findOne({ data: 'wf_processing_pending_flows_flag' });
45
- if (flag) {
46
- if (flag.value == 'true') {
47
- throw new core_1.PxpError(400, 'Already processing pending flows');
48
- }
49
- flag.value = 'true';
50
- let flowInstanceIdProcessing = -1;
51
- await (0, typeorm_1.getManager)().save(flag);
46
+ let flowInstanceIdProcessing = -1;
47
+ const { acquired } = await (0, dbLock_1.withDbLock)('wf_process_pending', async () => {
52
48
  try {
53
49
  await this.eventProcesss(manager);
54
50
  const maxFlows = await common_1.GlobalData.findOne({ data: 'wf_max_concurrent_flows_process' });
@@ -69,17 +65,17 @@ class FlowInstance extends core_1.Controller {
69
65
  }
70
66
  }
71
67
  catch (error) {
72
- flag.value = 'false';
73
- await (0, typeorm_1.getManager)().save(flag);
68
+ // The lock is released by withDbLock's finally block
74
69
  const log = new FlowLog_1.default();
75
70
  log.errorCustomMessage = `Error executing ${flowInstanceIdProcessing} in FlowInstance-> processPending `;
76
71
  log.errorMessage = error.message;
77
72
  log.stackTrace = error.stack || '';
78
- (0, typeorm_1.getManager)().save(log);
73
+ await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
79
74
  throw new core_1.PxpError(400, 'Error processing flow: ' + flowInstanceIdProcessing);
80
75
  }
81
- flag.value = 'false';
82
- await (0, typeorm_1.getManager)().save(flag);
76
+ });
77
+ if (!acquired) {
78
+ return { success: true, skipped: true, reason: 'already running' };
83
79
  }
84
80
  return { success: true };
85
81
  }
@@ -115,7 +111,7 @@ class FlowInstance extends core_1.Controller {
115
111
  log.errorCustomMessage = `Error processing event ${newEvent.eventId} in FlowInstance-> eventProcesss `;
116
112
  log.errorMessage = error.message;
117
113
  log.stackTrace = error.stack || '';
118
- (0, typeorm_1.getManager)().save(log);
114
+ await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
119
115
  }
120
116
  }
121
117
  }
@@ -13,6 +13,7 @@
13
13
  * Last modified :
14
14
  * ISSUE DATE AUTHOR DESCRIPTION
15
15
  * 25-Apr-2025 8914754251 Mercedes Zambrana Validate and use node?.action?.controllerFunction
16
+ * 08-Oct-2026 EF-23 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
16
17
  */
17
18
  import { EntityManager } from "typeorm";
18
19
  import { Controller } from "@pxp-nd/core";
@@ -14,6 +14,7 @@
14
14
  * Last modified :
15
15
  * ISSUE DATE AUTHOR DESCRIPTION
16
16
  * 25-Apr-2025 8914754251 Mercedes Zambrana Validate and use node?.action?.controllerFunction
17
+ * 08-Oct-2026 EF-23 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
17
18
  */
18
19
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
20
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -40,6 +41,7 @@ const ActionType_1 = __importDefault(require("../entity/ActionType"));
40
41
  const Action_1 = __importDefault(require("../entity/Action"));
41
42
  const lodash_1 = __importDefault(require("lodash"));
42
43
  const FlowInstance_1 = __importDefault(require("../entity/FlowInstance"));
44
+ const dbLock_1 = require("../helpers/dbLock");
43
45
  //TODO replace this flow-nd need tobe independent library
44
46
  const common_1 = require("@pxp-nd/common");
45
47
  let NodeInstance = class NodeInstance extends core_1.Controller {
@@ -101,6 +103,7 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
101
103
  "Content-Type": "application/json",
102
104
  },
103
105
  data: mergeJson,
106
+ timeout: Number(process.env.FLOW_ACTION_TIMEOUT_MS || 30000),
104
107
  };
105
108
  const resControllerAxios = await (0, core_1.__)((0, axios_1.default)(config));
106
109
  if ('data' in resControllerAxios && resControllerAxios.data) {
@@ -120,6 +123,7 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
120
123
  "Content-Type": "application/json",
121
124
  },
122
125
  data: mergeJson,
126
+ timeout: Number(process.env.FLOW_ACTION_TIMEOUT_MS || 30000),
123
127
  };
124
128
  const resControllerAxios = await (0, core_1.__)((0, axios_1.default)(config));
125
129
  if ('data' in resControllerAxios && resControllerAxios.data) {
@@ -152,21 +156,13 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
152
156
  log.errorCustomMessage = `Error executing ${flowInstance.flowInstanceId} in NodeInstance-> RecursiveInstance `;
153
157
  log.errorMessage = error.message;
154
158
  log.stackTrace = error.stack || '';
155
- (0, typeorm_1.getManager)().save(log);
159
+ await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
156
160
  }
157
161
  return {};
158
162
  }
159
163
  async ProcessDelay(params, manager) {
160
- const flag = await common_1.GlobalData.findOne({
161
- data: "wf_processing_delay_nodes_flag",
162
- });
163
- if (flag) {
164
- if (flag.value == "true") {
165
- throw new core_1.PxpError(400, "Already processing pending flows");
166
- }
167
- flag.value = "true";
168
- await (0, typeorm_1.getManager)().save(flag);
169
- let nodeInstanceIdProcessing = -1;
164
+ let nodeInstanceIdProcessing = -1;
165
+ const { acquired } = await (0, dbLock_1.withDbLock)("wf_process_delay", async () => {
170
166
  try {
171
167
  const maxNodes = await common_1.GlobalData.findOne({
172
168
  data: "wf_max_concurrent_delay_process",
@@ -216,12 +212,12 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
216
212
  }
217
213
  }
218
214
  catch (error) {
219
- flag.value = "false";
220
- await (0, typeorm_1.getManager)().save(flag);
215
+ // The lock is released by withDbLock's finally block
221
216
  throw new core_1.PxpError(400, "Error processing node: " + nodeInstanceIdProcessing);
222
217
  }
223
- flag.value = "false";
224
- await (0, typeorm_1.getManager)().save(flag);
218
+ });
219
+ if (!acquired) {
220
+ return { success: true, skipped: true, reason: "already running" };
225
221
  }
226
222
  return { success: true };
227
223
  }
@@ -0,0 +1,4 @@
1
+ export declare function withDbLock<T>(name: string, fn: () => Promise<T>): Promise<{
2
+ acquired: boolean;
3
+ result?: T;
4
+ }>;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withDbLock = void 0;
4
+ /**
5
+ * Named MySQL lock helper (GET_LOCK / RELEASE_LOCK).
6
+ *
7
+ * GET_LOCK is bound to the MySQL session, so the lock is taken and released on the
8
+ * same dedicated QueryRunner. Never use a transaction manager or getManager() here:
9
+ * those may run on a different pooled connection.
10
+ *
11
+ * ISSUE DATE AUTHOR DESCRIPTION
12
+ * EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
13
+ */
14
+ const typeorm_1 = require("typeorm");
15
+ async function withDbLock(name, fn) {
16
+ var _a, _b, _c, _d;
17
+ const qr = (0, typeorm_1.getConnection)(process.env.DB_WRITE_CONNECTION_NAME).createQueryRunner();
18
+ await qr.connect();
19
+ let got = 0;
20
+ try {
21
+ // GET_LOCK names are server-wide; prefix with the schema so environments sharing a MySQL instance do not block each other
22
+ const rows = await qr.query("SELECT GET_LOCK(CONCAT(DATABASE(), ':', ?), 0) AS got", [name]);
23
+ got = Number((_b = (_a = rows === null || rows === void 0 ? void 0 : rows[0]) === null || _a === void 0 ? void 0 : _a.got) !== null && _b !== void 0 ? _b : 0);
24
+ if (got !== 1)
25
+ return { acquired: false };
26
+ const result = await fn();
27
+ return { acquired: true, result };
28
+ }
29
+ finally {
30
+ if (got === 1) {
31
+ const rel = await qr
32
+ .query("SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':', ?)) AS rel", [name])
33
+ .catch(() => [{ rel: 0 }]);
34
+ if (Number((_d = (_c = rel === null || rel === void 0 ? void 0 : rel[0]) === null || _c === void 0 ? void 0 : _c.rel) !== null && _d !== void 0 ? _d : 0) !== 1) {
35
+ // Lock could not be released on this session: drop the physical connection
36
+ // so MySQL frees the lock instead of returning it to the pool still held.
37
+ const c = qr.databaseConnection;
38
+ if (c === null || c === void 0 ? void 0 : c.destroy)
39
+ c.destroy();
40
+ }
41
+ }
42
+ await qr.release().catch(() => { });
43
+ }
44
+ }
45
+ exports.withDbLock = withDbLock;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pxp-nd/flow",
3
- "version": "1.1.27",
3
+ "version": "1.1.30",
4
4
  "description": "Workflow module for pxp-nd framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",