@rytass/bpm-core-nestjs-module 0.16.2 → 0.17.0
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.
- package/CHANGELOG.md +52 -0
- package/README.md +415 -261
- package/package.json +2 -2
- package/src/lib/delegation/delegation-rule.entity.js +3 -1
- package/src/lib/delegation/delegation-rule.entity.js.map +1 -1
- package/src/lib/delegation/delegation.service.d.ts +4 -0
- package/src/lib/delegation/delegation.service.js +19 -3
- package/src/lib/delegation/delegation.service.js.map +1 -1
- package/src/lib/delegation/dto/delegation-rule.input.js +6 -2
- package/src/lib/delegation/dto/delegation-rule.input.js.map +1 -1
- package/src/lib/migrations/0000000000001-enable-postgres-extensions.js +6 -2
- package/src/lib/migrations/0000000000001-enable-postgres-extensions.js.map +1 -1
- package/src/lib/notification/notification-options.d.ts +5 -5
- package/src/lib/notification/notification.service.js +5 -2
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/organization/index.d.ts +1 -0
- package/src/lib/organization/index.js +1 -0
- package/src/lib/organization/index.js.map +1 -1
- package/src/lib/organization/org-unit-retirement-impact.object.d.ts +18 -0
- package/src/lib/organization/org-unit-retirement-impact.object.js +34 -0
- package/src/lib/organization/org-unit-retirement-impact.object.js.map +1 -0
- package/src/lib/organization/organization.mutations.d.ts +1 -1
- package/src/lib/organization/organization.mutations.js +7 -4
- package/src/lib/organization/organization.mutations.js.map +1 -1
- package/src/lib/organization/organization.queries.d.ts +2 -0
- package/src/lib/organization/organization.queries.js +13 -0
- package/src/lib/organization/organization.queries.js.map +1 -1
- package/src/lib/organization/organization.service.d.ts +32 -1
- package/src/lib/organization/organization.service.js +157 -16
- package/src/lib/organization/organization.service.js.map +1 -1
- package/src/lib/template/template.service.js +91 -57
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +11 -0
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +7 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +34 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.d.ts +8 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.js +39 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.js.map +1 -0
- package/src/lib/workflow-engine/index.d.ts +3 -0
- package/src/lib/workflow-engine/index.js +3 -0
- package/src/lib/workflow-engine/index.js.map +1 -1
- package/src/lib/workflow-engine/task-reassignment.object.d.ts +26 -0
- package/src/lib/workflow-engine/task-reassignment.object.js +97 -0
- package/src/lib/workflow-engine/task-reassignment.object.js.map +1 -0
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +29 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js +42 -1
- package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +5 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js +48 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +2 -0
- package/src/lib/workflow-engine/workflow-engine.queries.js +15 -0
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +150 -1
- package/src/lib/workflow-engine/workflow-engine.service.js +716 -45
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +2 -1
- package/src/lib/workflow-engine/workflow-engine.tokens.js.map +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ and keeps transport details and credentials out of form JSON.
|
|
|
21
21
|
|
|
22
22
|
## Package Status
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
The released versions and their changes are listed in
|
|
25
|
+
[CHANGELOG.md](./CHANGELOG.md). Sections marked **unreleased** describe code
|
|
26
|
+
that is on `main` but not in any published version yet.
|
|
25
27
|
|
|
26
28
|
The package is intended for NestJS backend hosts. It does not include the
|
|
27
29
|
Next.js backoffice UI and does not provide a production auth system by itself.
|
|
@@ -68,7 +70,10 @@ Your host application must provide:
|
|
|
68
70
|
- A configured TypeORM `DataSource` / `TypeOrmModule`.
|
|
69
71
|
- Auth/session/JWT logic.
|
|
70
72
|
- A `BPMAuthContext` bridge from the request execution context.
|
|
71
|
-
- A
|
|
73
|
+
- A member resolver for member metadata lookup (`memberResolverProvider`, or
|
|
74
|
+
the `BPM_MEMBER_RESOLVER` provider it registers). It is optional: without one,
|
|
75
|
+
BPM uses `DefaultBPMMemberResolver`, which shows raw member ids as names and
|
|
76
|
+
empty emails. That keeps a new host booting; it is not an identity system.
|
|
72
77
|
- Attachment storage configuration when local storage is not acceptable.
|
|
73
78
|
- A dedicated worker or host dispatcher when email/webhook/SLA work should not
|
|
74
79
|
run inside API replicas.
|
|
@@ -128,7 +133,7 @@ import { HostSessionService } from './host-session.service';
|
|
|
128
133
|
sortSchema: true,
|
|
129
134
|
}),
|
|
130
135
|
}),
|
|
131
|
-
|
|
136
|
+
BPMRootModule.forRoot({
|
|
132
137
|
imports: [HostAuthModule],
|
|
133
138
|
authContextFactory: buildBPMAuthContextFromExecutionContext,
|
|
134
139
|
memberResolverProvider: {
|
|
@@ -185,21 +190,13 @@ BPM also exports param decorators so host resolvers don't need to call
|
|
|
185
190
|
`authContextFactory` directly:
|
|
186
191
|
|
|
187
192
|
```ts
|
|
188
|
-
import {
|
|
189
|
-
BPMAuthenticated,
|
|
190
|
-
BPMCurrentAuthContext,
|
|
191
|
-
BPMCurrentMemberId,
|
|
192
|
-
type BPMAuthContext,
|
|
193
|
-
} from '@rytass/bpm-core-nestjs-module';
|
|
193
|
+
import { BPMAuthenticated, BPMCurrentAuthContext, BPMCurrentMemberId, type BPMAuthContext } from '@rytass/bpm-core-nestjs-module';
|
|
194
194
|
|
|
195
195
|
@Resolver()
|
|
196
196
|
class HostResolver {
|
|
197
197
|
@Query(() => HostSummary)
|
|
198
198
|
@BPMAuthenticated()
|
|
199
|
-
hostSummary(
|
|
200
|
-
@BPMCurrentAuthContext() auth: BPMAuthContext,
|
|
201
|
-
@BPMCurrentMemberId() memberId: string,
|
|
202
|
-
): HostSummary {
|
|
199
|
+
hostSummary(@BPMCurrentAuthContext() auth: BPMAuthContext, @BPMCurrentMemberId() memberId: string): HostSummary {
|
|
203
200
|
return buildHostSummary(auth, memberId);
|
|
204
201
|
}
|
|
205
202
|
}
|
|
@@ -224,11 +221,11 @@ interface ApiMember {
|
|
|
224
221
|
|
|
225
222
|
Status-code contract:
|
|
226
223
|
|
|
227
|
-
| Endpoint
|
|
228
|
-
|
|
|
229
|
-
| `POST /auth/login`
|
|
230
|
-
| `GET /auth/me`
|
|
231
|
-
| `POST /auth/logout`
|
|
224
|
+
| Endpoint | Success | Failure |
|
|
225
|
+
| ------------------- | ------------------------ | ---------------------------------------------------------------- |
|
|
226
|
+
| `POST /auth/login` | `200` + `ApiMember` JSON | `401` (bad credentials) / `400` (validation) |
|
|
227
|
+
| `GET /auth/me` | `200` + `ApiMember` JSON | `401` (no session — React client treats as anonymous, not error) |
|
|
228
|
+
| `POST /auth/logout` | `204` (or `200` empty) | `401` ignored |
|
|
232
229
|
|
|
233
230
|
Session lifetime is owned by the host. The React `<AuthProvider>`
|
|
234
231
|
relies on `credentials: 'include'` plus an HTTP-only cookie issued by
|
|
@@ -284,10 +281,10 @@ and compute `expiresAt` from the token TTL.
|
|
|
284
281
|
function projectMemberToApiMember(member: HostMember): ApiMember {
|
|
285
282
|
return {
|
|
286
283
|
memberId: member.id,
|
|
287
|
-
email: member.email ?? '',
|
|
284
|
+
email: member.email ?? '', // ApiMember.email is `string`, not `string | null`
|
|
288
285
|
name: member.name,
|
|
289
286
|
roles: projectHostRolesToBPMRoles(member.roles), // [] if no admin/designer claim
|
|
290
|
-
permissions: [],
|
|
287
|
+
permissions: [], // optional — return [] if not used
|
|
291
288
|
expiresAt: new Date(Date.now() + JWT_TTL_MS).toISOString(),
|
|
292
289
|
};
|
|
293
290
|
}
|
|
@@ -295,16 +292,19 @@ function projectMemberToApiMember(member: HostMember): ApiMember {
|
|
|
295
292
|
|
|
296
293
|
Response status contract that BPM's React client expects:
|
|
297
294
|
|
|
298
|
-
| Endpoint
|
|
299
|
-
|
|
300
|
-
| `POST /auth/login`
|
|
301
|
-
| `GET /auth/me`
|
|
302
|
-
| `POST /auth/logout` | `204` (or `200` with empty body)
|
|
295
|
+
| Endpoint | Success | Auth fail | Validation fail |
|
|
296
|
+
| ------------------- | ---------------------------------------------- | ------------------------------------------------- | --------------- |
|
|
297
|
+
| `POST /auth/login` | `200` + `ApiMember` JSON, set HTTP-only cookie | `401` | `400` |
|
|
298
|
+
| `GET /auth/me` | `200` + `ApiMember` JSON | `401` (client treats as anonymous, **not error**) | n/a |
|
|
299
|
+
| `POST /auth/logout` | `204` (or `200` with empty body) | `401` ignored — client clears local state anyway | n/a |
|
|
303
300
|
|
|
304
301
|
`roles` and `permissions` MUST be arrays (use `[]` for no claim), not
|
|
305
302
|
`null` or `undefined` — the React client's `member.roles.includes(...)`
|
|
306
303
|
checks would crash otherwise.
|
|
307
304
|
|
|
305
|
+
The table above is the contract the React client needs. The reference host
|
|
306
|
+
(`apps/api`) answers `POST /auth/logout` with `200` and `{ "ok": true }`.
|
|
307
|
+
|
|
308
308
|
#### Pattern B — Run BPM on a separate auth host
|
|
309
309
|
|
|
310
310
|
When BPM lives on its own subdomain (e.g. `bpm.example.com`) separate
|
|
@@ -389,11 +389,7 @@ registers one versioned catalog provider; a form stores only the source key,
|
|
|
389
389
|
version, and field/constant bindings:
|
|
390
390
|
|
|
391
391
|
```ts
|
|
392
|
-
import {
|
|
393
|
-
BPM_FORM_DATA_SOURCE_REGISTRY,
|
|
394
|
-
BPM_MEMBER_RESOLVER,
|
|
395
|
-
BPMRootModule,
|
|
396
|
-
} from '@rytass/bpm-core-nestjs-module';
|
|
392
|
+
import { BPM_FORM_DATA_SOURCE_REGISTRY, BPM_MEMBER_RESOLVER, BPMRootModule } from '@rytass/bpm-core-nestjs-module';
|
|
397
393
|
import { HostFormDataSourceRegistry } from './host-form-data-source.registry';
|
|
398
394
|
|
|
399
395
|
BPMRootModule.forRoot({
|
|
@@ -472,22 +468,20 @@ import { HostAuthProvider } from '@your-host/auth';
|
|
|
472
468
|
|
|
473
469
|
export default function RootLayout({ children }) {
|
|
474
470
|
return (
|
|
475
|
-
<html
|
|
476
|
-
<
|
|
477
|
-
|
|
471
|
+
<html>
|
|
472
|
+
<body>
|
|
473
|
+
<HostAuthProvider>{children}</HostAuthProvider>
|
|
474
|
+
</body>
|
|
475
|
+
</html>
|
|
478
476
|
);
|
|
479
477
|
}
|
|
480
478
|
|
|
481
479
|
// app/operations/approval/layout.tsx — BPM sub-tree
|
|
482
|
-
'use client';
|
|
480
|
+
('use client');
|
|
483
481
|
import { BPMNextProviders } from '@rytass/bpm-core-react/next';
|
|
484
482
|
|
|
485
483
|
export default function ApprovalLayout({ children }) {
|
|
486
|
-
return
|
|
487
|
-
<BPMNextProviders loginPath="/login">
|
|
488
|
-
{children}
|
|
489
|
-
</BPMNextProviders>
|
|
490
|
-
);
|
|
484
|
+
return <BPMNextProviders loginPath="/login">{children}</BPMNextProviders>;
|
|
491
485
|
}
|
|
492
486
|
```
|
|
493
487
|
|
|
@@ -511,11 +505,11 @@ exact-string matching. The host must inject one of the following strings to
|
|
|
511
505
|
clear admin / designer guards; otherwise BPM resolvers reject with
|
|
512
506
|
`ForbiddenException`.
|
|
513
507
|
|
|
514
|
-
| Tier
|
|
515
|
-
|
|
|
516
|
-
| Admin
|
|
517
|
-
| Designer
|
|
518
|
-
| Authenticated
|
|
508
|
+
| Tier | Accepted roles or permissions | Guards / decorators / helpers |
|
|
509
|
+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
510
|
+
| Admin | `roles: ['BPM_ADMIN']`; or `permissions` contains one of `bpm:*`, `bpm:admin`, `bpm.admin`, `bpm:admin:*` | `BPMAdminGuard`, `@BPMAdminOnly()`, `isBPMAdmin(authContext)` |
|
|
511
|
+
| Designer | Includes admin; or `roles: ['BPM_DESIGNER']`; or `permissions` contains one of `bpm:design`, `bpm.design`, `bpm.form.design`, `bpm.template.design`, `bpm:form:design`, `bpm:template:design` | `BPMDesignerGuard`, `@BPMDesignerOnly()`, `isBPMDesigner(authContext)` |
|
|
512
|
+
| Authenticated | `BPMAuthContext.memberId` is non-empty | `BPMAuthenticatedGuard`, `@BPMAuthenticated()` |
|
|
519
513
|
|
|
520
514
|
`@BPMAdminOnly()` and `@BPMDesignerOnly()` already chain
|
|
521
515
|
`BPMAuthenticatedGuard`. The host does not need to add `@BPMAuthenticated()`
|
|
@@ -555,9 +549,7 @@ export class BPMAuthContextFactory {
|
|
|
555
549
|
|
|
556
550
|
// 2. Translate into BPM's exact-string literals. Unknown host roles
|
|
557
551
|
// do nothing — BPM only checks for the documented strings.
|
|
558
|
-
const bpmRoles = hostRoles
|
|
559
|
-
.map((r) => HOST_TO_BPM_ROLES[r])
|
|
560
|
-
.filter((r): r is string => Boolean(r));
|
|
552
|
+
const bpmRoles = hostRoles.map((r) => HOST_TO_BPM_ROLES[r]).filter((r): r is string => Boolean(r));
|
|
561
553
|
|
|
562
554
|
return {
|
|
563
555
|
memberId: member.id,
|
|
@@ -590,9 +582,7 @@ writes — and `@rytass/member-base-nestjs-module`'s `CasbinGuard` rejects any
|
|
|
590
582
|
route without `@CheckPermission`:
|
|
591
583
|
|
|
592
584
|
```json
|
|
593
|
-
{ "errors": [{ "message": "Route has no permission metadata",
|
|
594
|
-
"path": ["orgUnits"],
|
|
595
|
-
"extensions": { "code": "FORBIDDEN" } }] }
|
|
585
|
+
{ "errors": [{ "message": "Route has no permission metadata", "path": ["orgUnits"], "extensions": { "code": "FORBIDDEN" } }] }
|
|
596
586
|
```
|
|
597
587
|
|
|
598
588
|
Every BPM query and mutation fails this way, before BPM's guards run at all.
|
|
@@ -615,7 +605,7 @@ const resolverMetadataFactory: BPMResolverMetadataFactory = ({ access }) => ({
|
|
|
615
605
|
[PERMISSION_METADATA_KEY]: HOST_PERMISSIONS[access],
|
|
616
606
|
});
|
|
617
607
|
|
|
618
|
-
BPMRootModule.forRoot({ resolverMetadataFactory
|
|
608
|
+
BPMRootModule.forRoot({ resolverMetadataFactory /* ... */ });
|
|
619
609
|
```
|
|
620
610
|
|
|
621
611
|
The factory is called once per handler and receives `{ access, resolverName,
|
|
@@ -641,7 +631,9 @@ console.table([...listBPMResolverHandlers()]);
|
|
|
641
631
|
## Member Resolver
|
|
642
632
|
|
|
643
633
|
The package resolves display names, email addresses, and approver candidates
|
|
644
|
-
through `BPM_MEMBER_RESOLVER`.
|
|
634
|
+
through `BPM_MEMBER_RESOLVER`. Registering one is optional: without it BPM
|
|
635
|
+
falls back to `DefaultBPMMemberResolver` (see Runtime Responsibilities), which
|
|
636
|
+
logs a warning outside `development` and `test`.
|
|
645
637
|
|
|
646
638
|
For a member-base-like directory, prefer the package helper. It creates a Nest
|
|
647
639
|
provider without forcing BPM to depend on the host identity package:
|
|
@@ -721,11 +713,11 @@ export interface BPMMemberResolver {
|
|
|
721
713
|
|
|
722
714
|
BPM is the **sole authority** for four organization tables:
|
|
723
715
|
|
|
724
|
-
| Table
|
|
725
|
-
|
|
726
|
-
| `org_units`
|
|
727
|
-
| `positions`
|
|
728
|
-
| `memberships`
|
|
716
|
+
| Table | Owns |
|
|
717
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
718
|
+
| `org_units` | Department / division / company nodes (typed by `OrgUnitType`) |
|
|
719
|
+
| `positions` | Job titles, with optional org-unit scoping |
|
|
720
|
+
| `memberships` | Member × org-unit × position relations (the join table that puts a member at a position inside a unit) |
|
|
729
721
|
| `manager_resolutions` | "Who is whose manager" rules (per-member, per-org-unit, or per-position scope), consumed by the `ORG_MANAGER` approver resolver |
|
|
730
722
|
|
|
731
723
|
There is **no** host-injectable `OrgUnitResolver` / `PositionResolver` /
|
|
@@ -738,8 +730,9 @@ exist, and the rest of this section explains why and what to do instead.
|
|
|
738
730
|
The BPM workflow engine reads the org graph on hot paths:
|
|
739
731
|
|
|
740
732
|
- **Approver routing** — every running instance evaluates `UserTaskNode`
|
|
741
|
-
candidates (resolved via `DIRECT`, `POSITION`, `
|
|
742
|
-
`
|
|
733
|
+
candidates (resolved via `DIRECT`, `POSITION`, `ORG_UNIT_MEMBER`,
|
|
734
|
+
`ORG_UNIT_POSITION`, `ORG_MANAGER`, `ORG_UNIT_MANAGER`, or
|
|
735
|
+
`ORG_MANAGER_CHAIN`) against the current org snapshot. A `ParallelGateway`
|
|
743
736
|
fanning out to "all managers in the IT division" may touch dozens of
|
|
744
737
|
membership rows in one step.
|
|
745
738
|
- **Tree-diff commits** — `commitOrgUnitTreeDraft` writes batched moves
|
|
@@ -774,17 +767,58 @@ and observable:
|
|
|
774
767
|
pagination payload. **For reconciliation, always key on `code`**
|
|
775
768
|
(which IS returned). Treat `metadataJson` as a debugging/audit
|
|
776
769
|
stash, not a live FK pointer.
|
|
777
|
-
3.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
`
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
770
|
+
3. **`deleteOrgUnit` is a soft delete, with a hard-delete exception.** It sets `deletedAt`, and
|
|
771
|
+
BPM's query layer hides soft-deleted units automatically; reading
|
|
772
|
+
`orgUnitCount()` reports the live count. Retiring a unit with a successor
|
|
773
|
+
(see "Retiring an org unit", unreleased) also hard-deletes not-yet-started
|
|
774
|
+
memberships that duplicate a row in the successor, and not-yet-started
|
|
775
|
+
`ORG_UNIT` manager rules. `deleteMembership`,
|
|
776
|
+
`deleteManagerResolution` and `deletePosition` are **hard deletes** —
|
|
777
|
+
those entities have no `deletedAt` column, so the row and its history
|
|
778
|
+
are gone.
|
|
779
|
+
4. **End a membership or manager rule with `effectiveTo`, don't delete
|
|
780
|
+
it.** To keep "who was in this unit / who managed whom, and when",
|
|
781
|
+
call `updateMembership` / `updateManagerResolution` with `effectiveTo`
|
|
782
|
+
set to the last day the row should still apply. `effectiveTo` is
|
|
783
|
+
inclusive, and "today" is read in `organizationTimeZone` (default
|
|
784
|
+
`Asia/Taipei`), so a row ending yesterday stops applying today. Keep
|
|
785
|
+
the delete methods for rows written by mistake.
|
|
786
|
+
5. **Positions are deleted only when nothing points at them.**
|
|
787
|
+
`deletePosition` refuses while any membership or manager resolution
|
|
788
|
+
(ended ones included) references the position, or while a template or
|
|
789
|
+
a `PENDING` ad-hoc directive routes approvers through it. Since the
|
|
790
|
+
delete is permanent, retire a position by ending the memberships that
|
|
791
|
+
use it and leaving the position in place; rename it (for example
|
|
792
|
+
`updatePosition({ id, name: 'retired-' + oldName, ... })`) if you want
|
|
793
|
+
a UI hint that it is no longer in use.
|
|
794
|
+
|
|
795
|
+
### Retiring an org unit (unreleased)
|
|
796
|
+
|
|
797
|
+
> Not in a published version yet. It landed after `v0.16.2`.
|
|
798
|
+
|
|
799
|
+
`deleteOrgUnit(id, { transferToOrgUnitId })` retires a unit. The GraphQL
|
|
800
|
+
mutation takes the same two values as arguments (`deleteOrgUnit(id,
|
|
801
|
+
transferToOrgUnitId)`), and the client takes `deleteOrgUnit(id, { transferToOrgUnitId })`.
|
|
802
|
+
|
|
803
|
+
- A unit with live child units is always refused.
|
|
804
|
+
- A unit with open memberships or open `ORG_UNIT` manager rules is refused,
|
|
805
|
+
unless `transferToOrgUnitId` names a different live unit.
|
|
806
|
+
- With a successor, one transaction runs:
|
|
807
|
+
- a membership that has already started is ended yesterday and continued in
|
|
808
|
+
the successor from today, so "who was in this unit" survives;
|
|
809
|
+
- a membership that starts today or later is moved to the successor as it
|
|
810
|
+
is, unless the successor already has that row, in which case it is
|
|
811
|
+
hard-deleted;
|
|
812
|
+
- an `ORG_UNIT` manager rule that has already started is ended yesterday;
|
|
813
|
+
one that has not started is hard-deleted. The successor keeps its own
|
|
814
|
+
managers.
|
|
815
|
+
- The unit is then soft-deleted. Ended memberships and manager rules never
|
|
816
|
+
block the delete.
|
|
817
|
+
|
|
818
|
+
Preview what a retirement would have to handle with the
|
|
819
|
+
`orgUnitRetirementImpact(id)` query, which returns `childUnitCount`,
|
|
820
|
+
`openMembershipCount` and `openManagerResolutionCount`. The client exposes the
|
|
821
|
+
same data as `readOrgUnitRetirementImpact(id)`.
|
|
788
822
|
|
|
789
823
|
### Worked example: idempotent sync
|
|
790
824
|
|
|
@@ -797,49 +831,39 @@ input shapes — no `{id, input: {...}}` wrapping):
|
|
|
797
831
|
- `readOrganizationDashboard({ orgUnitPageSize, orgUnitSearchText, ... })`
|
|
798
832
|
- `createOrgUnit({ code, name, type, parentId, metadataJson })`
|
|
799
833
|
- `updateOrgUnit({ id, code, name, type, parentId, metadataJson })`
|
|
800
|
-
- `deleteOrgUnit(id)` (soft
|
|
834
|
+
- `deleteOrgUnit(id)` (soft delete; refused while open memberships or ORG_UNIT manager rules remain, unless `transferToOrgUnitId` is given — unreleased, see "Retiring an org unit")
|
|
801
835
|
- `commitOrgUnitTreeDraft({ moves: { id, parentId, baseUpdatedAt }[] })` (transactional batch moves — each entry's `baseUpdatedAt` is the row's last-known `updatedAt`, used by BPM for optimistic-locking conflict detection)
|
|
802
|
-
- `createPosition({ code, name, level, metadataJson })` — `level` (1+)
|
|
836
|
+
- `createPosition({ code, name, level, metadataJson })` — `level` (1+) is the position grade; higher is more senior. The `ORG_MANAGER_CHAIN` resolver reads it: it walks up the manager chain, adds only managers whose grade is higher than the last one added, and stops at the first grade reaching `minPositionLevel`. Manager-rule `priority` does not use it. `metadataJson` is required (pass `'{}'` if unused)
|
|
803
837
|
- `updatePosition({ id, code, name, level, metadataJson })` — every field except `id` is `T | null`; pass `null` to leave a field unchanged
|
|
804
|
-
- `createMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — `positionId` and `effectiveTo` accept `null`; `effectiveFrom`
|
|
805
|
-
- `updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — every field except `id` is `T | null`
|
|
806
|
-
- `createManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })` — `scopeType` is `'MEMBER' | 'ORG_UNIT' | 'POSITION'`; `priority` orders the
|
|
807
|
-
- `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId)`)
|
|
808
|
-
- `createManagerResolution` / `updateManagerResolution`
|
|
838
|
+
- `createMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — `positionId` and `effectiveTo` accept `null`; `effectiveFrom` / `effectiveTo` are `YYYY-MM-DD` date strings (`effectiveTo` is inclusive). Uniqueness key is `(memberId, orgUnitId, positionId, effectiveFrom)`
|
|
839
|
+
- `updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — every field except `id` is `T | null`. Passing `null` leaves `orgUnitId`, `isPrimary` and `effectiveFrom` unchanged, but clears `positionId` and `effectiveTo` (an open-ended row)
|
|
840
|
+
- `createManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })` — `scopeType` is `'MEMBER' | 'ORG_UNIT' | 'POSITION'`; `priority` orders the rules: the highest number is checked first, and every rule that ties with the top priority and scope type applies. `manager_resolutions` has no unique constraint, so several rules for one scope can coexist
|
|
841
|
+
- `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId, effectiveFrom)`)
|
|
842
|
+
- `createManagerResolution` / `updateManagerResolution` — `updateManagerResolution` follows the same `null` rule: `null` clears `effectiveTo` and keeps the other fields
|
|
809
843
|
|
|
810
844
|
```ts
|
|
811
|
-
import {
|
|
812
|
-
readOrganizationDashboard,
|
|
813
|
-
createOrgUnit,
|
|
814
|
-
updateOrgUnit,
|
|
815
|
-
type OrgUnitRecord,
|
|
816
|
-
} from '@rytass/bpm-core-client/organization';
|
|
845
|
+
import { readOrganizationDashboard, createOrgUnit, updateOrgUnit, type OrgUnitRecord } from '@rytass/bpm-core-client/organization';
|
|
817
846
|
import type { OrgUnitType } from '@rytass/bpm-core-shared';
|
|
818
847
|
|
|
819
848
|
interface HostOrgUnit {
|
|
820
|
-
readonly hostId: string;
|
|
821
|
-
readonly code: string;
|
|
849
|
+
readonly hostId: string; // e.g. ERP primary key — your host-FK
|
|
850
|
+
readonly code: string; // stable across sync runs
|
|
822
851
|
readonly name: string;
|
|
823
|
-
readonly type: OrgUnitType;
|
|
852
|
+
readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
|
|
824
853
|
readonly parentCode: string | null;
|
|
825
854
|
}
|
|
826
855
|
|
|
827
|
-
async function syncOrgTree(
|
|
828
|
-
hostNodes: readonly HostOrgUnit[],
|
|
829
|
-
): Promise<void> {
|
|
856
|
+
async function syncOrgTree(hostNodes: readonly HostOrgUnit[]): Promise<void> {
|
|
830
857
|
// 1. Single round trip: load every existing BPM org unit, index by code.
|
|
831
858
|
const dashboard = await readOrganizationDashboard({ orgUnitPageSize: null });
|
|
832
|
-
const byCode = new Map<string, OrgUnitRecord>(
|
|
833
|
-
dashboard.orgUnits.map((unit) => [unit.code, unit]),
|
|
834
|
-
);
|
|
859
|
+
const byCode = new Map<string, OrgUnitRecord>(dashboard.orgUnits.map((unit) => [unit.code, unit]));
|
|
835
860
|
|
|
836
861
|
// 2. Topo-sort by parent so a parent always exists before its child
|
|
837
862
|
// is upserted (createOrgUnit needs the parentId to be valid).
|
|
838
863
|
const ordered = topoSortByParent(hostNodes);
|
|
839
864
|
|
|
840
865
|
for (const host of ordered) {
|
|
841
|
-
const parentId =
|
|
842
|
-
host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
|
|
866
|
+
const parentId = host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
|
|
843
867
|
const metadataJson = JSON.stringify({ hostId: host.hostId });
|
|
844
868
|
const existing = byCode.get(host.code);
|
|
845
869
|
|
|
@@ -866,8 +890,9 @@ async function syncOrgTree(
|
|
|
866
890
|
```
|
|
867
891
|
|
|
868
892
|
The same pattern works for `Position` (key on `code`), `Membership`
|
|
869
|
-
(unique by `memberId × orgUnitId × positionId`), and
|
|
870
|
-
`ManagerResolution` (
|
|
893
|
+
(unique by `memberId × orgUnitId × positionId × effectiveFrom`), and
|
|
894
|
+
`ManagerResolution` (no database unique constraint: look up existing rules by
|
|
895
|
+
scope and manager before inserting). For bulk
|
|
871
896
|
tree-shape changes (multi-node moves under a single parent),
|
|
872
897
|
`commitOrgUnitTreeDraft` accepts the full draft in one transaction.
|
|
873
898
|
|
|
@@ -880,7 +905,7 @@ tree-shape changes (multi-node moves under a single parent),
|
|
|
880
905
|
> **Programmatic base URL.** Server-side scripts that aren't running
|
|
881
906
|
> under Next.js (cron workers, one-off seeds) cannot rely on
|
|
882
907
|
> `NEXT_PUBLIC_API_URL` resolution — call `configureBPMClient({ baseUrl,
|
|
883
|
-
|
|
908
|
+
fetch, headers })` from `@rytass/bpm-core-client` once at startup to
|
|
884
909
|
> point the transport at the right host and inject a session cookie or
|
|
885
910
|
> bearer token.
|
|
886
911
|
|
|
@@ -891,6 +916,29 @@ through `BPMMemberResolver` (see the previous section). Only the
|
|
|
891
916
|
**org structure** (who reports where, what unit they sit in, who manages
|
|
892
917
|
them) needs to live inside BPM.
|
|
893
918
|
|
|
919
|
+
## Approver Fallback
|
|
920
|
+
|
|
921
|
+
`ORG_MANAGER`, `ORG_UNIT_MANAGER` and `ORG_MANAGER_CHAIN` approver resolvers
|
|
922
|
+
take an optional `fallback`. It supplies approvers when the org chart yields
|
|
923
|
+
none:
|
|
924
|
+
|
|
925
|
+
- `ORG_MANAGER` and `ORG_UNIT_MANAGER`: the fallback is used only when no manager
|
|
926
|
+
is found.
|
|
927
|
+
- `ORG_MANAGER_CHAIN`: the chain walk collects managers until one reaches
|
|
928
|
+
`minPositionLevel`. If the walk ends before that, the fallback's members are
|
|
929
|
+
added after the managers already collected. The chain is not replaced. If the
|
|
930
|
+
chain is empty, the fallback alone supplies the approver. With no fallback, or
|
|
931
|
+
`NONE`, a walk that ends below the level fails the whole step with a conflict
|
|
932
|
+
error; the managers it did find are not used on their own.
|
|
933
|
+
|
|
934
|
+
Fallback types:
|
|
935
|
+
|
|
936
|
+
| Type | Behavior |
|
|
937
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
938
|
+
| `NONE` | No approver. The step raises a conflict error. An absent `fallback` behaves the same way. |
|
|
939
|
+
| `DIRECT` | A fixed `memberId`. If that member is the initiator, the step fails unless `allowInitiatorSelfApproval` is `true`. |
|
|
940
|
+
| `POSITION` | The current holders of `positionId`, minus the initiator unless `allowInitiatorSelfApproval` is `true`. If nobody remains, the step raises a conflict error. Not released yet (it landed after `v0.16.2`); older engines reject it at run time. |
|
|
941
|
+
|
|
894
942
|
## Root Module Configuration
|
|
895
943
|
|
|
896
944
|
Use `BPMRootModule.forRoot()` for static configuration or
|
|
@@ -934,57 +982,75 @@ BPMRootModule.forRootAsync({
|
|
|
934
982
|
|
|
935
983
|
### Configuration Reference
|
|
936
984
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
|
954
|
-
|
|
|
955
|
-
| `
|
|
956
|
-
| `
|
|
957
|
-
| `
|
|
958
|
-
| `
|
|
959
|
-
| `
|
|
960
|
-
| `
|
|
961
|
-
| `
|
|
962
|
-
| `
|
|
963
|
-
| `
|
|
964
|
-
| `
|
|
965
|
-
| `
|
|
966
|
-
| `
|
|
967
|
-
| `
|
|
968
|
-
| `
|
|
969
|
-
| `
|
|
970
|
-
| `
|
|
971
|
-
| `
|
|
972
|
-
| `
|
|
973
|
-
| `
|
|
974
|
-
| `
|
|
975
|
-
| `
|
|
976
|
-
| `
|
|
977
|
-
| `
|
|
978
|
-
| `
|
|
979
|
-
| `
|
|
980
|
-
| `
|
|
981
|
-
| `
|
|
982
|
-
| `
|
|
983
|
-
| `
|
|
984
|
-
| `
|
|
985
|
-
| `
|
|
986
|
-
| `
|
|
987
|
-
| `
|
|
985
|
+
Every option is optional, and `BPMRootModule.forRoot()` boots with the defaults
|
|
986
|
+
below.
|
|
987
|
+
|
|
988
|
+
Options that are Nest providers have a **runtime twin**: a ready instance on the
|
|
989
|
+
options object that a `forRootAsync` factory can return instead. Prefer the twin
|
|
990
|
+
when the value needs a secret, because the factory runs after secrets are
|
|
991
|
+
loaded. The twins are: `attachmentStorageProvider` / `attachmentStorage`,
|
|
992
|
+
`businessCalendarProvider` / `businessCalendar`,
|
|
993
|
+
`formDataSourceRegistryProvider` / `formDataSourceRegistry`,
|
|
994
|
+
`memberResolverProvider` / `memberResolver`,
|
|
995
|
+
`workflowServiceTaskDispatcherProvider` / `workflowServiceTaskDispatcher`, and
|
|
996
|
+
`workflowWebhookRegistryProvider` / `workflowWebhookRegistry`.
|
|
997
|
+
`resolverMetadataFactory`, `attachmentRoutePrefix` and `identityRegisterResolvers`
|
|
998
|
+
have no twin: Nest reads them while it builds routes and the schema, before any
|
|
999
|
+
factory runs.
|
|
1000
|
+
|
|
1001
|
+
| Option | Default | Description |
|
|
1002
|
+
| ------------------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1003
|
+
| `authContextFactory` | `undefined` | Reads `BPMAuthContext` from NestJS `ExecutionContext`. |
|
|
1004
|
+
| `memberResolverProvider` | `undefined` | Optional. Provider for `BPM_MEMBER_RESOLVER`; without it `DefaultBPMMemberResolver` is used. Runtime twin: `memberResolver`. |
|
|
1005
|
+
| `resolverMetadataFactory` | `undefined` | Stamps the host's own route metadata onto every BPM GraphQL handler. Required when the host installs a global metadata-driven guard — see "Coexisting with a host global guard". |
|
|
1006
|
+
| `attachmentStorageProvider` | local `.storage/attachments` | Host-provided `@rytass/storages` adapter. |
|
|
1007
|
+
| `workflowServiceTaskDispatcherProvider` | built-in `fetch` dispatcher | Host provider for executable workflow `WEBHOOK` service tasks. |
|
|
1008
|
+
| `workflowWebhookRegistryProvider` | Empty catalog | Host catalog of NOTIFY webhook endpoints (`BPMWorkflowWebhookRegistry`). Runtime twin: `workflowWebhookRegistry`. See "NOTIFY webhooks". |
|
|
1009
|
+
| `businessCalendarProvider` | Monday–Friday calendar | Host provider for `BPM_BUSINESS_CALENDAR`, used by `BUSINESS_DAY` SLAs. BPMCore ships no holiday data. |
|
|
1010
|
+
| `formDataSourceRegistryProvider` | Empty catalog | Single host provider for versioned Select / AutoComplete / Radio / Checkbox DataSources. |
|
|
1011
|
+
| `attachmentRoutePrefix` | `/attachments` | Drives both the BPM signed URL path and the Nest controller mount path for `AttachmentController`. Must be set at module wiring time (see "Attachment Storage" below). |
|
|
1012
|
+
| `attachmentStorageProviderId` | `local` | Value stored on attachment metadata for the active adapter. |
|
|
1013
|
+
| `attachmentPublicBaseUrl` | requesting origin | Public base URL for signed attachment URLs. Unset, each URL uses the origin of the GraphQL request that asked for it (`X-Forwarded-Proto` / `X-Forwarded-Host`, else the request's protocol and `Host`), so a host behind a proxy needs no setting. |
|
|
1014
|
+
| `attachmentSignedUrlSecret` | random per process | HMAC secret for signed attachment download/preview tokens. Unset, each process generates one at boot and warns on stderr; a URL then only verifies on the process that signed it, so set a shared secret before running more than one API replica. |
|
|
1015
|
+
| `attachmentAllowInsecureSignedUrlSecret` | — | Deprecated and ignored: no signing secret ships in the package any more. |
|
|
1016
|
+
| `attachmentSignedUrlTtlSeconds` | `300` | Signed attachment URL lifetime in seconds. |
|
|
1017
|
+
| `identityMemberMetadataCacheTtlMs` | `300000` | Member metadata cache TTL. |
|
|
1018
|
+
| `identityRegisterResolvers` | `true` | Set to `false` when the host already publishes `member` / `members` / `memberCount` / `searchMembers` GraphQL queries. `IdentityService` stays available either way. |
|
|
1019
|
+
| `organizationTimeZone` | `Asia/Taipei` | IANA zone in which BPM reads "today" when it compares against the date-only `effectiveFrom` / `effectiveTo` of memberships and manager resolutions (manager chains, direct and org-unit managers, position levels, the initiator snapshot, SLA escalation, the "active only" organization views). Set `'UTC'` if your organization dates are kept in UTC. An unknown zone makes BPM fail at startup. Independent of `notificationSlaBusinessCalendarTimeZone`. Before this option the workflow engine read UTC while `OrganizationService` read `Asia/Taipei`; both now follow it. |
|
|
1020
|
+
| `notificationInAppEnabled` | `true` | Enables in-app notification records. |
|
|
1021
|
+
| `notificationEmailEnabled` | `auto` | Enables email when SMTP settings are complete. |
|
|
1022
|
+
| `notificationEmailSmtpHost` | `null` | SMTP host. |
|
|
1023
|
+
| `notificationEmailSmtpPort` | `null` | SMTP port. |
|
|
1024
|
+
| `notificationEmailSmtpSecure` | `false` | `true` for implicit TLS, `false` for STARTTLS. |
|
|
1025
|
+
| `notificationEmailSmtpUsername` | `null` | SMTP username. |
|
|
1026
|
+
| `notificationEmailSmtpPassword` | `null` | SMTP password or app password. |
|
|
1027
|
+
| `notificationEmailFrom` | `null` | Email sender address. |
|
|
1028
|
+
| `notificationWebhookEnabled` | `auto` | Enables webhook when URL and signing secret are complete. |
|
|
1029
|
+
| `notificationWebhookEndpointUrl` | `null` | Default webhook endpoint URL. |
|
|
1030
|
+
| `notificationWebhookSigningSecret` | `null` | HMAC secret for webhook payload signatures. |
|
|
1031
|
+
| `notificationDeliverySchedulerEnabled` | `false` | Runs pending email/webhook delivery loop in this process. |
|
|
1032
|
+
| `notificationDeliveryScanIntervalMs` | `30000` | Delivery scheduler interval. |
|
|
1033
|
+
| `notificationDeliveryBatchSize` | `25` | Maximum pending notifications per delivery scan. |
|
|
1034
|
+
| `notificationDeliveryMaxAttempts` | `3` | Attempts before a notification is marked failed. |
|
|
1035
|
+
| `notificationDeliveryRetryBaseDelayMs` | `60000` | Base retry delay multiplied by attempt count. |
|
|
1036
|
+
| `notificationDeliveryDispatchTimeoutMs` | `30000` | Maximum time for one delivery dispatch before it is recorded as a failed attempt. `0` waits indefinitely. |
|
|
1037
|
+
| `notificationSlaSchedulerEnabled` | `false` | Runs automatic SLA scan loop in this process. |
|
|
1038
|
+
| `notificationSlaScanIntervalMs` | `60000` | SLA scheduler interval. |
|
|
1039
|
+
| `notificationSlaBusinessCalendarTimeZone` | `UTC` | IANA zone for the built-in weekday calendar. Ignored when `businessCalendarProvider` is set. |
|
|
1040
|
+
| `notificationSlaTimeoutRemindEnabled` | `true` | Enables SLA timeout `REMIND`. |
|
|
1041
|
+
| `notificationSlaTimeoutAutoApproveEnabled` | `false` | Enables SLA timeout `AUTO_APPROVE`. |
|
|
1042
|
+
| `notificationSlaTimeoutEscalateEnabled` | `false` | Enables SLA timeout `ESCALATE`. |
|
|
1043
|
+
| `notificationSlaTimeoutTerminateInstanceEnabled` | `false` | Enables SLA timeout `TERMINATE_INSTANCE`. |
|
|
1044
|
+
| `notificationTemplateEngine` | `simple` | `simple` only — `handlebars` is reserved and currently rendered as `simple`. |
|
|
1045
|
+
| `notificationDefaultChannels` | `[IN_APP]` | Fallback channels when a workflow node has no channel list. |
|
|
1046
|
+
| `notificationDefaultEmailDigestMode` | `INSTANT` | Default digest mode for missing preferences. |
|
|
1047
|
+
| `notificationDefaultInAppPreferenceEnabled` | `true` | Default in-app preference for missing preferences. |
|
|
1048
|
+
| `notificationDefaultEmailPreferenceEnabled` | `true` | Default email preference for missing preferences. |
|
|
1049
|
+
| `notificationQuietHoursTimeZone` | registered calendar's zone | IANA zone the members' `quietHoursStart` / `quietHoursEnd` are read in. Falls back to `BPM_BUSINESS_CALENDAR.timeZone`, then `UTC`. |
|
|
1050
|
+
| `notificationEmailDigestHour` | `9` | Local hour a `DAILY` recipient's held email is flushed. |
|
|
1051
|
+
| `signatureCurrentKeyVersion` | `1` | Key version used for new signatures. |
|
|
1052
|
+
| `signatureKeyProvider` | built-in local development key provider | Host key provider for signing and verification. |
|
|
1053
|
+
| `signatureTimestampProvider` | built-in mock timestamp provider | Host timestamp token provider (RFC 3161-style). Replace the built-in mock in production. |
|
|
988
1054
|
|
|
989
1055
|
Production deployments should override all local development secrets.
|
|
990
1056
|
|
|
@@ -1005,14 +1071,14 @@ import { OrganizationModule, OrganizationService } from '@rytass/bpm-core-nestjs
|
|
|
1005
1071
|
export class HostOrganizationModule {}
|
|
1006
1072
|
```
|
|
1007
1073
|
|
|
1008
|
-
| Module | Self-contained | Notes
|
|
1009
|
-
| -------------------- | -------------- |
|
|
1010
|
-
| `OrganizationModule` | yes | Org units, positions, memberships, manager resolution. Mounted alone, `deletePosition`
|
|
1011
|
-
| `FormModule` | yes | Form definitions and versions.
|
|
1012
|
-
| `TemplateModule` | yes | Approval templates, versions, categories.
|
|
1013
|
-
| `ConditionModule` | yes | CEL lint and evaluation only.
|
|
1014
|
-
| `IdentityModule` | no |
|
|
1015
|
-
| `AttachmentModule` | no | Needs storage and signed-URL options; use `AttachmentModule.forRoot(...)`.
|
|
1074
|
+
| Module | Self-contained | Notes |
|
|
1075
|
+
| -------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1076
|
+
| `OrganizationModule` | yes | Org units, positions, memberships, manager resolution. Mounted alone, `deletePosition` skips its approval-template and ad-hoc-directive reference checks, logging a warning each time it does — those tables are not in the schema. |
|
|
1077
|
+
| `FormModule` | yes | Form definitions and versions. |
|
|
1078
|
+
| `TemplateModule` | yes | Approval templates, versions, categories. |
|
|
1079
|
+
| `ConditionModule` | yes | CEL lint and evaluation only. |
|
|
1080
|
+
| `IdentityModule` | no | Takes an optional `memberResolverProvider` (defaults to `DefaultBPMMemberResolver`); use `IdentityModule.forRoot(...)`. |
|
|
1081
|
+
| `AttachmentModule` | no | Needs storage and signed-URL options; use `AttachmentModule.forRoot(...)`. |
|
|
1016
1082
|
|
|
1017
1083
|
What you still owe them:
|
|
1018
1084
|
|
|
@@ -1040,7 +1106,7 @@ map `jest-resolve` cannot follow — lazily, on the first CEL expression it
|
|
|
1040
1106
|
parses. Importing anything from the package root does not reach it, so a host
|
|
1041
1107
|
jest suite needs no `moduleNameMapper` entry and no stub.
|
|
1042
1108
|
|
|
1043
|
-
A suite that
|
|
1109
|
+
A suite that _does_ exercise conditions (template validation, workflow
|
|
1044
1110
|
transitions) will load `cel-js` for real. If your jest version cannot resolve
|
|
1045
1111
|
it, map it to a stub of your own:
|
|
1046
1112
|
|
|
@@ -1053,18 +1119,22 @@ moduleNameMapper: { '^cel-js$': '<rootDir>/test/cel-js.stub.ts' },
|
|
|
1053
1119
|
|
|
1054
1120
|
### Database prerequisites
|
|
1055
1121
|
|
|
1056
|
-
BPM's first migration enables
|
|
1057
|
-
`uuid_generate_v4()` defaults
|
|
1122
|
+
BPM's first migration enables three PostgreSQL extensions — `uuid-ossp` for
|
|
1123
|
+
`uuid_generate_v4()` defaults, `ltree` for the org unit `path` column, and
|
|
1124
|
+
`pg_trgm` for the instance search indexes of
|
|
1125
|
+
`WorkflowQueryIndexes0000000013000` (which also creates it, for databases
|
|
1126
|
+
migrated before the first migration checked it):
|
|
1058
1127
|
|
|
1059
1128
|
```sql
|
|
1060
1129
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
1061
1130
|
CREATE EXTENSION IF NOT EXISTS "ltree";
|
|
1131
|
+
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
|
1062
1132
|
```
|
|
1063
1133
|
|
|
1064
1134
|
`CREATE EXTENSION` needs the CREATE privilege **on the database**, which a
|
|
1065
1135
|
least-privilege application role normally does not have — including on Rytass
|
|
1066
1136
|
platforms, where that privilege is revoked by convention. Have a superuser,
|
|
1067
|
-
the database owner, or `rds_superuser` run the
|
|
1137
|
+
the database owner, or `rds_superuser` run the three statements once before the
|
|
1068
1138
|
first migration run.
|
|
1069
1139
|
|
|
1070
1140
|
PostgreSQL itself returns early from `CREATE EXTENSION IF NOT EXISTS` when the
|
|
@@ -1148,47 +1218,37 @@ Required environment variables for direct Vault loading:
|
|
|
1148
1218
|
|
|
1149
1219
|
### Sharing a Postgres cluster with the host
|
|
1150
1220
|
|
|
1151
|
-
Hosts that already run their own Postgres (and their own Vault path,
|
|
1152
|
-
|
|
1153
|
-
live. Pick the one that matches your operational model:
|
|
1221
|
+
Hosts that already run their own Postgres (and their own Vault path, e.g.
|
|
1222
|
+
`shuttle/develop`) need to decide where BPM's 25 tables live.
|
|
1154
1223
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1224
|
+
BPM has no connection-name option. Its repositories bind to the **default**
|
|
1225
|
+
TypeORM DataSource of the Nest application, and `BPMRootModule` has no
|
|
1226
|
+
`typeormConnectionName`. A process has one default DataSource, so when BPM runs
|
|
1227
|
+
inside the host's Nest application, BPM and the host share that connection and
|
|
1228
|
+
its `schema` setting. Choose one of these layouts:
|
|
1160
1229
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1230
|
+
| Option | When to use | Trade-off |
|
|
1231
|
+
| ------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1232
|
+
| **Same database, same schema (`public`)** | Tiny prototypes, monorepo dev | Table-name collision risk: BPM owns `org_units`, `positions`, and other common names. Audit the host's tables before choosing. |
|
|
1233
|
+
| **Shared DataSource with a dedicated schema** | Host and BPM in one Nest application | The one DataSource's `schema` applies to both, so the host's own entities must also resolve in the BPM schema. Only practical when the host's tables live in that schema too. |
|
|
1234
|
+
| **Dedicated BPM application** (needed for a separate schema or database) | Compliance or multi-tenant boundary, or a different database | BPM runs in its own Nest application with its own DataSource and Vault path. The host integrates through GraphQL, not through shared TypeORM repositories. Migrations run per application. |
|
|
1165
1235
|
|
|
1166
|
-
|
|
1236
|
+
A dedicated BPM application wires its DataSource from its own Vault path:
|
|
1167
1237
|
|
|
1168
1238
|
```ts
|
|
1169
|
-
// apps/api/src/app/app.module.ts
|
|
1170
1239
|
@Module({
|
|
1171
1240
|
imports: [
|
|
1172
|
-
// Host's existing TypeORM connection (default name, host's schema).
|
|
1173
1241
|
TypeOrmModule.forRootAsync({
|
|
1174
|
-
imports: [
|
|
1175
|
-
inject: [
|
|
1176
|
-
useFactory:
|
|
1177
|
-
}),
|
|
1178
|
-
// BPM's own connection (named, separate schema, separate Vault path).
|
|
1179
|
-
TypeOrmModule.forRootAsync({
|
|
1180
|
-
name: 'bpm', // <-- distinct connection name
|
|
1181
|
-
imports: [BPMVaultModule],
|
|
1182
|
-
inject: [BPMVaultService],
|
|
1183
|
-
useFactory: buildTypeOrmModuleOptions, // reads DB_SCHEMA=bpm_core from Vault
|
|
1242
|
+
imports: [VaultModule],
|
|
1243
|
+
inject: [VaultService],
|
|
1244
|
+
useFactory: buildTypeOrmModuleOptions, // reads DB_SCHEMA=bpm_core from Vault
|
|
1184
1245
|
}),
|
|
1185
|
-
// Hand BPM the connection name so its repositories bind to the right pool.
|
|
1186
1246
|
BPMRootModule.forRootAsync({
|
|
1187
|
-
typeormConnectionName: 'bpm',
|
|
1188
1247
|
// ...
|
|
1189
1248
|
}),
|
|
1190
1249
|
],
|
|
1191
1250
|
})
|
|
1251
|
+
export class BPMApplicationModule {}
|
|
1192
1252
|
```
|
|
1193
1253
|
|
|
1194
1254
|
If the host runs only one Vault path (e.g. `shuttle/develop`) and you
|
|
@@ -1228,8 +1288,11 @@ import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';
|
|
|
1228
1288
|
`TypeOrmModule` setup still keeps `migrationsRun: false`; run migrations
|
|
1229
1289
|
explicitly during deploy.
|
|
1230
1290
|
|
|
1231
|
-
The first migration attempts to create required PostgreSQL extensions
|
|
1232
|
-
(`uuid-ossp`, `ltree`) if they do not exist.
|
|
1291
|
+
The first migration attempts to create the required PostgreSQL extensions
|
|
1292
|
+
(`uuid-ossp`, `ltree`, `pg_trgm`) if they do not exist. When one is missing and
|
|
1293
|
+
the role cannot create it, the migration fails with the SQL to run and who must
|
|
1294
|
+
run it (the `pg_trgm` check is unreleased; earlier versions surfaced a missing
|
|
1295
|
+
`pg_trgm` as a bare `42501` from migration `0000000013000`). Hosts that cannot grant
|
|
1233
1296
|
`CREATE EXTENSION` should pre-provision those extensions in the target database
|
|
1234
1297
|
before running BPM migrations. Schema selection comes from the TypeORM `schema`
|
|
1235
1298
|
option, so multi-schema hosts should run the same migration list once per BPM
|
|
@@ -1308,23 +1371,22 @@ This means:
|
|
|
1308
1371
|
`attachmentPublicBaseUrl` pointing at a CDN edge that enforces its own
|
|
1309
1372
|
authz on top — BPM's signature is freshness, not authorization.
|
|
1310
1373
|
|
|
1311
|
-
###
|
|
1374
|
+
### The demo `/auth/test-members` endpoint
|
|
1375
|
+
|
|
1376
|
+
`@rytass/bpm-core-nestjs-module` does not register `GET /auth/test-members`.
|
|
1377
|
+
The endpoint belongs to the wrapper host. BPMCore's demo host (`apps/api`)
|
|
1378
|
+
registers it **unconditionally**, with no `NODE_ENV` check, so the demo login
|
|
1379
|
+
form can list the fixture accounts seeded by `pnpm demo:reset` and
|
|
1380
|
+
`pnpm staging:reset`. Staging runs with `NODE_ENV=production` and depends on
|
|
1381
|
+
that endpoint.
|
|
1312
1382
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
production.** Two ways to keep it out:
|
|
1383
|
+
Do not copy that wiring into a production host as it is. Either omit the
|
|
1384
|
+
controller that serves `/auth/test-members`, or guard it yourself, for example
|
|
1385
|
+
by returning 404 unless an explicit non-production setting is on.
|
|
1317
1386
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
`/auth/test-members`. The reference implementation in BPMCore's
|
|
1322
|
-
`apps/api` only exposes it when `NODE_ENV !== 'production'`; copy
|
|
1323
|
-
that pattern in your own auth controller.
|
|
1324
|
-
2. The browser-side `listApiTestMembers()` client function tolerates a
|
|
1325
|
-
`404` / `401` and returns an empty array, so removing the endpoint
|
|
1326
|
-
degrades gracefully — the login form simply hides the demo-account
|
|
1327
|
-
picker.
|
|
1387
|
+
If the endpoint is missing or fails, the browser client `listApiTestMembers()`
|
|
1388
|
+
rejects, and `LoginView` in `@rytass/bpm-core-react` catches the error and hides
|
|
1389
|
+
the demo-account picker. The login page still works.
|
|
1328
1390
|
|
|
1329
1391
|
## Notifications and SLA
|
|
1330
1392
|
|
|
@@ -1344,10 +1406,10 @@ If any one of those is missing, email stays off even on `auto`. Webhook
|
|
|
1344
1406
|
`auto` requires both `notificationWebhookEndpointUrl` and
|
|
1345
1407
|
`notificationWebhookSigningSecret`.
|
|
1346
1408
|
|
|
1347
|
-
`notificationTemplateEngine
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1409
|
+
`notificationTemplateEngine` defaults to `simple`, which only replaces
|
|
1410
|
+
`{{path}}` placeholders. `handlebars` renders titles and bodies with Handlebars
|
|
1411
|
+
(`noEscape: true`, `strict: false`), so helpers and blocks work and values are
|
|
1412
|
+
not HTML-escaped.
|
|
1351
1413
|
|
|
1352
1414
|
Delivery and SLA schedulers are disabled by default. Enable them only in a
|
|
1353
1415
|
dedicated worker process or in a single-replica host that intentionally owns
|
|
@@ -1389,7 +1451,8 @@ export class HostNotificationModule {}
|
|
|
1389
1451
|
```
|
|
1390
1452
|
|
|
1391
1453
|
Workflow service tasks are a separate integration point from notification
|
|
1392
|
-
delivery. `WEBHOOK`
|
|
1454
|
+
delivery. Their `WEBHOOK` action and the webhooks attached to a `NOTIFY` action
|
|
1455
|
+
are different mechanisms; see "NOTIFY webhooks" below. `WEBHOOK` service-task nodes run inside the workflow engine and use
|
|
1393
1456
|
`BPM_WORKFLOW_SERVICE_TASK_DISPATCHER`. The default dispatcher sends a JSON
|
|
1394
1457
|
`POST` with `fetch`; wrapper apps can replace it when outbound workflow actions
|
|
1395
1458
|
must be signed, queued, audited, or routed through an internal integration
|
|
@@ -1403,14 +1466,7 @@ shared integration bus instances. Providers do not have to live in a
|
|
|
1403
1466
|
|
|
1404
1467
|
```ts
|
|
1405
1468
|
import { Injectable } from '@nestjs/common';
|
|
1406
|
-
import {
|
|
1407
|
-
BPMRootModule,
|
|
1408
|
-
BPM_MEMBER_RESOLVER,
|
|
1409
|
-
BPM_WORKFLOW_SERVICE_TASK_DISPATCHER,
|
|
1410
|
-
BPMWorkflowServiceTaskDispatcher,
|
|
1411
|
-
BPMWorkflowWebhookDispatchInput,
|
|
1412
|
-
BPMWorkflowWebhookDispatchResult,
|
|
1413
|
-
} from '@rytass/bpm-core-nestjs-module';
|
|
1469
|
+
import { BPMRootModule, BPM_MEMBER_RESOLVER, BPM_WORKFLOW_SERVICE_TASK_DISPATCHER, BPMWorkflowServiceTaskDispatcher, BPMWorkflowWebhookDispatchInput, BPMWorkflowWebhookDispatchResult } from '@rytass/bpm-core-nestjs-module';
|
|
1414
1470
|
|
|
1415
1471
|
interface IntegrationBusResult {
|
|
1416
1472
|
readonly accepted: boolean;
|
|
@@ -1418,19 +1474,14 @@ interface IntegrationBusResult {
|
|
|
1418
1474
|
}
|
|
1419
1475
|
|
|
1420
1476
|
interface IntegrationBus {
|
|
1421
|
-
enqueue(
|
|
1422
|
-
topic: string,
|
|
1423
|
-
payload: Readonly<Record<string, unknown>>,
|
|
1424
|
-
): Promise<IntegrationBusResult>;
|
|
1477
|
+
enqueue(topic: string, payload: Readonly<Record<string, unknown>>): Promise<IntegrationBusResult>;
|
|
1425
1478
|
}
|
|
1426
1479
|
|
|
1427
1480
|
@Injectable()
|
|
1428
1481
|
class HostWorkflowServiceTaskDispatcher implements BPMWorkflowServiceTaskDispatcher {
|
|
1429
1482
|
constructor(private readonly integrationBus: IntegrationBus) {}
|
|
1430
1483
|
|
|
1431
|
-
async dispatchWebhook(
|
|
1432
|
-
input: BPMWorkflowWebhookDispatchInput,
|
|
1433
|
-
): Promise<BPMWorkflowWebhookDispatchResult> {
|
|
1484
|
+
async dispatchWebhook(input: BPMWorkflowWebhookDispatchInput): Promise<BPMWorkflowWebhookDispatchResult> {
|
|
1434
1485
|
const result = await this.integrationBus.enqueue('bpm.workflow.webhook', {
|
|
1435
1486
|
headers: input.headers ?? {},
|
|
1436
1487
|
payload: input.payload,
|
|
@@ -1460,12 +1511,69 @@ BPMRootModule.forRoot({
|
|
|
1460
1511
|
|
|
1461
1512
|
SLA timeout actions that change workflow state are disabled by default:
|
|
1462
1513
|
|
|
1463
|
-
- `AUTO_APPROVE`
|
|
1464
|
-
|
|
1465
|
-
- `
|
|
1514
|
+
- `AUTO_APPROVE` decides the overdue task as its assignee (or as the first
|
|
1515
|
+
candidate when there is no assignee), with the comment `SLA timeout
|
|
1516
|
+
auto-approved this task.`. The decision, its signature and the `TASK_DECIDED`
|
|
1517
|
+
event are attributed to that member, not to the system. Only the comment and
|
|
1518
|
+
the `SLA_TRIGGERED` activity entry show that the decision was automatic, so
|
|
1519
|
+
audit reviews must read them.
|
|
1520
|
+
- `ESCALATE` is also disabled by default.
|
|
1521
|
+
- `TERMINATE_INSTANCE` ends the instance as `EXPIRED` (unreleased; earlier
|
|
1522
|
+
versions ended it as `CANCELLED`). The activity log records `INSTANCE_EXPIRED`
|
|
1523
|
+
with no actor, and the instance's node and task are the overdue ones. Ad-hoc
|
|
1524
|
+
completion notifications label it 逾期終止. An instance that an earlier version
|
|
1525
|
+
already ended by SLA remains `CANCELLED`.
|
|
1466
1526
|
|
|
1467
1527
|
Enable them only after the host application's business policy is explicit.
|
|
1468
1528
|
|
|
1529
|
+
### NOTIFY webhooks
|
|
1530
|
+
|
|
1531
|
+
A `NOTIFY` service action can call external endpoints through its `webhooks`
|
|
1532
|
+
list (`NotifyWebhookTarget`). A template stores only the endpoint reference
|
|
1533
|
+
(`key` and `version`) and parameter bindings, never a URL, header or secret. Each
|
|
1534
|
+
target has a stable `id`, which the delivery outbox uses for idempotency.
|
|
1535
|
+
|
|
1536
|
+
Endpoints come from the sources in `workflowWebhookTargetSources` (default
|
|
1537
|
+
`['REGISTRY']`):
|
|
1538
|
+
|
|
1539
|
+
- `REGISTRY`: endpoints that host code registers through
|
|
1540
|
+
`workflowWebhookRegistryProvider` (or the `workflowWebhookRegistry` twin). A
|
|
1541
|
+
`BPMWorkflowWebhookRegistry` exposes `get(key, version)` and `list()`. Each
|
|
1542
|
+
endpoint's `buildRequest(event)` returns the URL, method, headers and optional
|
|
1543
|
+
`signingSecret`. It runs once per delivery attempt, so a rotated credential
|
|
1544
|
+
applies to queued deliveries too. Without a registry, a template that
|
|
1545
|
+
references webhook endpoints cannot be published (`REGISTRY_MISSING`).
|
|
1546
|
+
- `DATABASE`: endpoints an administrator maintains in the database. The source
|
|
1547
|
+
stays off unless `workflowWebhookAllowedUrlPatterns` is non-empty and
|
|
1548
|
+
`workflowWebhookSecretEncryptionKey` is set. Header values and signing secrets
|
|
1549
|
+
are encrypted with that key.
|
|
1550
|
+
|
|
1551
|
+
The request body, when the endpoint does not build one, is the JSON of a
|
|
1552
|
+
`BPMWorkflowWebhookEvent`. It carries `deliveryId` (the receiver's idempotency
|
|
1553
|
+
key), `attempt`, `endpoint`, `instance`, `node`, `initiator`, `occurredAt`, and
|
|
1554
|
+
only the parameters the template bound.
|
|
1555
|
+
|
|
1556
|
+
Deliveries are written to `workflow_webhook_deliveries` and sent by a scheduler.
|
|
1557
|
+
The scheduler is enabled automatically once at least one endpoint is
|
|
1558
|
+
registered, and is always off under `NODE_ENV=test`. Options, all optional:
|
|
1559
|
+
|
|
1560
|
+
| Option | Default | Description |
|
|
1561
|
+
| -------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
1562
|
+
| `workflowWebhookTargetSources` | `['REGISTRY']` | Endpoint sources to enable. |
|
|
1563
|
+
| `workflowWebhookAllowedUrlPatterns` | `[]` | URL patterns such as `https://*.example.com/hooks/*`. An invalid pattern fails boot. Empty does not mean "allow all". |
|
|
1564
|
+
| `workflowWebhookEnforceAllowlistForRegistry` | `false` | Also check `REGISTRY` endpoint URLs against the allowlist. |
|
|
1565
|
+
| `workflowWebhookSecretEncryptionKey` | `null` | Encryption key for database-managed endpoints: 32 bytes, hex or base64. |
|
|
1566
|
+
| `workflowWebhookDeliverySchedulerEnabled` | auto | Runs the retry and release scan in this process. |
|
|
1567
|
+
| `workflowWebhookDeliveryScanIntervalMs` | `15000` | Scheduler scan interval. |
|
|
1568
|
+
| `workflowWebhookDeliveryBatchSize` | `25` | Deliveries claimed per scan. |
|
|
1569
|
+
| `workflowWebhookDeliveryMaxAttempts` | `6` | Attempts before a retryable failure becomes `FAILED`. |
|
|
1570
|
+
| `workflowWebhookDeliveryRetryBaseDelayMs` | `30000` | First retry delay. Each further attempt doubles it, with ±20 % jitter. |
|
|
1571
|
+
| `workflowWebhookDeliveryMaxRetryDelayMs` | `3600000` | Ceiling for one retry delay. |
|
|
1572
|
+
| `workflowWebhookDeliveryDefaultTimeoutMs` | `10000` | Request timeout when an endpoint sets none. An endpoint's own timeout is capped at 30 000 ms. |
|
|
1573
|
+
|
|
1574
|
+
The WEBHOOK service-task dispatcher (`workflowServiceTaskDispatcherProvider`)
|
|
1575
|
+
is separate from these options and from the notification dispatcher.
|
|
1576
|
+
|
|
1469
1577
|
## Signatures
|
|
1470
1578
|
|
|
1471
1579
|
Decision signatures use HMAC-SHA256 and are chained through the previous
|
|
@@ -1497,11 +1605,73 @@ or a `Promise`; the example above uses async because production KMS / TSA
|
|
|
1497
1605
|
clients are typically network-bound. Returning a synchronous value is fine
|
|
1498
1606
|
for in-memory or local-file providers.
|
|
1499
1607
|
|
|
1608
|
+
## Administrator Task Reassignment (unreleased)
|
|
1609
|
+
|
|
1610
|
+
Not in a published version yet. It landed after `v0.16.2`.
|
|
1611
|
+
|
|
1612
|
+
When an open task is held by someone who has left or been replaced, an
|
|
1613
|
+
administrator (`@BPMAdminOnly()`) can move it without that person's help:
|
|
1614
|
+
|
|
1615
|
+
- `memberOpenTasks(memberId)` lists the open seats a member holds on running
|
|
1616
|
+
instances, as the assignee or as a `PENDING` / `CLAIMED` / `WAITING`
|
|
1617
|
+
candidate.
|
|
1618
|
+
- `reassignTask(input)` moves one task's seat from `fromMemberId` to
|
|
1619
|
+
`toMemberId`. `reassignMemberTasks(input)` does the same for every open task
|
|
1620
|
+
the source member holds, one transaction and one result per task.
|
|
1621
|
+
|
|
1622
|
+
The rules that differ from the holder's own transfer:
|
|
1623
|
+
|
|
1624
|
+
- The task keeps its id and decision policy. Only the moved seat changes; the
|
|
1625
|
+
other seats of a `PARALLEL_ALL`, `PARALLEL_ANY`, `QUORUM` or `SEQUENTIAL`
|
|
1626
|
+
stage stay where they are, and a `WAITING` seat keeps its place in order.
|
|
1627
|
+
- It is not a decision. No `TaskDecision` and no signature are written. The
|
|
1628
|
+
delegation chain gets an `ADMIN_REASSIGN` step carrying `actorMemberId` and
|
|
1629
|
+
the typed `comment`, and the activity log records `TASK_REASSIGNED`.
|
|
1630
|
+
- The target is not run through delegation rules. The initiator / submitter
|
|
1631
|
+
exclusions and the ORG_MANAGER_CHAIN grade check still apply.
|
|
1632
|
+
- The node's `allowTransfer` is not consulted. It governs what a holder may do
|
|
1633
|
+
with their own task; an administrator override exists for holders who are
|
|
1634
|
+
gone, and a node that forbids transfers must not strand its tasks on them.
|
|
1635
|
+
- A refusal is reported as `REJECTED` with an `errorCode`
|
|
1636
|
+
(`SOURCE_NOT_ON_TASK`, `TARGET_SAME_AS_SOURCE`, `TARGET_ALREADY_ON_TASK`,
|
|
1637
|
+
`TARGET_ALREADY_DECIDED`, `TARGET_IS_INITIATOR`, `TARGET_IS_SUBMITTER`,
|
|
1638
|
+
`TARGET_GRADE_TOO_LOW`, `TASK_NOT_OPEN`, `INSTANCE_NOT_RUNNING`,
|
|
1639
|
+
`REASON_REQUIRED`, `UNEXPECTED_ERROR`). `UNEXPECTED_ERROR` is reported by
|
|
1640
|
+
`reassignMemberTasks` for the one task that failed, and the other tasks are still
|
|
1641
|
+
processed. `reassignTask` throws a `ConflictException` carrying the
|
|
1642
|
+
code unless `dryRun` is set; `dryRun` always reports and never writes.
|
|
1643
|
+
|
|
1644
|
+
A holder's own transfer on a `PARALLEL_ALL`, `PARALLEL_ANY` or `QUORUM` stage
|
|
1645
|
+
also moves only the transferring seat in place and keeps the task open. It
|
|
1646
|
+
still writes a `TaskDecision` with a signature, which the administrator path
|
|
1647
|
+
does not. Its target must not already hold an open seat on the task, nor have
|
|
1648
|
+
decided it already (both answer a `ConflictException`). The task-level
|
|
1649
|
+
assignee, delegation chain and `openedAt` stay as they were: a seat transfer is
|
|
1650
|
+
not a claim of the whole stage.
|
|
1651
|
+
|
|
1652
|
+
## Delegation Rules
|
|
1653
|
+
|
|
1654
|
+
> The priority order and the root check below are unreleased (they landed after
|
|
1655
|
+
> `v0.16.2`). Earlier versions ordered rules by creation time first.
|
|
1656
|
+
|
|
1657
|
+
A delegation rule passes a principal's approval seats to another member,
|
|
1658
|
+
optionally only when a CEL condition holds. When several active rules apply, the
|
|
1659
|
+
one with the higher `priority` number wins, as with manager rules. Between rules
|
|
1660
|
+
with equal priority, the one created first wins. The rule list is shown in
|
|
1661
|
+
priority-descending, newest-first order.
|
|
1662
|
+
|
|
1663
|
+
A rule's condition may read only these roots: `env`, `form`, `formData`,
|
|
1664
|
+
`initiator` (with `memberId`), `instance` (`id`, `nodeId`, `state`,
|
|
1665
|
+
`templateId`, `templateVersionId`, `title`), and `principal` (with `memberId`).
|
|
1666
|
+
Saving a rule with any other root is refused. Rules saved before that check was
|
|
1667
|
+
added are not changed. They still fail when evaluated.
|
|
1668
|
+
|
|
1500
1669
|
## GraphQL Surface
|
|
1501
1670
|
|
|
1502
1671
|
Importing `BPMRootModule` registers GraphQL resolvers for:
|
|
1503
1672
|
|
|
1504
|
-
- Organization units
|
|
1673
|
+
- Organization units (including the unreleased `orgUnitRetirementImpact` query),
|
|
1674
|
+
positions, memberships, manager resolution, and summary.
|
|
1505
1675
|
- Member profile lookup and member metadata cache inspection.
|
|
1506
1676
|
- Form definitions and form definition versions.
|
|
1507
1677
|
- Form option DataSource catalog, designer preview, authenticated runtime option
|
|
@@ -1525,12 +1695,12 @@ Stage approvers can attach **instance-scoped** directives to the single
|
|
|
1525
1695
|
approval instance they are deciding — the workflow template is never
|
|
1526
1696
|
modified. Four directive types exist (`AdhocDirectiveTypeEnum`):
|
|
1527
1697
|
|
|
1528
|
-
| Type
|
|
1529
|
-
|
|
1530
|
-
| `COUNTERSIGN`
|
|
1531
|
-
| `PRE_APPROVAL`
|
|
1532
|
-
| `STAGE_NOTIFY`
|
|
1533
|
-
| `COMPLETION_NOTIFY` | Instance reaches `APPROVED` / `REJECTED` / `CANCELLED` | Same delivery as `STAGE_NOTIFY`.
|
|
1698
|
+
| Type | Trigger | Effect |
|
|
1699
|
+
| ------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1700
|
+
| `COUNTERSIGN` | Next user task is created | Spawns a parallel ad-hoc task for the target on that stage. The token only advances once the original task **and** every countersign task are approved; a countersigner's rejection rejects the instance. |
|
|
1701
|
+
| `PRE_APPROVAL` | Immediately | Spawns a blocking ad-hoc task on the current stage. `onReject` (`AdhocPreApprovalRejectBehaviorEnum`) chooses `REJECT_INSTANCE` (whole instance rejected) or `RETURN_TO_ORIGIN` (decision handed back to the original approver, re-opening their task if needed). |
|
|
1702
|
+
| `STAGE_NOTIFY` | Current stage ends (approved / rejected / returned) | Notifies the target — members through `NotificationService`, webhooks through `BPM_WORKFLOW_SERVICE_TASK_DISPATCHER`. |
|
|
1703
|
+
| `COMPLETION_NOTIFY` | Instance reaches `APPROVED` / `REJECTED` / `CANCELLED` / `EXPIRED` | Same delivery as `STAGE_NOTIFY`. |
|
|
1534
1704
|
|
|
1535
1705
|
GraphQL surface: mutations `requestAdhocCountersign(taskId, target, comment?)`,
|
|
1536
1706
|
`requestAdhocPreApproval(taskId, target, onReject, comment?)`,
|
|
@@ -1568,7 +1738,7 @@ Workflow, form, condition, identity, organization, and status contracts live in
|
|
|
1568
1738
|
|
|
1569
1739
|
```ts
|
|
1570
1740
|
import type { WorkflowDefinition } from '@rytass/bpm-core-shared/workflow';
|
|
1571
|
-
import type {
|
|
1741
|
+
import type { FormDefinitionSchema } from '@rytass/bpm-core-shared/form';
|
|
1572
1742
|
```
|
|
1573
1743
|
|
|
1574
1744
|
Use shared contracts when building UI clients or host-side integration tests.
|
|
@@ -1580,20 +1750,7 @@ Stable feature subpaths are also published for narrower imports such as
|
|
|
1580
1750
|
migrations or notification dispatcher tokens.
|
|
1581
1751
|
|
|
1582
1752
|
```ts
|
|
1583
|
-
import {
|
|
1584
|
-
AllExceptionsFilter,
|
|
1585
|
-
BPMAdminGuard,
|
|
1586
|
-
BPMAdminOnly,
|
|
1587
|
-
BPMAuthenticated,
|
|
1588
|
-
BPMAuthenticatedGuard,
|
|
1589
|
-
BPMCurrentAuthContext,
|
|
1590
|
-
BPMCurrentMemberId,
|
|
1591
|
-
BPMDesignerGuard,
|
|
1592
|
-
BPMDesignerOnly,
|
|
1593
|
-
BPMRootModule,
|
|
1594
|
-
buildTypeOrmModuleOptions,
|
|
1595
|
-
createBPMMemberBaseResolverProvider,
|
|
1596
|
-
} from '@rytass/bpm-core-nestjs-module';
|
|
1753
|
+
import { AllExceptionsFilter, BPMAdminGuard, BPMAdminOnly, BPMAuthenticated, BPMAuthenticatedGuard, BPMCurrentAuthContext, BPMCurrentMemberId, BPMDesignerGuard, BPMDesignerOnly, BPMRootModule, buildTypeOrmModuleOptions, createBPMMemberBaseResolverProvider } from '@rytass/bpm-core-nestjs-module';
|
|
1597
1754
|
|
|
1598
1755
|
import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';
|
|
1599
1756
|
import { BPM_NOTIFICATION_DISPATCHER } from '@rytass/bpm-core-nestjs-module/notification';
|
|
@@ -1660,20 +1817,17 @@ pnpm test
|
|
|
1660
1817
|
pnpm build
|
|
1661
1818
|
```
|
|
1662
1819
|
|
|
1663
|
-
## Publishing
|
|
1820
|
+
## Publishing
|
|
1664
1821
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
7. Publish from the built package directory:
|
|
1822
|
+
Releases are cut with `nx release` for all four published packages
|
|
1823
|
+
(`shared`, `bpm-core`, `bpm-core-client`, `bpm-core-react`). They share one
|
|
1824
|
+
version number. Do not run `npm publish` by hand and do not edit `version` in
|
|
1825
|
+
`package.json`. The full procedure, including the build steps and the order of
|
|
1826
|
+
the release commands, is in the repository root `CLAUDE.md` ("Releasing") and in
|
|
1827
|
+
[`docs/api-reference.md`](../../docs/api-reference.md) ("Publish Procedure").
|
|
1672
1828
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
npm publish --access public
|
|
1676
|
-
```
|
|
1829
|
+
Check the registry after every release, because the command's exit code alone
|
|
1830
|
+
does not prove that every package was published.
|
|
1677
1831
|
|
|
1678
1832
|
## Production Notes
|
|
1679
1833
|
|