hierarchical-approval 1.2.0 → 1.3.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 +35 -0
- package/README.md +27 -0
- package/dist/{ApprovalEngine-CdtOJOsu.d.cts → ApprovalEngine-CfySjeuy.d.cts} +23 -1
- package/dist/{ApprovalEngine-DRC-o30z.d.ts → ApprovalEngine-Dhu5mlCG.d.ts} +23 -1
- package/dist/index.cjs +56 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +56 -8
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +56 -8
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +1 -1
- package/dist/nestjs.d.ts +1 -1
- package/dist/nestjs.js +56 -8
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +56 -8
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +56 -8
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
_Nothing yet._
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Added — out-of-office cover
|
|
13
|
+
|
|
14
|
+
- **`outOfOfficeProvider` swaps absent approvers for their stand-in.** An
|
|
15
|
+
approver on leave stalled the chain until somebody noticed and reassigned by
|
|
16
|
+
hand; `delegate()` needed the absent person to initiate it, and `reassign()`
|
|
17
|
+
needed an administrator to spot the problem first.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
new ApprovalEngine({
|
|
21
|
+
adapter,
|
|
22
|
+
outOfOfficeProvider: {
|
|
23
|
+
getDelegateFor: async (userId, at) => hr.coverFor(userId, at), // null when available
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Applied wherever approvers are resolved — at submit, when a level activates,
|
|
29
|
+
on escalation, and in `previewApprovalChain()`, so a preview cannot disagree
|
|
30
|
+
with what `submit()` goes on to do.
|
|
31
|
+
|
|
32
|
+
Kept as an injected provider rather than engine-owned state because absence
|
|
33
|
+
lives in the HR or directory system that already tracks leave; storing it here
|
|
34
|
+
too would guarantee the two disagree. The resolution time is passed to the
|
|
35
|
+
provider, so cover can be date-bound.
|
|
36
|
+
|
|
37
|
+
- **Substitution is transitive but bounded.** An A→B→C chain of absences lands
|
|
38
|
+
on whoever is actually present, up to five hops. A cover *cycle* (A covers B
|
|
39
|
+
while B covers A) stops and leaves the original approver assigned — visible and
|
|
40
|
+
fixable, unlike a hang. A provider that throws is treated as "no cover known",
|
|
41
|
+
because an HR lookup failing must not stop an approval being routed at all.
|
|
42
|
+
|
|
43
|
+
New export: `OutOfOfficeProvider`.
|
|
44
|
+
|
|
10
45
|
## [1.2.0] - 2026-09-04
|
|
11
46
|
|
|
12
47
|
### Added — template inheritance
|
package/README.md
CHANGED
|
@@ -399,6 +399,33 @@ engine.registerConditionOperator('between', (actual, expected) => {
|
|
|
399
399
|
});
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
+
### Out-of-office cover
|
|
403
|
+
|
|
404
|
+
Approvers go on leave, and a chain that waits on an absent person stalls.
|
|
405
|
+
Configure a provider and absent approvers are swapped for their stand-in
|
|
406
|
+
wherever approvers are resolved — at submit, when a level activates, on
|
|
407
|
+
escalation, and in `previewApprovalChain()`:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const engine = new ApprovalEngine({
|
|
411
|
+
adapter,
|
|
412
|
+
outOfOfficeProvider: {
|
|
413
|
+
// Return the stand-in, or null when the approver is available.
|
|
414
|
+
getDelegateFor: async (userId, at) => hr.coverFor(userId, at),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Absence lives in the HR or directory system that already tracks leave, so this
|
|
420
|
+
is an injected provider rather than engine-owned state — duplicating that data
|
|
421
|
+
here would guarantee the two disagree. The resolution time is passed in, so
|
|
422
|
+
cover can be date-bound.
|
|
423
|
+
|
|
424
|
+
Substitution is transitive (A away → B, B away → C lands on C) up to five hops.
|
|
425
|
+
A cover *cycle* stops rather than looping, and a provider that throws is treated
|
|
426
|
+
as "no cover known" — an HR lookup failing must not stop an approval being
|
|
427
|
+
routed at all.
|
|
428
|
+
|
|
402
429
|
### Template inheritance
|
|
403
430
|
|
|
404
431
|
ERP tenants run many near-identical workflows — one per region, entity or
|
|
@@ -136,6 +136,23 @@ interface OrgProvider {
|
|
|
136
136
|
/** Optional: resolve users matching a custom attribute/value pair. */
|
|
137
137
|
getUsersByAttribute?(attr: string, value: unknown, tenantId?: string): Promise<string[]> | string[];
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Supplies out-of-office cover so an approver on leave does not stall a chain.
|
|
141
|
+
*
|
|
142
|
+
* Consulted whenever a level's approvers are resolved — at submit, when a level
|
|
143
|
+
* activates, and when a chain is previewed. Kept as an injected provider rather
|
|
144
|
+
* than engine-owned state because absence lives in the HR or directory system
|
|
145
|
+
* that already knows about leave; duplicating it here would guarantee the two
|
|
146
|
+
* disagree.
|
|
147
|
+
*/
|
|
148
|
+
interface OutOfOfficeProvider {
|
|
149
|
+
/**
|
|
150
|
+
* @param userId - The approver about to be assigned.
|
|
151
|
+
* @param at - The moment cover is being resolved for.
|
|
152
|
+
* @returns The user to stand in, or null/undefined when the approver is available.
|
|
153
|
+
*/
|
|
154
|
+
getDelegateFor(userId: string, at: Date): Promise<string | null | undefined> | string | null | undefined;
|
|
155
|
+
}
|
|
139
156
|
type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
|
|
140
157
|
submittedBy: string;
|
|
141
158
|
data: Record<string, unknown>;
|
|
@@ -296,6 +313,11 @@ interface ApprovalEngineOptions {
|
|
|
296
313
|
adapter: IStorageAdapter;
|
|
297
314
|
tenantId?: string;
|
|
298
315
|
orgProvider?: OrgProvider;
|
|
316
|
+
/**
|
|
317
|
+
* Supplies stand-ins for approvers who are away, so leave does not stall a
|
|
318
|
+
* chain. Consulted every time approvers are resolved.
|
|
319
|
+
*/
|
|
320
|
+
outOfOfficeProvider?: OutOfOfficeProvider;
|
|
299
321
|
logger?: Logger;
|
|
300
322
|
escalationPollIntervalMs?: number;
|
|
301
323
|
/** Maximum number of instances allowed in a single bulk operation. Default: 200. */
|
|
@@ -546,4 +568,4 @@ declare class ApprovalEngine {
|
|
|
546
568
|
private runExternalAudit;
|
|
547
569
|
}
|
|
548
570
|
|
|
549
|
-
export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type
|
|
571
|
+
export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type RejectOptions as n, type ResubmitOptions as o, type RetryPolicy as p, defaultIdGenerator as q, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
|
|
@@ -136,6 +136,23 @@ interface OrgProvider {
|
|
|
136
136
|
/** Optional: resolve users matching a custom attribute/value pair. */
|
|
137
137
|
getUsersByAttribute?(attr: string, value: unknown, tenantId?: string): Promise<string[]> | string[];
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Supplies out-of-office cover so an approver on leave does not stall a chain.
|
|
141
|
+
*
|
|
142
|
+
* Consulted whenever a level's approvers are resolved — at submit, when a level
|
|
143
|
+
* activates, and when a chain is previewed. Kept as an injected provider rather
|
|
144
|
+
* than engine-owned state because absence lives in the HR or directory system
|
|
145
|
+
* that already knows about leave; duplicating it here would guarantee the two
|
|
146
|
+
* disagree.
|
|
147
|
+
*/
|
|
148
|
+
interface OutOfOfficeProvider {
|
|
149
|
+
/**
|
|
150
|
+
* @param userId - The approver about to be assigned.
|
|
151
|
+
* @param at - The moment cover is being resolved for.
|
|
152
|
+
* @returns The user to stand in, or null/undefined when the approver is available.
|
|
153
|
+
*/
|
|
154
|
+
getDelegateFor(userId: string, at: Date): Promise<string | null | undefined> | string | null | undefined;
|
|
155
|
+
}
|
|
139
156
|
type ApproverResolverFn = (config: Record<string, unknown>, ctx: {
|
|
140
157
|
submittedBy: string;
|
|
141
158
|
data: Record<string, unknown>;
|
|
@@ -296,6 +313,11 @@ interface ApprovalEngineOptions {
|
|
|
296
313
|
adapter: IStorageAdapter;
|
|
297
314
|
tenantId?: string;
|
|
298
315
|
orgProvider?: OrgProvider;
|
|
316
|
+
/**
|
|
317
|
+
* Supplies stand-ins for approvers who are away, so leave does not stall a
|
|
318
|
+
* chain. Consulted every time approvers are resolved.
|
|
319
|
+
*/
|
|
320
|
+
outOfOfficeProvider?: OutOfOfficeProvider;
|
|
299
321
|
logger?: Logger;
|
|
300
322
|
escalationPollIntervalMs?: number;
|
|
301
323
|
/** Maximum number of instances allowed in a single bulk operation. Default: 200. */
|
|
@@ -546,4 +568,4 @@ declare class ApprovalEngine {
|
|
|
546
568
|
private runExternalAudit;
|
|
547
569
|
}
|
|
548
570
|
|
|
549
|
-
export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type
|
|
571
|
+
export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type RejectOptions as n, type ResubmitOptions as o, type RetryPolicy as p, defaultIdGenerator as q, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
|
package/dist/index.cjs
CHANGED
|
@@ -331,6 +331,7 @@ var TemplateRegistry = class {
|
|
|
331
331
|
};
|
|
332
332
|
|
|
333
333
|
// src/engine/LevelResolver.ts
|
|
334
|
+
var MAX_OOO_HOPS = 5;
|
|
334
335
|
var LevelResolver = class {
|
|
335
336
|
constructor() {
|
|
336
337
|
this.resolvers = /* @__PURE__ */ new Map();
|
|
@@ -342,7 +343,7 @@ var LevelResolver = class {
|
|
|
342
343
|
registerApproverType(typeName, fn) {
|
|
343
344
|
this.approverTypes.set(typeName, fn);
|
|
344
345
|
}
|
|
345
|
-
async resolveApprovers(approvers, submittedBy, data, orgProvider) {
|
|
346
|
+
async resolveApprovers(approvers, submittedBy, data, orgProvider, outOfOffice, at) {
|
|
346
347
|
const resolved = [];
|
|
347
348
|
for (const approver of approvers) {
|
|
348
349
|
switch (approver.type) {
|
|
@@ -362,7 +363,9 @@ var LevelResolver = class {
|
|
|
362
363
|
break;
|
|
363
364
|
}
|
|
364
365
|
case "dynamic": {
|
|
365
|
-
const fn = this.resolvers.get(
|
|
366
|
+
const fn = this.resolvers.get(
|
|
367
|
+
approver.resolver
|
|
368
|
+
);
|
|
366
369
|
if (!fn) {
|
|
367
370
|
throw new Error(
|
|
368
371
|
`No resolver registered for "${approver.resolver}". Call engine.registerResolver("${approver.resolver}", fn) first.`
|
|
@@ -379,7 +382,11 @@ var LevelResolver = class {
|
|
|
379
382
|
`Unknown approver type "${approver.type}". Register it with engine.registerApproverType("${approver.type}", fn) first.`
|
|
380
383
|
);
|
|
381
384
|
}
|
|
382
|
-
const ids = await customFn(approver, {
|
|
385
|
+
const ids = await customFn(approver, {
|
|
386
|
+
submittedBy,
|
|
387
|
+
data,
|
|
388
|
+
orgProvider
|
|
389
|
+
});
|
|
383
390
|
resolved.push(...ids);
|
|
384
391
|
break;
|
|
385
392
|
}
|
|
@@ -391,7 +398,40 @@ var LevelResolver = class {
|
|
|
391
398
|
"No approvers resolved for this level. Check your approver configuration \u2014 role may have no members or dynamic resolver returned empty."
|
|
392
399
|
);
|
|
393
400
|
}
|
|
394
|
-
return result;
|
|
401
|
+
return this.applyOutOfOffice(result, outOfOffice, at);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Replace approvers who are away with their cover.
|
|
405
|
+
*
|
|
406
|
+
* Substitution is transitive up to {@link MAX_OOO_HOPS} so an A→B→C chain of
|
|
407
|
+
* absences still lands on someone present, but a cycle (A covers B while B
|
|
408
|
+
* covers A) simply stops rather than looping — leaving the original approver
|
|
409
|
+
* assigned, which is visible and fixable, unlike a hang.
|
|
410
|
+
*
|
|
411
|
+
* A provider that throws is treated as "no cover known": an HR lookup failing
|
|
412
|
+
* must not block an approval from being routed at all.
|
|
413
|
+
*/
|
|
414
|
+
async applyOutOfOffice(userIds, provider, at) {
|
|
415
|
+
if (!provider) return userIds;
|
|
416
|
+
const asOf = at ?? /* @__PURE__ */ new Date();
|
|
417
|
+
const covered = [];
|
|
418
|
+
for (const original of userIds) {
|
|
419
|
+
let current = original;
|
|
420
|
+
const seen = /* @__PURE__ */ new Set([current]);
|
|
421
|
+
for (let hop = 0; hop < MAX_OOO_HOPS; hop++) {
|
|
422
|
+
let delegate;
|
|
423
|
+
try {
|
|
424
|
+
delegate = await provider.getDelegateFor(current, asOf);
|
|
425
|
+
} catch {
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (!delegate || delegate === current || seen.has(delegate)) break;
|
|
429
|
+
seen.add(delegate);
|
|
430
|
+
current = delegate;
|
|
431
|
+
}
|
|
432
|
+
covered.push(current);
|
|
433
|
+
}
|
|
434
|
+
return [...new Set(covered)];
|
|
395
435
|
}
|
|
396
436
|
};
|
|
397
437
|
|
|
@@ -1149,7 +1189,9 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1149
1189
|
lvl.approverConfigs,
|
|
1150
1190
|
opts.submittedBy,
|
|
1151
1191
|
opts.data,
|
|
1152
|
-
this.opts.orgProvider
|
|
1192
|
+
this.opts.orgProvider,
|
|
1193
|
+
this.opts.outOfOfficeProvider,
|
|
1194
|
+
now
|
|
1153
1195
|
);
|
|
1154
1196
|
}
|
|
1155
1197
|
const auditEntry = {
|
|
@@ -2152,7 +2194,9 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2152
2194
|
cfg.approvers,
|
|
2153
2195
|
submittedBy,
|
|
2154
2196
|
data,
|
|
2155
|
-
this.opts.orgProvider
|
|
2197
|
+
this.opts.orgProvider,
|
|
2198
|
+
this.opts.outOfOfficeProvider,
|
|
2199
|
+
this.clock.now()
|
|
2156
2200
|
);
|
|
2157
2201
|
levels.push({ level: cfg.level, name: cfg.name, resolvedApprovers, mode: cfg.mode });
|
|
2158
2202
|
} catch {
|
|
@@ -2531,7 +2575,9 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2531
2575
|
[escalationConfig.escalateTo],
|
|
2532
2576
|
instance.submittedBy,
|
|
2533
2577
|
instance.data,
|
|
2534
|
-
this.opts.orgProvider
|
|
2578
|
+
this.opts.orgProvider,
|
|
2579
|
+
this.opts.outOfOfficeProvider,
|
|
2580
|
+
this.clock.now()
|
|
2535
2581
|
);
|
|
2536
2582
|
const filteredApprovers = newApprovers.filter((id) => id !== instance.submittedBy);
|
|
2537
2583
|
if (filteredApprovers.length === 0) {
|
|
@@ -2816,7 +2862,9 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2816
2862
|
lvl.approverConfigs,
|
|
2817
2863
|
instance.submittedBy,
|
|
2818
2864
|
instance.data,
|
|
2819
|
-
this.opts.orgProvider
|
|
2865
|
+
this.opts.orgProvider,
|
|
2866
|
+
this.opts.outOfOfficeProvider,
|
|
2867
|
+
now
|
|
2820
2868
|
);
|
|
2821
2869
|
if (lvl.escalationAfterDays) {
|
|
2822
2870
|
lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
|