hierarchical-approval 2.5.0 → 2.7.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +58 -0
  3. package/dist/{ApprovalEngine-CW7Uu1bV.d.cts → ApprovalEngine-BH9GvzMI.d.cts} +97 -7
  4. package/dist/{ApprovalEngine-BHWls8-z.d.ts → ApprovalEngine-DkQcLaeT.d.ts} +97 -7
  5. package/dist/{IAuditAdapter-Dwx8ZP4J.d.cts → IAuditAdapter-2UU5edKG.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-CMCirvY-.d.ts → IAuditAdapter-DSor54iY.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-BryQIKko.d.ts → IAuthorizationPolicy-BOWg-3AH.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-B3vaV69v.d.cts → IAuthorizationPolicy-DPpiKzyh.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-BBq2czpO.d.ts → INotificationAdapter-BNGivLgU.d.ts} +1 -1
  10. package/dist/{INotificationAdapter-AwbhZEqz.d.cts → INotificationAdapter-C6HPrzh6.d.cts} +1 -1
  11. package/dist/{IOperationMiddleware-CqzFklMn.d.ts → IOperationMiddleware-CXtg2wzF.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-3J-_NSGq.d.cts → IOperationMiddleware-CxnPzpqL.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-Dw2xXP9B.d.ts → IStorageAdapter-ChPT7ZDp.d.ts} +1 -1
  14. package/dist/{IStorageAdapter-Pq9J60xv.d.cts → IStorageAdapter-yX9ERfQE.d.cts} +1 -1
  15. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  16. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  17. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  18. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  19. package/dist/index.cjs +157 -8
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +8 -8
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +157 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-BO-i9-nq.d.cts → instance-weh2w7Ji.d.cts} +26 -1
  26. package/dist/{instance-BO-i9-nq.d.ts → instance-weh2w7Ji.d.ts} +26 -1
  27. package/dist/nestjs.cjs +157 -8
  28. package/dist/nestjs.cjs.map +1 -1
  29. package/dist/nestjs.d.cts +7 -7
  30. package/dist/nestjs.d.ts +7 -7
  31. package/dist/nestjs.js +157 -8
  32. package/dist/nestjs.js.map +1 -1
  33. package/dist/plugins/audit.d.cts +2 -2
  34. package/dist/plugins/audit.d.ts +2 -2
  35. package/dist/plugins/notify.d.cts +2 -2
  36. package/dist/plugins/notify.d.ts +2 -2
  37. package/dist/plugins/resilience.d.cts +3 -3
  38. package/dist/plugins/resilience.d.ts +3 -3
  39. package/dist/plugins/tracing.d.cts +2 -2
  40. package/dist/plugins/tracing.d.ts +2 -2
  41. package/dist/plugins/webhook.d.cts +2 -2
  42. package/dist/plugins/webhook.d.ts +2 -2
  43. package/dist/testing.cjs +157 -8
  44. package/dist/testing.cjs.map +1 -1
  45. package/dist/testing.d.cts +7 -7
  46. package/dist/testing.d.ts +7 -7
  47. package/dist/testing.js +157 -8
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -1273,7 +1273,8 @@ var ApprovalEngine = class _ApprovalEngine {
1273
1273
  weights: cfg.weights,
1274
1274
  escalationAfterDays: cfg.escalationAfterDays,
1275
1275
  escalationAfterHours: cfg.escalationAfterHours,
1276
- escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg) : void 0,
1276
+ escalationStep: 0,
1277
+ escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
1277
1278
  subWorkflowTemplate: cfg.subWorkflow?.templateName,
1278
1279
  reminderAfterDays: cfg.reminderAfterDays,
1279
1280
  reminderEveryDays: cfg.reminderEveryDays,
@@ -1330,6 +1331,7 @@ var ApprovalEngine = class _ApprovalEngine {
1330
1331
  slaDeadlineAt,
1331
1332
  templateSnapshot: {
1332
1333
  escalation: template.escalation,
1334
+ escalationSteps: template.escalationSteps,
1333
1335
  slaDeadlineDays: template.slaDeadlineDays,
1334
1336
  slaDeadlineHours: template.slaDeadlineHours,
1335
1337
  allowOverride: template.allowOverride
@@ -2625,6 +2627,7 @@ var ApprovalEngine = class _ApprovalEngine {
2625
2627
  slaDeadlineAt,
2626
2628
  templateSnapshot: {
2627
2629
  escalation: template.escalation,
2630
+ escalationSteps: template.escalationSteps,
2628
2631
  slaDeadlineDays: template.slaDeadlineDays,
2629
2632
  slaDeadlineHours: template.slaDeadlineHours,
2630
2633
  allowOverride: template.allowOverride
@@ -2690,6 +2693,98 @@ var ApprovalEngine = class _ApprovalEngine {
2690
2693
  return { levels, conditionsApplied };
2691
2694
  }
2692
2695
  /** Check whether a user is eligible to approve a specific instance. Never throws. */
2696
+ /**
2697
+ * Explain why a chain resolves the way it does for a given document.
2698
+ *
2699
+ * `previewApprovalChain()` answers *what* the chain will be. This answers
2700
+ * *why*: which rule added a level, which rule removed one, which rules were
2701
+ * evaluated and did not match, and where each level's approvers came from —
2702
+ * the question behind "why does this purchase order have a CFO level?", which
2703
+ * previously meant reading the template and re-evaluating the conditions by
2704
+ * hand.
2705
+ *
2706
+ * A rule that throws — an operator nobody registered, a malformed group — is
2707
+ * reported against that rule rather than failing the whole explanation. The
2708
+ * explanation is a diagnostic tool, and it is least useful at exactly the
2709
+ * moment a broken rule makes it throw.
2710
+ *
2711
+ * Reads nothing and writes nothing; safe to expose to a support UI.
2712
+ *
2713
+ * @param templateName - Template to explain.
2714
+ * @param data - Document data the conditions are evaluated against.
2715
+ * @param submittedBy - Submitter, used for approver resolution.
2716
+ */
2717
+ async explainChain(templateName, data, submittedBy) {
2718
+ const template = await this.registry.get(templateName);
2719
+ const conditions = template.conditions ?? [];
2720
+ const rules = [];
2721
+ const addedBy = /* @__PURE__ */ new Map();
2722
+ const skippedBy = /* @__PURE__ */ new Map();
2723
+ conditions.forEach((rule, index) => {
2724
+ const addsLevels = (rule.addLevels ?? []).map((l) => l.level);
2725
+ const skipsLevels = rule.skipLevels ?? [];
2726
+ try {
2727
+ const outcome = evaluateConditions([rule], data);
2728
+ const matched = outcome.addLevels.length > 0 || outcome.skipLevels.size > 0;
2729
+ rules.push({ index, matched, addsLevels, skipsLevels });
2730
+ if (matched) {
2731
+ for (const l of outcome.addLevels) if (!addedBy.has(l.level)) addedBy.set(l.level, index);
2732
+ for (const l of outcome.skipLevels) if (!skippedBy.has(l)) skippedBy.set(l, index);
2733
+ }
2734
+ } catch (err) {
2735
+ rules.push({
2736
+ index,
2737
+ matched: false,
2738
+ addsLevels,
2739
+ skipsLevels,
2740
+ error: err.message
2741
+ });
2742
+ }
2743
+ });
2744
+ let mutations;
2745
+ try {
2746
+ mutations = evaluateConditions(conditions, data);
2747
+ } catch {
2748
+ mutations = { addLevels: [], skipLevels: /* @__PURE__ */ new Set() };
2749
+ }
2750
+ const active = [...template.levels, ...mutations.addLevels].filter((l) => !mutations.skipLevels.has(l.level)).sort((a, b) => a.level - b.level);
2751
+ const levels = [];
2752
+ for (const cfg of active) {
2753
+ const fromCondition = addedBy.has(cfg.level);
2754
+ const base = {
2755
+ level: cfg.level,
2756
+ name: cfg.name,
2757
+ mode: cfg.mode,
2758
+ source: fromCondition ? "condition" : "template",
2759
+ ...fromCondition ? { addedByRule: addedBy.get(cfg.level) } : {},
2760
+ resolvedApprovers: [],
2761
+ ...cfg.subWorkflow ? { subWorkflowTemplate: cfg.subWorkflow.templateName } : {}
2762
+ };
2763
+ if (cfg.subWorkflow) {
2764
+ levels.push(base);
2765
+ continue;
2766
+ }
2767
+ try {
2768
+ base.resolvedApprovers = await this.resolver.resolveApprovers(
2769
+ cfg.approvers,
2770
+ submittedBy,
2771
+ data,
2772
+ this.opts.orgProvider,
2773
+ this.opts.outOfOfficeProvider,
2774
+ this.clock.now()
2775
+ );
2776
+ } catch (err) {
2777
+ base.resolutionError = err.message;
2778
+ }
2779
+ levels.push(base);
2780
+ }
2781
+ const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
2782
+ level: l.level,
2783
+ name: l.name,
2784
+ skippedByRule: skippedBy.get(l.level) ?? -1
2785
+ })).sort((a, b) => a.level - b.level);
2786
+ return { templateName: template.name, levels, skipped, rules };
2787
+ }
2693
2788
  async canApprove(instanceId, userId) {
2694
2789
  let instance;
2695
2790
  try {
@@ -3373,10 +3468,14 @@ var ApprovalEngine = class _ApprovalEngine {
3373
3468
  async escalateInternal(instanceId, escalatedBy = "system", auditCtx, levelNumber) {
3374
3469
  return this.withOptimisticRetry(instanceId, async (instance) => {
3375
3470
  if (instance.status !== "pending") return instance;
3376
- const escalationConfig = instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation;
3377
- if (!escalationConfig) return instance;
3471
+ const ladder = await this.escalationLadder(instance);
3472
+ const targetLevel = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3473
+ const rungIndex = targetLevel.escalationStep ?? 0;
3474
+ const rung = ladder[rungIndex];
3475
+ const escalateTo = rung?.escalateTo ?? (instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation)?.escalateTo;
3476
+ if (!escalateTo) return instance;
3378
3477
  const newApprovers = await this.resolver.resolveApprovers(
3379
- [escalationConfig.escalateTo],
3478
+ [escalateTo],
3380
3479
  instance.submittedBy,
3381
3480
  instance.data,
3382
3481
  this.opts.orgProvider,
@@ -3394,10 +3493,13 @@ var ApprovalEngine = class _ApprovalEngine {
3394
3493
  );
3395
3494
  return instance;
3396
3495
  }
3397
- const level = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3496
+ const level = targetLevel;
3398
3497
  level.approverIds = [.../* @__PURE__ */ new Set([...level.approverIds, ...filteredApprovers])];
3399
- level.escalationDueAt = void 0;
3400
3498
  const now = this.clock.now();
3499
+ level.escalationStep = rungIndex + 1;
3500
+ const nextRung = ladder[rungIndex + 1];
3501
+ const levelOpenedAt = this.levelOpenedAt(instance, level, now);
3502
+ level.escalationDueAt = nextRung ? this.stepDueAt(levelOpenedAt, nextRung) : void 0;
3401
3503
  const escalatedTo = filteredApprovers[0] ?? "unknown";
3402
3504
  const auditEntry = {
3403
3505
  action: "escalated",
@@ -3582,9 +3684,54 @@ var ApprovalEngine = class _ApprovalEngine {
3582
3684
  return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
3583
3685
  }
3584
3686
  /** Level deadline from whichever of days/hours the template configured. */
3585
- levelEscalationDue(from, level) {
3687
+ levelEscalationDue(from, level, firstRung) {
3586
3688
  if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
3587
3689
  if (level.escalationAfterDays) return this.deadlineFrom(from, level.escalationAfterDays);
3690
+ return firstRung ? this.stepDueAt(from, firstRung) : void 0;
3691
+ }
3692
+ /** First rung of a ladder, sorted by delay, or undefined when there is none. */
3693
+ firstRungOf(steps) {
3694
+ if (!steps || steps.length === 0) return void 0;
3695
+ return [...steps].sort(
3696
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3697
+ )[0];
3698
+ }
3699
+ /**
3700
+ * The escalation ladder for an instance, sorted by delay.
3701
+ *
3702
+ * Read from the instance's template snapshot so an in-flight approval keeps
3703
+ * the ladder it was submitted under, exactly as the single-step
3704
+ * {@link EscalationConfig} already did.
3705
+ */
3706
+ async escalationLadder(instance) {
3707
+ const snapshot = instance.templateSnapshot;
3708
+ const steps = snapshot?.escalationSteps ?? (await this.registry.get(instance.templateName)).escalationSteps ?? [];
3709
+ return [...steps].sort(
3710
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3711
+ );
3712
+ }
3713
+ /**
3714
+ * When a level started collecting decisions.
3715
+ *
3716
+ * Escalation rungs are measured from this, not from the previous rung, so a
3717
+ * ladder reads the way it is written. Recovered from the audit trail — the
3718
+ * `submitted` entry for the opening level, the `level_advanced` entry
3719
+ * otherwise — and falls back to `now` when no entry exists, which only leaves
3720
+ * the ladder no worse off than the single-step behaviour it replaces.
3721
+ */
3722
+ levelOpenedAt(instance, level, fallback) {
3723
+ for (let i = instance.auditLog.length - 1; i >= 0; i--) {
3724
+ const entry = instance.auditLog[i];
3725
+ if (!entry) continue;
3726
+ const opensThisLevel = (entry.action === "level_advanced" || entry.action === "submitted") && entry.level === level.level;
3727
+ if (opensThisLevel) return new Date(entry.timestamp);
3728
+ }
3729
+ return fallback;
3730
+ }
3731
+ /** Deadline for one rung, measured from when the level opened. */
3732
+ stepDueAt(from, step) {
3733
+ if (step.afterHours) return this.deadlineFromHours(from, step.afterHours);
3734
+ if (step.afterDays) return this.deadlineFrom(from, step.afterDays);
3588
3735
  return void 0;
3589
3736
  }
3590
3737
  async requireInstance(id) {
@@ -3679,6 +3826,7 @@ var ApprovalEngine = class _ApprovalEngine {
3679
3826
  }
3680
3827
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3681
3828
  async activateGroup(instance, group, now) {
3829
+ const firstRung = this.firstRungOf(instance.templateSnapshot?.escalationSteps);
3682
3830
  for (const lvl of group) {
3683
3831
  if (lvl.subWorkflowTemplate) {
3684
3832
  lvl.approverIds = [];
@@ -3692,7 +3840,8 @@ var ApprovalEngine = class _ApprovalEngine {
3692
3840
  now
3693
3841
  );
3694
3842
  }
3695
- lvl.escalationDueAt = this.levelEscalationDue(now, lvl);
3843
+ lvl.escalationDueAt = this.levelEscalationDue(now, lvl, firstRung);
3844
+ lvl.escalationStep = 0;
3696
3845
  this.scheduleReminder(lvl, now);
3697
3846
  lvl.status = "pending";
3698
3847
  }