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/dist/index.cjs
ADDED
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
var zod = require('zod');
|
|
5
|
+
var EventEmitter = require('eventemitter3');
|
|
6
|
+
|
|
7
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
8
|
+
|
|
9
|
+
var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
|
|
10
|
+
|
|
11
|
+
// src/engine/ApprovalEngine.ts
|
|
12
|
+
var SubmitOptionsSchema = zod.z.object({
|
|
13
|
+
templateName: zod.z.string().min(1),
|
|
14
|
+
documentId: zod.z.string().min(1),
|
|
15
|
+
documentType: zod.z.string().min(1),
|
|
16
|
+
submittedBy: zod.z.string().min(1),
|
|
17
|
+
data: zod.z.record(zod.z.string(), zod.z.unknown()).default({}),
|
|
18
|
+
metadata: zod.z.record(zod.z.string(), zod.z.unknown()).default({}),
|
|
19
|
+
expiresAt: zod.z.coerce.date().optional(),
|
|
20
|
+
deadlineAction: zod.z.enum(["cancel", "reject"]).optional()
|
|
21
|
+
});
|
|
22
|
+
var ApproveOptionsSchema = zod.z.object({
|
|
23
|
+
approverId: zod.z.string().min(1),
|
|
24
|
+
comment: zod.z.string().optional()
|
|
25
|
+
});
|
|
26
|
+
var RejectOptionsSchema = zod.z.object({
|
|
27
|
+
approverId: zod.z.string().min(1),
|
|
28
|
+
reason: zod.z.string().min(1),
|
|
29
|
+
returnTo: zod.z.enum(["originator", "previous"]).optional()
|
|
30
|
+
});
|
|
31
|
+
var DelegateOptionsSchema = zod.z.object({
|
|
32
|
+
fromApprover: zod.z.string().min(1),
|
|
33
|
+
toApprover: zod.z.string().min(1),
|
|
34
|
+
reason: zod.z.string().min(1),
|
|
35
|
+
until: zod.z.coerce.date().optional()
|
|
36
|
+
});
|
|
37
|
+
var CancelOptionsSchema = zod.z.object({
|
|
38
|
+
cancelledBy: zod.z.string().min(1),
|
|
39
|
+
reason: zod.z.string().min(1)
|
|
40
|
+
});
|
|
41
|
+
var EscalateOptionsSchema = zod.z.object({
|
|
42
|
+
escalatedBy: zod.z.string().min(1)
|
|
43
|
+
});
|
|
44
|
+
var ResubmitOptionsSchema = zod.z.object({
|
|
45
|
+
resubmittedBy: zod.z.string().min(1),
|
|
46
|
+
reason: zod.z.string().optional(),
|
|
47
|
+
updatedData: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
48
|
+
});
|
|
49
|
+
var AddCommentOptionsSchema = zod.z.object({
|
|
50
|
+
actorId: zod.z.string().min(1),
|
|
51
|
+
comment: zod.z.string().min(1)
|
|
52
|
+
});
|
|
53
|
+
var OverrideOptionsSchema = zod.z.object({
|
|
54
|
+
overriddenBy: zod.z.string().min(1),
|
|
55
|
+
justification: zod.z.string().min(1)
|
|
56
|
+
});
|
|
57
|
+
var EventBus = class {
|
|
58
|
+
constructor() {
|
|
59
|
+
this.emitter = new EventEmitter__default.default();
|
|
60
|
+
}
|
|
61
|
+
emit(event, payload) {
|
|
62
|
+
this.emitter.emit(event, payload);
|
|
63
|
+
}
|
|
64
|
+
on(event, listener) {
|
|
65
|
+
this.emitter.on(event, listener);
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
off(event, listener) {
|
|
69
|
+
this.emitter.off(event, listener);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
once(event, listener) {
|
|
73
|
+
this.emitter.once(event, listener);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/utils/Logger.ts
|
|
79
|
+
var noopLogger = {
|
|
80
|
+
info: () => {
|
|
81
|
+
},
|
|
82
|
+
warn: () => {
|
|
83
|
+
},
|
|
84
|
+
error: () => {
|
|
85
|
+
},
|
|
86
|
+
debug: () => {
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/utils/Clock.ts
|
|
91
|
+
var systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
92
|
+
|
|
93
|
+
// src/utils/IdGenerator.ts
|
|
94
|
+
var defaultIdGenerator = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
95
|
+
|
|
96
|
+
// src/errors.ts
|
|
97
|
+
var ApprovalError = class extends Error {
|
|
98
|
+
constructor(message, code) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.code = code;
|
|
101
|
+
this.name = "ApprovalError";
|
|
102
|
+
}
|
|
103
|
+
toJSON() {
|
|
104
|
+
return { code: this.code, message: this.message, name: this.name };
|
|
105
|
+
}
|
|
106
|
+
toHttpStatus() {
|
|
107
|
+
const map = {
|
|
108
|
+
NOT_FOUND: 404,
|
|
109
|
+
CONFLICT: 409,
|
|
110
|
+
FORBIDDEN: 403,
|
|
111
|
+
VALIDATION: 422,
|
|
112
|
+
TEMPLATE_NOT_FOUND: 404
|
|
113
|
+
};
|
|
114
|
+
return map[this.code] ?? 500;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var ApprovalNotFoundError = class extends ApprovalError {
|
|
118
|
+
constructor(resource, id) {
|
|
119
|
+
super(`${resource} "${id}" not found.`, "NOT_FOUND");
|
|
120
|
+
this.name = "ApprovalNotFoundError";
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var ApprovalConflictError = class extends ApprovalError {
|
|
124
|
+
constructor(instanceId) {
|
|
125
|
+
super(
|
|
126
|
+
`Concurrent modification detected on instance "${instanceId}". The record was updated by another process. Please retry.`,
|
|
127
|
+
"CONFLICT"
|
|
128
|
+
);
|
|
129
|
+
this.name = "ApprovalConflictError";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
var ApprovalForbiddenError = class extends ApprovalError {
|
|
133
|
+
constructor(message) {
|
|
134
|
+
super(message, "FORBIDDEN");
|
|
135
|
+
this.name = "ApprovalForbiddenError";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var ApprovalValidationError = class extends ApprovalError {
|
|
139
|
+
constructor(message, cause) {
|
|
140
|
+
super(message, "VALIDATION");
|
|
141
|
+
this.cause = cause;
|
|
142
|
+
this.name = "ApprovalValidationError";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var ApprovalTemplateNotFoundError = class extends ApprovalError {
|
|
146
|
+
constructor(name) {
|
|
147
|
+
super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
|
|
148
|
+
this.name = "ApprovalTemplateNotFoundError";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/engine/TemplateRegistry.ts
|
|
153
|
+
var TemplateRegistry = class {
|
|
154
|
+
constructor(adapter, tenantId, opts) {
|
|
155
|
+
this.adapter = adapter;
|
|
156
|
+
this.tenantId = tenantId;
|
|
157
|
+
this.clock = opts?.clock ?? systemClock;
|
|
158
|
+
this.generateId = opts?.generateId ?? defaultIdGenerator;
|
|
159
|
+
}
|
|
160
|
+
async define(config) {
|
|
161
|
+
const existing = await this.adapter.getTemplate(this.tenantId, config.name);
|
|
162
|
+
if (existing) {
|
|
163
|
+
throw new ApprovalValidationError(
|
|
164
|
+
`Template "${config.name}" already exists for this tenant. Delete it first or use engine.updateTemplate() to modify it.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const id = this.generateId("tpl");
|
|
168
|
+
const template = {
|
|
169
|
+
...config,
|
|
170
|
+
id,
|
|
171
|
+
tenantId: this.tenantId,
|
|
172
|
+
createdAt: this.clock.now(),
|
|
173
|
+
version: 1
|
|
174
|
+
};
|
|
175
|
+
await this.adapter.saveTemplate(template);
|
|
176
|
+
return id;
|
|
177
|
+
}
|
|
178
|
+
/** Update an existing template, incrementing its version. Throws if the template doesn't exist. */
|
|
179
|
+
async update(config) {
|
|
180
|
+
const existing = await this.adapter.getTemplate(this.tenantId, config.name);
|
|
181
|
+
if (!existing) {
|
|
182
|
+
throw new ApprovalTemplateNotFoundError(config.name);
|
|
183
|
+
}
|
|
184
|
+
const newId = this.generateId("tpl");
|
|
185
|
+
const updated = {
|
|
186
|
+
...config,
|
|
187
|
+
id: newId,
|
|
188
|
+
tenantId: this.tenantId,
|
|
189
|
+
createdAt: existing.createdAt,
|
|
190
|
+
version: (existing.version ?? 1) + 1,
|
|
191
|
+
previousVersionId: existing.id
|
|
192
|
+
};
|
|
193
|
+
await this.adapter.saveTemplate(updated);
|
|
194
|
+
return newId;
|
|
195
|
+
}
|
|
196
|
+
async get(name) {
|
|
197
|
+
const template = await this.adapter.getTemplate(this.tenantId, name);
|
|
198
|
+
if (!template) throw new ApprovalTemplateNotFoundError(name);
|
|
199
|
+
return template;
|
|
200
|
+
}
|
|
201
|
+
async list() {
|
|
202
|
+
return this.adapter.listTemplates(this.tenantId);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// src/engine/LevelResolver.ts
|
|
207
|
+
var LevelResolver = class {
|
|
208
|
+
constructor() {
|
|
209
|
+
this.resolvers = /* @__PURE__ */ new Map();
|
|
210
|
+
this.approverTypes = /* @__PURE__ */ new Map();
|
|
211
|
+
}
|
|
212
|
+
register(name, fn) {
|
|
213
|
+
this.resolvers.set(name, fn);
|
|
214
|
+
}
|
|
215
|
+
registerApproverType(typeName, fn) {
|
|
216
|
+
this.approverTypes.set(typeName, fn);
|
|
217
|
+
}
|
|
218
|
+
async resolveApprovers(approvers, submittedBy, data, orgProvider) {
|
|
219
|
+
const resolved = [];
|
|
220
|
+
for (const approver of approvers) {
|
|
221
|
+
switch (approver.type) {
|
|
222
|
+
case "user":
|
|
223
|
+
resolved.push(approver.userId);
|
|
224
|
+
break;
|
|
225
|
+
case "role": {
|
|
226
|
+
if (!orgProvider) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Cannot resolve role "${approver.role}" without an orgProvider configured on ApprovalEngine.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const users = await orgProvider.getUsersByRole(
|
|
232
|
+
approver.role
|
|
233
|
+
);
|
|
234
|
+
resolved.push(...users);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "dynamic": {
|
|
238
|
+
const fn = this.resolvers.get(approver.resolver);
|
|
239
|
+
if (!fn) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`No resolver registered for "${approver.resolver}". Call engine.registerResolver("${approver.resolver}", fn) first.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const userId = await fn(submittedBy, data);
|
|
245
|
+
resolved.push(userId);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
default: {
|
|
249
|
+
const customFn = this.approverTypes.get(approver.type);
|
|
250
|
+
if (!customFn) {
|
|
251
|
+
throw new ApprovalValidationError(
|
|
252
|
+
`Unknown approver type "${approver.type}". Register it with engine.registerApproverType("${approver.type}", fn) first.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const ids = await customFn(approver, { submittedBy, data, orgProvider });
|
|
256
|
+
resolved.push(...ids);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const result = [...new Set(resolved)];
|
|
262
|
+
if (result.length === 0) {
|
|
263
|
+
throw new ApprovalValidationError(
|
|
264
|
+
"No approvers resolved for this level. Check your approver configuration \u2014 role may have no members or dynamic resolver returned empty."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// src/engine/EscalationScheduler.ts
|
|
272
|
+
var EscalationScheduler = class {
|
|
273
|
+
constructor(opts) {
|
|
274
|
+
this.intervalId = null;
|
|
275
|
+
this.tickPromise = null;
|
|
276
|
+
this.lastTickAt = null;
|
|
277
|
+
this.adapter = opts.adapter;
|
|
278
|
+
this.tenantId = opts.tenantId;
|
|
279
|
+
this.onEscalate = opts.onEscalate;
|
|
280
|
+
this.onExpire = opts.onExpire;
|
|
281
|
+
this.onSlaBreach = opts.onSlaBreach;
|
|
282
|
+
this.onRevertDelegation = opts.onRevertDelegation;
|
|
283
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 6e4;
|
|
284
|
+
this.logger = opts.logger ?? noopLogger;
|
|
285
|
+
this.clock = opts.clock ?? systemClock;
|
|
286
|
+
}
|
|
287
|
+
get isRunning() {
|
|
288
|
+
return this.intervalId !== null;
|
|
289
|
+
}
|
|
290
|
+
start() {
|
|
291
|
+
if (this.intervalId !== null) return;
|
|
292
|
+
this.intervalId = setInterval(() => {
|
|
293
|
+
this.tickPromise = this.tick().catch((err) => {
|
|
294
|
+
this.logger.error("EscalationScheduler: unhandled error in tick", err, { tenantId: this.tenantId });
|
|
295
|
+
}).finally(() => {
|
|
296
|
+
this.tickPromise = null;
|
|
297
|
+
});
|
|
298
|
+
}, this.pollIntervalMs);
|
|
299
|
+
}
|
|
300
|
+
async stop() {
|
|
301
|
+
if (this.intervalId !== null) {
|
|
302
|
+
clearInterval(this.intervalId);
|
|
303
|
+
this.intervalId = null;
|
|
304
|
+
}
|
|
305
|
+
if (this.tickPromise) {
|
|
306
|
+
await this.tickPromise;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async tick() {
|
|
310
|
+
this.lastTickAt = this.clock.now();
|
|
311
|
+
const now = this.lastTickAt;
|
|
312
|
+
let instances;
|
|
313
|
+
try {
|
|
314
|
+
instances = await this.adapter.getOverdueInstances(this.tenantId, now);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.logger.error("EscalationScheduler: failed to fetch overdue instances", err, {
|
|
317
|
+
tenantId: this.tenantId
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
for (const instance of instances) {
|
|
322
|
+
try {
|
|
323
|
+
if (this.onRevertDelegation) {
|
|
324
|
+
for (const level of instance.levels) {
|
|
325
|
+
if (level.delegatedUntil && new Date(level.delegatedUntil) <= now && level.status === "pending" && level.delegatedFrom) {
|
|
326
|
+
try {
|
|
327
|
+
await this.onRevertDelegation(instance.id, level.level, level.delegatedFrom);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
this.logger.error("EscalationScheduler: failed to revert delegation", err, {
|
|
330
|
+
tenantId: this.tenantId,
|
|
331
|
+
instanceId: instance.id,
|
|
332
|
+
levelNumber: level.level
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (instance.expiresAt && new Date(instance.expiresAt) <= now) {
|
|
339
|
+
if (this.onExpire) {
|
|
340
|
+
await this.onExpire(instance.id, instance.deadlineAction ?? "cancel");
|
|
341
|
+
this.logger.debug("EscalationScheduler: expired instance", {
|
|
342
|
+
tenantId: this.tenantId,
|
|
343
|
+
instanceId: instance.id,
|
|
344
|
+
deadlineAction: instance.deadlineAction ?? "cancel"
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (instance.slaDeadlineAt && new Date(instance.slaDeadlineAt) <= now && !instance.slaBreachedAt) {
|
|
350
|
+
if (this.onSlaBreach) {
|
|
351
|
+
try {
|
|
352
|
+
await this.onSlaBreach(instance.id);
|
|
353
|
+
this.logger.warn("EscalationScheduler: SLA breached", {
|
|
354
|
+
tenantId: this.tenantId,
|
|
355
|
+
instanceId: instance.id,
|
|
356
|
+
slaDeadlineAt: instance.slaDeadlineAt
|
|
357
|
+
});
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this.logger.error("EscalationScheduler: failed to record SLA breach", err, {
|
|
360
|
+
tenantId: this.tenantId,
|
|
361
|
+
instanceId: instance.id
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const currentLevel = instance.levels.find((l) => l.level === instance.currentLevel);
|
|
367
|
+
if (currentLevel?.escalationDueAt && new Date(currentLevel.escalationDueAt) <= now) {
|
|
368
|
+
await this.onEscalate(instance.id);
|
|
369
|
+
this.logger.debug("EscalationScheduler: escalated instance", {
|
|
370
|
+
tenantId: this.tenantId,
|
|
371
|
+
instanceId: instance.id
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
this.logger.error("EscalationScheduler: failed to process instance", err, {
|
|
376
|
+
tenantId: this.tenantId,
|
|
377
|
+
instanceId: instance.id
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
static computeEscalationDue(escalationAfterDays, fromDate) {
|
|
383
|
+
if (escalationAfterDays <= 0) return void 0;
|
|
384
|
+
const due = new Date(fromDate);
|
|
385
|
+
due.setDate(due.getDate() + escalationAfterDays);
|
|
386
|
+
return due;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/engine/ConditionEvaluator.ts
|
|
391
|
+
var operatorRegistry = /* @__PURE__ */ new Map([
|
|
392
|
+
[">", (a, e) => Number(a) > Number(e)],
|
|
393
|
+
["<", (a, e) => Number(a) < Number(e)],
|
|
394
|
+
[">=", (a, e) => Number(a) >= Number(e)],
|
|
395
|
+
["<=", (a, e) => Number(a) <= Number(e)],
|
|
396
|
+
["==", (a, e) => a === e],
|
|
397
|
+
["!=", (a, e) => a !== e],
|
|
398
|
+
["in", (a, e) => Array.isArray(e) && e.includes(a)],
|
|
399
|
+
["not_in", (a, e) => Array.isArray(e) && !e.includes(a)]
|
|
400
|
+
]);
|
|
401
|
+
function registerConditionOperator(name, fn) {
|
|
402
|
+
operatorRegistry.set(name, fn);
|
|
403
|
+
}
|
|
404
|
+
function getField(data, path) {
|
|
405
|
+
return path.split(".").reduce((obj, key) => {
|
|
406
|
+
if (obj !== null && typeof obj === "object" && key in obj) {
|
|
407
|
+
return obj[key];
|
|
408
|
+
}
|
|
409
|
+
return void 0;
|
|
410
|
+
}, data);
|
|
411
|
+
}
|
|
412
|
+
function evaluateCondition(condition, data) {
|
|
413
|
+
const fn = operatorRegistry.get(condition.operator);
|
|
414
|
+
if (!fn) {
|
|
415
|
+
throw new ApprovalValidationError(
|
|
416
|
+
`Unknown condition operator "${condition.operator}". Register it with engine.registerConditionOperator() or use a built-in: ${[...operatorRegistry.keys()].join(", ")}.`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
const actual = getField(data, condition.field);
|
|
420
|
+
return fn(actual, condition.value);
|
|
421
|
+
}
|
|
422
|
+
function evaluateRule(rule, data) {
|
|
423
|
+
if (Array.isArray(rule)) {
|
|
424
|
+
return rule.every((c) => evaluateCondition(c, data));
|
|
425
|
+
}
|
|
426
|
+
return evaluateCondition(rule, data);
|
|
427
|
+
}
|
|
428
|
+
function evaluateConditions(conditions, data) {
|
|
429
|
+
const mutations = { addLevels: [], skipLevels: /* @__PURE__ */ new Set() };
|
|
430
|
+
for (const rule of conditions) {
|
|
431
|
+
if (evaluateRule(rule.when, data)) {
|
|
432
|
+
if (rule.addLevels) mutations.addLevels.push(...rule.addLevels);
|
|
433
|
+
if (rule.skipLevels) rule.skipLevels.forEach((l) => mutations.skipLevels.add(l));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return mutations;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/engine/StateMachine.ts
|
|
440
|
+
function assertStatus(instance, expected) {
|
|
441
|
+
if (instance.status !== expected) {
|
|
442
|
+
throw new ApprovalError(
|
|
443
|
+
`Expected instance status "${expected}" but got "${instance.status}".`,
|
|
444
|
+
"INVALID_STATUS"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function assertApproverOnLevel(level, approverId) {
|
|
449
|
+
if (!level.approverIds.includes(approverId)) {
|
|
450
|
+
throw new ApprovalForbiddenError(
|
|
451
|
+
`User "${approverId}" is not an approver for level ${level.level}.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function hasAlreadyActed(level, approverId) {
|
|
456
|
+
return level.approvedBy.includes(approverId) || level.rejectedBy.includes(approverId);
|
|
457
|
+
}
|
|
458
|
+
function isLevelApproved(level) {
|
|
459
|
+
const { mode, approverIds, approvedBy } = level;
|
|
460
|
+
const total = approverIds.length;
|
|
461
|
+
if (total === 0) {
|
|
462
|
+
throw new ApprovalValidationError(
|
|
463
|
+
`Level ${level.level} ("${level.name}") has no approvers. Ensure resolvers return at least one user.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const count = approvedBy.length;
|
|
467
|
+
switch (mode) {
|
|
468
|
+
case "any":
|
|
469
|
+
return count >= 1;
|
|
470
|
+
case "all":
|
|
471
|
+
return count >= total;
|
|
472
|
+
case "majority":
|
|
473
|
+
return count > Math.floor(total / 2);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function isLevelRejected(level) {
|
|
477
|
+
const { mode, approverIds, rejectedBy } = level;
|
|
478
|
+
const total = approverIds.length;
|
|
479
|
+
if (total === 0) {
|
|
480
|
+
throw new ApprovalValidationError(
|
|
481
|
+
`Level ${level.level} ("${level.name}") has no approvers. Ensure resolvers return at least one user.`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const rejectCount = rejectedBy.length;
|
|
485
|
+
switch (mode) {
|
|
486
|
+
case "any":
|
|
487
|
+
return rejectCount >= total;
|
|
488
|
+
case "all":
|
|
489
|
+
return rejectCount >= 1;
|
|
490
|
+
case "majority":
|
|
491
|
+
return rejectCount > Math.floor(total / 2);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/engine/ApprovalEngine.ts
|
|
496
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
497
|
+
var DEFAULT_BASE_DELAY_MS = 50;
|
|
498
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["approved", "rejected", "cancelled", "expired"]);
|
|
499
|
+
var ApprovalEngine = class {
|
|
500
|
+
constructor(opts) {
|
|
501
|
+
this.opts = opts;
|
|
502
|
+
this.bus = new EventBus();
|
|
503
|
+
this.tenantId = opts.tenantId ?? "default";
|
|
504
|
+
this.logger = opts.logger ?? noopLogger;
|
|
505
|
+
this.clock = opts.clock ?? systemClock;
|
|
506
|
+
this.generateId = opts.generateId ?? defaultIdGenerator;
|
|
507
|
+
this.maxBulkItems = opts.maxBulkItems ?? 200;
|
|
508
|
+
this.retryPolicy = {
|
|
509
|
+
maxAttempts: opts.retryPolicy?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
510
|
+
baseDelayMs: opts.retryPolicy?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
|
|
511
|
+
maxDelayMs: opts.retryPolicy?.maxDelayMs ?? Infinity,
|
|
512
|
+
jitter: opts.retryPolicy?.jitter ?? true
|
|
513
|
+
};
|
|
514
|
+
this.idempotencyKeyFn = opts.idempotencyKeyFn ?? defaultIdempotencyKeyFn;
|
|
515
|
+
this.registry = new TemplateRegistry(opts.adapter, this.tenantId, {
|
|
516
|
+
clock: this.clock,
|
|
517
|
+
generateId: this.generateId
|
|
518
|
+
});
|
|
519
|
+
this.resolver = new LevelResolver();
|
|
520
|
+
this.escalation = new EscalationScheduler({
|
|
521
|
+
adapter: opts.adapter,
|
|
522
|
+
tenantId: this.tenantId,
|
|
523
|
+
onEscalate: async (id) => {
|
|
524
|
+
await this.escalateInternal(id);
|
|
525
|
+
},
|
|
526
|
+
onExpire: async (id, action) => {
|
|
527
|
+
await this.expireInstance(id, action);
|
|
528
|
+
},
|
|
529
|
+
onSlaBreach: async (id) => {
|
|
530
|
+
await this.markSlaBreached(id);
|
|
531
|
+
},
|
|
532
|
+
onRevertDelegation: async (id, level, from) => {
|
|
533
|
+
await this.revertDelegation(id, level, from);
|
|
534
|
+
},
|
|
535
|
+
pollIntervalMs: opts.escalationPollIntervalMs ?? 6e4,
|
|
536
|
+
logger: this.logger,
|
|
537
|
+
clock: this.clock
|
|
538
|
+
});
|
|
539
|
+
this.escalation.start();
|
|
540
|
+
}
|
|
541
|
+
on(event, listener) {
|
|
542
|
+
this.bus.on(event, listener);
|
|
543
|
+
return this;
|
|
544
|
+
}
|
|
545
|
+
off(event, listener) {
|
|
546
|
+
this.bus.off(event, listener);
|
|
547
|
+
return this;
|
|
548
|
+
}
|
|
549
|
+
registerResolver(name, fn) {
|
|
550
|
+
this.resolver.register(name, fn);
|
|
551
|
+
}
|
|
552
|
+
registerApproverType(typeName, fn) {
|
|
553
|
+
this.resolver.registerApproverType(typeName, fn);
|
|
554
|
+
}
|
|
555
|
+
registerConditionOperator(name, fn) {
|
|
556
|
+
registerConditionOperator(name, fn);
|
|
557
|
+
}
|
|
558
|
+
// ─── Template management ──────────────────────────────────────────────────
|
|
559
|
+
/** Validate a template config without persisting. Synchronous; never throws. */
|
|
560
|
+
validateTemplate(config) {
|
|
561
|
+
const errors = [];
|
|
562
|
+
if (!config.levels || config.levels.length === 0) {
|
|
563
|
+
errors.push({ field: "levels", message: "Template must have at least one level." });
|
|
564
|
+
} else {
|
|
565
|
+
const levelNums = /* @__PURE__ */ new Set();
|
|
566
|
+
config.levels.forEach((l, i) => {
|
|
567
|
+
if (levelNums.has(l.level)) {
|
|
568
|
+
errors.push({ field: `levels[${i}].level`, message: `Duplicate level number: ${l.level}.` });
|
|
569
|
+
}
|
|
570
|
+
levelNums.add(l.level);
|
|
571
|
+
if (!l.approvers || l.approvers.length === 0) {
|
|
572
|
+
errors.push({ field: `levels[${i}].approvers`, message: `Level ${l.level} must have at least one approver.` });
|
|
573
|
+
}
|
|
574
|
+
if (l.escalationAfterDays !== void 0 && l.escalationAfterDays <= 0) {
|
|
575
|
+
errors.push({ field: `levels[${i}].escalationAfterDays`, message: `Level ${l.level} escalationAfterDays must be a positive number.` });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (config.conditions) {
|
|
580
|
+
config.conditions.forEach((rule, ruleIdx) => {
|
|
581
|
+
if (rule.addLevels) {
|
|
582
|
+
rule.addLevels.forEach((al, alIdx) => {
|
|
583
|
+
const conflictsWithStatic = config.levels.some((l) => l.level === al.level);
|
|
584
|
+
if (conflictsWithStatic) {
|
|
585
|
+
errors.push({
|
|
586
|
+
field: `conditions[${ruleIdx}].addLevels[${alIdx}].level`,
|
|
587
|
+
message: `Level ${al.level} in addLevels conflicts with an existing static level.`
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (rule.skipLevels?.includes(al.level)) {
|
|
591
|
+
errors.push({
|
|
592
|
+
field: `conditions[${ruleIdx}].addLevels[${alIdx}].level`,
|
|
593
|
+
message: `Level ${al.level} appears in both addLevels and skipLevels in the same condition.`
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return { valid: errors.length === 0, errors };
|
|
601
|
+
}
|
|
602
|
+
async defineTemplate(config) {
|
|
603
|
+
const validation = this.validateTemplate(config);
|
|
604
|
+
if (!validation.valid) {
|
|
605
|
+
const first = validation.errors[0];
|
|
606
|
+
throw new ApprovalValidationError(
|
|
607
|
+
`Invalid template configuration: ${first?.message ?? "unknown error"}`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return this.registry.define(config);
|
|
611
|
+
}
|
|
612
|
+
/** Update an existing template, incrementing its version. In-flight instances are protected by their templateSnapshot. */
|
|
613
|
+
async updateTemplate(config) {
|
|
614
|
+
const validation = this.validateTemplate(config);
|
|
615
|
+
if (!validation.valid) {
|
|
616
|
+
const first = validation.errors[0];
|
|
617
|
+
throw new ApprovalValidationError(
|
|
618
|
+
`Invalid template configuration: ${first?.message ?? "unknown error"}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return this.registry.update(config);
|
|
622
|
+
}
|
|
623
|
+
async getTemplate(name) {
|
|
624
|
+
return this.registry.get(name);
|
|
625
|
+
}
|
|
626
|
+
async listTemplates() {
|
|
627
|
+
return this.registry.list();
|
|
628
|
+
}
|
|
629
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
630
|
+
async submit(raw, auditCtx) {
|
|
631
|
+
const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
|
|
632
|
+
const startMs = this.clock.now().getTime();
|
|
633
|
+
const template = await this.registry.get(opts.templateName);
|
|
634
|
+
const idempotencyKey = this.idempotencyKeyFn(this.tenantId, opts.documentType, opts.documentId, opts.templateName, opts.data);
|
|
635
|
+
const existing = await this.opts.adapter.getIdempotentInstance(this.tenantId, idempotencyKey);
|
|
636
|
+
if (existing && !TERMINAL_STATUSES.has(existing.status)) {
|
|
637
|
+
this.logger.info("submit: returning idempotent existing instance", {
|
|
638
|
+
tenantId: this.tenantId,
|
|
639
|
+
instanceId: existing.id,
|
|
640
|
+
idempotencyKey
|
|
641
|
+
});
|
|
642
|
+
return existing;
|
|
643
|
+
}
|
|
644
|
+
const mutations = evaluateConditions(template.conditions ?? [], opts.data);
|
|
645
|
+
const allLevelCfgs = [...template.levels, ...mutations.addLevels].filter((l) => !mutations.skipLevels.has(l.level)).sort((a, b) => a.level - b.level);
|
|
646
|
+
if (allLevelCfgs.length === 0) {
|
|
647
|
+
throw new ApprovalValidationError(
|
|
648
|
+
"Template has no active levels after condition evaluation. Check that skipLevels conditions are not removing all levels."
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const levelNumSet = new Set(allLevelCfgs.map((l) => l.level));
|
|
652
|
+
if (levelNumSet.size !== allLevelCfgs.length) {
|
|
653
|
+
const seen = /* @__PURE__ */ new Set();
|
|
654
|
+
for (const l of allLevelCfgs) {
|
|
655
|
+
if (seen.has(l.level)) {
|
|
656
|
+
throw new ApprovalValidationError(
|
|
657
|
+
`Duplicate level number ${l.level} after condition evaluation. Check addLevels in conditions.`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
seen.add(l.level);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const now = this.clock.now();
|
|
664
|
+
const instanceId = this.generateId("inst");
|
|
665
|
+
const levels = allLevelCfgs.map((cfg, idx) => ({
|
|
666
|
+
level: cfg.level,
|
|
667
|
+
name: cfg.name,
|
|
668
|
+
mode: cfg.mode,
|
|
669
|
+
approverConfigs: cfg.approvers,
|
|
670
|
+
approverIds: [],
|
|
671
|
+
approvedBy: [],
|
|
672
|
+
rejectedBy: [],
|
|
673
|
+
status: idx === 0 ? "pending" : "waiting",
|
|
674
|
+
escalationAfterDays: cfg.escalationAfterDays,
|
|
675
|
+
escalationDueAt: idx === 0 && cfg.escalationAfterDays ? new Date(now.getTime() + cfg.escalationAfterDays * 864e5) : void 0
|
|
676
|
+
}));
|
|
677
|
+
const firstCfg = allLevelCfgs[0];
|
|
678
|
+
const firstLevel = levels[0];
|
|
679
|
+
if (firstCfg && firstLevel) {
|
|
680
|
+
firstLevel.approverIds = await this.resolver.resolveApprovers(
|
|
681
|
+
firstCfg.approvers,
|
|
682
|
+
opts.submittedBy,
|
|
683
|
+
opts.data,
|
|
684
|
+
this.opts.orgProvider
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
const auditEntry = {
|
|
688
|
+
action: "submitted",
|
|
689
|
+
actorId: opts.submittedBy,
|
|
690
|
+
level: allLevelCfgs[0]?.level ?? 1,
|
|
691
|
+
timestamp: now,
|
|
692
|
+
...auditCtx
|
|
693
|
+
};
|
|
694
|
+
const slaDeadlineAt = template.slaDeadlineDays ? new Date(now.getTime() + template.slaDeadlineDays * 864e5) : void 0;
|
|
695
|
+
const instance = {
|
|
696
|
+
id: instanceId,
|
|
697
|
+
tenantId: this.tenantId,
|
|
698
|
+
templateId: template.id,
|
|
699
|
+
templateName: template.name,
|
|
700
|
+
documentId: opts.documentId,
|
|
701
|
+
documentType: opts.documentType,
|
|
702
|
+
submittedBy: opts.submittedBy,
|
|
703
|
+
status: "pending",
|
|
704
|
+
currentLevel: allLevelCfgs[0]?.level ?? 1,
|
|
705
|
+
version: 1,
|
|
706
|
+
idempotencyKey,
|
|
707
|
+
levels,
|
|
708
|
+
auditLog: [auditEntry],
|
|
709
|
+
data: opts.data,
|
|
710
|
+
metadata: opts.metadata,
|
|
711
|
+
createdAt: now,
|
|
712
|
+
updatedAt: now,
|
|
713
|
+
expiresAt: opts.expiresAt,
|
|
714
|
+
deadlineAction: opts.deadlineAction,
|
|
715
|
+
slaDeadlineAt,
|
|
716
|
+
templateSnapshot: {
|
|
717
|
+
escalation: template.escalation,
|
|
718
|
+
slaDeadlineDays: template.slaDeadlineDays,
|
|
719
|
+
allowOverride: template.allowOverride
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
await this.runMiddlewareBefore({ operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts });
|
|
723
|
+
await this.opts.adapter.saveInstance(instance);
|
|
724
|
+
this.logger.info("submit: instance created", {
|
|
725
|
+
tenantId: this.tenantId,
|
|
726
|
+
instanceId,
|
|
727
|
+
documentId: opts.documentId,
|
|
728
|
+
templateName: opts.templateName
|
|
729
|
+
});
|
|
730
|
+
this.opts.metricsAdapter?.increment("approval.submitted", { tenantId: this.tenantId, templateName: template.name });
|
|
731
|
+
this.opts.metricsAdapter?.timing("approval.operation_duration_ms", this.clock.now().getTime() - startMs, { operation: "submit" });
|
|
732
|
+
const eventPayload = {
|
|
733
|
+
instanceId: instance.id,
|
|
734
|
+
documentId: instance.documentId,
|
|
735
|
+
documentType: instance.documentType,
|
|
736
|
+
timestamp: now,
|
|
737
|
+
submittedBy: opts.submittedBy,
|
|
738
|
+
currentApprovers: firstLevel?.approverIds ?? []
|
|
739
|
+
};
|
|
740
|
+
this.bus.emit("approval:submitted", eventPayload);
|
|
741
|
+
await this.notifyAdapters("approval:submitted", instance, eventPayload);
|
|
742
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
743
|
+
await this.runMiddlewareAfter({ operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts }, instance);
|
|
744
|
+
return instance;
|
|
745
|
+
}
|
|
746
|
+
async approve(instanceId, raw, auditCtx) {
|
|
747
|
+
const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
|
|
748
|
+
const startMs = this.clock.now().getTime();
|
|
749
|
+
return this.withOptimisticRetry(instanceId, async (instance) => {
|
|
750
|
+
assertStatus(instance, "pending");
|
|
751
|
+
if (opts.approverId === instance.submittedBy) {
|
|
752
|
+
throw new ApprovalForbiddenError(
|
|
753
|
+
`Self-approval is not permitted. Approver "${opts.approverId}" submitted this request.`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
const level = this.currentLevelInstance(instance);
|
|
757
|
+
await this.runAuthorizationPolicy({ operation: "approve", actorId: opts.approverId, instance, level, opts });
|
|
758
|
+
await this.runMiddlewareBefore({ operation: "approve", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts });
|
|
759
|
+
assertApproverOnLevel(level, opts.approverId);
|
|
760
|
+
if (hasAlreadyActed(level, opts.approverId)) {
|
|
761
|
+
throw new ApprovalError(
|
|
762
|
+
`Approver "${opts.approverId}" has already acted on level ${level.level}.`,
|
|
763
|
+
"ALREADY_ACTED"
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const now = this.clock.now();
|
|
767
|
+
const oldValue = snapshotLevel(level);
|
|
768
|
+
level.approvedBy.push(opts.approverId);
|
|
769
|
+
const auditEntry = {
|
|
770
|
+
action: "approved",
|
|
771
|
+
actorId: opts.approverId,
|
|
772
|
+
level: level.level,
|
|
773
|
+
timestamp: now,
|
|
774
|
+
comment: opts.comment,
|
|
775
|
+
oldValue,
|
|
776
|
+
newValue: snapshotLevel(level),
|
|
777
|
+
...auditCtx
|
|
778
|
+
};
|
|
779
|
+
instance.auditLog.push(auditEntry);
|
|
780
|
+
instance.updatedAt = now;
|
|
781
|
+
if (isLevelApproved(level)) {
|
|
782
|
+
level.status = "approved";
|
|
783
|
+
const nextLevel = this.findNextLevel(instance);
|
|
784
|
+
if (!nextLevel) {
|
|
785
|
+
instance.status = "approved";
|
|
786
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
787
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
788
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
789
|
+
this.logger.info("approve: instance fully approved", { tenantId: this.tenantId, instanceId });
|
|
790
|
+
this.opts.metricsAdapter?.increment("approval.approved", { tenantId: this.tenantId, isFinal: "true" });
|
|
791
|
+
this.opts.metricsAdapter?.timing("approval.operation_duration_ms", this.clock.now().getTime() - startMs, { operation: "approve" });
|
|
792
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, approverId: opts.approverId, level: level.level, comment: opts.comment, isFinal: true };
|
|
793
|
+
this.bus.emit("approval:approved", p);
|
|
794
|
+
this.bus.emit("approval:completed", instance);
|
|
795
|
+
await this.notifyAdapters("approval:approved", instance, p);
|
|
796
|
+
await this.runMiddlewareAfter({ operation: "approve", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts }, instance);
|
|
797
|
+
return instance;
|
|
798
|
+
}
|
|
799
|
+
nextLevel.approverIds = await this.resolver.resolveApprovers(
|
|
800
|
+
nextLevel.approverConfigs,
|
|
801
|
+
instance.submittedBy,
|
|
802
|
+
instance.data,
|
|
803
|
+
this.opts.orgProvider
|
|
804
|
+
);
|
|
805
|
+
if (nextLevel.escalationAfterDays) {
|
|
806
|
+
nextLevel.escalationDueAt = new Date(now.getTime() + nextLevel.escalationAfterDays * 864e5);
|
|
807
|
+
}
|
|
808
|
+
nextLevel.status = "pending";
|
|
809
|
+
instance.currentLevel = nextLevel.level;
|
|
810
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
811
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
812
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
813
|
+
this.opts.metricsAdapter?.increment("approval.approved", { tenantId: this.tenantId, isFinal: "false" });
|
|
814
|
+
this.opts.metricsAdapter?.timing("approval.operation_duration_ms", this.clock.now().getTime() - startMs, { operation: "approve" });
|
|
815
|
+
const pAdv = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, approverId: opts.approverId, level: level.level, comment: opts.comment, isFinal: false };
|
|
816
|
+
this.bus.emit("approval:approved", pAdv);
|
|
817
|
+
const pLvl = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, fromLevel: level.level, toLevel: nextLevel.level, newApprovers: nextLevel.approverIds };
|
|
818
|
+
this.bus.emit("approval:level_advanced", pLvl);
|
|
819
|
+
await this.notifyAdapters("approval:level_advanced", instance, pLvl);
|
|
820
|
+
await this.runMiddlewareAfter({ operation: "approve", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts }, instance);
|
|
821
|
+
} else {
|
|
822
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
823
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
824
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
825
|
+
this.opts.metricsAdapter?.increment("approval.approved", { tenantId: this.tenantId });
|
|
826
|
+
this.opts.metricsAdapter?.timing("approval.operation_duration_ms", this.clock.now().getTime() - startMs, { operation: "approve" });
|
|
827
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, approverId: opts.approverId, level: level.level, comment: opts.comment, isFinal: false };
|
|
828
|
+
this.bus.emit("approval:approved", p);
|
|
829
|
+
await this.notifyAdapters("approval:approved", instance, p);
|
|
830
|
+
await this.runMiddlewareAfter({ operation: "approve", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts }, instance);
|
|
831
|
+
}
|
|
832
|
+
return instance;
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
async reject(instanceId, raw, auditCtx) {
|
|
836
|
+
const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
|
|
837
|
+
return this.withOptimisticRetry(instanceId, async (instance) => {
|
|
838
|
+
assertStatus(instance, "pending");
|
|
839
|
+
if (opts.approverId === instance.submittedBy) {
|
|
840
|
+
throw new ApprovalForbiddenError(
|
|
841
|
+
`Self-rejection is not permitted. Approver "${opts.approverId}" submitted this request.`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
const level = this.currentLevelInstance(instance);
|
|
845
|
+
await this.runAuthorizationPolicy({ operation: "reject", actorId: opts.approverId, instance, level, opts });
|
|
846
|
+
await this.runMiddlewareBefore({ operation: "reject", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts });
|
|
847
|
+
assertApproverOnLevel(level, opts.approverId);
|
|
848
|
+
if (hasAlreadyActed(level, opts.approverId)) {
|
|
849
|
+
throw new ApprovalError(
|
|
850
|
+
`Approver "${opts.approverId}" has already acted on level ${level.level}.`,
|
|
851
|
+
"ALREADY_ACTED"
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const now = this.clock.now();
|
|
855
|
+
const oldValue = snapshotLevel(level);
|
|
856
|
+
level.rejectedBy.push(opts.approverId);
|
|
857
|
+
const auditEntry = {
|
|
858
|
+
action: "rejected",
|
|
859
|
+
actorId: opts.approverId,
|
|
860
|
+
level: level.level,
|
|
861
|
+
timestamp: now,
|
|
862
|
+
reason: opts.reason,
|
|
863
|
+
oldValue,
|
|
864
|
+
newValue: snapshotLevel(level),
|
|
865
|
+
...auditCtx
|
|
866
|
+
};
|
|
867
|
+
instance.auditLog.push(auditEntry);
|
|
868
|
+
instance.updatedAt = now;
|
|
869
|
+
if (isLevelRejected(level)) {
|
|
870
|
+
level.status = "rejected";
|
|
871
|
+
if (opts.returnTo === "previous") {
|
|
872
|
+
const prevLevel = this.findPreviousLevel(instance);
|
|
873
|
+
if (!prevLevel) {
|
|
874
|
+
throw new ApprovalValidationError(
|
|
875
|
+
`Cannot return to previous level: instance "${instanceId}" is already at the first level (${level.level}). Remove returnTo: 'previous' or use returnTo: 'originator' to fully reject.`
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
prevLevel.status = "pending";
|
|
879
|
+
prevLevel.approvedBy = [];
|
|
880
|
+
prevLevel.rejectedBy = [];
|
|
881
|
+
instance.currentLevel = prevLevel.level;
|
|
882
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
883
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
884
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
885
|
+
const p2 = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, approverId: opts.approverId, level: level.level, reason: opts.reason, returnTo: "previous" };
|
|
886
|
+
this.bus.emit("approval:rejected", p2);
|
|
887
|
+
await this.notifyAdapters("approval:rejected", instance, p2);
|
|
888
|
+
await this.runMiddlewareAfter({ operation: "reject", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts }, instance);
|
|
889
|
+
return instance;
|
|
890
|
+
}
|
|
891
|
+
instance.status = "rejected";
|
|
892
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
893
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
894
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
895
|
+
this.opts.metricsAdapter?.increment("approval.rejected", { tenantId: this.tenantId });
|
|
896
|
+
this.logger.info("reject: instance rejected", { tenantId: this.tenantId, instanceId });
|
|
897
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, approverId: opts.approverId, level: level.level, reason: opts.reason, returnTo: opts.returnTo === "originator" ? "originator" : null };
|
|
898
|
+
this.bus.emit("approval:rejected", p);
|
|
899
|
+
await this.notifyAdapters("approval:rejected", instance, p);
|
|
900
|
+
await this.runMiddlewareAfter({ operation: "reject", instanceId, actorId: opts.approverId, tenantId: this.tenantId, input: opts }, instance);
|
|
901
|
+
} else {
|
|
902
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
903
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
904
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
905
|
+
}
|
|
906
|
+
return instance;
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
async delegate(instanceId, raw, auditCtx) {
|
|
910
|
+
const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
|
|
911
|
+
await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
912
|
+
assertStatus(instance, "pending");
|
|
913
|
+
if (opts.fromApprover === opts.toApprover) {
|
|
914
|
+
throw new ApprovalForbiddenError("Cannot delegate to yourself.");
|
|
915
|
+
}
|
|
916
|
+
const level = this.currentLevelInstance(instance);
|
|
917
|
+
await this.runAuthorizationPolicy({ operation: "delegate", actorId: opts.fromApprover, instance, level, opts });
|
|
918
|
+
await this.runMiddlewareBefore({ operation: "delegate", instanceId, actorId: opts.fromApprover, tenantId: this.tenantId, input: opts });
|
|
919
|
+
assertApproverOnLevel(level, opts.fromApprover);
|
|
920
|
+
if (hasAlreadyActed(level, opts.fromApprover)) {
|
|
921
|
+
throw new ApprovalForbiddenError(
|
|
922
|
+
`Cannot delegate after acting: "${opts.fromApprover}" has already approved or rejected level ${level.level}.`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const now = this.clock.now();
|
|
926
|
+
const idx = level.approverIds.indexOf(opts.fromApprover);
|
|
927
|
+
level.approverIds[idx] = opts.toApprover;
|
|
928
|
+
if (opts.until) {
|
|
929
|
+
level.delegatedUntil = opts.until;
|
|
930
|
+
level.delegatedFrom = opts.fromApprover;
|
|
931
|
+
level.delegatedTo = opts.toApprover;
|
|
932
|
+
}
|
|
933
|
+
const auditEntry = {
|
|
934
|
+
action: "delegated",
|
|
935
|
+
actorId: opts.fromApprover,
|
|
936
|
+
level: level.level,
|
|
937
|
+
timestamp: now,
|
|
938
|
+
reason: opts.reason,
|
|
939
|
+
delegateTo: opts.toApprover,
|
|
940
|
+
...auditCtx
|
|
941
|
+
};
|
|
942
|
+
instance.auditLog.push(auditEntry);
|
|
943
|
+
instance.updatedAt = now;
|
|
944
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
945
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
946
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
947
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, fromApprover: opts.fromApprover, toApprover: opts.toApprover, level: level.level, reason: opts.reason };
|
|
948
|
+
this.bus.emit("approval:delegated", p);
|
|
949
|
+
await this.notifyAdapters("approval:delegated", instance, p);
|
|
950
|
+
await this.runMiddlewareAfter({ operation: "delegate", instanceId, actorId: opts.fromApprover, tenantId: this.tenantId, input: opts }, instance);
|
|
951
|
+
return instance;
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
async cancel(instanceId, raw, auditCtx) {
|
|
955
|
+
const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
|
|
956
|
+
return this.withOptimisticRetry(instanceId, async (instance) => {
|
|
957
|
+
if (instance.status === "approved" || instance.status === "rejected") {
|
|
958
|
+
throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
|
|
959
|
+
}
|
|
960
|
+
await this.runAuthorizationPolicy({ operation: "cancel", actorId: opts.cancelledBy, instance, opts });
|
|
961
|
+
await this.runMiddlewareBefore({ operation: "cancel", instanceId, actorId: opts.cancelledBy, tenantId: this.tenantId, input: opts });
|
|
962
|
+
const now = this.clock.now();
|
|
963
|
+
instance.status = "cancelled";
|
|
964
|
+
instance.updatedAt = now;
|
|
965
|
+
const auditEntry = {
|
|
966
|
+
action: "cancelled",
|
|
967
|
+
actorId: opts.cancelledBy,
|
|
968
|
+
level: instance.currentLevel,
|
|
969
|
+
timestamp: now,
|
|
970
|
+
reason: opts.reason,
|
|
971
|
+
...auditCtx
|
|
972
|
+
};
|
|
973
|
+
instance.auditLog.push(auditEntry);
|
|
974
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
975
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
976
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
977
|
+
this.opts.metricsAdapter?.increment("approval.cancelled", { tenantId: this.tenantId });
|
|
978
|
+
this.logger.info("cancel: instance cancelled", { tenantId: this.tenantId, instanceId });
|
|
979
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, cancelledBy: opts.cancelledBy, reason: opts.reason };
|
|
980
|
+
this.bus.emit("approval:cancelled", p);
|
|
981
|
+
await this.notifyAdapters("approval:cancelled", instance, p);
|
|
982
|
+
await this.runMiddlewareAfter({ operation: "cancel", instanceId, actorId: opts.cancelledBy, tenantId: this.tenantId, input: opts }, instance);
|
|
983
|
+
return instance;
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
async escalate(instanceId, raw, auditCtx) {
|
|
987
|
+
parseOrThrow(() => EscalateOptionsSchema.parse(raw));
|
|
988
|
+
return this.escalateInternal(instanceId, raw.escalatedBy, auditCtx);
|
|
989
|
+
}
|
|
990
|
+
/** Add a comment to an instance without approving or rejecting. */
|
|
991
|
+
async addComment(instanceId, raw, auditCtx) {
|
|
992
|
+
const opts = parseOrThrow(() => AddCommentOptionsSchema.parse(raw));
|
|
993
|
+
const instance = await this.requireInstance(instanceId);
|
|
994
|
+
await this.runAuthorizationPolicy({ operation: "addComment", actorId: opts.actorId, instance, opts });
|
|
995
|
+
await this.runMiddlewareBefore({ operation: "addComment", instanceId, actorId: opts.actorId, tenantId: this.tenantId, input: opts });
|
|
996
|
+
const now = this.clock.now();
|
|
997
|
+
const auditEntry = {
|
|
998
|
+
action: "commented",
|
|
999
|
+
actorId: opts.actorId,
|
|
1000
|
+
level: instance.currentLevel,
|
|
1001
|
+
timestamp: now,
|
|
1002
|
+
comment: opts.comment,
|
|
1003
|
+
...auditCtx
|
|
1004
|
+
};
|
|
1005
|
+
instance.auditLog.push(auditEntry);
|
|
1006
|
+
instance.updatedAt = now;
|
|
1007
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1008
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
1009
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
1010
|
+
await this.runMiddlewareAfter({ operation: "addComment", instanceId, actorId: opts.actorId, tenantId: this.tenantId, input: opts });
|
|
1011
|
+
}
|
|
1012
|
+
/** Resubmit a rejected instance, creating a new linked instance from level 1. */
|
|
1013
|
+
async resubmit(instanceId, raw, auditCtx) {
|
|
1014
|
+
const opts = parseOrThrow(() => ResubmitOptionsSchema.parse(raw));
|
|
1015
|
+
const original = await this.requireInstance(instanceId);
|
|
1016
|
+
if (original.status !== "rejected") {
|
|
1017
|
+
throw new ApprovalForbiddenError(
|
|
1018
|
+
`Cannot resubmit an instance with status "${original.status}". Only rejected instances can be resubmitted.`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
await this.runAuthorizationPolicy({ operation: "resubmit", actorId: opts.resubmittedBy, instance: original, opts });
|
|
1022
|
+
await this.runMiddlewareBefore({ operation: "resubmit", instanceId, actorId: opts.resubmittedBy, tenantId: this.tenantId, input: opts });
|
|
1023
|
+
const template = await this.registry.get(original.templateName);
|
|
1024
|
+
const mergedData = { ...original.data, ...opts.updatedData ?? {} };
|
|
1025
|
+
const mutations = evaluateConditions(template.conditions ?? [], mergedData);
|
|
1026
|
+
const allLevelCfgs = [...template.levels, ...mutations.addLevels].filter((l) => !mutations.skipLevels.has(l.level)).sort((a, b) => a.level - b.level);
|
|
1027
|
+
if (allLevelCfgs.length === 0) {
|
|
1028
|
+
throw new ApprovalValidationError("Template has no active levels after condition evaluation.");
|
|
1029
|
+
}
|
|
1030
|
+
const levelNums = new Set(allLevelCfgs.map((l) => l.level));
|
|
1031
|
+
if (levelNums.size !== allLevelCfgs.length) {
|
|
1032
|
+
throw new ApprovalValidationError("Duplicate level numbers after condition evaluation.");
|
|
1033
|
+
}
|
|
1034
|
+
const now = this.clock.now();
|
|
1035
|
+
const newInstanceId = this.generateId("inst");
|
|
1036
|
+
const levels = allLevelCfgs.map((cfg, idx) => ({
|
|
1037
|
+
level: cfg.level,
|
|
1038
|
+
name: cfg.name,
|
|
1039
|
+
mode: cfg.mode,
|
|
1040
|
+
approverConfigs: cfg.approvers,
|
|
1041
|
+
approverIds: [],
|
|
1042
|
+
approvedBy: [],
|
|
1043
|
+
rejectedBy: [],
|
|
1044
|
+
status: idx === 0 ? "pending" : "waiting",
|
|
1045
|
+
escalationAfterDays: cfg.escalationAfterDays,
|
|
1046
|
+
escalationDueAt: idx === 0 && cfg.escalationAfterDays ? new Date(now.getTime() + cfg.escalationAfterDays * 864e5) : void 0
|
|
1047
|
+
}));
|
|
1048
|
+
const firstCfg = allLevelCfgs[0];
|
|
1049
|
+
const firstLevel = levels[0];
|
|
1050
|
+
if (firstCfg && firstLevel) {
|
|
1051
|
+
firstLevel.approverIds = await this.resolver.resolveApprovers(
|
|
1052
|
+
firstCfg.approvers,
|
|
1053
|
+
opts.resubmittedBy,
|
|
1054
|
+
mergedData,
|
|
1055
|
+
this.opts.orgProvider
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const auditEntry = {
|
|
1059
|
+
action: "resubmitted",
|
|
1060
|
+
actorId: opts.resubmittedBy,
|
|
1061
|
+
level: allLevelCfgs[0]?.level ?? 1,
|
|
1062
|
+
timestamp: now,
|
|
1063
|
+
reason: opts.reason,
|
|
1064
|
+
...auditCtx
|
|
1065
|
+
};
|
|
1066
|
+
const slaDeadlineAt = template.slaDeadlineDays ? new Date(now.getTime() + template.slaDeadlineDays * 864e5) : void 0;
|
|
1067
|
+
const newInstance = {
|
|
1068
|
+
id: newInstanceId,
|
|
1069
|
+
tenantId: this.tenantId,
|
|
1070
|
+
templateId: template.id,
|
|
1071
|
+
templateName: template.name,
|
|
1072
|
+
documentId: original.documentId,
|
|
1073
|
+
documentType: original.documentType,
|
|
1074
|
+
submittedBy: opts.resubmittedBy,
|
|
1075
|
+
status: "pending",
|
|
1076
|
+
currentLevel: allLevelCfgs[0]?.level ?? 1,
|
|
1077
|
+
version: 1,
|
|
1078
|
+
parentInstanceId: instanceId,
|
|
1079
|
+
levels,
|
|
1080
|
+
auditLog: [auditEntry],
|
|
1081
|
+
data: mergedData,
|
|
1082
|
+
metadata: original.metadata,
|
|
1083
|
+
createdAt: now,
|
|
1084
|
+
updatedAt: now,
|
|
1085
|
+
slaDeadlineAt,
|
|
1086
|
+
templateSnapshot: {
|
|
1087
|
+
escalation: template.escalation,
|
|
1088
|
+
slaDeadlineDays: template.slaDeadlineDays,
|
|
1089
|
+
allowOverride: template.allowOverride
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
await this.opts.adapter.saveInstance(newInstance);
|
|
1093
|
+
await this.runExternalAudit(newInstance, auditEntry);
|
|
1094
|
+
this.logger.info("resubmit: new instance created from rejected original", {
|
|
1095
|
+
tenantId: this.tenantId,
|
|
1096
|
+
originalInstanceId: instanceId,
|
|
1097
|
+
newInstanceId
|
|
1098
|
+
});
|
|
1099
|
+
const p = {
|
|
1100
|
+
instanceId: newInstanceId,
|
|
1101
|
+
documentId: newInstance.documentId,
|
|
1102
|
+
documentType: newInstance.documentType,
|
|
1103
|
+
timestamp: now,
|
|
1104
|
+
resubmittedBy: opts.resubmittedBy,
|
|
1105
|
+
originalInstanceId: instanceId
|
|
1106
|
+
};
|
|
1107
|
+
this.bus.emit("approval:resubmitted", p);
|
|
1108
|
+
await this.notifyAdapters("approval:resubmitted", newInstance, p);
|
|
1109
|
+
await this.runMiddlewareAfter({ operation: "resubmit", instanceId, actorId: opts.resubmittedBy, tenantId: this.tenantId, input: opts }, newInstance);
|
|
1110
|
+
return newInstance;
|
|
1111
|
+
}
|
|
1112
|
+
/** Preview the resolved approval chain for a template and document data, without creating an instance. */
|
|
1113
|
+
async previewApprovalChain(templateName, data, submittedBy) {
|
|
1114
|
+
const template = await this.registry.get(templateName);
|
|
1115
|
+
const mutations = evaluateConditions(template.conditions ?? [], data);
|
|
1116
|
+
const conditionsApplied = [];
|
|
1117
|
+
(template.conditions ?? []).forEach((rule, idx) => {
|
|
1118
|
+
const m = evaluateConditions([rule], data);
|
|
1119
|
+
if (m.addLevels.length > 0 || m.skipLevels.size > 0) {
|
|
1120
|
+
conditionsApplied.push(idx);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
const allLevelCfgs = [...template.levels, ...mutations.addLevels].filter((l) => !mutations.skipLevels.has(l.level)).sort((a, b) => a.level - b.level);
|
|
1124
|
+
const levels = [];
|
|
1125
|
+
for (const cfg of allLevelCfgs) {
|
|
1126
|
+
try {
|
|
1127
|
+
const resolvedApprovers = await this.resolver.resolveApprovers(
|
|
1128
|
+
cfg.approvers,
|
|
1129
|
+
submittedBy,
|
|
1130
|
+
data,
|
|
1131
|
+
this.opts.orgProvider
|
|
1132
|
+
);
|
|
1133
|
+
levels.push({ level: cfg.level, name: cfg.name, resolvedApprovers, mode: cfg.mode });
|
|
1134
|
+
} catch {
|
|
1135
|
+
levels.push({ level: cfg.level, name: cfg.name, resolvedApprovers: [], mode: cfg.mode });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return { levels, conditionsApplied };
|
|
1139
|
+
}
|
|
1140
|
+
/** Check whether a user is eligible to approve a specific instance. Never throws. */
|
|
1141
|
+
async canApprove(instanceId, userId) {
|
|
1142
|
+
let instance;
|
|
1143
|
+
try {
|
|
1144
|
+
instance = await this.requireInstance(instanceId);
|
|
1145
|
+
} catch {
|
|
1146
|
+
return { eligible: false, reason: "wrong_status" };
|
|
1147
|
+
}
|
|
1148
|
+
if (instance.status !== "pending") {
|
|
1149
|
+
return { eligible: false, reason: "wrong_status" };
|
|
1150
|
+
}
|
|
1151
|
+
if (userId === instance.submittedBy) {
|
|
1152
|
+
return { eligible: false, reason: "self_approval" };
|
|
1153
|
+
}
|
|
1154
|
+
const level = this.currentLevelInstance(instance);
|
|
1155
|
+
if (!level.approverIds.includes(userId)) {
|
|
1156
|
+
const hasDelegated = instance.auditLog.some(
|
|
1157
|
+
(e) => e.action === "delegated" && e.actorId === userId && e.level === level.level
|
|
1158
|
+
);
|
|
1159
|
+
return { eligible: false, reason: hasDelegated ? "delegated_away" : "not_an_approver" };
|
|
1160
|
+
}
|
|
1161
|
+
if (hasAlreadyActed(level, userId)) {
|
|
1162
|
+
return { eligible: false, reason: "already_acted" };
|
|
1163
|
+
}
|
|
1164
|
+
return { eligible: true };
|
|
1165
|
+
}
|
|
1166
|
+
/** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
|
|
1167
|
+
async override(instanceId, raw, auditCtx) {
|
|
1168
|
+
const opts = parseOrThrow(() => OverrideOptionsSchema.parse(raw));
|
|
1169
|
+
return this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1170
|
+
assertStatus(instance, "pending");
|
|
1171
|
+
const allowOverride = instance.templateSnapshot?.allowOverride ?? (await this.registry.get(instance.templateName)).allowOverride;
|
|
1172
|
+
if (!allowOverride) {
|
|
1173
|
+
throw new ApprovalForbiddenError(
|
|
1174
|
+
`Override is not enabled for template "${instance.templateName}". Set allowOverride: true in the template config.`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (opts.overriddenBy === instance.submittedBy) {
|
|
1178
|
+
throw new ApprovalForbiddenError(
|
|
1179
|
+
"Override cannot be performed by the original submitter."
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
await this.runAuthorizationPolicy({ operation: "override", actorId: opts.overriddenBy, instance, opts });
|
|
1183
|
+
await this.runMiddlewareBefore({ operation: "override", instanceId, actorId: opts.overriddenBy, tenantId: this.tenantId, input: opts });
|
|
1184
|
+
const now = this.clock.now();
|
|
1185
|
+
instance.status = "approved";
|
|
1186
|
+
instance.updatedAt = now;
|
|
1187
|
+
const auditEntry = {
|
|
1188
|
+
action: "overridden",
|
|
1189
|
+
actorId: opts.overriddenBy,
|
|
1190
|
+
level: instance.currentLevel,
|
|
1191
|
+
timestamp: now,
|
|
1192
|
+
reason: opts.justification,
|
|
1193
|
+
...auditCtx
|
|
1194
|
+
};
|
|
1195
|
+
instance.auditLog.push(auditEntry);
|
|
1196
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1197
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
1198
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
1199
|
+
this.opts.metricsAdapter?.increment("approval.overridden", { tenantId: this.tenantId });
|
|
1200
|
+
this.logger.info("override: instance force-approved", {
|
|
1201
|
+
tenantId: this.tenantId,
|
|
1202
|
+
instanceId,
|
|
1203
|
+
overriddenBy: opts.overriddenBy
|
|
1204
|
+
});
|
|
1205
|
+
const p = {
|
|
1206
|
+
instanceId,
|
|
1207
|
+
documentId: instance.documentId,
|
|
1208
|
+
documentType: instance.documentType,
|
|
1209
|
+
timestamp: now,
|
|
1210
|
+
overriddenBy: opts.overriddenBy,
|
|
1211
|
+
justification: opts.justification
|
|
1212
|
+
};
|
|
1213
|
+
this.bus.emit("approval:overridden", p);
|
|
1214
|
+
this.bus.emit("approval:completed", instance);
|
|
1215
|
+
await this.notifyAdapters("approval:overridden", instance, p);
|
|
1216
|
+
await this.runMiddlewareAfter({ operation: "override", instanceId, actorId: opts.overriddenBy, tenantId: this.tenantId, input: opts }, instance);
|
|
1217
|
+
return instance;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
/** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
1221
|
+
async bulkApprove(instanceIds, raw, auditCtx) {
|
|
1222
|
+
const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
|
|
1223
|
+
this.guardBulkSize(instanceIds);
|
|
1224
|
+
const result = { succeeded: [], failed: [], total: instanceIds.length };
|
|
1225
|
+
for (const id of instanceIds) {
|
|
1226
|
+
try {
|
|
1227
|
+
result.succeeded.push(await this.approve(id, opts, auditCtx));
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
result.failed.push({ instanceId: id, error: err instanceof ApprovalError ? err : new ApprovalError(String(err), "UNKNOWN") });
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return result;
|
|
1233
|
+
}
|
|
1234
|
+
/** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
|
|
1235
|
+
async bulkReject(instanceIds, raw, auditCtx) {
|
|
1236
|
+
const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
|
|
1237
|
+
this.guardBulkSize(instanceIds);
|
|
1238
|
+
const result = { succeeded: [], failed: [], total: instanceIds.length };
|
|
1239
|
+
for (const id of instanceIds) {
|
|
1240
|
+
try {
|
|
1241
|
+
result.succeeded.push(await this.reject(id, opts, auditCtx));
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
result.failed.push({ instanceId: id, error: err instanceof ApprovalError ? err : new ApprovalError(String(err), "UNKNOWN") });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
// ─── Queries ──────────────────────────────────────────────────────────────
|
|
1249
|
+
async getInstance(instanceId) {
|
|
1250
|
+
return this.requireInstance(instanceId);
|
|
1251
|
+
}
|
|
1252
|
+
async getPendingFor(approverId, opts) {
|
|
1253
|
+
return this.opts.adapter.getInstancesByApprover(this.tenantId, approverId, opts);
|
|
1254
|
+
}
|
|
1255
|
+
async queryInstances(filter, opts) {
|
|
1256
|
+
return this.opts.adapter.getInstancesByFilter(this.tenantId, filter, opts);
|
|
1257
|
+
}
|
|
1258
|
+
async queryInstancesByCursor(filter, opts) {
|
|
1259
|
+
if (!this.opts.adapter.getInstancesByCursor) {
|
|
1260
|
+
throw new ApprovalError(
|
|
1261
|
+
"The configured storage adapter does not support cursor pagination. Implement getInstancesByCursor() or use queryInstances() instead.",
|
|
1262
|
+
"NOT_SUPPORTED"
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
return this.opts.adapter.getInstancesByCursor(this.tenantId, filter, opts);
|
|
1266
|
+
}
|
|
1267
|
+
async getHistory(instanceId) {
|
|
1268
|
+
const instance = await this.requireInstance(instanceId);
|
|
1269
|
+
return instance.auditLog;
|
|
1270
|
+
}
|
|
1271
|
+
async getCurrentApprovers(instanceId) {
|
|
1272
|
+
const instance = await this.requireInstance(instanceId);
|
|
1273
|
+
if (instance.status !== "pending") return [];
|
|
1274
|
+
return this.currentLevelInstance(instance).approverIds;
|
|
1275
|
+
}
|
|
1276
|
+
/** Check adapter connectivity and escalation scheduler health. */
|
|
1277
|
+
async healthCheck() {
|
|
1278
|
+
let adapterStatus = "connected";
|
|
1279
|
+
let pendingCount = 0;
|
|
1280
|
+
let overdueCount = 0;
|
|
1281
|
+
try {
|
|
1282
|
+
const result = await this.opts.adapter.getInstancesByFilter(
|
|
1283
|
+
this.tenantId,
|
|
1284
|
+
{ status: "pending" },
|
|
1285
|
+
{ limit: 1, offset: 0 }
|
|
1286
|
+
);
|
|
1287
|
+
pendingCount = result.total;
|
|
1288
|
+
} catch {
|
|
1289
|
+
adapterStatus = "error";
|
|
1290
|
+
}
|
|
1291
|
+
if (adapterStatus === "connected") {
|
|
1292
|
+
try {
|
|
1293
|
+
const overdue = await this.opts.adapter.getOverdueInstances(this.tenantId, this.clock.now());
|
|
1294
|
+
overdueCount = overdue.length;
|
|
1295
|
+
} catch {
|
|
1296
|
+
adapterStatus = "error";
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const status = adapterStatus === "error" ? "unhealthy" : overdueCount > 0 ? "degraded" : "healthy";
|
|
1300
|
+
return {
|
|
1301
|
+
status,
|
|
1302
|
+
adapter: adapterStatus,
|
|
1303
|
+
pendingCount,
|
|
1304
|
+
overdueCount,
|
|
1305
|
+
escalationRunning: this.escalation.isRunning,
|
|
1306
|
+
lastTickAt: this.escalation.lastTickAt ?? void 0
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
async shutdown() {
|
|
1310
|
+
await this.escalation.stop();
|
|
1311
|
+
await this.opts.schedulerAdapter?.shutdown();
|
|
1312
|
+
}
|
|
1313
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
1314
|
+
async escalateInternal(instanceId, escalatedBy = "system", auditCtx) {
|
|
1315
|
+
return this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1316
|
+
if (instance.status !== "pending") return instance;
|
|
1317
|
+
const escalationConfig = instance.templateSnapshot?.escalation ?? (await this.registry.get(instance.templateName)).escalation;
|
|
1318
|
+
if (!escalationConfig) return instance;
|
|
1319
|
+
const newApprovers = await this.resolver.resolveApprovers(
|
|
1320
|
+
[escalationConfig.escalateTo],
|
|
1321
|
+
instance.submittedBy,
|
|
1322
|
+
instance.data,
|
|
1323
|
+
this.opts.orgProvider
|
|
1324
|
+
);
|
|
1325
|
+
const filteredApprovers = newApprovers.filter((id) => id !== instance.submittedBy);
|
|
1326
|
+
if (filteredApprovers.length === 0) {
|
|
1327
|
+
this.logger.warn("escalateInternal: escalation resolved to submitter only \u2014 no approvers added", {
|
|
1328
|
+
tenantId: this.tenantId,
|
|
1329
|
+
instanceId
|
|
1330
|
+
});
|
|
1331
|
+
return instance;
|
|
1332
|
+
}
|
|
1333
|
+
const level = this.currentLevelInstance(instance);
|
|
1334
|
+
level.approverIds = [.../* @__PURE__ */ new Set([...level.approverIds, ...filteredApprovers])];
|
|
1335
|
+
level.escalationDueAt = void 0;
|
|
1336
|
+
const now = this.clock.now();
|
|
1337
|
+
const escalatedTo = filteredApprovers[0] ?? "unknown";
|
|
1338
|
+
const auditEntry = {
|
|
1339
|
+
action: "escalated",
|
|
1340
|
+
actorId: escalatedBy,
|
|
1341
|
+
level: level.level,
|
|
1342
|
+
timestamp: now,
|
|
1343
|
+
delegateTo: escalatedTo,
|
|
1344
|
+
...auditCtx
|
|
1345
|
+
};
|
|
1346
|
+
instance.auditLog.push(auditEntry);
|
|
1347
|
+
instance.updatedAt = now;
|
|
1348
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1349
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
1350
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
1351
|
+
this.opts.metricsAdapter?.increment("approval.escalated", { tenantId: this.tenantId });
|
|
1352
|
+
this.logger.info("escalate: instance escalated", { tenantId: this.tenantId, instanceId, escalatedTo });
|
|
1353
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, level: level.level, escalatedTo };
|
|
1354
|
+
this.bus.emit("approval:escalated", p);
|
|
1355
|
+
await this.notifyAdapters("approval:escalated", instance, p);
|
|
1356
|
+
return instance;
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
async expireInstance(instanceId, deadlineAction) {
|
|
1360
|
+
try {
|
|
1361
|
+
await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1362
|
+
if (instance.status !== "pending") return instance;
|
|
1363
|
+
const now = this.clock.now();
|
|
1364
|
+
instance.status = deadlineAction === "reject" ? "rejected" : "cancelled";
|
|
1365
|
+
instance.updatedAt = now;
|
|
1366
|
+
const auditEntry = {
|
|
1367
|
+
action: "expired",
|
|
1368
|
+
actorId: "system",
|
|
1369
|
+
level: instance.currentLevel,
|
|
1370
|
+
timestamp: now,
|
|
1371
|
+
reason: `Approval deadline reached. Action: ${deadlineAction}.`
|
|
1372
|
+
};
|
|
1373
|
+
instance.auditLog.push(auditEntry);
|
|
1374
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1375
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, instanceId, auditEntry);
|
|
1376
|
+
await this.runExternalAudit(instance, auditEntry);
|
|
1377
|
+
this.opts.metricsAdapter?.increment("approval.expired", { tenantId: this.tenantId });
|
|
1378
|
+
this.logger.warn("expireInstance: instance expired by deadline", {
|
|
1379
|
+
tenantId: this.tenantId,
|
|
1380
|
+
instanceId,
|
|
1381
|
+
deadlineAction
|
|
1382
|
+
});
|
|
1383
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, deadlineAction };
|
|
1384
|
+
this.bus.emit("approval:expired", p);
|
|
1385
|
+
await this.notifyAdapters("approval:expired", instance, p);
|
|
1386
|
+
return instance;
|
|
1387
|
+
});
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
this.logger.error("expireInstance: failed", err, { tenantId: this.tenantId, instanceId });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
async markSlaBreached(instanceId) {
|
|
1393
|
+
try {
|
|
1394
|
+
await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1395
|
+
if (instance.status !== "pending" || instance.slaBreachedAt) return instance;
|
|
1396
|
+
const now = this.clock.now();
|
|
1397
|
+
instance.slaBreachedAt = now;
|
|
1398
|
+
instance.updatedAt = now;
|
|
1399
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1400
|
+
this.opts.metricsAdapter?.increment("approval.sla_breached", { tenantId: this.tenantId });
|
|
1401
|
+
this.logger.warn("markSlaBreached: SLA breached", { tenantId: this.tenantId, instanceId });
|
|
1402
|
+
const p = { instanceId, documentId: instance.documentId, documentType: instance.documentType, timestamp: now, slaDeadlineAt: instance.slaDeadlineAt ?? now };
|
|
1403
|
+
this.bus.emit("approval:sla_breached", p);
|
|
1404
|
+
await this.notifyAdapters("approval:sla_breached", instance, p);
|
|
1405
|
+
return instance;
|
|
1406
|
+
});
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
this.logger.error("markSlaBreached: failed", err, { tenantId: this.tenantId, instanceId });
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
async revertDelegation(instanceId, levelNumber, fromApprover) {
|
|
1412
|
+
try {
|
|
1413
|
+
await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1414
|
+
if (instance.status !== "pending") return instance;
|
|
1415
|
+
const level = instance.levels.find((l) => l.level === levelNumber);
|
|
1416
|
+
if (!level || level.status !== "pending") return instance;
|
|
1417
|
+
const delegateTo = level.delegatedTo;
|
|
1418
|
+
if (delegateTo) {
|
|
1419
|
+
const delegateIdx = level.approverIds.indexOf(delegateTo);
|
|
1420
|
+
if (delegateIdx >= 0) {
|
|
1421
|
+
level.approverIds[delegateIdx] = fromApprover;
|
|
1422
|
+
} else {
|
|
1423
|
+
level.approverIds.push(fromApprover);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
level.delegatedUntil = void 0;
|
|
1427
|
+
level.delegatedFrom = void 0;
|
|
1428
|
+
level.delegatedTo = void 0;
|
|
1429
|
+
const now = this.clock.now();
|
|
1430
|
+
instance.updatedAt = now;
|
|
1431
|
+
await this.opts.adapter.updateInstance(instance, instance.version);
|
|
1432
|
+
this.logger.info("revertDelegation: delegation expired and reverted", {
|
|
1433
|
+
tenantId: this.tenantId,
|
|
1434
|
+
instanceId,
|
|
1435
|
+
levelNumber,
|
|
1436
|
+
fromApprover
|
|
1437
|
+
});
|
|
1438
|
+
return instance;
|
|
1439
|
+
});
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
this.logger.error("revertDelegation: failed", err, { tenantId: this.tenantId, instanceId });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** Read-modify-write with optimistic locking retry. */
|
|
1445
|
+
async withOptimisticRetry(instanceId, fn) {
|
|
1446
|
+
const { maxAttempts, baseDelayMs, maxDelayMs = Infinity, jitter = true } = this.retryPolicy;
|
|
1447
|
+
let lastError;
|
|
1448
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1449
|
+
if (attempt > 0) {
|
|
1450
|
+
let delay = Math.min(baseDelayMs * attempt, maxDelayMs);
|
|
1451
|
+
if (jitter) delay += Math.random() * baseDelayMs;
|
|
1452
|
+
await sleep(delay);
|
|
1453
|
+
this.opts.metricsAdapter?.increment("approval.conflict_retry", { tenantId: this.tenantId, attempt: String(attempt) });
|
|
1454
|
+
this.logger.warn("withOptimisticRetry: retrying after conflict", {
|
|
1455
|
+
tenantId: this.tenantId,
|
|
1456
|
+
instanceId,
|
|
1457
|
+
attempt
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
const instance = await this.requireInstance(instanceId);
|
|
1461
|
+
if (attempt > 0 && TERMINAL_STATUSES.has(instance.status)) {
|
|
1462
|
+
throw new ApprovalForbiddenError(
|
|
1463
|
+
`Instance "${instanceId}" is already in terminal status "${instance.status}" and cannot be modified.`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
try {
|
|
1467
|
+
return await fn(instance);
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
if (err instanceof ApprovalConflictError) {
|
|
1470
|
+
lastError = err;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
throw err;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
throw lastError ?? new ApprovalConflictError(instanceId);
|
|
1477
|
+
}
|
|
1478
|
+
async requireInstance(id) {
|
|
1479
|
+
const instance = await this.opts.adapter.getInstance(this.tenantId, id);
|
|
1480
|
+
if (!instance) throw new ApprovalNotFoundError("Instance", id);
|
|
1481
|
+
return instance;
|
|
1482
|
+
}
|
|
1483
|
+
currentLevelInstance(instance) {
|
|
1484
|
+
const level = instance.levels.find((l) => l.level === instance.currentLevel);
|
|
1485
|
+
if (!level) {
|
|
1486
|
+
const available = instance.levels.map((l) => l.level).join(", ");
|
|
1487
|
+
throw new ApprovalError(
|
|
1488
|
+
`Level ${instance.currentLevel} not found on instance (available: ${available}).`,
|
|
1489
|
+
"INVALID_LEVEL"
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
return level;
|
|
1493
|
+
}
|
|
1494
|
+
findNextLevel(instance) {
|
|
1495
|
+
return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
|
|
1496
|
+
}
|
|
1497
|
+
findPreviousLevel(instance) {
|
|
1498
|
+
return [...instance.levels].filter((l) => l.level < instance.currentLevel).sort((a, b) => b.level - a.level)[0] ?? null;
|
|
1499
|
+
}
|
|
1500
|
+
guardBulkSize(instanceIds) {
|
|
1501
|
+
if (instanceIds.length > this.maxBulkItems) {
|
|
1502
|
+
throw new ApprovalValidationError(
|
|
1503
|
+
`Bulk operation exceeds maximum allowed items (${this.maxBulkItems}). Got ${instanceIds.length}.`
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
// ─── Extension point helpers ──────────────────────────────────────────────
|
|
1508
|
+
async runAuthorizationPolicy(ctx) {
|
|
1509
|
+
if (!this.opts.authorizationPolicy) return;
|
|
1510
|
+
try {
|
|
1511
|
+
const denial = await this.opts.authorizationPolicy.authorize(ctx);
|
|
1512
|
+
if (denial) throw new ApprovalForbiddenError(denial);
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
if (err instanceof ApprovalForbiddenError) throw err;
|
|
1515
|
+
this.logger.error("authorizationPolicy.authorize threw unexpectedly", err, { tenantId: this.tenantId });
|
|
1516
|
+
throw err;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
async runMiddlewareBefore(ctx) {
|
|
1520
|
+
if (!this.opts.middleware?.length) return;
|
|
1521
|
+
for (const mw of this.opts.middleware) {
|
|
1522
|
+
try {
|
|
1523
|
+
await mw.before?.(ctx);
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
this.logger.error("middleware.before threw", err, { operation: ctx.operation });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
async runMiddlewareAfter(ctx, result) {
|
|
1530
|
+
if (!this.opts.middleware?.length) return;
|
|
1531
|
+
for (const mw of this.opts.middleware) {
|
|
1532
|
+
try {
|
|
1533
|
+
await mw.after?.(ctx, result);
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
this.logger.error("middleware.after threw", err, { operation: ctx.operation });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
async notifyAdapters(eventType, instance, payload) {
|
|
1540
|
+
if (!this.opts.notificationAdapter) return;
|
|
1541
|
+
const level = instance.levels.find((l) => l.level === instance.currentLevel);
|
|
1542
|
+
const notifEvent = {
|
|
1543
|
+
type: eventType,
|
|
1544
|
+
instanceId: instance.id,
|
|
1545
|
+
documentId: instance.documentId,
|
|
1546
|
+
documentType: instance.documentType,
|
|
1547
|
+
timestamp: this.clock.now(),
|
|
1548
|
+
recipients: level?.approverIds ?? [],
|
|
1549
|
+
templateName: instance.templateName,
|
|
1550
|
+
tenantId: instance.tenantId,
|
|
1551
|
+
payload
|
|
1552
|
+
};
|
|
1553
|
+
try {
|
|
1554
|
+
await this.opts.notificationAdapter.notify(notifEvent);
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
this.logger.error("notificationAdapter.notify threw", err, { tenantId: this.tenantId, instanceId: instance.id });
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
async runExternalAudit(instance, entry) {
|
|
1560
|
+
if (!this.opts.auditAdapter) return;
|
|
1561
|
+
try {
|
|
1562
|
+
await this.opts.auditAdapter.append(this.tenantId, instance.id, entry, instance);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
this.logger.error("auditAdapter.append threw", err, { tenantId: this.tenantId, instanceId: instance.id });
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
function defaultIdempotencyKeyFn(tenantId, documentType, documentId, templateName, _data) {
|
|
1569
|
+
return crypto.createHash("sha256").update(`${tenantId}:${documentType}:${documentId}:${templateName}`).digest("hex");
|
|
1570
|
+
}
|
|
1571
|
+
function snapshotLevel(level) {
|
|
1572
|
+
return {
|
|
1573
|
+
approverIds: [...level.approverIds],
|
|
1574
|
+
approvedBy: [...level.approvedBy],
|
|
1575
|
+
rejectedBy: [...level.rejectedBy],
|
|
1576
|
+
status: level.status
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
function sleep(ms) {
|
|
1580
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1581
|
+
}
|
|
1582
|
+
function parseOrThrow(fn) {
|
|
1583
|
+
try {
|
|
1584
|
+
return fn();
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
throw new ApprovalValidationError(
|
|
1587
|
+
err instanceof Error ? err.message : "Invalid input",
|
|
1588
|
+
err
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/adapters/MemoryAdapter.ts
|
|
1594
|
+
function deepClone(value) {
|
|
1595
|
+
return JSON.parse(JSON.stringify(value));
|
|
1596
|
+
}
|
|
1597
|
+
function reviveDates(instance) {
|
|
1598
|
+
return {
|
|
1599
|
+
...instance,
|
|
1600
|
+
createdAt: new Date(instance.createdAt),
|
|
1601
|
+
updatedAt: new Date(instance.updatedAt),
|
|
1602
|
+
expiresAt: instance.expiresAt ? new Date(instance.expiresAt) : void 0,
|
|
1603
|
+
slaDeadlineAt: instance.slaDeadlineAt ? new Date(instance.slaDeadlineAt) : void 0,
|
|
1604
|
+
slaBreachedAt: instance.slaBreachedAt ? new Date(instance.slaBreachedAt) : void 0,
|
|
1605
|
+
auditLog: instance.auditLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })),
|
|
1606
|
+
levels: instance.levels.map((l) => {
|
|
1607
|
+
const level = { ...l };
|
|
1608
|
+
if (l.escalationDueAt) level.escalationDueAt = new Date(l.escalationDueAt);
|
|
1609
|
+
if (l.delegatedUntil) level.delegatedUntil = new Date(l.delegatedUntil);
|
|
1610
|
+
return level;
|
|
1611
|
+
})
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function applyFilter(instance, filter) {
|
|
1615
|
+
if (filter.status && instance.status !== filter.status) return false;
|
|
1616
|
+
if (filter.documentType && instance.documentType !== filter.documentType) return false;
|
|
1617
|
+
if (filter.submittedBy && instance.submittedBy !== filter.submittedBy) return false;
|
|
1618
|
+
if (filter.fromDate && instance.createdAt < filter.fromDate) return false;
|
|
1619
|
+
if (filter.toDate && instance.createdAt > filter.toDate) return false;
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
var MemoryAdapter = class {
|
|
1623
|
+
constructor() {
|
|
1624
|
+
// keyed by `${tenantId}:${template.name}`
|
|
1625
|
+
this.templates = /* @__PURE__ */ new Map();
|
|
1626
|
+
// keyed by `${tenantId}:${instance.id}`
|
|
1627
|
+
this.instances = /* @__PURE__ */ new Map();
|
|
1628
|
+
}
|
|
1629
|
+
async saveTemplate(template) {
|
|
1630
|
+
this.templates.set(`${template.tenantId}:${template.name}`, deepClone(template));
|
|
1631
|
+
}
|
|
1632
|
+
async getTemplate(tenantId, name) {
|
|
1633
|
+
return deepClone(this.templates.get(`${tenantId}:${name}`) ?? null);
|
|
1634
|
+
}
|
|
1635
|
+
async listTemplates(tenantId) {
|
|
1636
|
+
const result = [];
|
|
1637
|
+
for (const [key, template] of this.templates) {
|
|
1638
|
+
if (key.startsWith(`${tenantId}:`)) {
|
|
1639
|
+
result.push(deepClone(template));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return result;
|
|
1643
|
+
}
|
|
1644
|
+
async saveInstance(instance) {
|
|
1645
|
+
this.instances.set(`${instance.tenantId}:${instance.id}`, deepClone(instance));
|
|
1646
|
+
}
|
|
1647
|
+
async updateInstance(instance, expectedVersion) {
|
|
1648
|
+
const key = `${instance.tenantId}:${instance.id}`;
|
|
1649
|
+
const stored = this.instances.get(key);
|
|
1650
|
+
if (!stored) throw new ApprovalConflictError(instance.id);
|
|
1651
|
+
if (stored.version !== expectedVersion) throw new ApprovalConflictError(instance.id);
|
|
1652
|
+
const updated = deepClone(instance);
|
|
1653
|
+
updated.version = expectedVersion + 1;
|
|
1654
|
+
this.instances.set(key, updated);
|
|
1655
|
+
}
|
|
1656
|
+
async getInstance(tenantId, id) {
|
|
1657
|
+
const raw = this.instances.get(`${tenantId}:${id}`);
|
|
1658
|
+
if (!raw) return null;
|
|
1659
|
+
return reviveDates(deepClone(raw));
|
|
1660
|
+
}
|
|
1661
|
+
async getInstancesByApprover(tenantId, approverId, opts) {
|
|
1662
|
+
const all = [...this.instances.values()].filter((i) => {
|
|
1663
|
+
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
1664
|
+
const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
|
|
1665
|
+
return currentLevel?.approverIds.includes(approverId) ?? false;
|
|
1666
|
+
});
|
|
1667
|
+
return paginate(all.map((i) => reviveDates(deepClone(i))), opts);
|
|
1668
|
+
}
|
|
1669
|
+
async getInstancesByFilter(tenantId, filter, opts) {
|
|
1670
|
+
const all = [...this.instances.values()].filter(
|
|
1671
|
+
(i) => i.tenantId === tenantId && applyFilter(i, filter)
|
|
1672
|
+
);
|
|
1673
|
+
return paginate(all.map((i) => reviveDates(deepClone(i))), opts);
|
|
1674
|
+
}
|
|
1675
|
+
async getOverdueInstances(tenantId, asOf) {
|
|
1676
|
+
return [...this.instances.values()].filter((i) => {
|
|
1677
|
+
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
1678
|
+
const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
|
|
1679
|
+
const hasOverdueEscalation = currentLevel?.escalationDueAt != null && new Date(currentLevel.escalationDueAt) <= asOf;
|
|
1680
|
+
const isExpired = i.expiresAt != null && new Date(i.expiresAt) <= asOf;
|
|
1681
|
+
const hasSLABreach = i.slaDeadlineAt != null && new Date(i.slaDeadlineAt) <= asOf && !i.slaBreachedAt;
|
|
1682
|
+
const hasDelegationExpiry = i.levels.some(
|
|
1683
|
+
(l) => l.status === "pending" && l.delegatedUntil != null && new Date(l.delegatedUntil) <= asOf && l.delegatedFrom != null
|
|
1684
|
+
);
|
|
1685
|
+
return hasOverdueEscalation || isExpired || hasSLABreach || hasDelegationExpiry;
|
|
1686
|
+
}).map((i) => reviveDates(deepClone(i)));
|
|
1687
|
+
}
|
|
1688
|
+
async getInstancesByCursor(tenantId, filter, opts) {
|
|
1689
|
+
const all = [...this.instances.values()].filter((i) => i.tenantId === tenantId && applyFilter(i, filter)).map((i) => reviveDates(deepClone(i))).sort((a, b) => {
|
|
1690
|
+
const ta = a.updatedAt.getTime();
|
|
1691
|
+
const tb = b.updatedAt.getTime();
|
|
1692
|
+
return ta !== tb ? ta - tb : a.id.localeCompare(b.id);
|
|
1693
|
+
});
|
|
1694
|
+
const { cursor, limit, direction = "forward" } = opts;
|
|
1695
|
+
let startIdx = 0;
|
|
1696
|
+
if (cursor) {
|
|
1697
|
+
const [ts, id] = decodeCursor(cursor);
|
|
1698
|
+
const idx = all.findIndex(
|
|
1699
|
+
(i) => i.updatedAt.getTime() > ts || i.updatedAt.getTime() === ts && i.id > id
|
|
1700
|
+
);
|
|
1701
|
+
startIdx = idx === -1 ? all.length : idx;
|
|
1702
|
+
}
|
|
1703
|
+
if (direction === "backward" && startIdx > 0) {
|
|
1704
|
+
startIdx = Math.max(0, startIdx - limit - 1);
|
|
1705
|
+
}
|
|
1706
|
+
const slice = all.slice(startIdx, startIdx + limit);
|
|
1707
|
+
const hasMore = startIdx + limit < all.length;
|
|
1708
|
+
const nextCursor = hasMore ? encodeCursor(slice[slice.length - 1]) : void 0;
|
|
1709
|
+
const prevCursor = startIdx > 0 ? encodeCursor(all[startIdx - 1]) : void 0;
|
|
1710
|
+
return { items: slice, nextCursor, prevCursor, hasMore };
|
|
1711
|
+
}
|
|
1712
|
+
async getIdempotentInstance(tenantId, idempotencyKey) {
|
|
1713
|
+
for (const instance of this.instances.values()) {
|
|
1714
|
+
if (instance.tenantId === tenantId && instance.idempotencyKey === idempotencyKey) {
|
|
1715
|
+
return reviveDates(deepClone(instance));
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
async appendAuditEntry(tenantId, instanceId, entry) {
|
|
1721
|
+
const key = `${tenantId}:${instanceId}`;
|
|
1722
|
+
const instance = this.instances.get(key);
|
|
1723
|
+
if (!instance) return;
|
|
1724
|
+
instance.auditLog.push(deepClone(entry));
|
|
1725
|
+
instance.updatedAt = new Date(entry.timestamp);
|
|
1726
|
+
}
|
|
1727
|
+
/** Test helper — total stored instances across all tenants. */
|
|
1728
|
+
get size() {
|
|
1729
|
+
return this.instances.size;
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
function paginate(items, opts) {
|
|
1733
|
+
const total = items.length;
|
|
1734
|
+
if (!opts) return { items, total };
|
|
1735
|
+
return { items: items.slice(opts.offset, opts.offset + opts.limit), total };
|
|
1736
|
+
}
|
|
1737
|
+
function encodeCursor(instance) {
|
|
1738
|
+
return Buffer.from(`${instance.updatedAt.getTime()}|${instance.id}`).toString("base64");
|
|
1739
|
+
}
|
|
1740
|
+
function decodeCursor(cursor) {
|
|
1741
|
+
const decoded = Buffer.from(cursor, "base64").toString("utf8");
|
|
1742
|
+
const pipeIdx = decoded.indexOf("|");
|
|
1743
|
+
return [Number(decoded.slice(0, pipeIdx)), decoded.slice(pipeIdx + 1)];
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
exports.ApprovalConflictError = ApprovalConflictError;
|
|
1747
|
+
exports.ApprovalEngine = ApprovalEngine;
|
|
1748
|
+
exports.ApprovalError = ApprovalError;
|
|
1749
|
+
exports.ApprovalForbiddenError = ApprovalForbiddenError;
|
|
1750
|
+
exports.ApprovalNotFoundError = ApprovalNotFoundError;
|
|
1751
|
+
exports.ApprovalTemplateNotFoundError = ApprovalTemplateNotFoundError;
|
|
1752
|
+
exports.ApprovalValidationError = ApprovalValidationError;
|
|
1753
|
+
exports.EscalationScheduler = EscalationScheduler;
|
|
1754
|
+
exports.MemoryAdapter = MemoryAdapter;
|
|
1755
|
+
exports.defaultIdGenerator = defaultIdGenerator;
|
|
1756
|
+
exports.noopLogger = noopLogger;
|
|
1757
|
+
exports.systemClock = systemClock;
|
|
1758
|
+
//# sourceMappingURL=index.cjs.map
|
|
1759
|
+
//# sourceMappingURL=index.cjs.map
|