hierarchical-approval 1.7.0 → 1.9.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 +75 -0
- package/README.md +51 -0
- package/dist/{ApprovalEngine-D16llfJv.d.cts → ApprovalEngine-BDI4r_KB.d.cts} +95 -1
- package/dist/{ApprovalEngine-gQdFy2CO.d.ts → ApprovalEngine-a1jXglT3.d.ts} +95 -1
- package/dist/index.cjs +173 -7
- 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 +173 -7
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +173 -7
- 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 +173 -7
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +173 -7
- 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 +173 -7
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,81 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
_Nothing yet._
|
|
9
9
|
|
|
10
|
+
## [1.9.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Added — `getWorkload()`
|
|
13
|
+
|
|
14
|
+
- **Reports who currently owes a decision, and how overdue they are.**
|
|
15
|
+
`getStatistics()` answers how the tenant is doing; nothing answered who is
|
|
16
|
+
holding it up — the question behind rebalancing a queue, spotting the approver
|
|
17
|
+
who has been on leave for a week, or deciding whom to `transferApprovals()` a
|
|
18
|
+
departing colleague's work to.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
await engine.getWorkload({ documentType: 'purchase_order' });
|
|
22
|
+
// [{ approverId: 'alice', pending: 12, instances: 11, overdue: 3, onHold: 1,
|
|
23
|
+
// oldestPendingAt: …, oldestAgeMs: 604800000 }, …]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Sorted busiest first. `pending` counts open **levels** while `instances`
|
|
27
|
+
counts distinct documents — they differ when one person holds several branches
|
|
28
|
+
of a parallel group. `overdue` measures against each level's escalation
|
|
29
|
+
deadline, and `onHold` counts work paused by a clarification request.
|
|
30
|
+
|
|
31
|
+
- **An approver who has already voted is not counted**, even while the level
|
|
32
|
+
stays open collecting other votes: they owe nothing more, and counting them
|
|
33
|
+
would overstate the queue of every quorum and weighted level.
|
|
34
|
+
|
|
35
|
+
- Computed from pending instances rather than a dedicated index, so it works on
|
|
36
|
+
any storage adapter with no new adapter methods. That means it reads every
|
|
37
|
+
pending instance in the tenant — fine for the volumes an approval queue
|
|
38
|
+
reaches, but it is a reporting call, not something for a hot path.
|
|
39
|
+
|
|
40
|
+
New export: `ApproverWorkload`.
|
|
41
|
+
|
|
42
|
+
## [1.8.0] - 2026-09-04
|
|
43
|
+
|
|
44
|
+
### Added — `transferApprovals()`
|
|
45
|
+
|
|
46
|
+
- **Move every pending approval assigned to one person over to another.**
|
|
47
|
+
Someone leaves, changes team, or goes on long-term leave, and their queue has
|
|
48
|
+
to go somewhere. Doing it by hand meant finding every open instance first —
|
|
49
|
+
across parallel branches, where one person can hold several open levels on the
|
|
50
|
+
same document — and missing one left an approval that could never complete.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await engine.transferApprovals({
|
|
54
|
+
fromApprover: 'alice',
|
|
55
|
+
toApprover: 'bob',
|
|
56
|
+
transferredBy: 'workflow-admin',
|
|
57
|
+
reason: 'Alice left the company',
|
|
58
|
+
documentType: 'purchase_order', // optional
|
|
59
|
+
dryRun: true, // see what would move first
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each move goes through `reassign()`, so every guard, audit entry, event and
|
|
64
|
+
authorization check that applies to a single reassignment applies here too.
|
|
65
|
+
The sweep is deliberately **not atomic**: it reports per-instance failures
|
|
66
|
+
rather than rolling back, because a partial transfer is the useful outcome —
|
|
67
|
+
what can move should move, and what cannot is named for a human to look at.
|
|
68
|
+
|
|
69
|
+
New exports: `TransferResult`, `TransferApprovalsOptions`.
|
|
70
|
+
|
|
71
|
+
### Fixed — `delegate()` and `reassign()` could not reach an upper parallel branch
|
|
72
|
+
|
|
73
|
+
- **Both resolved the level via `currentLevelInstance`**, which names only the
|
|
74
|
+
lowest-numbered open level. Inside a parallel group they therefore always
|
|
75
|
+
acted on the lowest branch: an approver could not hand off their own
|
|
76
|
+
upper-branch work, and an administrator reassigning a departing user silently
|
|
77
|
+
moved the wrong branch — or failed, because that person was not an approver on
|
|
78
|
+
the branch being targeted. Introduced with parallel groups in 1.0.0; sequential
|
|
79
|
+
templates were never affected.
|
|
80
|
+
|
|
81
|
+
Both now resolve against the approver actually being moved, and take an
|
|
82
|
+
optional `level` to disambiguate when one person holds more than one open
|
|
83
|
+
branch — matching what `approve()` and `reject()` already did.
|
|
84
|
+
|
|
10
85
|
## [1.7.0] - 2026-09-04
|
|
11
86
|
|
|
12
87
|
### Added — attachment references
|
package/README.md
CHANGED
|
@@ -399,6 +399,57 @@ engine.registerConditionOperator('between', (actual, expected) => {
|
|
|
399
399
|
});
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
+
### Who is holding things up
|
|
403
|
+
|
|
404
|
+
`getStatistics()` answers how the tenant is doing; `getWorkload()` answers who
|
|
405
|
+
is holding it up — the question behind rebalancing a queue or deciding whom to
|
|
406
|
+
hand a departing colleague's work to:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const workload = await engine.getWorkload({ documentType: 'purchase_order' });
|
|
410
|
+
// [
|
|
411
|
+
// { approverId: 'alice', pending: 12, instances: 11, overdue: 3, onHold: 1,
|
|
412
|
+
// oldestPendingAt: 2026-01-02T…, oldestAgeMs: 604800000 },
|
|
413
|
+
// ...
|
|
414
|
+
// ]
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Busiest queue first. `pending` counts open **levels**, `instances` counts
|
|
418
|
+
distinct documents — they differ when one person holds several branches of a
|
|
419
|
+
parallel group. An approver who has already voted on a level that is still
|
|
420
|
+
collecting other votes owes nothing more and is not counted.
|
|
421
|
+
|
|
422
|
+
Computed from pending instances rather than a dedicated index, so it works on
|
|
423
|
+
any adapter with no new adapter methods — but it reads every pending instance in
|
|
424
|
+
the tenant. It is a reporting call, not something for a hot path.
|
|
425
|
+
|
|
426
|
+
### Transferring a person's queue
|
|
427
|
+
|
|
428
|
+
Someone leaves, changes team, or goes on long-term leave, and their open
|
|
429
|
+
approvals have to go somewhere:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const result = await engine.transferApprovals({
|
|
433
|
+
fromApprover: 'alice',
|
|
434
|
+
toApprover: 'bob',
|
|
435
|
+
transferredBy: 'workflow-admin',
|
|
436
|
+
reason: 'Alice left the company',
|
|
437
|
+
documentType: 'purchase_order', // optional scoping
|
|
438
|
+
dryRun: true, // see what would move first
|
|
439
|
+
});
|
|
440
|
+
// { transferred: [{ instanceId, level, documentId }], failed: [...], scanned, dryRun }
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Each move goes through `reassign()`, so every guard, audit entry, event and
|
|
444
|
+
authorization check that applies to a single reassignment applies here too —
|
|
445
|
+
there is no bulk short-cut around them. Inside a parallel group each open branch
|
|
446
|
+
is moved separately, since one person can hold several open levels on the same
|
|
447
|
+
document.
|
|
448
|
+
|
|
449
|
+
The sweep is **not atomic**: it reports per-instance failures rather than rolling
|
|
450
|
+
back. A partial transfer is the useful outcome — the approvals that can move
|
|
451
|
+
should move, and the ones that cannot are named so a human can look at them.
|
|
452
|
+
|
|
402
453
|
### Attachments
|
|
403
454
|
|
|
404
455
|
Attach supporting evidence — a quote PDF, a signed contract, a screenshot of a
|
|
@@ -42,12 +42,14 @@ declare const DelegateOptionsSchema: z.ZodObject<{
|
|
|
42
42
|
toApprover: z.ZodString;
|
|
43
43
|
reason: z.ZodString;
|
|
44
44
|
until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
|
|
45
|
+
level: z.ZodOptional<z.ZodNumber>;
|
|
45
46
|
}, z.core.$strip>;
|
|
46
47
|
declare const ReassignOptionsSchema: z.ZodObject<{
|
|
47
48
|
reassignedBy: z.ZodString;
|
|
48
49
|
fromApprover: z.ZodString;
|
|
49
50
|
toApprover: z.ZodString;
|
|
50
51
|
reason: z.ZodString;
|
|
52
|
+
level: z.ZodOptional<z.ZodNumber>;
|
|
51
53
|
}, z.core.$strip>;
|
|
52
54
|
declare const CancelOptionsSchema: z.ZodObject<{
|
|
53
55
|
cancelledBy: z.ZodString;
|
|
@@ -90,6 +92,15 @@ declare const RemoveAttachmentOptionsSchema: z.ZodObject<{
|
|
|
90
92
|
attachmentId: z.ZodString;
|
|
91
93
|
reason: z.ZodOptional<z.ZodString>;
|
|
92
94
|
}, z.core.$strip>;
|
|
95
|
+
declare const TransferApprovalsOptionsSchema: z.ZodObject<{
|
|
96
|
+
fromApprover: z.ZodString;
|
|
97
|
+
toApprover: z.ZodString;
|
|
98
|
+
transferredBy: z.ZodString;
|
|
99
|
+
reason: z.ZodString;
|
|
100
|
+
documentType: z.ZodOptional<z.ZodString>;
|
|
101
|
+
dryRun: z.ZodDefault<z.ZodBoolean>;
|
|
102
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
103
|
+
}, z.core.$strip>;
|
|
93
104
|
declare const UpdateDataOptionsSchema: z.ZodObject<{
|
|
94
105
|
updatedBy: z.ZodString;
|
|
95
106
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
@@ -115,6 +126,7 @@ type RequestInfoOptions = z.infer<typeof RequestInfoOptionsSchema>;
|
|
|
115
126
|
type ProvideInfoOptions = z.infer<typeof ProvideInfoOptionsSchema>;
|
|
116
127
|
type AddAttachmentOptions = z.infer<typeof AddAttachmentOptionsSchema>;
|
|
117
128
|
type RemoveAttachmentOptions = z.infer<typeof RemoveAttachmentOptionsSchema>;
|
|
129
|
+
type TransferApprovalsOptions = z.infer<typeof TransferApprovalsOptionsSchema>;
|
|
118
130
|
|
|
119
131
|
/**
|
|
120
132
|
* Computes deadline dates from a number of days. The default engine behaviour
|
|
@@ -254,6 +266,45 @@ interface BulkResult {
|
|
|
254
266
|
}>;
|
|
255
267
|
total: number;
|
|
256
268
|
}
|
|
269
|
+
/** Outcome of a {@link ApprovalEngine.transferApprovals} sweep. */
|
|
270
|
+
interface TransferResult {
|
|
271
|
+
/** One entry per level actually moved (an instance can hold the approver on several open branches). */
|
|
272
|
+
transferred: Array<{
|
|
273
|
+
instanceId: string;
|
|
274
|
+
level: number;
|
|
275
|
+
documentId: string;
|
|
276
|
+
}>;
|
|
277
|
+
/** Instances that could not be moved, with the reason. */
|
|
278
|
+
failed: Array<{
|
|
279
|
+
instanceId: string;
|
|
280
|
+
error: ApprovalError;
|
|
281
|
+
}>;
|
|
282
|
+
/** Instances examined. */
|
|
283
|
+
scanned: number;
|
|
284
|
+
/** True when nothing was written. */
|
|
285
|
+
dryRun: boolean;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What one approver currently owes a decision on.
|
|
289
|
+
*
|
|
290
|
+
* Durations are milliseconds. An approver appears only while they hold at least
|
|
291
|
+
* one open level.
|
|
292
|
+
*/
|
|
293
|
+
interface ApproverWorkload {
|
|
294
|
+
approverId: string;
|
|
295
|
+
/** Open levels assigned to them. One instance can contribute several across parallel branches. */
|
|
296
|
+
pending: number;
|
|
297
|
+
/** Distinct documents involved — usually, but not always, equal to {@link pending}. */
|
|
298
|
+
instances: number;
|
|
299
|
+
/** Open levels already past their escalation deadline. */
|
|
300
|
+
overdue: number;
|
|
301
|
+
/** Open levels currently paused by a clarification request. */
|
|
302
|
+
onHold: number;
|
|
303
|
+
/** When the oldest of their open items was submitted. */
|
|
304
|
+
oldestPendingAt?: Date;
|
|
305
|
+
/** Age of that oldest item. `0` when they hold nothing. */
|
|
306
|
+
oldestAgeMs: number;
|
|
307
|
+
}
|
|
257
308
|
interface ApprovalStatistics {
|
|
258
309
|
/** Total instances matching the filter (across all statuses). */
|
|
259
310
|
total: number;
|
|
@@ -539,6 +590,28 @@ declare class ApprovalEngine {
|
|
|
539
590
|
/** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
|
|
540
591
|
override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
|
|
541
592
|
/** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
593
|
+
/**
|
|
594
|
+
* Move every pending approval assigned to one person over to another.
|
|
595
|
+
*
|
|
596
|
+
* Someone leaves, changes team, or goes on long-term leave, and their queue
|
|
597
|
+
* has to go somewhere. Doing it by hand means finding every open instance
|
|
598
|
+
* first — across parallel branches, where one person can hold several open
|
|
599
|
+
* levels on the same document — and missing one leaves an approval that can
|
|
600
|
+
* never complete.
|
|
601
|
+
*
|
|
602
|
+
* Each move goes through {@link reassign}, so every guard, audit entry, event
|
|
603
|
+
* and authorization check that applies to a single reassignment applies here
|
|
604
|
+
* too. There is no bulk short-cut around them.
|
|
605
|
+
*
|
|
606
|
+
* The sweep is **not atomic**: it reassigns one level at a time and reports
|
|
607
|
+
* per-instance failures rather than rolling back. A partial transfer is the
|
|
608
|
+
* useful outcome — the approvals that could move should move, and the ones
|
|
609
|
+
* that could not are named so a human can look at them.
|
|
610
|
+
*
|
|
611
|
+
* @param raw - Who is moving to whom, and why.
|
|
612
|
+
* @returns What moved, what did not, and why. With `dryRun` nothing is written.
|
|
613
|
+
*/
|
|
614
|
+
transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
|
|
542
615
|
bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
|
|
543
616
|
/** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
544
617
|
bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
|
|
@@ -555,6 +628,27 @@ declare class ApprovalEngine {
|
|
|
555
628
|
* submittedBy, date range) — `status` is ignored since every status is counted.
|
|
556
629
|
* Adapter-agnostic: issues one cheap count query per status plus an overdue scan.
|
|
557
630
|
*/
|
|
631
|
+
/**
|
|
632
|
+
* Who currently owes a decision, and how overdue they are.
|
|
633
|
+
*
|
|
634
|
+
* `getStatistics()` answers how the tenant is doing; this answers who is
|
|
635
|
+
* holding it up — the question behind rebalancing a queue, spotting the
|
|
636
|
+
* approver who has been on leave for a week, or deciding whom to
|
|
637
|
+
* {@link transferApprovals} a departing colleague's work to.
|
|
638
|
+
*
|
|
639
|
+
* Computed from pending instances rather than a dedicated index, so it works
|
|
640
|
+
* on any storage adapter with no new adapter methods. That means it reads
|
|
641
|
+
* every pending instance in the tenant: fine for the operational volumes an
|
|
642
|
+
* approval queue reaches, but it is a reporting call, not something to put on
|
|
643
|
+
* a hot path.
|
|
644
|
+
*
|
|
645
|
+
* Rows are sorted by {@link ApproverWorkload.pending} descending, so the
|
|
646
|
+
* busiest queue is first.
|
|
647
|
+
*
|
|
648
|
+
* @param filter - Optional scoping; `status` is ignored, since only pending work counts.
|
|
649
|
+
* @returns One row per approver holding at least one open level.
|
|
650
|
+
*/
|
|
651
|
+
getWorkload(filter?: Omit<InstanceFilter, 'status'>): Promise<ApproverWorkload[]>;
|
|
558
652
|
getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
|
|
559
653
|
shutdown(): Promise<void>;
|
|
560
654
|
/**
|
|
@@ -652,4 +746,4 @@ declare class ApprovalEngine {
|
|
|
652
746
|
private runExternalAudit;
|
|
653
747
|
}
|
|
654
748
|
|
|
655
|
-
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
|
|
749
|
+
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 TransferResult as T, 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 ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type OutOfOfficeProvider as l, type OverrideOptions as m, type PreviewResult as n, type ProvideInfoOptions as o, type RejectOptions as p, type RequestInfoOptions as q, type ResubmitOptions as r, type RetryPolicy as s, defaultIdGenerator as t, toComparableNumber as u, validateConditionExpression as v, weekendCalendar as w };
|
|
@@ -42,12 +42,14 @@ declare const DelegateOptionsSchema: z.ZodObject<{
|
|
|
42
42
|
toApprover: z.ZodString;
|
|
43
43
|
reason: z.ZodString;
|
|
44
44
|
until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
|
|
45
|
+
level: z.ZodOptional<z.ZodNumber>;
|
|
45
46
|
}, z.core.$strip>;
|
|
46
47
|
declare const ReassignOptionsSchema: z.ZodObject<{
|
|
47
48
|
reassignedBy: z.ZodString;
|
|
48
49
|
fromApprover: z.ZodString;
|
|
49
50
|
toApprover: z.ZodString;
|
|
50
51
|
reason: z.ZodString;
|
|
52
|
+
level: z.ZodOptional<z.ZodNumber>;
|
|
51
53
|
}, z.core.$strip>;
|
|
52
54
|
declare const CancelOptionsSchema: z.ZodObject<{
|
|
53
55
|
cancelledBy: z.ZodString;
|
|
@@ -90,6 +92,15 @@ declare const RemoveAttachmentOptionsSchema: z.ZodObject<{
|
|
|
90
92
|
attachmentId: z.ZodString;
|
|
91
93
|
reason: z.ZodOptional<z.ZodString>;
|
|
92
94
|
}, z.core.$strip>;
|
|
95
|
+
declare const TransferApprovalsOptionsSchema: z.ZodObject<{
|
|
96
|
+
fromApprover: z.ZodString;
|
|
97
|
+
toApprover: z.ZodString;
|
|
98
|
+
transferredBy: z.ZodString;
|
|
99
|
+
reason: z.ZodString;
|
|
100
|
+
documentType: z.ZodOptional<z.ZodString>;
|
|
101
|
+
dryRun: z.ZodDefault<z.ZodBoolean>;
|
|
102
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
103
|
+
}, z.core.$strip>;
|
|
93
104
|
declare const UpdateDataOptionsSchema: z.ZodObject<{
|
|
94
105
|
updatedBy: z.ZodString;
|
|
95
106
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
@@ -115,6 +126,7 @@ type RequestInfoOptions = z.infer<typeof RequestInfoOptionsSchema>;
|
|
|
115
126
|
type ProvideInfoOptions = z.infer<typeof ProvideInfoOptionsSchema>;
|
|
116
127
|
type AddAttachmentOptions = z.infer<typeof AddAttachmentOptionsSchema>;
|
|
117
128
|
type RemoveAttachmentOptions = z.infer<typeof RemoveAttachmentOptionsSchema>;
|
|
129
|
+
type TransferApprovalsOptions = z.infer<typeof TransferApprovalsOptionsSchema>;
|
|
118
130
|
|
|
119
131
|
/**
|
|
120
132
|
* Computes deadline dates from a number of days. The default engine behaviour
|
|
@@ -254,6 +266,45 @@ interface BulkResult {
|
|
|
254
266
|
}>;
|
|
255
267
|
total: number;
|
|
256
268
|
}
|
|
269
|
+
/** Outcome of a {@link ApprovalEngine.transferApprovals} sweep. */
|
|
270
|
+
interface TransferResult {
|
|
271
|
+
/** One entry per level actually moved (an instance can hold the approver on several open branches). */
|
|
272
|
+
transferred: Array<{
|
|
273
|
+
instanceId: string;
|
|
274
|
+
level: number;
|
|
275
|
+
documentId: string;
|
|
276
|
+
}>;
|
|
277
|
+
/** Instances that could not be moved, with the reason. */
|
|
278
|
+
failed: Array<{
|
|
279
|
+
instanceId: string;
|
|
280
|
+
error: ApprovalError;
|
|
281
|
+
}>;
|
|
282
|
+
/** Instances examined. */
|
|
283
|
+
scanned: number;
|
|
284
|
+
/** True when nothing was written. */
|
|
285
|
+
dryRun: boolean;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What one approver currently owes a decision on.
|
|
289
|
+
*
|
|
290
|
+
* Durations are milliseconds. An approver appears only while they hold at least
|
|
291
|
+
* one open level.
|
|
292
|
+
*/
|
|
293
|
+
interface ApproverWorkload {
|
|
294
|
+
approverId: string;
|
|
295
|
+
/** Open levels assigned to them. One instance can contribute several across parallel branches. */
|
|
296
|
+
pending: number;
|
|
297
|
+
/** Distinct documents involved — usually, but not always, equal to {@link pending}. */
|
|
298
|
+
instances: number;
|
|
299
|
+
/** Open levels already past their escalation deadline. */
|
|
300
|
+
overdue: number;
|
|
301
|
+
/** Open levels currently paused by a clarification request. */
|
|
302
|
+
onHold: number;
|
|
303
|
+
/** When the oldest of their open items was submitted. */
|
|
304
|
+
oldestPendingAt?: Date;
|
|
305
|
+
/** Age of that oldest item. `0` when they hold nothing. */
|
|
306
|
+
oldestAgeMs: number;
|
|
307
|
+
}
|
|
257
308
|
interface ApprovalStatistics {
|
|
258
309
|
/** Total instances matching the filter (across all statuses). */
|
|
259
310
|
total: number;
|
|
@@ -539,6 +590,28 @@ declare class ApprovalEngine {
|
|
|
539
590
|
/** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
|
|
540
591
|
override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
|
|
541
592
|
/** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
593
|
+
/**
|
|
594
|
+
* Move every pending approval assigned to one person over to another.
|
|
595
|
+
*
|
|
596
|
+
* Someone leaves, changes team, or goes on long-term leave, and their queue
|
|
597
|
+
* has to go somewhere. Doing it by hand means finding every open instance
|
|
598
|
+
* first — across parallel branches, where one person can hold several open
|
|
599
|
+
* levels on the same document — and missing one leaves an approval that can
|
|
600
|
+
* never complete.
|
|
601
|
+
*
|
|
602
|
+
* Each move goes through {@link reassign}, so every guard, audit entry, event
|
|
603
|
+
* and authorization check that applies to a single reassignment applies here
|
|
604
|
+
* too. There is no bulk short-cut around them.
|
|
605
|
+
*
|
|
606
|
+
* The sweep is **not atomic**: it reassigns one level at a time and reports
|
|
607
|
+
* per-instance failures rather than rolling back. A partial transfer is the
|
|
608
|
+
* useful outcome — the approvals that could move should move, and the ones
|
|
609
|
+
* that could not are named so a human can look at them.
|
|
610
|
+
*
|
|
611
|
+
* @param raw - Who is moving to whom, and why.
|
|
612
|
+
* @returns What moved, what did not, and why. With `dryRun` nothing is written.
|
|
613
|
+
*/
|
|
614
|
+
transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
|
|
542
615
|
bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
|
|
543
616
|
/** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
544
617
|
bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
|
|
@@ -555,6 +628,27 @@ declare class ApprovalEngine {
|
|
|
555
628
|
* submittedBy, date range) — `status` is ignored since every status is counted.
|
|
556
629
|
* Adapter-agnostic: issues one cheap count query per status plus an overdue scan.
|
|
557
630
|
*/
|
|
631
|
+
/**
|
|
632
|
+
* Who currently owes a decision, and how overdue they are.
|
|
633
|
+
*
|
|
634
|
+
* `getStatistics()` answers how the tenant is doing; this answers who is
|
|
635
|
+
* holding it up — the question behind rebalancing a queue, spotting the
|
|
636
|
+
* approver who has been on leave for a week, or deciding whom to
|
|
637
|
+
* {@link transferApprovals} a departing colleague's work to.
|
|
638
|
+
*
|
|
639
|
+
* Computed from pending instances rather than a dedicated index, so it works
|
|
640
|
+
* on any storage adapter with no new adapter methods. That means it reads
|
|
641
|
+
* every pending instance in the tenant: fine for the operational volumes an
|
|
642
|
+
* approval queue reaches, but it is a reporting call, not something to put on
|
|
643
|
+
* a hot path.
|
|
644
|
+
*
|
|
645
|
+
* Rows are sorted by {@link ApproverWorkload.pending} descending, so the
|
|
646
|
+
* busiest queue is first.
|
|
647
|
+
*
|
|
648
|
+
* @param filter - Optional scoping; `status` is ignored, since only pending work counts.
|
|
649
|
+
* @returns One row per approver holding at least one open level.
|
|
650
|
+
*/
|
|
651
|
+
getWorkload(filter?: Omit<InstanceFilter, 'status'>): Promise<ApproverWorkload[]>;
|
|
558
652
|
getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
|
|
559
653
|
shutdown(): Promise<void>;
|
|
560
654
|
/**
|
|
@@ -652,4 +746,4 @@ declare class ApprovalEngine {
|
|
|
652
746
|
private runExternalAudit;
|
|
653
747
|
}
|
|
654
748
|
|
|
655
|
-
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
|
|
749
|
+
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 TransferResult as T, 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 ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type OutOfOfficeProvider as l, type OverrideOptions as m, type PreviewResult as n, type ProvideInfoOptions as o, type RejectOptions as p, type RequestInfoOptions as q, type ResubmitOptions as r, type RetryPolicy as s, defaultIdGenerator as t, toComparableNumber as u, validateConditionExpression as v, weekendCalendar as w };
|
package/dist/index.cjs
CHANGED
|
@@ -39,13 +39,20 @@ var DelegateOptionsSchema = zod.z.object({
|
|
|
39
39
|
fromApprover: zod.z.string().min(1),
|
|
40
40
|
toApprover: zod.z.string().min(1),
|
|
41
41
|
reason: zod.z.string().min(1),
|
|
42
|
-
until: zod.z.coerce.date().optional()
|
|
42
|
+
until: zod.z.coerce.date().optional(),
|
|
43
|
+
/**
|
|
44
|
+
* Which open level to act on. Only needed inside a parallel group, where the
|
|
45
|
+
* approver may hold more than one open branch.
|
|
46
|
+
*/
|
|
47
|
+
level: zod.z.number().int().optional()
|
|
43
48
|
});
|
|
44
49
|
var ReassignOptionsSchema = zod.z.object({
|
|
45
50
|
reassignedBy: zod.z.string().min(1),
|
|
46
51
|
fromApprover: zod.z.string().min(1),
|
|
47
52
|
toApprover: zod.z.string().min(1),
|
|
48
|
-
reason: zod.z.string().min(1)
|
|
53
|
+
reason: zod.z.string().min(1),
|
|
54
|
+
/** Which open level to act on; see {@link DelegateOptionsSchema}. */
|
|
55
|
+
level: zod.z.number().int().optional()
|
|
49
56
|
});
|
|
50
57
|
var CancelOptionsSchema = zod.z.object({
|
|
51
58
|
cancelledBy: zod.z.string().min(1),
|
|
@@ -90,6 +97,18 @@ var RemoveAttachmentOptionsSchema = zod.z.object({
|
|
|
90
97
|
attachmentId: zod.z.string().min(1),
|
|
91
98
|
reason: zod.z.string().optional()
|
|
92
99
|
});
|
|
100
|
+
var TransferApprovalsOptionsSchema = zod.z.object({
|
|
101
|
+
fromApprover: zod.z.string().min(1),
|
|
102
|
+
toApprover: zod.z.string().min(1),
|
|
103
|
+
transferredBy: zod.z.string().min(1),
|
|
104
|
+
reason: zod.z.string().min(1),
|
|
105
|
+
/** Restrict the sweep to one document type. */
|
|
106
|
+
documentType: zod.z.string().optional(),
|
|
107
|
+
/** Report what would move without changing anything. */
|
|
108
|
+
dryRun: zod.z.boolean().default(false),
|
|
109
|
+
/** Safety cap on how many instances one sweep will touch. */
|
|
110
|
+
limit: zod.z.number().int().positive().default(500)
|
|
111
|
+
});
|
|
93
112
|
var UpdateDataOptionsSchema = zod.z.object({
|
|
94
113
|
updatedBy: zod.z.string().min(1),
|
|
95
114
|
data: zod.z.record(zod.z.string(), zod.z.unknown()),
|
|
@@ -1647,7 +1666,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1647
1666
|
if (opts.fromApprover === opts.toApprover) {
|
|
1648
1667
|
throw new ApprovalForbiddenError("Cannot delegate to yourself.");
|
|
1649
1668
|
}
|
|
1650
|
-
const level = this.
|
|
1669
|
+
const level = this.resolveActorLevel(instance, opts.fromApprover, opts.level);
|
|
1651
1670
|
await this.runAuthorizationPolicy({
|
|
1652
1671
|
operation: "delegate",
|
|
1653
1672
|
actorId: opts.fromApprover,
|
|
@@ -1729,7 +1748,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1729
1748
|
if (opts.fromApprover === opts.toApprover) {
|
|
1730
1749
|
throw new ApprovalForbiddenError("Cannot reassign an approver to themselves.");
|
|
1731
1750
|
}
|
|
1732
|
-
const level = this.
|
|
1751
|
+
const level = this.resolveActorLevel(instance, opts.fromApprover, opts.level);
|
|
1733
1752
|
await this.runAuthorizationPolicy({
|
|
1734
1753
|
operation: "reassign",
|
|
1735
1754
|
actorId: opts.reassignedBy,
|
|
@@ -2365,9 +2384,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2365
2384
|
input: opts
|
|
2366
2385
|
});
|
|
2367
2386
|
const now = this.clock.now();
|
|
2368
|
-
instance.attachments = (instance.attachments ?? []).filter(
|
|
2369
|
-
(a) => a.id !== opts.attachmentId
|
|
2370
|
-
);
|
|
2387
|
+
instance.attachments = (instance.attachments ?? []).filter((a) => a.id !== opts.attachmentId);
|
|
2371
2388
|
instance.updatedAt = now;
|
|
2372
2389
|
const auditEntry = {
|
|
2373
2390
|
action: "attachment_removed",
|
|
@@ -2706,6 +2723,95 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2706
2723
|
});
|
|
2707
2724
|
}
|
|
2708
2725
|
/** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
2726
|
+
/**
|
|
2727
|
+
* Move every pending approval assigned to one person over to another.
|
|
2728
|
+
*
|
|
2729
|
+
* Someone leaves, changes team, or goes on long-term leave, and their queue
|
|
2730
|
+
* has to go somewhere. Doing it by hand means finding every open instance
|
|
2731
|
+
* first — across parallel branches, where one person can hold several open
|
|
2732
|
+
* levels on the same document — and missing one leaves an approval that can
|
|
2733
|
+
* never complete.
|
|
2734
|
+
*
|
|
2735
|
+
* Each move goes through {@link reassign}, so every guard, audit entry, event
|
|
2736
|
+
* and authorization check that applies to a single reassignment applies here
|
|
2737
|
+
* too. There is no bulk short-cut around them.
|
|
2738
|
+
*
|
|
2739
|
+
* The sweep is **not atomic**: it reassigns one level at a time and reports
|
|
2740
|
+
* per-instance failures rather than rolling back. A partial transfer is the
|
|
2741
|
+
* useful outcome — the approvals that could move should move, and the ones
|
|
2742
|
+
* that could not are named so a human can look at them.
|
|
2743
|
+
*
|
|
2744
|
+
* @param raw - Who is moving to whom, and why.
|
|
2745
|
+
* @returns What moved, what did not, and why. With `dryRun` nothing is written.
|
|
2746
|
+
*/
|
|
2747
|
+
async transferApprovals(raw, auditCtx) {
|
|
2748
|
+
const opts = parseOrThrow(() => TransferApprovalsOptionsSchema.parse(raw));
|
|
2749
|
+
if (opts.fromApprover === opts.toApprover) {
|
|
2750
|
+
throw new ApprovalValidationError(
|
|
2751
|
+
"transferApprovals requires different fromApprover and toApprover."
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
const queue = await this.opts.adapter.getInstancesByApprover(this.tenantId, opts.fromApprover, {
|
|
2755
|
+
limit: opts.limit,
|
|
2756
|
+
offset: 0
|
|
2757
|
+
});
|
|
2758
|
+
const result = {
|
|
2759
|
+
transferred: [],
|
|
2760
|
+
failed: [],
|
|
2761
|
+
scanned: 0,
|
|
2762
|
+
dryRun: opts.dryRun
|
|
2763
|
+
};
|
|
2764
|
+
for (const instance of queue.items) {
|
|
2765
|
+
if (opts.documentType && instance.documentType !== opts.documentType) continue;
|
|
2766
|
+
result.scanned++;
|
|
2767
|
+
const levels = instance.levels.filter(
|
|
2768
|
+
(l) => l.status === "pending" && l.approverIds.includes(opts.fromApprover)
|
|
2769
|
+
);
|
|
2770
|
+
for (const level of levels) {
|
|
2771
|
+
if (opts.dryRun) {
|
|
2772
|
+
result.transferred.push({
|
|
2773
|
+
instanceId: instance.id,
|
|
2774
|
+
level: level.level,
|
|
2775
|
+
documentId: instance.documentId
|
|
2776
|
+
});
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
try {
|
|
2780
|
+
await this.reassign(
|
|
2781
|
+
instance.id,
|
|
2782
|
+
{
|
|
2783
|
+
reassignedBy: opts.transferredBy,
|
|
2784
|
+
fromApprover: opts.fromApprover,
|
|
2785
|
+
toApprover: opts.toApprover,
|
|
2786
|
+
reason: opts.reason,
|
|
2787
|
+
level: level.level
|
|
2788
|
+
},
|
|
2789
|
+
auditCtx
|
|
2790
|
+
);
|
|
2791
|
+
result.transferred.push({
|
|
2792
|
+
instanceId: instance.id,
|
|
2793
|
+
level: level.level,
|
|
2794
|
+
documentId: instance.documentId
|
|
2795
|
+
});
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
result.failed.push({
|
|
2798
|
+
instanceId: instance.id,
|
|
2799
|
+
error: err instanceof ApprovalError ? err : new ApprovalError(String(err), "UNKNOWN")
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
this.logger.info("transferApprovals: sweep complete", {
|
|
2805
|
+
tenantId: this.tenantId,
|
|
2806
|
+
fromApprover: opts.fromApprover,
|
|
2807
|
+
toApprover: opts.toApprover,
|
|
2808
|
+
scanned: result.scanned,
|
|
2809
|
+
transferred: result.transferred.length,
|
|
2810
|
+
failed: result.failed.length,
|
|
2811
|
+
dryRun: opts.dryRun
|
|
2812
|
+
});
|
|
2813
|
+
return result;
|
|
2814
|
+
}
|
|
2709
2815
|
async bulkApprove(instanceIds, raw, auditCtx) {
|
|
2710
2816
|
const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
|
|
2711
2817
|
this.guardBulkSize(instanceIds);
|
|
@@ -2808,6 +2914,66 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2808
2914
|
* submittedBy, date range) — `status` is ignored since every status is counted.
|
|
2809
2915
|
* Adapter-agnostic: issues one cheap count query per status plus an overdue scan.
|
|
2810
2916
|
*/
|
|
2917
|
+
/**
|
|
2918
|
+
* Who currently owes a decision, and how overdue they are.
|
|
2919
|
+
*
|
|
2920
|
+
* `getStatistics()` answers how the tenant is doing; this answers who is
|
|
2921
|
+
* holding it up — the question behind rebalancing a queue, spotting the
|
|
2922
|
+
* approver who has been on leave for a week, or deciding whom to
|
|
2923
|
+
* {@link transferApprovals} a departing colleague's work to.
|
|
2924
|
+
*
|
|
2925
|
+
* Computed from pending instances rather than a dedicated index, so it works
|
|
2926
|
+
* on any storage adapter with no new adapter methods. That means it reads
|
|
2927
|
+
* every pending instance in the tenant: fine for the operational volumes an
|
|
2928
|
+
* approval queue reaches, but it is a reporting call, not something to put on
|
|
2929
|
+
* a hot path.
|
|
2930
|
+
*
|
|
2931
|
+
* Rows are sorted by {@link ApproverWorkload.pending} descending, so the
|
|
2932
|
+
* busiest queue is first.
|
|
2933
|
+
*
|
|
2934
|
+
* @param filter - Optional scoping; `status` is ignored, since only pending work counts.
|
|
2935
|
+
* @returns One row per approver holding at least one open level.
|
|
2936
|
+
*/
|
|
2937
|
+
async getWorkload(filter = {}) {
|
|
2938
|
+
const pending = await this.fetchAllByFilter({ ...filter, status: "pending" });
|
|
2939
|
+
const now = this.clock.now();
|
|
2940
|
+
const byApprover = /* @__PURE__ */ new Map();
|
|
2941
|
+
for (const instance of pending) {
|
|
2942
|
+
const submittedAt = new Date(instance.createdAt).getTime();
|
|
2943
|
+
const held = Boolean(instance.infoRequest);
|
|
2944
|
+
for (const level of instance.levels) {
|
|
2945
|
+
if (level.status !== "pending") continue;
|
|
2946
|
+
const isOverdue = level.escalationDueAt !== void 0 && new Date(level.escalationDueAt) <= now;
|
|
2947
|
+
for (const approverId of level.approverIds) {
|
|
2948
|
+
if (level.approvedBy.includes(approverId) || level.rejectedBy.includes(approverId)) {
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
const row = byApprover.get(approverId) ?? {
|
|
2952
|
+
pending: 0,
|
|
2953
|
+
instances: /* @__PURE__ */ new Set(),
|
|
2954
|
+
overdue: 0,
|
|
2955
|
+
onHold: 0,
|
|
2956
|
+
oldest: Number.POSITIVE_INFINITY
|
|
2957
|
+
};
|
|
2958
|
+
row.pending++;
|
|
2959
|
+
row.instances.add(instance.id);
|
|
2960
|
+
if (isOverdue) row.overdue++;
|
|
2961
|
+
if (held) row.onHold++;
|
|
2962
|
+
row.oldest = Math.min(row.oldest, submittedAt);
|
|
2963
|
+
byApprover.set(approverId, row);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
return [...byApprover.entries()].map(([approverId, row]) => ({
|
|
2968
|
+
approverId,
|
|
2969
|
+
pending: row.pending,
|
|
2970
|
+
instances: row.instances.size,
|
|
2971
|
+
overdue: row.overdue,
|
|
2972
|
+
onHold: row.onHold,
|
|
2973
|
+
oldestPendingAt: Number.isFinite(row.oldest) ? new Date(row.oldest) : void 0,
|
|
2974
|
+
oldestAgeMs: Number.isFinite(row.oldest) ? now.getTime() - row.oldest : 0
|
|
2975
|
+
})).sort((a, b) => b.pending - a.pending || a.approverId.localeCompare(b.approverId));
|
|
2976
|
+
}
|
|
2811
2977
|
async getStatistics(filter = {}) {
|
|
2812
2978
|
const statuses = [
|
|
2813
2979
|
"pending",
|