hierarchical-approval 0.1.1 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthews Wong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,24 @@
1
+ <div align="center">
2
+
1
3
  # hierarchical-approval
2
4
 
3
- TypeScript-first multi-level approval workflows for enterprise systems. Multi-tenant, audit-ready, fully pluggable.
5
+ **TypeScript-first multi-level approval workflows for enterprise systems.**
6
+ Multi-tenant · audit-ready · fully pluggable · zero runtime dependencies you don't opt into.
7
+
8
+ [![npm version](https://img.shields.io/npm/v/hierarchical-approval.svg?logo=npm&color=cb3837)](https://www.npmjs.com/package/hierarchical-approval)
9
+ [![npm downloads](https://img.shields.io/npm/dm/hierarchical-approval.svg?color=cb3837)](https://www.npmjs.com/package/hierarchical-approval)
10
+ [![CI](https://github.com/matthews-wong/hierarchical-approval/actions/workflows/ci.yml/badge.svg)](https://github.com/matthews-wong/hierarchical-approval/actions/workflows/ci.yml)
11
+ [![types](https://img.shields.io/npm/types/hierarchical-approval.svg?logo=typescript&logoColor=white&color=3178c6)](https://www.typescriptlang.org/)
12
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/hierarchical-approval?color=44cc11)](https://bundlephobia.com/package/hierarchical-approval)
13
+ [![license](https://img.shields.io/npm/l/hierarchical-approval.svg?color=blue)](./LICENSE)
14
+ [![tests](https://img.shields.io/badge/tests-195%20passing-44cc11.svg?logo=vitest&logoColor=white)](./tests)
15
+
16
+ [**Documentation**](https://hierarchical-approval.matthewswong.com) ·
17
+ [**npm**](https://www.npmjs.com/package/hierarchical-approval) ·
18
+ [**Changelog**](./IMPROVEMENTS.md) ·
19
+ [**Examples**](./examples)
20
+
21
+ </div>
4
22
 
5
23
  ```sh
6
24
  npm install hierarchical-approval
@@ -8,6 +26,8 @@ npm install hierarchical-approval
8
26
  npm install pg @types/pg
9
27
  ```
10
28
 
29
+ > 📖 Full docs & live guides: **[hierarchical-approval.matthewswong.com](https://hierarchical-approval.matthewswong.com)**
30
+
11
31
  ---
12
32
 
13
33
  ## Why another approval library?
@@ -33,7 +53,7 @@ Approval workflows are deceptively simple until they aren't. Most teams start wi
33
53
  **`approval-flow`** — Single-level only; no multi-tenancy; no audit trail; last published 2020.
34
54
  **`workflow-engine`** — Generic state machine; you implement every guard, every condition, every audit entry yourself.
35
55
  **`node-approval`** — No TypeScript; no idempotency; no optimistic locking.
36
- **Hand-rolled** — You *will* hit the concurrent-approval race condition eventually. This library has 167 tests covering it.
56
+ **Hand-rolled** — You *will* hit the concurrent-approval race condition eventually. This library has 195 tests covering it.
37
57
 
38
58
  ---
39
59
 
@@ -137,11 +157,44 @@ An **instance** is a single document moving through a template. Key fields:
137
157
 
138
158
  Each level's `mode` field controls how many approvers are required:
139
159
 
140
- | Mode | Required |
141
- |---|---|
142
- | `'any'` | One approver is enough |
143
- | `'all'` | Every listed approver must act |
144
- | `'majority'` | More than half must approve |
160
+ | Mode | Required | Extra config |
161
+ |---|---|---|
162
+ | `'any'` | One approver is enough | — |
163
+ | `'all'` | Every listed approver must act | — |
164
+ | `'majority'` | More than half must approve | — |
165
+ | `'quorum'` | A fixed **N-of-M** threshold approves | `minApprovals` |
166
+ | `'weighted'` | Cumulative approver **weight** meets a threshold | `threshold`, optional `weights` |
167
+
168
+ A level is **rejected** as soon as the outcome becomes mathematically impossible — e.g. a `quorum` of 2-of-3 is rejected after the second rejection (only one approver remains), and a `weighted` level is rejected once the weight still achievable falls below `threshold`.
169
+
170
+ ```ts
171
+ // Quorum: any 2 of these 3 directors must approve
172
+ {
173
+ level: 1,
174
+ name: 'Board',
175
+ mode: 'quorum',
176
+ minApprovals: 2,
177
+ approvers: [
178
+ { type: 'user', userId: 'd1' },
179
+ { type: 'user', userId: 'd2' },
180
+ { type: 'user', userId: 'd3' },
181
+ ],
182
+ }
183
+
184
+ // Weighted: the CFO's vote (weight 3) clears the threshold alone;
185
+ // otherwise three default-weight (1) approvers are needed.
186
+ {
187
+ level: 1,
188
+ name: 'Exec Committee',
189
+ mode: 'weighted',
190
+ threshold: 3,
191
+ weights: { cfo: 3 }, // unlisted approvers default to weight 1
192
+ approvers: [
193
+ { type: 'user', userId: 'cfo' },
194
+ { type: 'user', userId: 'mgr' },
195
+ ],
196
+ }
197
+ ```
145
198
 
146
199
  ### Conditional chains
147
200
 
@@ -344,6 +397,21 @@ await engine.delegate(instanceId, {
344
397
  });
345
398
  ```
346
399
 
400
+ ### Reassign
401
+
402
+ Administratively swap an approver on the current level — for when an assigned approver is unavailable and a third party (e.g. an admin) needs to hand the task to someone else. Unlike `delegate`, the original approver doesn't initiate it. The approver being replaced must still be pending (one who has already approved or rejected cannot be reassigned), and the new approver must not already be on the level.
403
+
404
+ ```ts
405
+ await engine.reassign(instanceId, {
406
+ reassignedBy: 'workflow-admin',
407
+ fromApprover: 'mgr-1',
408
+ toApprover: 'mgr-2',
409
+ reason: 'Approver left the company',
410
+ });
411
+ ```
412
+
413
+ Emits `approval:reassigned` and records a `reassigned` audit entry.
414
+
347
415
  ### Escalate
348
416
 
349
417
  ```ts
@@ -352,6 +420,24 @@ await engine.escalate(instanceId, { escalatedBy: 'system' });
352
420
 
353
421
  Adds the escalation approver (from `template.escalation.escalateTo`) to the current level's approver list. Also fires automatically via the scheduler when `escalationAfterDays` elapses.
354
422
 
423
+ #### Business-day deadlines
424
+
425
+ By default `escalationAfterDays` and `slaDeadlineDays` count plain calendar days. Pass a `calendar` to the engine to count **business days** instead — skipping weekends and holidays:
426
+
427
+ ```ts
428
+ import { ApprovalEngine, weekendCalendar } from 'hierarchical-approval';
429
+
430
+ const engine = new ApprovalEngine({
431
+ adapter,
432
+ calendar: weekendCalendar({
433
+ holidays: [new Date('2026-12-25'), new Date('2027-01-01')],
434
+ // weekendDays: [5, 6], // optional — e.g. Fri/Sat weekend
435
+ }),
436
+ });
437
+ ```
438
+
439
+ With this calendar, a level whose `escalationAfterDays: 2` is submitted on a Friday becomes due the following Tuesday rather than Sunday. Provide your own `BusinessCalendar` implementation for region-specific rules.
440
+
355
441
  ### Cancel
356
442
 
357
443
  ```ts
@@ -535,6 +621,20 @@ const result = await engine.canApprove(instanceId, 'mgr-1');
535
621
 
536
622
  Never throws — always returns a structured result.
537
623
 
624
+ ### Statistics
625
+
626
+ Aggregate counts for dashboards. Pass an optional filter (`documentType`, `submittedBy`, `fromDate`/`toDate`) to scope the numbers; `status` is ignored since every status is counted.
627
+
628
+ ```ts
629
+ const stats = await engine.getStatistics({ documentType: 'purchase_order' });
630
+ // {
631
+ // total: number,
632
+ // byStatus: { pending, approved, rejected, cancelled, expired },
633
+ // overdue: number, // pending past an escalation/expiry deadline
634
+ // approvalRate: number, // approved / (approved + rejected); 0 when none resolved
635
+ // }
636
+ ```
637
+
538
638
  ### Health check
539
639
 
540
640
  ```ts
@@ -567,6 +667,7 @@ engine.on('approval:approved', (payload) => { /* level or fully approved *
567
667
  engine.on('approval:rejected', (payload) => { /* level rejected */ });
568
668
  engine.on('approval:level_advanced', (payload) => { /* moved to next level */ });
569
669
  engine.on('approval:delegated', (payload) => { /* approver delegated */ });
670
+ engine.on('approval:reassigned', (payload) => { /* approver reassigned by admin */ });
570
671
  engine.on('approval:escalated', (payload) => { /* escalated to new approver */ });
571
672
  engine.on('approval:cancelled', (payload) => { /* cancelled */ });
572
673
  engine.on('approval:expired', (payload) => { /* deadline passed */ });
@@ -681,7 +782,7 @@ import { ApprovalForbiddenError } from 'hierarchical-approval';
681
782
 
682
783
  class SigningAuthorityPolicy implements IAuthorizationPolicy {
683
784
  async authorize(ctx: AuthorizationContext): Promise<string | undefined> {
684
- // ctx.operation — 'approve' | 'reject' | 'delegate' | 'cancel' | 'escalate'
785
+ // ctx.operation — 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate'
685
786
  // | 'override' | 'resubmit' | 'addComment' | 'submit'
686
787
  // ctx.actorId — who is performing the action
687
788
  // ctx.instance — current instance (read-only)
@@ -956,7 +1057,10 @@ interface ApprovalLevelConfig {
956
1057
  level: number; // execution order (must be unique within template)
957
1058
  name: string; // display name
958
1059
  approvers: ApproverConfig[]; // at least one required
959
- mode: 'any' | 'all' | 'majority';
1060
+ mode: 'any' | 'all' | 'majority' | 'quorum' | 'weighted';
1061
+ minApprovals?: number; // required when mode is 'quorum' (N-of-M threshold)
1062
+ threshold?: number; // required when mode is 'weighted' (cumulative weight to pass)
1063
+ weights?: Record<string, number>; // optional per-approver weights for 'weighted'; default 1
960
1064
  escalationAfterDays?: number; // per-level escalation (overrides template.escalation timing)
961
1065
  }
962
1066
  ```
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as AuditEntry, g as ApprovalLevelInstance, I as IStorageAdapter, q as ResolverFn, j as ApprovalTemplateConfig, A as ApprovalTemplate, m as AuditContext, h as ApprovalMode, P as PaginationOpts, b as PaginatedResult, c as InstanceFilter, C as CursorPaginationOpts, e as CursorPaginatedResult } from './IStorageAdapter-RAiLF8bc.js';
1
+ import { a as ApprovalInstance, d as AuditEntry, g as ApprovalLevelInstance, I as IStorageAdapter, q as ResolverFn, j as ApprovalTemplateConfig, A as ApprovalTemplate, m as AuditContext, h as ApprovalMode, P as PaginationOpts, b as PaginatedResult, c as InstanceFilter, C as CursorPaginationOpts, e as CursorPaginatedResult } from './IStorageAdapter-B2xGuaKi.cjs';
2
2
  import { z } from 'zod';
3
3
 
4
4
  interface ApprovalEvent {
@@ -29,6 +29,13 @@ interface DelegatedEvent extends ApprovalEvent {
29
29
  level: number;
30
30
  reason: string;
31
31
  }
32
+ interface ReassignedEvent extends ApprovalEvent {
33
+ reassignedBy: string;
34
+ fromApprover: string;
35
+ toApprover: string;
36
+ level: number;
37
+ reason: string;
38
+ }
32
39
  interface EscalatedEvent extends ApprovalEvent {
33
40
  level: number;
34
41
  escalatedTo: string;
@@ -61,6 +68,7 @@ interface ApprovalEventMap {
61
68
  'approval:approved': ApprovedEvent;
62
69
  'approval:rejected': RejectedEvent;
63
70
  'approval:delegated': DelegatedEvent;
71
+ 'approval:reassigned': ReassignedEvent;
64
72
  'approval:escalated': EscalatedEvent;
65
73
  'approval:cancelled': CancelledEvent;
66
74
  'approval:completed': ApprovalInstance;
@@ -106,6 +114,12 @@ declare const DelegateOptionsSchema: z.ZodObject<{
106
114
  reason: z.ZodString;
107
115
  until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
108
116
  }, z.core.$strip>;
117
+ declare const ReassignOptionsSchema: z.ZodObject<{
118
+ reassignedBy: z.ZodString;
119
+ fromApprover: z.ZodString;
120
+ toApprover: z.ZodString;
121
+ reason: z.ZodString;
122
+ }, z.core.$strip>;
109
123
  declare const CancelOptionsSchema: z.ZodObject<{
110
124
  cancelledBy: z.ZodString;
111
125
  reason: z.ZodString;
@@ -130,6 +144,7 @@ type SubmitOptions = z.infer<typeof SubmitOptionsSchema>;
130
144
  type ApproveOptions = z.infer<typeof ApproveOptionsSchema>;
131
145
  type RejectOptions = z.infer<typeof RejectOptionsSchema>;
132
146
  type DelegateOptions = z.infer<typeof DelegateOptionsSchema>;
147
+ type ReassignOptions = z.infer<typeof ReassignOptionsSchema>;
133
148
  type CancelOptions = z.infer<typeof CancelOptionsSchema>;
134
149
  type EscalateOptions = z.infer<typeof EscalateOptionsSchema>;
135
150
  type ResubmitOptions = z.infer<typeof ResubmitOptionsSchema>;
@@ -149,6 +164,36 @@ interface Clock {
149
164
  }
150
165
  declare const systemClock: Clock;
151
166
 
167
+ /**
168
+ * Computes deadline dates from a number of days. The default engine behaviour
169
+ * treats day offsets (escalationAfterDays, slaDeadlineDays) as plain calendar
170
+ * days; provide a BusinessCalendar on ApprovalEngine to interpret them as
171
+ * business days instead — skipping weekends and configured holidays.
172
+ */
173
+ interface BusinessCalendar {
174
+ /**
175
+ * Return the date that is `days` business days after `from`. A fractional
176
+ * `days` adds whole business days first, then the remaining fraction as
177
+ * elapsed clock time within the resulting business day.
178
+ */
179
+ addBusinessDays(from: Date, days: number): Date;
180
+ }
181
+ interface WeekendCalendarOptions {
182
+ /** Dates to treat as non-working days (compared by local calendar date). */
183
+ holidays?: Date[];
184
+ /**
185
+ * Weekday numbers (0 = Sunday … 6 = Saturday) that count as weekend.
186
+ * Defaults to [0, 6]. Override for regions with different work weeks
187
+ * (e.g. [5, 6] for a Friday/Saturday weekend).
188
+ */
189
+ weekendDays?: number[];
190
+ }
191
+ /**
192
+ * A calendar that skips weekends (Sat/Sun by default) and any supplied
193
+ * holidays. Day arithmetic is performed in the host's local timezone.
194
+ */
195
+ declare function weekendCalendar(options?: WeekendCalendarOptions): BusinessCalendar;
196
+
152
197
  type IdGeneratorFn = (prefix: 'inst' | 'tpl') => string;
153
198
  declare const defaultIdGenerator: IdGeneratorFn;
154
199
 
@@ -224,7 +269,7 @@ interface IAuditAdapter {
224
269
  append(tenantId: string, instanceId: string, entry: AuditEntry, instance: Readonly<ApprovalInstance>): Promise<void>;
225
270
  }
226
271
 
227
- type MetricName = 'approval.submitted' | 'approval.approved' | 'approval.rejected' | 'approval.cancelled' | 'approval.expired' | 'approval.sla_breached' | 'approval.escalated' | 'approval.overridden' | 'approval.conflict_retry' | 'approval.operation_duration_ms';
272
+ type MetricName = 'approval.submitted' | 'approval.approved' | 'approval.rejected' | 'approval.cancelled' | 'approval.expired' | 'approval.sla_breached' | 'approval.escalated' | 'approval.reassigned' | 'approval.overridden' | 'approval.conflict_retry' | 'approval.operation_duration_ms';
228
273
  interface IMetricsAdapter {
229
274
  /** Increment a counter. Synchronous — never awaited. */
230
275
  increment(metric: MetricName, labels?: Record<string, string>): void;
@@ -245,7 +290,7 @@ interface ISchedulerAdapter {
245
290
  }
246
291
 
247
292
  interface AuthorizationContext {
248
- operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
293
+ operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
249
294
  actorId: string;
250
295
  instance: Readonly<ApprovalInstance>;
251
296
  level?: Readonly<ApprovalLevelInstance>;
@@ -306,6 +351,16 @@ interface BulkResult {
306
351
  }>;
307
352
  total: number;
308
353
  }
354
+ interface ApprovalStatistics {
355
+ /** Total instances matching the filter (across all statuses). */
356
+ total: number;
357
+ /** Count per status. */
358
+ byStatus: Record<ApprovalInstance['status'], number>;
359
+ /** Instances still pending past their escalation/expiry deadline. */
360
+ overdue: number;
361
+ /** approved / (approved + rejected); 0 when nothing has been resolved. */
362
+ approvalRate: number;
363
+ }
309
364
  interface HealthResult {
310
365
  status: 'healthy' | 'degraded' | 'unhealthy';
311
366
  adapter: 'connected' | 'error';
@@ -331,6 +386,12 @@ interface ApprovalEngineOptions {
331
386
  maxBulkItems?: number;
332
387
  /** Injectable clock — defaults to system clock. Enables deterministic tests and custom time sources. */
333
388
  clock?: Clock;
389
+ /**
390
+ * Optional business-day calendar. When provided, escalationAfterDays and
391
+ * slaDeadlineDays are interpreted as business days (skipping weekends and
392
+ * holidays) instead of plain calendar days. See weekendCalendar().
393
+ */
394
+ calendar?: BusinessCalendar;
334
395
  /** Custom ID generator for instances and templates. Defaults to timestamp+random. */
335
396
  generateId?: IdGeneratorFn;
336
397
  /** Custom optimistic locking retry policy. */
@@ -359,6 +420,7 @@ declare class ApprovalEngine {
359
420
  private readonly tenantId;
360
421
  private readonly logger;
361
422
  private readonly clock;
423
+ private readonly calendar?;
362
424
  private readonly generateId;
363
425
  private readonly maxBulkItems;
364
426
  private readonly retryPolicy;
@@ -380,6 +442,14 @@ declare class ApprovalEngine {
380
442
  approve(instanceId: string, raw: ApproveOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
381
443
  reject(instanceId: string, raw: RejectOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
382
444
  delegate(instanceId: string, raw: DelegateOptions, auditCtx?: AuditContext): Promise<void>;
445
+ /**
446
+ * Administratively replace an approver on the current level. Unlike delegate(),
447
+ * this is performed by a third party (e.g. an admin handling an unavailable
448
+ * approver) and does not require the original approver to initiate it. The
449
+ * target approver must still be pending — an approver who has already acted
450
+ * cannot be reassigned.
451
+ */
452
+ reassign(instanceId: string, raw: ReassignOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
383
453
  cancel(instanceId: string, raw: CancelOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
384
454
  escalate(instanceId: string, raw: EscalateOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
385
455
  /** Add a comment to an instance without approving or rejecting. */
@@ -404,6 +474,12 @@ declare class ApprovalEngine {
404
474
  getCurrentApprovers(instanceId: string): Promise<string[]>;
405
475
  /** Check adapter connectivity and escalation scheduler health. */
406
476
  healthCheck(): Promise<HealthResult>;
477
+ /**
478
+ * Aggregate counts for dashboards. Accepts an optional filter (documentType,
479
+ * submittedBy, date range) — `status` is ignored since every status is counted.
480
+ * Adapter-agnostic: issues one cheap count query per status plus an overdue scan.
481
+ */
482
+ getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
407
483
  shutdown(): Promise<void>;
408
484
  private escalateInternal;
409
485
  private expireInstance;
@@ -411,6 +487,8 @@ declare class ApprovalEngine {
411
487
  private revertDelegation;
412
488
  /** Read-modify-write with optimistic locking retry. */
413
489
  private withOptimisticRetry;
490
+ /** Compute a deadline `days` from `from`, honouring the business calendar if one is configured. */
491
+ private deadlineFrom;
414
492
  private requireInstance;
415
493
  private currentLevelInstance;
416
494
  private findNextLevel;
@@ -423,4 +501,4 @@ declare class ApprovalEngine {
423
501
  private runExternalAudit;
424
502
  }
425
503
 
426
- export { type SubmitOptions as $, type AddCommentOptions as A, type BulkResult as B, type Clock as C, type DelegateOptions as D, type EscalateOptions as E, type IOperationMiddleware as F, type ISchedulerAdapter as G, type HealthResult as H, type IAuditAdapter as I, type IdGeneratorFn as J, type IdempotencyKeyFn as K, type Logger as L, type LevelAdvancedEvent as M, type MetricName as N, type NotificationEvent as O, type OperationContext as P, type OrgProvider as Q, type OverriddenEvent as R, type OverrideOptions as S, type PreviewChainLevel as T, type PreviewResult as U, type RejectOptions as V, type RejectedEvent as W, type ResubmitOptions as X, type ResubmittedEvent as Y, type RetryPolicy as Z, type SlaBreachedEvent as _, ApprovalConflictError as a, type SubmittedEvent as a0, type ValidationResult as a1, defaultIdGenerator as a2, noopLogger as a3, systemClock as a4, ApprovalEngine as b, type ApprovalEngineOptions as c, ApprovalError as d, type ApprovalEvent as e, type ApprovalEventMap as f, type ApprovalEventName as g, ApprovalForbiddenError as h, ApprovalNotFoundError as i, ApprovalTemplateNotFoundError as j, ApprovalValidationError as k, type ApproveOptions as l, type ApprovedEvent as m, type ApproverResolverFn as n, type AuthorizationContext as o, type CanApproveResult as p, type CancelOptions as q, type CancelledEvent as r, type ConditionOperatorFn as s, type DelegatedEvent as t, type EscalatedEvent as u, type ExpiredEvent as v, type HistoryEntry as w, type IAuthorizationPolicy as x, type IMetricsAdapter as y, type INotificationAdapter as z };
504
+ export { type ResubmitOptions as $, type AddCommentOptions as A, type BulkResult as B, type Clock as C, type DelegateOptions as D, type EscalateOptions as E, type IMetricsAdapter as F, type INotificationAdapter as G, type HealthResult as H, type IAuditAdapter as I, type IOperationMiddleware as J, type ISchedulerAdapter as K, type Logger as L, type IdGeneratorFn as M, type IdempotencyKeyFn as N, type LevelAdvancedEvent as O, type MetricName as P, type NotificationEvent as Q, type OperationContext as R, type OrgProvider as S, type OverriddenEvent as T, type OverrideOptions as U, type PreviewChainLevel as V, type PreviewResult as W, type ReassignOptions as X, type ReassignedEvent as Y, type RejectOptions as Z, type RejectedEvent as _, ApprovalConflictError as a, type ResubmittedEvent as a0, type RetryPolicy as a1, type SlaBreachedEvent as a2, type SubmitOptions as a3, type SubmittedEvent as a4, type ValidationResult as a5, type WeekendCalendarOptions as a6, defaultIdGenerator as a7, noopLogger as a8, systemClock as a9, weekendCalendar as aa, ApprovalEngine as b, type ApprovalEngineOptions as c, ApprovalError as d, type ApprovalEvent as e, type ApprovalEventMap as f, type ApprovalEventName as g, ApprovalForbiddenError as h, ApprovalNotFoundError as i, type ApprovalStatistics as j, ApprovalTemplateNotFoundError as k, ApprovalValidationError as l, type ApproveOptions as m, type ApprovedEvent as n, type ApproverResolverFn as o, type AuthorizationContext as p, type BusinessCalendar as q, type CanApproveResult as r, type CancelOptions as s, type CancelledEvent as t, type ConditionOperatorFn as u, type DelegatedEvent as v, type EscalatedEvent as w, type ExpiredEvent as x, type HistoryEntry as y, type IAuthorizationPolicy as z };
@@ -1,4 +1,4 @@
1
- import { a as ApprovalInstance, d as AuditEntry, g as ApprovalLevelInstance, I as IStorageAdapter, q as ResolverFn, j as ApprovalTemplateConfig, A as ApprovalTemplate, m as AuditContext, h as ApprovalMode, P as PaginationOpts, b as PaginatedResult, c as InstanceFilter, C as CursorPaginationOpts, e as CursorPaginatedResult } from './IStorageAdapter-RAiLF8bc.cjs';
1
+ import { a as ApprovalInstance, d as AuditEntry, g as ApprovalLevelInstance, I as IStorageAdapter, q as ResolverFn, j as ApprovalTemplateConfig, A as ApprovalTemplate, m as AuditContext, h as ApprovalMode, P as PaginationOpts, b as PaginatedResult, c as InstanceFilter, C as CursorPaginationOpts, e as CursorPaginatedResult } from './IStorageAdapter-B2xGuaKi.js';
2
2
  import { z } from 'zod';
3
3
 
4
4
  interface ApprovalEvent {
@@ -29,6 +29,13 @@ interface DelegatedEvent extends ApprovalEvent {
29
29
  level: number;
30
30
  reason: string;
31
31
  }
32
+ interface ReassignedEvent extends ApprovalEvent {
33
+ reassignedBy: string;
34
+ fromApprover: string;
35
+ toApprover: string;
36
+ level: number;
37
+ reason: string;
38
+ }
32
39
  interface EscalatedEvent extends ApprovalEvent {
33
40
  level: number;
34
41
  escalatedTo: string;
@@ -61,6 +68,7 @@ interface ApprovalEventMap {
61
68
  'approval:approved': ApprovedEvent;
62
69
  'approval:rejected': RejectedEvent;
63
70
  'approval:delegated': DelegatedEvent;
71
+ 'approval:reassigned': ReassignedEvent;
64
72
  'approval:escalated': EscalatedEvent;
65
73
  'approval:cancelled': CancelledEvent;
66
74
  'approval:completed': ApprovalInstance;
@@ -106,6 +114,12 @@ declare const DelegateOptionsSchema: z.ZodObject<{
106
114
  reason: z.ZodString;
107
115
  until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
108
116
  }, z.core.$strip>;
117
+ declare const ReassignOptionsSchema: z.ZodObject<{
118
+ reassignedBy: z.ZodString;
119
+ fromApprover: z.ZodString;
120
+ toApprover: z.ZodString;
121
+ reason: z.ZodString;
122
+ }, z.core.$strip>;
109
123
  declare const CancelOptionsSchema: z.ZodObject<{
110
124
  cancelledBy: z.ZodString;
111
125
  reason: z.ZodString;
@@ -130,6 +144,7 @@ type SubmitOptions = z.infer<typeof SubmitOptionsSchema>;
130
144
  type ApproveOptions = z.infer<typeof ApproveOptionsSchema>;
131
145
  type RejectOptions = z.infer<typeof RejectOptionsSchema>;
132
146
  type DelegateOptions = z.infer<typeof DelegateOptionsSchema>;
147
+ type ReassignOptions = z.infer<typeof ReassignOptionsSchema>;
133
148
  type CancelOptions = z.infer<typeof CancelOptionsSchema>;
134
149
  type EscalateOptions = z.infer<typeof EscalateOptionsSchema>;
135
150
  type ResubmitOptions = z.infer<typeof ResubmitOptionsSchema>;
@@ -149,6 +164,36 @@ interface Clock {
149
164
  }
150
165
  declare const systemClock: Clock;
151
166
 
167
+ /**
168
+ * Computes deadline dates from a number of days. The default engine behaviour
169
+ * treats day offsets (escalationAfterDays, slaDeadlineDays) as plain calendar
170
+ * days; provide a BusinessCalendar on ApprovalEngine to interpret them as
171
+ * business days instead — skipping weekends and configured holidays.
172
+ */
173
+ interface BusinessCalendar {
174
+ /**
175
+ * Return the date that is `days` business days after `from`. A fractional
176
+ * `days` adds whole business days first, then the remaining fraction as
177
+ * elapsed clock time within the resulting business day.
178
+ */
179
+ addBusinessDays(from: Date, days: number): Date;
180
+ }
181
+ interface WeekendCalendarOptions {
182
+ /** Dates to treat as non-working days (compared by local calendar date). */
183
+ holidays?: Date[];
184
+ /**
185
+ * Weekday numbers (0 = Sunday … 6 = Saturday) that count as weekend.
186
+ * Defaults to [0, 6]. Override for regions with different work weeks
187
+ * (e.g. [5, 6] for a Friday/Saturday weekend).
188
+ */
189
+ weekendDays?: number[];
190
+ }
191
+ /**
192
+ * A calendar that skips weekends (Sat/Sun by default) and any supplied
193
+ * holidays. Day arithmetic is performed in the host's local timezone.
194
+ */
195
+ declare function weekendCalendar(options?: WeekendCalendarOptions): BusinessCalendar;
196
+
152
197
  type IdGeneratorFn = (prefix: 'inst' | 'tpl') => string;
153
198
  declare const defaultIdGenerator: IdGeneratorFn;
154
199
 
@@ -224,7 +269,7 @@ interface IAuditAdapter {
224
269
  append(tenantId: string, instanceId: string, entry: AuditEntry, instance: Readonly<ApprovalInstance>): Promise<void>;
225
270
  }
226
271
 
227
- type MetricName = 'approval.submitted' | 'approval.approved' | 'approval.rejected' | 'approval.cancelled' | 'approval.expired' | 'approval.sla_breached' | 'approval.escalated' | 'approval.overridden' | 'approval.conflict_retry' | 'approval.operation_duration_ms';
272
+ type MetricName = 'approval.submitted' | 'approval.approved' | 'approval.rejected' | 'approval.cancelled' | 'approval.expired' | 'approval.sla_breached' | 'approval.escalated' | 'approval.reassigned' | 'approval.overridden' | 'approval.conflict_retry' | 'approval.operation_duration_ms';
228
273
  interface IMetricsAdapter {
229
274
  /** Increment a counter. Synchronous — never awaited. */
230
275
  increment(metric: MetricName, labels?: Record<string, string>): void;
@@ -245,7 +290,7 @@ interface ISchedulerAdapter {
245
290
  }
246
291
 
247
292
  interface AuthorizationContext {
248
- operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
293
+ operation: 'submit' | 'approve' | 'reject' | 'delegate' | 'reassign' | 'cancel' | 'escalate' | 'override' | 'resubmit' | 'addComment';
249
294
  actorId: string;
250
295
  instance: Readonly<ApprovalInstance>;
251
296
  level?: Readonly<ApprovalLevelInstance>;
@@ -306,6 +351,16 @@ interface BulkResult {
306
351
  }>;
307
352
  total: number;
308
353
  }
354
+ interface ApprovalStatistics {
355
+ /** Total instances matching the filter (across all statuses). */
356
+ total: number;
357
+ /** Count per status. */
358
+ byStatus: Record<ApprovalInstance['status'], number>;
359
+ /** Instances still pending past their escalation/expiry deadline. */
360
+ overdue: number;
361
+ /** approved / (approved + rejected); 0 when nothing has been resolved. */
362
+ approvalRate: number;
363
+ }
309
364
  interface HealthResult {
310
365
  status: 'healthy' | 'degraded' | 'unhealthy';
311
366
  adapter: 'connected' | 'error';
@@ -331,6 +386,12 @@ interface ApprovalEngineOptions {
331
386
  maxBulkItems?: number;
332
387
  /** Injectable clock — defaults to system clock. Enables deterministic tests and custom time sources. */
333
388
  clock?: Clock;
389
+ /**
390
+ * Optional business-day calendar. When provided, escalationAfterDays and
391
+ * slaDeadlineDays are interpreted as business days (skipping weekends and
392
+ * holidays) instead of plain calendar days. See weekendCalendar().
393
+ */
394
+ calendar?: BusinessCalendar;
334
395
  /** Custom ID generator for instances and templates. Defaults to timestamp+random. */
335
396
  generateId?: IdGeneratorFn;
336
397
  /** Custom optimistic locking retry policy. */
@@ -359,6 +420,7 @@ declare class ApprovalEngine {
359
420
  private readonly tenantId;
360
421
  private readonly logger;
361
422
  private readonly clock;
423
+ private readonly calendar?;
362
424
  private readonly generateId;
363
425
  private readonly maxBulkItems;
364
426
  private readonly retryPolicy;
@@ -380,6 +442,14 @@ declare class ApprovalEngine {
380
442
  approve(instanceId: string, raw: ApproveOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
381
443
  reject(instanceId: string, raw: RejectOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
382
444
  delegate(instanceId: string, raw: DelegateOptions, auditCtx?: AuditContext): Promise<void>;
445
+ /**
446
+ * Administratively replace an approver on the current level. Unlike delegate(),
447
+ * this is performed by a third party (e.g. an admin handling an unavailable
448
+ * approver) and does not require the original approver to initiate it. The
449
+ * target approver must still be pending — an approver who has already acted
450
+ * cannot be reassigned.
451
+ */
452
+ reassign(instanceId: string, raw: ReassignOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
383
453
  cancel(instanceId: string, raw: CancelOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
384
454
  escalate(instanceId: string, raw: EscalateOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
385
455
  /** Add a comment to an instance without approving or rejecting. */
@@ -404,6 +474,12 @@ declare class ApprovalEngine {
404
474
  getCurrentApprovers(instanceId: string): Promise<string[]>;
405
475
  /** Check adapter connectivity and escalation scheduler health. */
406
476
  healthCheck(): Promise<HealthResult>;
477
+ /**
478
+ * Aggregate counts for dashboards. Accepts an optional filter (documentType,
479
+ * submittedBy, date range) — `status` is ignored since every status is counted.
480
+ * Adapter-agnostic: issues one cheap count query per status plus an overdue scan.
481
+ */
482
+ getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
407
483
  shutdown(): Promise<void>;
408
484
  private escalateInternal;
409
485
  private expireInstance;
@@ -411,6 +487,8 @@ declare class ApprovalEngine {
411
487
  private revertDelegation;
412
488
  /** Read-modify-write with optimistic locking retry. */
413
489
  private withOptimisticRetry;
490
+ /** Compute a deadline `days` from `from`, honouring the business calendar if one is configured. */
491
+ private deadlineFrom;
414
492
  private requireInstance;
415
493
  private currentLevelInstance;
416
494
  private findNextLevel;
@@ -423,4 +501,4 @@ declare class ApprovalEngine {
423
501
  private runExternalAudit;
424
502
  }
425
503
 
426
- export { type SubmitOptions as $, type AddCommentOptions as A, type BulkResult as B, type Clock as C, type DelegateOptions as D, type EscalateOptions as E, type IOperationMiddleware as F, type ISchedulerAdapter as G, type HealthResult as H, type IAuditAdapter as I, type IdGeneratorFn as J, type IdempotencyKeyFn as K, type Logger as L, type LevelAdvancedEvent as M, type MetricName as N, type NotificationEvent as O, type OperationContext as P, type OrgProvider as Q, type OverriddenEvent as R, type OverrideOptions as S, type PreviewChainLevel as T, type PreviewResult as U, type RejectOptions as V, type RejectedEvent as W, type ResubmitOptions as X, type ResubmittedEvent as Y, type RetryPolicy as Z, type SlaBreachedEvent as _, ApprovalConflictError as a, type SubmittedEvent as a0, type ValidationResult as a1, defaultIdGenerator as a2, noopLogger as a3, systemClock as a4, ApprovalEngine as b, type ApprovalEngineOptions as c, ApprovalError as d, type ApprovalEvent as e, type ApprovalEventMap as f, type ApprovalEventName as g, ApprovalForbiddenError as h, ApprovalNotFoundError as i, ApprovalTemplateNotFoundError as j, ApprovalValidationError as k, type ApproveOptions as l, type ApprovedEvent as m, type ApproverResolverFn as n, type AuthorizationContext as o, type CanApproveResult as p, type CancelOptions as q, type CancelledEvent as r, type ConditionOperatorFn as s, type DelegatedEvent as t, type EscalatedEvent as u, type ExpiredEvent as v, type HistoryEntry as w, type IAuthorizationPolicy as x, type IMetricsAdapter as y, type INotificationAdapter as z };
504
+ export { type ResubmitOptions as $, type AddCommentOptions as A, type BulkResult as B, type Clock as C, type DelegateOptions as D, type EscalateOptions as E, type IMetricsAdapter as F, type INotificationAdapter as G, type HealthResult as H, type IAuditAdapter as I, type IOperationMiddleware as J, type ISchedulerAdapter as K, type Logger as L, type IdGeneratorFn as M, type IdempotencyKeyFn as N, type LevelAdvancedEvent as O, type MetricName as P, type NotificationEvent as Q, type OperationContext as R, type OrgProvider as S, type OverriddenEvent as T, type OverrideOptions as U, type PreviewChainLevel as V, type PreviewResult as W, type ReassignOptions as X, type ReassignedEvent as Y, type RejectOptions as Z, type RejectedEvent as _, ApprovalConflictError as a, type ResubmittedEvent as a0, type RetryPolicy as a1, type SlaBreachedEvent as a2, type SubmitOptions as a3, type SubmittedEvent as a4, type ValidationResult as a5, type WeekendCalendarOptions as a6, defaultIdGenerator as a7, noopLogger as a8, systemClock as a9, weekendCalendar as aa, ApprovalEngine as b, type ApprovalEngineOptions as c, ApprovalError as d, type ApprovalEvent as e, type ApprovalEventMap as f, type ApprovalEventName as g, ApprovalForbiddenError as h, ApprovalNotFoundError as i, type ApprovalStatistics as j, ApprovalTemplateNotFoundError as k, ApprovalValidationError as l, type ApproveOptions as m, type ApprovedEvent as n, type ApproverResolverFn as o, type AuthorizationContext as p, type BusinessCalendar as q, type CanApproveResult as r, type CancelOptions as s, type CancelledEvent as t, type ConditionOperatorFn as u, type DelegatedEvent as v, type EscalatedEvent as w, type ExpiredEvent as x, type HistoryEntry as y, type IAuthorizationPolicy as z };
@@ -19,7 +19,7 @@ interface ResolvedApprover {
19
19
  }
20
20
  type ResolverFn = (submittedBy: string, data: Record<string, unknown>) => Promise<string> | string;
21
21
 
22
- type ApprovalMode = 'all' | 'any' | 'majority';
22
+ type ApprovalMode = 'all' | 'any' | 'majority' | 'quorum' | 'weighted';
23
23
  /** Built-in operators. Use engine.registerConditionOperator() to add custom ones. */
24
24
  type ConditionOperator = '>' | '<' | '>=' | '<=' | '==' | '!=' | 'in' | 'not_in' | (string & {});
25
25
  interface Condition {
@@ -33,6 +33,23 @@ interface ApprovalLevelConfig {
33
33
  approvers: ApproverConfig[];
34
34
  mode: ApprovalMode;
35
35
  escalationAfterDays?: number;
36
+ /**
37
+ * Required when mode is 'quorum'. The minimum number of approvals needed to
38
+ * pass this level (an N-of-M threshold). The level is rejected as soon as it
39
+ * becomes impossible to reach this count.
40
+ */
41
+ minApprovals?: number;
42
+ /**
43
+ * Required when mode is 'weighted'. The cumulative approver weight needed to
44
+ * pass this level. The level is rejected once the remaining achievable weight
45
+ * can no longer reach this threshold.
46
+ */
47
+ threshold?: number;
48
+ /**
49
+ * Optional per-approver voting weights for 'weighted' mode, keyed by approver
50
+ * id. Approvers not listed default to a weight of 1.
51
+ */
52
+ weights?: Record<string, number>;
36
53
  }
37
54
  interface ConditionRule {
38
55
  when: Condition | Condition[];
@@ -66,7 +83,7 @@ interface ApprovalTemplate extends ApprovalTemplateConfig {
66
83
 
67
84
  type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'expired';
68
85
  type LevelStatus = 'waiting' | 'pending' | 'approved' | 'rejected' | 'skipped';
69
- type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'expired';
86
+ type AuditAction = 'submitted' | 'approved' | 'rejected' | 'delegated' | 'reassigned' | 'escalated' | 'cancelled' | 'level_advanced' | 'commented' | 'resubmitted' | 'overridden' | 'expired';
70
87
  interface AuditEntry {
71
88
  action: AuditAction;
72
89
  actorId: string;
@@ -98,6 +115,12 @@ interface ApprovalLevelInstance {
98
115
  approvedBy: string[];
99
116
  rejectedBy: string[];
100
117
  status: LevelStatus;
118
+ /** Minimum approvals required to pass this level (set when mode is 'quorum'). */
119
+ minApprovals?: number;
120
+ /** Cumulative approver weight required to pass this level (set when mode is 'weighted'). */
121
+ threshold?: number;
122
+ /** Per-approver voting weights for 'weighted' mode; unlisted approvers default to 1. */
123
+ weights?: Record<string, number>;
101
124
  escalationDueAt?: Date;
102
125
  escalationAfterDays?: number;
103
126
  /** Set when a delegation has a time limit — the original approver is restored when this date passes. */