hierarchical-approval 0.1.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/README.md +968 -0
- package/dist/ApprovalEngine-BcnLzfAU.d.cts +426 -0
- package/dist/ApprovalEngine-DdZtyeB5.d.ts +426 -0
- package/dist/IStorageAdapter-RAiLF8bc.d.cts +192 -0
- package/dist/IStorageAdapter-RAiLF8bc.d.ts +192 -0
- package/dist/adapters/MemoryAdapter.cjs +189 -0
- package/dist/adapters/MemoryAdapter.cjs.map +1 -0
- package/dist/adapters/MemoryAdapter.d.cts +22 -0
- package/dist/adapters/MemoryAdapter.d.ts +22 -0
- package/dist/adapters/MemoryAdapter.js +187 -0
- package/dist/adapters/MemoryAdapter.js.map +1 -0
- package/dist/adapters/PostgresAdapter.cjs +468 -0
- package/dist/adapters/PostgresAdapter.cjs.map +1 -0
- package/dist/adapters/PostgresAdapter.d.cts +45 -0
- package/dist/adapters/PostgresAdapter.d.ts +45 -0
- package/dist/adapters/PostgresAdapter.js +466 -0
- package/dist/adapters/PostgresAdapter.js.map +1 -0
- package/dist/index.cjs +1759 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +1742 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.cjs +1797 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +24 -0
- package/dist/testing.d.ts +24 -0
- package/dist/testing.js +1790 -0
- package/dist/testing.js.map +1 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
# hierarchical-approval
|
|
2
|
+
|
|
3
|
+
TypeScript-first multi-level approval workflows for enterprise systems. Multi-tenant, audit-ready, fully pluggable.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install hierarchical-approval
|
|
7
|
+
# peer dep for Postgres only
|
|
8
|
+
npm install pg @types/pg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why another approval library?
|
|
14
|
+
|
|
15
|
+
Approval workflows are deceptively simple until they aren't. Most teams start with `if (amount > 1000) notifyManager()` and end up with hundreds of lines of procedural logic entangled with their database, email service, and audit code — and rewrite it whenever a new requirement arrives.
|
|
16
|
+
|
|
17
|
+
`hierarchical-approval` gives that logic a permanent, tested home:
|
|
18
|
+
|
|
19
|
+
| Pain point | What this library does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Hardcoded approval chains | Named **templates** — define once, reuse across any document type |
|
|
22
|
+
| Race conditions on concurrent approvals | **Optimistic locking** with a version field + configurable retry policy |
|
|
23
|
+
| Duplicate submissions (network retry, double-click) | Built-in **idempotency** keyed by tenant + document + template |
|
|
24
|
+
| No compliance trail | Immutable **audit log** with old/new state diff on every mutation |
|
|
25
|
+
| `new Date()` in production code makes tests unreliable | Injectable **Clock** interface — freeze time without monkey-patching |
|
|
26
|
+
| "Escalate to skip-level manager after 48 h" | Built-in **escalation scheduler** with delegation + time-limited proxying |
|
|
27
|
+
| SLA as an afterthought | **SLA tracking** baked in; `approval:sla_breached` event fires automatically |
|
|
28
|
+
| Test suites hit a real database | **`ApprovalTestKit`** + **`ManualClock`** — deterministic, zero I/O |
|
|
29
|
+
| Kafka/Datadog/BullMQ integration needed | Six **pluggable adapter interfaces** for notifications, metrics, audit, scheduling, auth, and middleware |
|
|
30
|
+
|
|
31
|
+
### Compared to existing libraries
|
|
32
|
+
|
|
33
|
+
**`approval-flow`** — Single-level only; no multi-tenancy; no audit trail; last published 2020.
|
|
34
|
+
**`workflow-engine`** — Generic state machine; you implement every guard, every condition, every audit entry yourself.
|
|
35
|
+
**`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.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { ApprovalEngine } from 'hierarchical-approval';
|
|
44
|
+
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
|
|
45
|
+
|
|
46
|
+
const engine = new ApprovalEngine({
|
|
47
|
+
adapter: new MemoryAdapter(),
|
|
48
|
+
tenantId: 'acme',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 1. Define a reusable template
|
|
52
|
+
await engine.defineTemplate({
|
|
53
|
+
name: 'purchase-order',
|
|
54
|
+
documentType: 'purchase_order',
|
|
55
|
+
levels: [
|
|
56
|
+
{
|
|
57
|
+
level: 1,
|
|
58
|
+
name: 'Manager',
|
|
59
|
+
approvers: [{ type: 'user', userId: 'mgr-1' }],
|
|
60
|
+
mode: 'any',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
level: 2,
|
|
64
|
+
name: 'Finance',
|
|
65
|
+
approvers: [{ type: 'role', role: 'finance-team' }],
|
|
66
|
+
mode: 'any',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
// Finance level only activates above $10 k
|
|
70
|
+
conditions: [
|
|
71
|
+
{
|
|
72
|
+
when: { field: 'amount', operator: '>', value: 10000 },
|
|
73
|
+
addLevels: [
|
|
74
|
+
{
|
|
75
|
+
level: 2,
|
|
76
|
+
name: 'Finance',
|
|
77
|
+
approvers: [{ type: 'user', userId: 'fin-1' }],
|
|
78
|
+
mode: 'any',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
slaDeadlineDays: 2,
|
|
84
|
+
allowOverride: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 2. Submit a document
|
|
88
|
+
const instance = await engine.submit({
|
|
89
|
+
templateName: 'purchase-order',
|
|
90
|
+
documentId: 'po-0042',
|
|
91
|
+
documentType: 'purchase_order',
|
|
92
|
+
submittedBy: 'alice',
|
|
93
|
+
data: { amount: 15000, vendor: 'Acme Corp' },
|
|
94
|
+
});
|
|
95
|
+
// instance.levels has two levels because amount > 10000
|
|
96
|
+
|
|
97
|
+
// 3. Manager approves
|
|
98
|
+
await engine.approve(instance.id, { approverId: 'mgr-1', comment: 'Looks good' });
|
|
99
|
+
|
|
100
|
+
// 4. Finance approves — instance is now 'approved'
|
|
101
|
+
await engine.approve(instance.id, { approverId: 'fin-1' });
|
|
102
|
+
|
|
103
|
+
await engine.shutdown();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Core concepts
|
|
109
|
+
|
|
110
|
+
### Templates
|
|
111
|
+
|
|
112
|
+
A **template** is the reusable definition of an approval chain. It specifies who must approve (by user ID, role, or custom resolver), in what order, under what conditions, and what SLA applies.
|
|
113
|
+
|
|
114
|
+
Templates are **snapshotted** at submit time. You can update a template (via `updateTemplate()`) without affecting any in-flight instances — each instance carries a `templateSnapshot` with the escalation and SLA config that was in effect when it was submitted. Each template also carries a `version` counter and `previousVersionId` for lineage tracking.
|
|
115
|
+
|
|
116
|
+
### Instances
|
|
117
|
+
|
|
118
|
+
An **instance** is a single document moving through a template. Key fields:
|
|
119
|
+
|
|
120
|
+
| Field | Type | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `id` | `string` | Unique instance ID |
|
|
123
|
+
| `status` | `'pending' \| 'approved' \| 'rejected' \| 'cancelled' \| 'expired'` | Current status |
|
|
124
|
+
| `currentLevel` | `number` | Active level number |
|
|
125
|
+
| `version` | `number` | Optimistic lock version |
|
|
126
|
+
| `levels` | `ApprovalLevelInstance[]` | Per-level state (approverIds, approvedBy, rejectedBy) |
|
|
127
|
+
| `auditLog` | `AuditEntry[]` | Full immutable history |
|
|
128
|
+
| `idempotencyKey` | `string` | Dedup key — same submit returns the same instance |
|
|
129
|
+
| `slaDeadlineAt` | `Date?` | When the SLA expires |
|
|
130
|
+
| `slaBreachedAt` | `Date?` | When the SLA was breached (set automatically) |
|
|
131
|
+
| `expiresAt` | `Date?` | Hard deadline — instance auto-expires after this |
|
|
132
|
+
| `deadlineAction` | `'cancel' \| 'reject'` | What to do when `expiresAt` passes |
|
|
133
|
+
| `parentInstanceId` | `string?` | Set on resubmitted instances |
|
|
134
|
+
| `templateSnapshot` | `object` | Frozen copy of escalation/SLA config at submit time |
|
|
135
|
+
|
|
136
|
+
### Approval modes
|
|
137
|
+
|
|
138
|
+
Each level's `mode` field controls how many approvers are required:
|
|
139
|
+
|
|
140
|
+
| Mode | Required |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `'any'` | One approver is enough |
|
|
143
|
+
| `'all'` | Every listed approver must act |
|
|
144
|
+
| `'majority'` | More than half must approve |
|
|
145
|
+
|
|
146
|
+
### Conditional chains
|
|
147
|
+
|
|
148
|
+
Conditions are evaluated **once at submit time** against `data` and determine which levels the instance will include:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
conditions: [
|
|
152
|
+
// Add level 3 only when amount exceeds $50k
|
|
153
|
+
{
|
|
154
|
+
when: { field: 'amount', operator: '>', value: 50000 },
|
|
155
|
+
addLevels: [{ level: 3, name: 'CFO', approvers: [{ type: 'user', userId: 'cfo' }], mode: 'any' }],
|
|
156
|
+
},
|
|
157
|
+
// Skip manager level for internal transfers
|
|
158
|
+
{
|
|
159
|
+
when: { field: 'category', operator: '==', value: 'internal_transfer' },
|
|
160
|
+
skipLevels: [1],
|
|
161
|
+
},
|
|
162
|
+
// Multi-condition AND (array form)
|
|
163
|
+
{
|
|
164
|
+
when: [
|
|
165
|
+
{ field: 'region', operator: 'in', value: ['APAC', 'EMEA'] },
|
|
166
|
+
{ field: 'amount', operator: '>=', value: 5000 },
|
|
167
|
+
],
|
|
168
|
+
addLevels: [{ level: 4, name: 'Regional VP', approvers: [{ type: 'role', role: 'regional-vp' }], mode: 'any' }],
|
|
169
|
+
},
|
|
170
|
+
]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Built-in operators:** `>`, `<`, `>=`, `<=`, `==`, `!=`, `in`, `not_in`
|
|
174
|
+
|
|
175
|
+
**Register custom operators** at engine level:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
engine.registerConditionOperator('contains', (actual, expected) =>
|
|
179
|
+
typeof actual === 'string' && actual.includes(String(expected)));
|
|
180
|
+
|
|
181
|
+
engine.registerConditionOperator('between', (actual, [min, max]: number[]) =>
|
|
182
|
+
Number(actual) >= min && Number(actual) <= max);
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Installation and setup
|
|
188
|
+
|
|
189
|
+
### PostgreSQL (production)
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { ApprovalEngine } from 'hierarchical-approval';
|
|
193
|
+
import { PostgresAdapter } from 'hierarchical-approval/adapters/postgres';
|
|
194
|
+
|
|
195
|
+
const adapter = new PostgresAdapter({
|
|
196
|
+
connectionString: process.env.DATABASE_URL,
|
|
197
|
+
// or: pool — bring your own pg.Pool
|
|
198
|
+
schema: 'public', // default
|
|
199
|
+
tablePrefix: 'ha', // prefix for all table names (validated: /^[a-z][a-z0-9_]*$/)
|
|
200
|
+
statementTimeoutMs: 5000,
|
|
201
|
+
ssl: { rejectUnauthorized: true },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const engine = new ApprovalEngine({ adapter, tenantId: 'acme' });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### In-memory (development / tests)
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
|
|
211
|
+
// or via the main export:
|
|
212
|
+
import { MemoryAdapter } from 'hierarchical-approval';
|
|
213
|
+
|
|
214
|
+
const engine = new ApprovalEngine({ adapter: new MemoryAdapter(), tenantId: 'dev' });
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## `new ApprovalEngine(options)`
|
|
220
|
+
|
|
221
|
+
All options except `adapter` are optional.
|
|
222
|
+
|
|
223
|
+
| Option | Type | Default | Description |
|
|
224
|
+
|---|---|---|---|
|
|
225
|
+
| `adapter` | `IStorageAdapter` | **required** | Storage backend |
|
|
226
|
+
| `tenantId` | `string` | `'default'` | Tenant scope — all data is isolated per tenant |
|
|
227
|
+
| `clock` | `Clock` | `systemClock` | Injectable time source — `{ now(): Date }` |
|
|
228
|
+
| `generateId` | `IdGeneratorFn` | timestamp + random | Custom ID generator (ULID, UUID v7, etc.) |
|
|
229
|
+
| `retryPolicy` | `RetryPolicy` | `{ maxAttempts: 3, baseDelayMs: 50, jitter: true }` | Optimistic lock retry behaviour |
|
|
230
|
+
| `idempotencyKeyFn` | `IdempotencyKeyFn` | SHA-256 of tenant+docType+docId+template | Custom dedup key strategy |
|
|
231
|
+
| `notificationAdapter` | `INotificationAdapter` | — | Typed events after each approval action |
|
|
232
|
+
| `auditAdapter` | `IAuditAdapter` | — | Write-once external audit sink |
|
|
233
|
+
| `metricsAdapter` | `IMetricsAdapter` | — | Prometheus / Datadog counters + timings |
|
|
234
|
+
| `schedulerAdapter` | `ISchedulerAdapter` | built-in `setInterval` poll | BullMQ / Temporal / EventBridge escalation |
|
|
235
|
+
| `authorizationPolicy` | `IAuthorizationPolicy` | — | Per-operation authorization rules |
|
|
236
|
+
| `middleware` | `IOperationMiddleware[]` | — | Before / after / onError hooks on every operation |
|
|
237
|
+
| `orgProvider` | `OrgProvider` | — | Resolves role members and org hierarchy |
|
|
238
|
+
| `logger` | `Logger` | `noopLogger` | Pino-compatible logger |
|
|
239
|
+
| `escalationPollIntervalMs` | `number` | `60_000` | Escalation poll interval (ms) — set `0` to disable polling |
|
|
240
|
+
| `maxBulkItems` | `number` | `200` | Max instances per `bulkApprove` / `bulkReject` call |
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Approver types
|
|
245
|
+
|
|
246
|
+
### Built-in types
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// Exact user ID
|
|
250
|
+
{ type: 'user', userId: 'alice' }
|
|
251
|
+
|
|
252
|
+
// Role — resolved via orgProvider.getUsersByRole()
|
|
253
|
+
{ type: 'role', role: 'finance-team' }
|
|
254
|
+
|
|
255
|
+
// Dynamic — resolved via a named resolver registered with engine.registerResolver()
|
|
256
|
+
{ type: 'dynamic', resolver: 'direct-manager' }
|
|
257
|
+
engine.registerResolver('direct-manager', async (submittedBy, data) => {
|
|
258
|
+
const manager = await hrSystem.getManager(submittedBy);
|
|
259
|
+
return manager.id;
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Custom approver types
|
|
264
|
+
|
|
265
|
+
Register any type beyond the built-in three:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
engine.registerApproverType('department', async (config, ctx) => {
|
|
269
|
+
// config is the raw approver object from the template
|
|
270
|
+
const dept = config.department as string;
|
|
271
|
+
return ctx.orgProvider?.getUsersByDepartment?.(dept) ?? [];
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Use it in a template:
|
|
275
|
+
{ type: 'department', department: 'legal' }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The `OrgProvider` interface also exposes optional methods for richer org traversal:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
interface OrgProvider {
|
|
282
|
+
getUsersByRole(role: string, tenantId?: string): Promise<string[]> | string[];
|
|
283
|
+
getUsersByDepartment?(dept: string, tenantId?: string): Promise<string[]> | string[];
|
|
284
|
+
getManagerOf?(userId: string, tenantId?: string): Promise<string | null> | string | null;
|
|
285
|
+
getSkipLevelManagerOf?(userId: string, tenantId?: string): Promise<string | null> | string | null;
|
|
286
|
+
getUsersByAttribute?(attr: string, value: unknown, tenantId?: string): Promise<string[]> | string[];
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Lifecycle operations
|
|
293
|
+
|
|
294
|
+
### Submit
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const instance = await engine.submit({
|
|
298
|
+
templateName: 'purchase-order', // must exist
|
|
299
|
+
documentId: 'po-0042',
|
|
300
|
+
documentType: 'purchase_order',
|
|
301
|
+
submittedBy: 'alice',
|
|
302
|
+
data: { amount: 15000 }, // evaluated against template conditions
|
|
303
|
+
metadata: { source: 'web-ui' }, // arbitrary metadata, not evaluated
|
|
304
|
+
expiresAt: new Date('2025-12-31'), // hard deadline (optional)
|
|
305
|
+
deadlineAction: 'reject', // what to do when deadline passes (default: 'cancel')
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Submitting the same `(tenantId, documentType, documentId, templateName)` again while the instance is still pending returns the **existing instance** rather than creating a duplicate.
|
|
310
|
+
|
|
311
|
+
### Approve
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
await engine.approve(instanceId, {
|
|
315
|
+
approverId: 'mgr-1',
|
|
316
|
+
comment: 'Approved for Q4 budget', // optional
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Advancing the last level sets `instance.status = 'approved'` and emits `approval:completed`.
|
|
321
|
+
|
|
322
|
+
### Reject
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
await engine.reject(instanceId, {
|
|
326
|
+
approverId: 'mgr-1',
|
|
327
|
+
reason: 'Over budget cap', // required
|
|
328
|
+
returnTo: 'previous', // optional: 'originator' | 'previous'
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- Omitting `returnTo`: instance is marked `'rejected'`
|
|
333
|
+
- `returnTo: 'previous'`: resets the previous level so the chain can continue
|
|
334
|
+
- `returnTo: 'originator'`: same as default — marks `'rejected'`
|
|
335
|
+
|
|
336
|
+
### Delegate
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
await engine.delegate(instanceId, {
|
|
340
|
+
fromApprover: 'mgr-1',
|
|
341
|
+
toApprover: 'deputy-mgr',
|
|
342
|
+
reason: 'On leave',
|
|
343
|
+
until: new Date('2025-11-15'), // optional — delegation auto-reverts after this date
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Escalate
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
await engine.escalate(instanceId, { escalatedBy: 'system' });
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
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
|
+
|
|
355
|
+
### Cancel
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
await engine.cancel(instanceId, {
|
|
359
|
+
cancelledBy: 'alice',
|
|
360
|
+
reason: 'PO no longer needed',
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Resubmit
|
|
365
|
+
|
|
366
|
+
Creates a new instance linked to the rejected original via `parentInstanceId`:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const newInstance = await engine.resubmit(instanceId, {
|
|
370
|
+
resubmittedBy: 'alice',
|
|
371
|
+
reason: 'Revised amount',
|
|
372
|
+
updatedData: { amount: 9000 }, // merged with original data
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Override
|
|
377
|
+
|
|
378
|
+
Bypasses all remaining levels. Requires `template.allowOverride: true`:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
await engine.override(instanceId, {
|
|
382
|
+
overriddenBy: 'cfo',
|
|
383
|
+
justification: 'Board resolution 2025-Q4-001',
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Add comment
|
|
388
|
+
|
|
389
|
+
Adds a comment to the audit log without changing state:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
await engine.addComment(instanceId, {
|
|
393
|
+
actorId: 'legal-team',
|
|
394
|
+
comment: 'Legal review completed — no issues found',
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Bulk operations
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const result = await engine.bulkApprove(['id-1', 'id-2', 'id-3'], {
|
|
402
|
+
approverId: 'mgr-1',
|
|
403
|
+
comment: 'Year-end batch approval',
|
|
404
|
+
});
|
|
405
|
+
// result: { succeeded: ApprovalInstance[], failed: { instanceId, error }[], total: number }
|
|
406
|
+
|
|
407
|
+
const result = await engine.bulkReject(['id-4', 'id-5'], {
|
|
408
|
+
approverId: 'mgr-1',
|
|
409
|
+
reason: 'Over budget',
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Individual failures do not abort the batch — they are collected in `result.failed`.
|
|
414
|
+
|
|
415
|
+
### Audit context
|
|
416
|
+
|
|
417
|
+
Every mutating operation accepts an optional second argument to attach request metadata to the audit entry:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
await engine.approve(instanceId, { approverId: 'mgr-1' }, {
|
|
421
|
+
ipAddress: req.ip,
|
|
422
|
+
userAgent: req.headers['user-agent'],
|
|
423
|
+
sessionId: req.session.id,
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Queries
|
|
430
|
+
|
|
431
|
+
### Single instance
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
const instance = await engine.getInstance(instanceId);
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Pending work for an approver
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
const { items, total } = await engine.getPendingFor('mgr-1', { limit: 20, offset: 0 });
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Filtered list (offset pagination)
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
const { items, total } = await engine.queryInstances(
|
|
447
|
+
{
|
|
448
|
+
status: 'pending', // optional
|
|
449
|
+
documentType: 'invoice', // optional
|
|
450
|
+
submittedBy: 'alice', // optional
|
|
451
|
+
fromDate: new Date('2025-01-01'), // optional
|
|
452
|
+
toDate: new Date('2025-12-31'), // optional
|
|
453
|
+
},
|
|
454
|
+
{ limit: 50, offset: 0 },
|
|
455
|
+
);
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Cursor pagination (large datasets)
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
let cursor: string | undefined;
|
|
462
|
+
do {
|
|
463
|
+
const page = await engine.queryInstancesByCursor(
|
|
464
|
+
{ status: 'pending' },
|
|
465
|
+
{ limit: 100, cursor },
|
|
466
|
+
);
|
|
467
|
+
await processPage(page.items);
|
|
468
|
+
cursor = page.nextCursor;
|
|
469
|
+
} while (cursor);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Returns `{ items, nextCursor, prevCursor, hasMore }`. Requires the storage adapter to implement `getInstancesByCursor()` — both `MemoryAdapter` and `PostgresAdapter` do.
|
|
473
|
+
|
|
474
|
+
### Audit history
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
const entries: AuditEntry[] = await engine.getHistory(instanceId);
|
|
478
|
+
// entries[n]: { action, actorId, level, timestamp, comment?, reason?, oldValue?, newValue? }
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Current approvers
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
const approverIds: string[] = await engine.getCurrentApprovers(instanceId);
|
|
485
|
+
// [] if not pending
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## Template management
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
// Define (throws if name already exists)
|
|
494
|
+
const templateId = await engine.defineTemplate({ name, documentType, levels, ... });
|
|
495
|
+
|
|
496
|
+
// Update — increments version, sets previousVersionId, never breaks in-flight instances
|
|
497
|
+
const newTemplateId = await engine.updateTemplate({ name, documentType, levels, ... });
|
|
498
|
+
|
|
499
|
+
// Read
|
|
500
|
+
const template = await engine.getTemplate('purchase-order');
|
|
501
|
+
// template.version, template.previousVersionId, template.createdAt
|
|
502
|
+
|
|
503
|
+
const all = await engine.listTemplates();
|
|
504
|
+
|
|
505
|
+
// Validate without persisting (synchronous, never throws)
|
|
506
|
+
const { valid, errors } = engine.validateTemplate(config);
|
|
507
|
+
// errors: [{ field: string, message: string }]
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Utility methods
|
|
513
|
+
|
|
514
|
+
### Preview the approval chain
|
|
515
|
+
|
|
516
|
+
See which levels would be active for a given document's data — without creating an instance:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
const preview = await engine.previewApprovalChain(
|
|
520
|
+
'purchase-order',
|
|
521
|
+
{ amount: 15000 },
|
|
522
|
+
'alice', // submittedBy — needed for dynamic resolver context
|
|
523
|
+
);
|
|
524
|
+
// preview.levels: [{ level, name, resolvedApprovers, mode }]
|
|
525
|
+
// preview.conditionsApplied: [0, 1] — 0-based indices of conditions that fired
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Check eligibility
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
const result = await engine.canApprove(instanceId, 'mgr-1');
|
|
532
|
+
// result.eligible: boolean
|
|
533
|
+
// result.reason: 'not_an_approver' | 'already_acted' | 'self_approval' | 'wrong_status' | 'delegated_away'
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Never throws — always returns a structured result.
|
|
537
|
+
|
|
538
|
+
### Health check
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
const health = await engine.healthCheck();
|
|
542
|
+
// {
|
|
543
|
+
// status: 'healthy' | 'degraded' | 'unhealthy',
|
|
544
|
+
// adapter: 'connected' | 'error',
|
|
545
|
+
// pendingCount: number,
|
|
546
|
+
// overdueCount: number, // > 0 → status becomes 'degraded'
|
|
547
|
+
// escalationRunning: boolean,
|
|
548
|
+
// lastTickAt?: Date,
|
|
549
|
+
// }
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Shutdown
|
|
553
|
+
|
|
554
|
+
Always call `shutdown()` before process exit to stop the escalation scheduler and any custom `schedulerAdapter`:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
await engine.shutdown();
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Events
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
engine.on('approval:submitted', (payload) => { /* instance submitted */ });
|
|
566
|
+
engine.on('approval:approved', (payload) => { /* level or fully approved */ });
|
|
567
|
+
engine.on('approval:rejected', (payload) => { /* level rejected */ });
|
|
568
|
+
engine.on('approval:level_advanced', (payload) => { /* moved to next level */ });
|
|
569
|
+
engine.on('approval:delegated', (payload) => { /* approver delegated */ });
|
|
570
|
+
engine.on('approval:escalated', (payload) => { /* escalated to new approver */ });
|
|
571
|
+
engine.on('approval:cancelled', (payload) => { /* cancelled */ });
|
|
572
|
+
engine.on('approval:expired', (payload) => { /* deadline passed */ });
|
|
573
|
+
engine.on('approval:overridden', (payload) => { /* override applied */ });
|
|
574
|
+
engine.on('approval:resubmitted', (payload) => { /* new instance from rejected */ });
|
|
575
|
+
engine.on('approval:sla_breached', (payload) => { /* SLA deadline passed */ });
|
|
576
|
+
engine.on('approval:completed', (instance) => { /* fully approved or overridden */ });
|
|
577
|
+
|
|
578
|
+
engine.off('approval:approved', handler);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Each event payload contains `instanceId`, `documentId`, `documentType`, `timestamp`, plus event-specific fields (e.g., `approverId`, `level`, `isFinal` on `approval:approved`).
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Enterprise adapters
|
|
586
|
+
|
|
587
|
+
All adapters are **fail-safe** — a failure in any adapter is caught, logged via the configured `Logger`, and swallowed. A broken Kafka connection will never prevent an approval from completing.
|
|
588
|
+
|
|
589
|
+
### `INotificationAdapter` — send notifications on every event
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
import type { INotificationAdapter, NotificationEvent } from 'hierarchical-approval';
|
|
593
|
+
|
|
594
|
+
class SlackNotifier implements INotificationAdapter {
|
|
595
|
+
async notify(event: NotificationEvent): Promise<void> {
|
|
596
|
+
// event.type — e.g. 'approval:approved'
|
|
597
|
+
// event.instanceId — approval instance ID
|
|
598
|
+
// event.recipients — current level's approverIds
|
|
599
|
+
// event.templateName
|
|
600
|
+
// event.tenantId
|
|
601
|
+
// event.payload — full typed event payload
|
|
602
|
+
if (event.type === 'approval:level_advanced') {
|
|
603
|
+
await slack.post(event.recipients, `Your approval is needed on ${event.documentId}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const engine = new ApprovalEngine({ adapter, notificationAdapter: new SlackNotifier() });
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### `IAuditAdapter` — write-once external audit sink
|
|
612
|
+
|
|
613
|
+
Called in addition to the storage adapter's built-in audit log. Use for Kafka, S3, CloudTrail, or any WORM sink:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
import type { IAuditAdapter } from 'hierarchical-approval';
|
|
617
|
+
|
|
618
|
+
class KafkaAudit implements IAuditAdapter {
|
|
619
|
+
async append(
|
|
620
|
+
tenantId: string,
|
|
621
|
+
instanceId: string,
|
|
622
|
+
entry: AuditEntry,
|
|
623
|
+
instance: Readonly<ApprovalInstance>,
|
|
624
|
+
): Promise<void> {
|
|
625
|
+
await producer.send({
|
|
626
|
+
topic: 'approvals.audit',
|
|
627
|
+
messages: [{ value: JSON.stringify({ tenantId, instanceId, entry, version: instance.version }) }],
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### `IMetricsAdapter` — Prometheus / Datadog / OpenTelemetry
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import type { IMetricsAdapter, MetricName } from 'hierarchical-approval';
|
|
637
|
+
|
|
638
|
+
class PrometheusAdapter implements IMetricsAdapter {
|
|
639
|
+
increment(metric: MetricName, labels?: Record<string, string>): void {
|
|
640
|
+
counters[metric].labels(labels ?? {}).inc();
|
|
641
|
+
}
|
|
642
|
+
timing(metric: MetricName, durationMs: number, labels?: Record<string, string>): void {
|
|
643
|
+
histograms[metric].labels(labels ?? {}).observe(durationMs / 1000);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Built-in metric names: `approval.submitted`, `approval.approved`, `approval.rejected`, `approval.cancelled`, `approval.expired`, `approval.sla_breached`, `approval.escalated`, `approval.overridden`, `approval.conflict_retry`, `approval.operation_duration_ms`.
|
|
649
|
+
|
|
650
|
+
All increments include a `tenantId` label. Duration metrics include an `operation` label.
|
|
651
|
+
|
|
652
|
+
### `ISchedulerAdapter` — BullMQ, Temporal, EventBridge
|
|
653
|
+
|
|
654
|
+
Replace the built-in `setInterval` poll with a proper job queue:
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
import type { ISchedulerAdapter } from 'hierarchical-approval';
|
|
658
|
+
|
|
659
|
+
class BullMQScheduler implements ISchedulerAdapter {
|
|
660
|
+
async scheduleAt(id: string, runAt: Date, callback: () => Promise<void>): Promise<string> {
|
|
661
|
+
const job = await queue.add('tick', { id }, { delay: runAt.getTime() - Date.now() });
|
|
662
|
+
// store job → callback mapping in your queue worker
|
|
663
|
+
return job.id;
|
|
664
|
+
}
|
|
665
|
+
async cancel(handle: string): Promise<void> {
|
|
666
|
+
await queue.remove(handle);
|
|
667
|
+
}
|
|
668
|
+
async shutdown(): Promise<void> {
|
|
669
|
+
await queue.close();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### `IAuthorizationPolicy` — per-operation access control
|
|
675
|
+
|
|
676
|
+
Called after input validation, before any state changes:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
import type { IAuthorizationPolicy, AuthorizationContext } from 'hierarchical-approval';
|
|
680
|
+
import { ApprovalForbiddenError } from 'hierarchical-approval';
|
|
681
|
+
|
|
682
|
+
class SigningAuthorityPolicy implements IAuthorizationPolicy {
|
|
683
|
+
async authorize(ctx: AuthorizationContext): Promise<string | undefined> {
|
|
684
|
+
// ctx.operation — 'approve' | 'reject' | 'delegate' | 'cancel' | 'escalate'
|
|
685
|
+
// | 'override' | 'resubmit' | 'addComment' | 'submit'
|
|
686
|
+
// ctx.actorId — who is performing the action
|
|
687
|
+
// ctx.instance — current instance (read-only)
|
|
688
|
+
// ctx.level — current level instance (read-only, on level operations)
|
|
689
|
+
// ctx.opts — raw operation options
|
|
690
|
+
if (ctx.operation === 'override') {
|
|
691
|
+
const cap = await budgetSystem.getSigningCap(ctx.actorId);
|
|
692
|
+
const amount = ctx.instance.data.amount as number;
|
|
693
|
+
if (amount > cap) {
|
|
694
|
+
return `Signing cap exceeded: ${ctx.actorId} can approve up to ${cap}`;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return undefined; // undefined = allow; string = deny with that message
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### `IOperationMiddleware` — before / after / onError hooks
|
|
703
|
+
|
|
704
|
+
Useful for OpenTelemetry tracing, audit enrichment, or quota enforcement:
|
|
705
|
+
|
|
706
|
+
```ts
|
|
707
|
+
import type { IOperationMiddleware, OperationContext } from 'hierarchical-approval';
|
|
708
|
+
|
|
709
|
+
class TracingMiddleware implements IOperationMiddleware {
|
|
710
|
+
private spans = new Map<string, Span>();
|
|
711
|
+
|
|
712
|
+
async before(ctx: OperationContext): Promise<void> {
|
|
713
|
+
// ctx.operation — operation name
|
|
714
|
+
// ctx.instanceId, ctx.actorId, ctx.tenantId, ctx.input
|
|
715
|
+
const span = tracer.startSpan(`approval.${ctx.operation}`);
|
|
716
|
+
this.spans.set(ctx.instanceId ?? ctx.operation, span);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async after(ctx: OperationContext, result: ApprovalInstance | void): Promise<void> {
|
|
720
|
+
this.spans.get(ctx.instanceId ?? ctx.operation)?.finish();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async onError(ctx: OperationContext, error: ApprovalError): Promise<void> {
|
|
724
|
+
const span = this.spans.get(ctx.instanceId ?? ctx.operation);
|
|
725
|
+
span?.setTag('error', error.code).finish();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const engine = new ApprovalEngine({
|
|
730
|
+
adapter,
|
|
731
|
+
middleware: [new TracingMiddleware(), new AnotherMiddleware()],
|
|
732
|
+
});
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
Middleware errors are caught and logged — they never propagate to callers.
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## Custom storage adapter
|
|
740
|
+
|
|
741
|
+
Implement `IStorageAdapter` to use any database:
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
import type {
|
|
745
|
+
IStorageAdapter,
|
|
746
|
+
ApprovalInstance,
|
|
747
|
+
ApprovalTemplate,
|
|
748
|
+
AuditEntry,
|
|
749
|
+
InstanceFilter,
|
|
750
|
+
PaginationOpts,
|
|
751
|
+
PaginatedResult,
|
|
752
|
+
CursorPaginationOpts,
|
|
753
|
+
CursorPaginatedResult,
|
|
754
|
+
} from 'hierarchical-approval';
|
|
755
|
+
import { ApprovalConflictError } from 'hierarchical-approval';
|
|
756
|
+
|
|
757
|
+
class DynamoAdapter implements IStorageAdapter {
|
|
758
|
+
async saveTemplate(template: ApprovalTemplate): Promise<void> { ... }
|
|
759
|
+
async getTemplate(tenantId: string, name: string): Promise<ApprovalTemplate | null> { ... }
|
|
760
|
+
async listTemplates(tenantId: string): Promise<ApprovalTemplate[]> { ... }
|
|
761
|
+
|
|
762
|
+
async saveInstance(instance: ApprovalInstance): Promise<void> { ... }
|
|
763
|
+
async updateInstance(instance: ApprovalInstance, expectedVersion: number): Promise<void> {
|
|
764
|
+
// Must throw ApprovalConflictError(instance.id) if stored version !== expectedVersion
|
|
765
|
+
// Must increment instance.version by 1 on success
|
|
766
|
+
}
|
|
767
|
+
async getInstance(tenantId: string, id: string): Promise<ApprovalInstance | null> { ... }
|
|
768
|
+
async getInstancesByApprover(tenantId: string, approverId: string, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>> { ... }
|
|
769
|
+
async getInstancesByFilter(tenantId: string, filter: InstanceFilter, opts?: PaginationOpts): Promise<PaginatedResult<ApprovalInstance>> { ... }
|
|
770
|
+
async getOverdueInstances(tenantId: string, asOf: Date): Promise<ApprovalInstance[]> { ... }
|
|
771
|
+
async getIdempotentInstance(tenantId: string, key: string): Promise<ApprovalInstance | null> { ... }
|
|
772
|
+
async appendAuditEntry(tenantId: string, instanceId: string, entry: AuditEntry): Promise<void> { ... }
|
|
773
|
+
|
|
774
|
+
// Optional — enables engine.queryInstancesByCursor()
|
|
775
|
+
async getInstancesByCursor(tenantId: string, filter: InstanceFilter, opts: CursorPaginationOpts): Promise<CursorPaginatedResult<ApprovalInstance>> { ... }
|
|
776
|
+
}
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
The `updateInstance` contract is the most important: read the stored version, compare to `expectedVersion`, throw `ApprovalConflictError` on mismatch, then write with `version + 1`. This is the foundation of the optimistic locking guarantee.
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## Testing
|
|
784
|
+
|
|
785
|
+
### `ApprovalTestKit` and `ManualClock`
|
|
786
|
+
|
|
787
|
+
```ts
|
|
788
|
+
import { ApprovalTestKit, ManualClock } from 'hierarchical-approval/testing';
|
|
789
|
+
|
|
790
|
+
test('finance level activates above $10k', async () => {
|
|
791
|
+
const { engine, adapter, clock } = ApprovalTestKit.create();
|
|
792
|
+
|
|
793
|
+
await engine.defineTemplate({
|
|
794
|
+
name: 'po',
|
|
795
|
+
documentType: 'purchase_order',
|
|
796
|
+
levels: [
|
|
797
|
+
{ level: 1, name: 'Manager', approvers: [{ type: 'user', userId: 'mgr' }], mode: 'any' },
|
|
798
|
+
],
|
|
799
|
+
conditions: [{
|
|
800
|
+
when: { field: 'amount', operator: '>', value: 10000 },
|
|
801
|
+
addLevels: [{ level: 2, name: 'Finance', approvers: [{ type: 'user', userId: 'fin' }], mode: 'any' }],
|
|
802
|
+
}],
|
|
803
|
+
slaDeadlineDays: 2,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const { id } = await engine.submit({
|
|
807
|
+
templateName: 'po', documentId: 'po-1', documentType: 'purchase_order',
|
|
808
|
+
submittedBy: 'alice', data: { amount: 50000 },
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const inst = await engine.getInstance(id);
|
|
812
|
+
expect(inst.levels).toHaveLength(2);
|
|
813
|
+
expect(inst.slaDeadlineAt).toBeDefined();
|
|
814
|
+
|
|
815
|
+
// Walk all levels with one helper
|
|
816
|
+
const final = await ApprovalTestKit.fullyApprove(engine, id, { 1: 'mgr', 2: 'fin' });
|
|
817
|
+
expect(final.status).toBe('approved');
|
|
818
|
+
|
|
819
|
+
await engine.shutdown();
|
|
820
|
+
});
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### Testing time-dependent behaviour
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
test('SLA breach fires after 2 days', async () => {
|
|
827
|
+
const { engine, clock } = ApprovalTestKit.create();
|
|
828
|
+
|
|
829
|
+
await engine.defineTemplate({
|
|
830
|
+
name: 'invoice',
|
|
831
|
+
documentType: 'invoice',
|
|
832
|
+
levels: [{ level: 1, name: 'Mgr', approvers: [{ type: 'user', userId: 'mgr' }], mode: 'any' }],
|
|
833
|
+
slaDeadlineDays: 2,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const { id } = await engine.submit({
|
|
837
|
+
templateName: 'invoice', documentId: 'inv-1', documentType: 'invoice',
|
|
838
|
+
submittedBy: 'alice', data: {},
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const breached: string[] = [];
|
|
842
|
+
engine.on('approval:sla_breached', (p) => breached.push(p.instanceId));
|
|
843
|
+
|
|
844
|
+
// Fast-forward 3 days — no real timers needed
|
|
845
|
+
clock.advanceDays(3);
|
|
846
|
+
|
|
847
|
+
// Manually tick the scheduler (the engine's poll interval is 0 in test mode)
|
|
848
|
+
await engine['escalation'].tick();
|
|
849
|
+
|
|
850
|
+
expect(breached).toContain(id);
|
|
851
|
+
await engine.shutdown();
|
|
852
|
+
});
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
### `ManualClock` API
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
const clock = new ManualClock(new Date('2025-01-01T00:00:00Z'));
|
|
859
|
+
|
|
860
|
+
clock.now(); // → Date at current virtual time
|
|
861
|
+
clock.advance(60_000); // +60 seconds
|
|
862
|
+
clock.advanceDays(7); // +7 days
|
|
863
|
+
clock.set(new Date('2026-06-01')); // jump to exact date
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
### `ApprovalTestKit.create(overrides?)`
|
|
867
|
+
|
|
868
|
+
Returns `{ engine, adapter, clock }` pre-wired together. The engine is created with `tenantId: 'test'`, `escalationPollIntervalMs: 0` (disables background polling), and a `ManualClock` starting at `2025-01-01T00:00:00Z`. Pass any `ApprovalEngineOptions` to override:
|
|
869
|
+
|
|
870
|
+
```ts
|
|
871
|
+
const { engine } = ApprovalTestKit.create({
|
|
872
|
+
tenantId: 'acme',
|
|
873
|
+
metricsAdapter: mockMetrics,
|
|
874
|
+
authorizationPolicy: testPolicy,
|
|
875
|
+
});
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### `ApprovalTestKit.fullyApprove(engine, instanceId, approverMap)`
|
|
879
|
+
|
|
880
|
+
Drives an instance through all levels in one call. Throws if a level number is missing from `approverMap`:
|
|
881
|
+
|
|
882
|
+
```ts
|
|
883
|
+
const instance = await ApprovalTestKit.fullyApprove(engine, id, {
|
|
884
|
+
1: 'manager-id',
|
|
885
|
+
2: 'director-id',
|
|
886
|
+
3: 'cfo-id',
|
|
887
|
+
});
|
|
888
|
+
// instance.status === 'approved'
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## Error handling
|
|
894
|
+
|
|
895
|
+
All errors extend `ApprovalError` and carry two utilities:
|
|
896
|
+
|
|
897
|
+
```ts
|
|
898
|
+
import { ApprovalError } from 'hierarchical-approval';
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
await engine.approve(id, { approverId: 'blocked-user' });
|
|
902
|
+
} catch (err) {
|
|
903
|
+
if (err instanceof ApprovalError) {
|
|
904
|
+
res.status(err.toHttpStatus()).json(err.toJSON());
|
|
905
|
+
// err.toJSON() → { code: string, message: string, name: string }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
| Class | HTTP | Thrown when |
|
|
911
|
+
|---|---|---|
|
|
912
|
+
| `ApprovalNotFoundError` | 404 | Instance or template not found |
|
|
913
|
+
| `ApprovalTemplateNotFoundError` | 404 | Template missing at submit time |
|
|
914
|
+
| `ApprovalForbiddenError` | 403 | Self-approval, unauthorized actor, disabled override |
|
|
915
|
+
| `ApprovalConflictError` | 409 | Optimistic lock version mismatch (after all retries) |
|
|
916
|
+
| `ApprovalValidationError` | 422 | Zod input validation failure, invalid template config |
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
## Multi-tenancy
|
|
921
|
+
|
|
922
|
+
Every engine instance is scoped to a single `tenantId`. All storage adapter methods receive `tenantId` as the first argument — there is no cross-tenant data access at the library level.
|
|
923
|
+
|
|
924
|
+
For multi-tenant systems, share one adapter but instantiate one engine per tenant:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
const sharedAdapter = new PostgresAdapter({ connectionString: process.env.DATABASE_URL });
|
|
928
|
+
|
|
929
|
+
// Per-request pattern
|
|
930
|
+
function getEngine(tenantId: string): ApprovalEngine {
|
|
931
|
+
return new ApprovalEngine({ adapter: sharedAdapter, tenantId, orgProvider: orgProviders[tenantId] });
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
## Template reference
|
|
938
|
+
|
|
939
|
+
### `ApprovalTemplateConfig`
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
interface ApprovalTemplateConfig {
|
|
943
|
+
name: string; // unique per tenant
|
|
944
|
+
documentType: string; // e.g. 'purchase_order'
|
|
945
|
+
levels: ApprovalLevelConfig[]; // at least one required
|
|
946
|
+
conditions?: ConditionRule[]; // optional conditional levels
|
|
947
|
+
escalation?: {
|
|
948
|
+
afterDays: number; // escalate if not acted on in N days
|
|
949
|
+
escalateTo: ApproverConfig; // who gets added as approver
|
|
950
|
+
};
|
|
951
|
+
slaDeadlineDays?: number; // overall SLA; emits sla_breached when elapsed
|
|
952
|
+
allowOverride?: boolean; // enables engine.override() (default: false)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
interface ApprovalLevelConfig {
|
|
956
|
+
level: number; // execution order (must be unique within template)
|
|
957
|
+
name: string; // display name
|
|
958
|
+
approvers: ApproverConfig[]; // at least one required
|
|
959
|
+
mode: 'any' | 'all' | 'majority';
|
|
960
|
+
escalationAfterDays?: number; // per-level escalation (overrides template.escalation timing)
|
|
961
|
+
}
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
---
|
|
965
|
+
|
|
966
|
+
## License
|
|
967
|
+
|
|
968
|
+
MIT
|