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 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 OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
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 OverrideOptions as k, type PreviewResult as l, type RejectOptions as m, type ResubmitOptions as n, type RetryPolicy as o, defaultIdGenerator as p, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
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(approver.resolver);
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, { submittedBy, data, orgProvider });
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);