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.
- package/CHANGELOG.md +71 -0
- package/README.md +54 -0
- package/dist/{ApprovalEngine-C1dUPnLM.d.cts → ApprovalEngine-BWrblNQ0.d.cts} +81 -7
- package/dist/{ApprovalEngine-Ci9urTDI.d.ts → ApprovalEngine-D4DdSx-M.d.ts} +81 -7
- package/dist/{IAuditAdapter-BHVKMMv6.d.cts → IAuditAdapter-2UU5edKG.d.cts} +1 -1
- package/dist/{IAuditAdapter-C4OASS6u.d.ts → IAuditAdapter-DSor54iY.d.ts} +1 -1
- package/dist/{IAuthorizationPolicy-BA_m2Dg9.d.ts → IAuthorizationPolicy-BOWg-3AH.d.ts} +1 -1
- package/dist/{IAuthorizationPolicy-CtLkwQ8I.d.cts → IAuthorizationPolicy-DPpiKzyh.d.cts} +1 -1
- package/dist/{INotificationAdapter-BdgfUrIn.d.ts → INotificationAdapter-BNGivLgU.d.ts} +1 -1
- package/dist/{INotificationAdapter-JJAiMyMd.d.cts → INotificationAdapter-C6HPrzh6.d.cts} +1 -1
- package/dist/{IOperationMiddleware-BGYrAqep.d.ts → IOperationMiddleware-CXtg2wzF.d.ts} +1 -1
- package/dist/{IOperationMiddleware-D0ksiFM3.d.cts → IOperationMiddleware-CxnPzpqL.d.cts} +1 -1
- package/dist/{IStorageAdapter-BU3sau5W.d.ts → IStorageAdapter-ChPT7ZDp.d.ts} +1 -1
- package/dist/{IStorageAdapter-DdHO4Rf1.d.cts → IStorageAdapter-yX9ERfQE.d.cts} +1 -1
- package/dist/adapters/MemoryAdapter.d.cts +2 -2
- package/dist/adapters/MemoryAdapter.d.ts +2 -2
- package/dist/adapters/PostgresAdapter.d.cts +2 -2
- package/dist/adapters/PostgresAdapter.d.ts +2 -2
- package/dist/index.cjs +179 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +179 -12
- package/dist/index.js.map +1 -1
- package/dist/{instance-DUJY_Axf.d.cts → instance-weh2w7Ji.d.cts} +39 -1
- package/dist/{instance-DUJY_Axf.d.ts → instance-weh2w7Ji.d.ts} +39 -1
- package/dist/nestjs.cjs +111 -11
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +7 -7
- package/dist/nestjs.d.ts +7 -7
- package/dist/nestjs.js +111 -11
- package/dist/nestjs.js.map +1 -1
- package/dist/plugins/audit.d.cts +2 -2
- package/dist/plugins/audit.d.ts +2 -2
- package/dist/plugins/notify.d.cts +2 -2
- package/dist/plugins/notify.d.ts +2 -2
- package/dist/plugins/resilience.d.cts +3 -3
- package/dist/plugins/resilience.d.ts +3 -3
- package/dist/plugins/tracing.d.cts +2 -2
- package/dist/plugins/tracing.d.ts +2 -2
- package/dist/plugins/webhook.d.cts +2 -2
- package/dist/plugins/webhook.d.ts +2 -2
- package/dist/testing.cjs +111 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +7 -7
- package/dist/testing.d.ts +7 -7
- package/dist/testing.js +111 -11
- package/dist/testing.js.map +1 -1
- 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
|
-
|
|
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
|
|
3350
|
-
|
|
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
|
-
[
|
|
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 =
|
|
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
|
-
|
|
3651
|
-
|
|
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;
|