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 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