hierarchical-approval 2.4.0 → 2.6.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 +71 -0
  2. package/README.md +54 -0
  3. package/dist/{ApprovalEngine-C1dUPnLM.d.cts → ApprovalEngine-BWrblNQ0.d.cts} +81 -7
  4. package/dist/{ApprovalEngine-Ci9urTDI.d.ts → ApprovalEngine-D4DdSx-M.d.ts} +81 -7
  5. package/dist/{IAuditAdapter-BHVKMMv6.d.cts → IAuditAdapter-2UU5edKG.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-C4OASS6u.d.ts → IAuditAdapter-DSor54iY.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-BA_m2Dg9.d.ts → IAuthorizationPolicy-BOWg-3AH.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-CtLkwQ8I.d.cts → IAuthorizationPolicy-DPpiKzyh.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-BdgfUrIn.d.ts → INotificationAdapter-BNGivLgU.d.ts} +1 -1
  10. package/dist/{INotificationAdapter-JJAiMyMd.d.cts → INotificationAdapter-C6HPrzh6.d.cts} +1 -1
  11. package/dist/{IOperationMiddleware-BGYrAqep.d.ts → IOperationMiddleware-CXtg2wzF.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-D0ksiFM3.d.cts → IOperationMiddleware-CxnPzpqL.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-BU3sau5W.d.ts → IStorageAdapter-ChPT7ZDp.d.ts} +1 -1
  14. package/dist/{IStorageAdapter-DdHO4Rf1.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 +179 -11
  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 +179 -12
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-DUJY_Axf.d.cts → instance-weh2w7Ji.d.cts} +39 -1
  26. package/dist/{instance-DUJY_Axf.d.ts → instance-weh2w7Ji.d.ts} +39 -1
  27. package/dist/nestjs.cjs +111 -11
  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 +111 -11
  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 +111 -11
  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 +111 -11
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -1071,6 +1071,18 @@ var ApprovalEngine = class _ApprovalEngine {
1071
1071
  message: `Level ${l.level} sets reminderEveryDays without reminderAfterDays, so no reminder would ever be sent.`
1072
1072
  });
1073
1073
  }
1074
+ if (l.escalationAfterHours !== void 0 && l.escalationAfterHours <= 0) {
1075
+ errors.push({
1076
+ field: `levels[${i}].escalationAfterHours`,
1077
+ message: `Level ${l.level} escalationAfterHours must be a positive number.`
1078
+ });
1079
+ }
1080
+ if (l.escalationAfterDays !== void 0 && l.escalationAfterHours !== void 0) {
1081
+ errors.push({
1082
+ field: `levels[${i}].escalationAfterHours`,
1083
+ message: `Level ${l.level} sets both escalationAfterDays and escalationAfterHours; pick one so the deadline is unambiguous.`
1084
+ });
1085
+ }
1074
1086
  if (l.escalationAfterDays !== void 0 && l.escalationAfterDays <= 0) {
1075
1087
  errors.push({
1076
1088
  field: `levels[${i}].escalationAfterDays`,
@@ -1135,6 +1147,18 @@ var ApprovalEngine = class _ApprovalEngine {
1135
1147
  }
1136
1148
  });
1137
1149
  }
1150
+ if (config.slaDeadlineDays !== void 0 && config.slaDeadlineHours !== void 0) {
1151
+ errors.push({
1152
+ field: "slaDeadlineHours",
1153
+ message: "Template sets both slaDeadlineDays and slaDeadlineHours; pick one so the SLA is unambiguous."
1154
+ });
1155
+ }
1156
+ if (config.slaDeadlineHours !== void 0 && config.slaDeadlineHours <= 0) {
1157
+ errors.push({
1158
+ field: "slaDeadlineHours",
1159
+ message: "slaDeadlineHours must be a positive number."
1160
+ });
1161
+ }
1138
1162
  if (config.conditions) {
1139
1163
  config.conditions.forEach((rule, ruleIdx) => {
1140
1164
  errors.push(...validateConditionExpression(rule.when, `conditions[${ruleIdx}].when`));
@@ -1248,7 +1272,9 @@ var ApprovalEngine = class _ApprovalEngine {
1248
1272
  threshold: cfg.threshold,
1249
1273
  weights: cfg.weights,
1250
1274
  escalationAfterDays: cfg.escalationAfterDays,
1251
- escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1275
+ escalationAfterHours: cfg.escalationAfterHours,
1276
+ escalationStep: 0,
1277
+ escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
1252
1278
  subWorkflowTemplate: cfg.subWorkflow?.templateName,
1253
1279
  reminderAfterDays: cfg.reminderAfterDays,
1254
1280
  reminderEveryDays: cfg.reminderEveryDays,
@@ -1278,7 +1304,7 @@ var ApprovalEngine = class _ApprovalEngine {
1278
1304
  timestamp: now,
1279
1305
  ...auditCtx
1280
1306
  };
1281
- const slaDeadlineAt = template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
1307
+ const slaDeadlineAt = template.slaDeadlineHours ? this.deadlineFromHours(now, template.slaDeadlineHours) : template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
1282
1308
  const instance = {
1283
1309
  id: instanceId,
1284
1310
  tenantId: this.tenantId,
@@ -1305,7 +1331,9 @@ var ApprovalEngine = class _ApprovalEngine {
1305
1331
  slaDeadlineAt,
1306
1332
  templateSnapshot: {
1307
1333
  escalation: template.escalation,
1334
+ escalationSteps: template.escalationSteps,
1308
1335
  slaDeadlineDays: template.slaDeadlineDays,
1336
+ slaDeadlineHours: template.slaDeadlineHours,
1309
1337
  allowOverride: template.allowOverride
1310
1338
  }
1311
1339
  };
@@ -2577,7 +2605,7 @@ var ApprovalEngine = class _ApprovalEngine {
2577
2605
  reason: opts.reason,
2578
2606
  ...auditCtx
2579
2607
  };
2580
- const slaDeadlineAt = template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
2608
+ const slaDeadlineAt = template.slaDeadlineHours ? this.deadlineFromHours(now, template.slaDeadlineHours) : template.slaDeadlineDays ? this.deadlineFrom(now, template.slaDeadlineDays) : void 0;
2581
2609
  const newInstance = {
2582
2610
  id: newInstanceId,
2583
2611
  tenantId: this.tenantId,
@@ -2599,7 +2627,9 @@ var ApprovalEngine = class _ApprovalEngine {
2599
2627
  slaDeadlineAt,
2600
2628
  templateSnapshot: {
2601
2629
  escalation: template.escalation,
2630
+ escalationSteps: template.escalationSteps,
2602
2631
  slaDeadlineDays: template.slaDeadlineDays,
2632
+ slaDeadlineHours: template.slaDeadlineHours,
2603
2633
  allowOverride: template.allowOverride
2604
2634
  }
2605
2635
  };
@@ -3346,10 +3376,14 @@ var ApprovalEngine = class _ApprovalEngine {
3346
3376
  async escalateInternal(instanceId, escalatedBy = "system", auditCtx, levelNumber) {
3347
3377
  return this.withOptimisticRetry(instanceId, async (instance) => {
3348
3378
  if (instance.status !== "pending") return instance;
3349
- const escalationConfig = instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation;
3350
- if (!escalationConfig) return instance;
3379
+ const ladder = await this.escalationLadder(instance);
3380
+ const targetLevel = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3381
+ const rungIndex = targetLevel.escalationStep ?? 0;
3382
+ const rung = ladder[rungIndex];
3383
+ const escalateTo = rung?.escalateTo ?? (instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation)?.escalateTo;
3384
+ if (!escalateTo) return instance;
3351
3385
  const newApprovers = await this.resolver.resolveApprovers(
3352
- [escalationConfig.escalateTo],
3386
+ [escalateTo],
3353
3387
  instance.submittedBy,
3354
3388
  instance.data,
3355
3389
  this.opts.orgProvider,
@@ -3367,10 +3401,13 @@ var ApprovalEngine = class _ApprovalEngine {
3367
3401
  );
3368
3402
  return instance;
3369
3403
  }
3370
- const level = levelNumber === void 0 ? this.currentLevelInstance(instance) : instance.levels.find((l) => l.level === levelNumber) ?? this.currentLevelInstance(instance);
3404
+ const level = targetLevel;
3371
3405
  level.approverIds = [.../* @__PURE__ */ new Set([...level.approverIds, ...filteredApprovers])];
3372
- level.escalationDueAt = void 0;
3373
3406
  const now = this.clock.now();
3407
+ level.escalationStep = rungIndex + 1;
3408
+ const nextRung = ladder[rungIndex + 1];
3409
+ const levelOpenedAt = this.levelOpenedAt(instance, level, now);
3410
+ level.escalationDueAt = nextRung ? this.stepDueAt(levelOpenedAt, nextRung) : void 0;
3374
3411
  const escalatedTo = filteredApprovers[0] ?? "unknown";
3375
3412
  const auditEntry = {
3376
3413
  action: "escalated",
@@ -3542,6 +3579,69 @@ var ApprovalEngine = class _ApprovalEngine {
3542
3579
  deadlineFrom(from, days) {
3543
3580
  return this.calendar ? this.calendar.addBusinessDays(from, days) : new Date(from.getTime() + days * 864e5);
3544
3581
  }
3582
+ /**
3583
+ * Resolve an hour-based deadline.
3584
+ *
3585
+ * A calendar that only knows whole days — `weekendCalendar`, say — has no
3586
+ * `addBusinessHours`, and this falls back to elapsed clock time rather than
3587
+ * quietly pretending the calendar was applied. Configure
3588
+ * `businessHoursCalendar` to have hours skip evenings and weekends.
3589
+ */
3590
+ deadlineFromHours(from, hours) {
3591
+ const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
3592
+ return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
3593
+ }
3594
+ /** Level deadline from whichever of days/hours the template configured. */
3595
+ levelEscalationDue(from, level, firstRung) {
3596
+ if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
3597
+ if (level.escalationAfterDays) return this.deadlineFrom(from, level.escalationAfterDays);
3598
+ return firstRung ? this.stepDueAt(from, firstRung) : void 0;
3599
+ }
3600
+ /** First rung of a ladder, sorted by delay, or undefined when there is none. */
3601
+ firstRungOf(steps) {
3602
+ if (!steps || steps.length === 0) return void 0;
3603
+ return [...steps].sort(
3604
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3605
+ )[0];
3606
+ }
3607
+ /**
3608
+ * The escalation ladder for an instance, sorted by delay.
3609
+ *
3610
+ * Read from the instance's template snapshot so an in-flight approval keeps
3611
+ * the ladder it was submitted under, exactly as the single-step
3612
+ * {@link EscalationConfig} already did.
3613
+ */
3614
+ async escalationLadder(instance) {
3615
+ const snapshot = instance.templateSnapshot;
3616
+ const steps = snapshot?.escalationSteps ?? (await this.registry.get(instance.templateName)).escalationSteps ?? [];
3617
+ return [...steps].sort(
3618
+ (a, b) => (a.afterHours ?? (a.afterDays ?? 0) * 24) - (b.afterHours ?? (b.afterDays ?? 0) * 24)
3619
+ );
3620
+ }
3621
+ /**
3622
+ * When a level started collecting decisions.
3623
+ *
3624
+ * Escalation rungs are measured from this, not from the previous rung, so a
3625
+ * ladder reads the way it is written. Recovered from the audit trail — the
3626
+ * `submitted` entry for the opening level, the `level_advanced` entry
3627
+ * otherwise — and falls back to `now` when no entry exists, which only leaves
3628
+ * the ladder no worse off than the single-step behaviour it replaces.
3629
+ */
3630
+ levelOpenedAt(instance, level, fallback) {
3631
+ for (let i = instance.auditLog.length - 1; i >= 0; i--) {
3632
+ const entry = instance.auditLog[i];
3633
+ if (!entry) continue;
3634
+ const opensThisLevel = (entry.action === "level_advanced" || entry.action === "submitted") && entry.level === level.level;
3635
+ if (opensThisLevel) return new Date(entry.timestamp);
3636
+ }
3637
+ return fallback;
3638
+ }
3639
+ /** Deadline for one rung, measured from when the level opened. */
3640
+ stepDueAt(from, step) {
3641
+ if (step.afterHours) return this.deadlineFromHours(from, step.afterHours);
3642
+ if (step.afterDays) return this.deadlineFrom(from, step.afterDays);
3643
+ return void 0;
3644
+ }
3545
3645
  async requireInstance(id) {
3546
3646
  const instance = await this.opts.adapter.getInstance(this.tenantId, id);
3547
3647
  if (!instance) throw new ApprovalNotFoundError("Instance", id);
@@ -3634,6 +3734,7 @@ var ApprovalEngine = class _ApprovalEngine {
3634
3734
  }
3635
3735
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3636
3736
  async activateGroup(instance, group, now) {
3737
+ const firstRung = this.firstRungOf(instance.templateSnapshot?.escalationSteps);
3637
3738
  for (const lvl of group) {
3638
3739
  if (lvl.subWorkflowTemplate) {
3639
3740
  lvl.approverIds = [];
@@ -3647,9 +3748,8 @@ var ApprovalEngine = class _ApprovalEngine {
3647
3748
  now
3648
3749
  );
3649
3750
  }
3650
- if (lvl.escalationAfterDays) {
3651
- lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3652
- }
3751
+ lvl.escalationDueAt = this.levelEscalationDue(now, lvl, firstRung);
3752
+ lvl.escalationStep = 0;
3653
3753
  this.scheduleReminder(lvl, now);
3654
3754
  lvl.status = "pending";
3655
3755
  }
@@ -4290,6 +4390,73 @@ function weekendCalendar(options = {}) {
4290
4390
  }
4291
4391
  };
4292
4392
  }
4393
+ var HOUR_MS = 36e5;
4394
+ function businessHoursCalendar(options = {}) {
4395
+ const startHour = options.workdayStartHour ?? 9;
4396
+ const endHour = options.workdayEndHour ?? 17;
4397
+ if (!(endHour > startHour)) {
4398
+ throw new Error(
4399
+ `businessHoursCalendar: workdayEndHour (${endHour}) must be greater than workdayStartHour (${startHour}).`
4400
+ );
4401
+ }
4402
+ const weekend = new Set(options.weekendDays ?? [0, 6]);
4403
+ const holidays = new Set((options.holidays ?? []).map(dayKey));
4404
+ const isBusinessDay = (d) => !weekend.has(d.getDay()) && !holidays.has(dayKey(d));
4405
+ const startOfWorkday = (d) => {
4406
+ const out = new Date(d.getTime());
4407
+ out.setHours(startHour, 0, 0, 0);
4408
+ return out;
4409
+ };
4410
+ const endOfWorkday = (d) => {
4411
+ const out = new Date(d.getTime());
4412
+ out.setHours(endHour, 0, 0, 0);
4413
+ return out;
4414
+ };
4415
+ const toWorkingMoment = (from) => {
4416
+ const cursor = new Date(from.getTime());
4417
+ for (let guard = 0; guard < 3660; guard++) {
4418
+ if (!isBusinessDay(cursor)) {
4419
+ cursor.setDate(cursor.getDate() + 1);
4420
+ cursor.setHours(startHour, 0, 0, 0);
4421
+ continue;
4422
+ }
4423
+ if (cursor.getTime() < startOfWorkday(cursor).getTime()) return startOfWorkday(cursor);
4424
+ if (cursor.getTime() >= endOfWorkday(cursor).getTime()) {
4425
+ cursor.setDate(cursor.getDate() + 1);
4426
+ cursor.setHours(startHour, 0, 0, 0);
4427
+ continue;
4428
+ }
4429
+ return cursor;
4430
+ }
4431
+ throw new Error(
4432
+ "businessHoursCalendar: no working day found within 10 years of the start date."
4433
+ );
4434
+ };
4435
+ const base = weekendCalendar(options);
4436
+ return {
4437
+ addBusinessDays: base.addBusinessDays,
4438
+ addBusinessHours(from, hours) {
4439
+ if (hours <= 0 || Number.isNaN(hours)) return new Date(from.getTime());
4440
+ let cursor = toWorkingMoment(from);
4441
+ let remainingMs = Math.round(hours * HOUR_MS);
4442
+ for (let guard = 0; remainingMs > 0 && guard < 3660; guard++) {
4443
+ const dayEnd = endOfWorkday(cursor);
4444
+ const availableMs = dayEnd.getTime() - cursor.getTime();
4445
+ if (remainingMs <= availableMs) {
4446
+ return new Date(cursor.getTime() + remainingMs);
4447
+ }
4448
+ remainingMs -= availableMs;
4449
+ const next = new Date(cursor.getTime());
4450
+ next.setDate(next.getDate() + 1);
4451
+ next.setHours(startHour, 0, 0, 0);
4452
+ cursor = toWorkingMoment(next);
4453
+ }
4454
+ throw new Error(
4455
+ `businessHoursCalendar: ${hours} working hours could not be scheduled within 10 years \u2014 check weekendDays and holidays.`
4456
+ );
4457
+ }
4458
+ };
4459
+ }
4293
4460
 
4294
4461
  exports.ApprovalConflictError = ApprovalConflictError;
4295
4462
  exports.ApprovalEngine = ApprovalEngine;
@@ -4301,6 +4468,7 @@ exports.ApprovalValidationError = ApprovalValidationError;
4301
4468
  exports.EscalationScheduler = EscalationScheduler;
4302
4469
  exports.MemoryAdapter = MemoryAdapter;
4303
4470
  exports.TEMPLATE_BUNDLE_VERSION = TEMPLATE_BUNDLE_VERSION;
4471
+ exports.businessHoursCalendar = businessHoursCalendar;
4304
4472
  exports.defaultIdGenerator = defaultIdGenerator;
4305
4473
  exports.noopLogger = noopLogger;
4306
4474
  exports.systemClock = systemClock;