hierarchical-approval 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/{ApprovalEngine-DKn7KymS.d.ts → ApprovalEngine-B3PVWw05.d.ts} +68 -2
- package/dist/{ApprovalEngine-D6BmYI8B.d.cts → ApprovalEngine-DMM55f5R.d.cts} +68 -2
- package/dist/index.cjs +399 -282
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +399 -282
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +377 -56
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +1 -1
- package/dist/nestjs.d.ts +1 -1
- package/dist/nestjs.js +377 -56
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +399 -282
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +399 -282
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -122,6 +122,266 @@ var UpdateDataOptionsSchema = zod.z.object({
|
|
|
122
122
|
/** Re-evaluate template conditions against the new data (default true). */
|
|
123
123
|
recomputeChain: zod.z.boolean().default(true)
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
// src/errors.ts
|
|
127
|
+
var ApprovalError = class extends Error {
|
|
128
|
+
constructor(message, code) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.code = code;
|
|
131
|
+
this.name = "ApprovalError";
|
|
132
|
+
}
|
|
133
|
+
toJSON() {
|
|
134
|
+
return { code: this.code, message: this.message, name: this.name };
|
|
135
|
+
}
|
|
136
|
+
toHttpStatus() {
|
|
137
|
+
const map = {
|
|
138
|
+
NOT_FOUND: 404,
|
|
139
|
+
CONFLICT: 409,
|
|
140
|
+
FORBIDDEN: 403,
|
|
141
|
+
VALIDATION: 422,
|
|
142
|
+
TEMPLATE_NOT_FOUND: 404
|
|
143
|
+
};
|
|
144
|
+
return map[this.code] ?? 500;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var ApprovalNotFoundError = class extends ApprovalError {
|
|
148
|
+
constructor(resource, id) {
|
|
149
|
+
super(`${resource} "${id}" not found.`, "NOT_FOUND");
|
|
150
|
+
this.name = "ApprovalNotFoundError";
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
var ApprovalConflictError = class extends ApprovalError {
|
|
154
|
+
constructor(instanceId) {
|
|
155
|
+
super(
|
|
156
|
+
`Concurrent modification detected on instance "${instanceId}". The record was updated by another process. Please retry.`,
|
|
157
|
+
"CONFLICT"
|
|
158
|
+
);
|
|
159
|
+
this.name = "ApprovalConflictError";
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var ApprovalForbiddenError = class extends ApprovalError {
|
|
163
|
+
constructor(message) {
|
|
164
|
+
super(message, "FORBIDDEN");
|
|
165
|
+
this.name = "ApprovalForbiddenError";
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
var ApprovalValidationError = class extends ApprovalError {
|
|
169
|
+
constructor(message, cause) {
|
|
170
|
+
super(message, "VALIDATION");
|
|
171
|
+
this.cause = cause;
|
|
172
|
+
this.name = "ApprovalValidationError";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var ApprovalTemplateNotFoundError = class extends ApprovalError {
|
|
176
|
+
constructor(name) {
|
|
177
|
+
super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
|
|
178
|
+
this.name = "ApprovalTemplateNotFoundError";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/adapters/MemoryAdapter.ts
|
|
183
|
+
function deepClone(value) {
|
|
184
|
+
return JSON.parse(JSON.stringify(value));
|
|
185
|
+
}
|
|
186
|
+
function reviveDates(instance) {
|
|
187
|
+
return {
|
|
188
|
+
...instance,
|
|
189
|
+
createdAt: new Date(instance.createdAt),
|
|
190
|
+
updatedAt: new Date(instance.updatedAt),
|
|
191
|
+
expiresAt: instance.expiresAt ? new Date(instance.expiresAt) : void 0,
|
|
192
|
+
slaDeadlineAt: instance.slaDeadlineAt ? new Date(instance.slaDeadlineAt) : void 0,
|
|
193
|
+
slaBreachedAt: instance.slaBreachedAt ? new Date(instance.slaBreachedAt) : void 0,
|
|
194
|
+
auditLog: instance.auditLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })),
|
|
195
|
+
infoRequest: instance.infoRequest ? { ...instance.infoRequest, askedAt: new Date(instance.infoRequest.askedAt) } : void 0,
|
|
196
|
+
attachments: instance.attachments ? instance.attachments.map((a) => ({ ...a, addedAt: new Date(a.addedAt) })) : void 0,
|
|
197
|
+
comments: instance.comments ? instance.comments.map((c) => ({ ...c, createdAt: new Date(c.createdAt) })) : void 0,
|
|
198
|
+
levels: instance.levels.map((l) => {
|
|
199
|
+
const level = { ...l };
|
|
200
|
+
if (l.escalationDueAt) level.escalationDueAt = new Date(l.escalationDueAt);
|
|
201
|
+
if (l.delegatedUntil) level.delegatedUntil = new Date(l.delegatedUntil);
|
|
202
|
+
if (l.reminderDueAt) level.reminderDueAt = new Date(l.reminderDueAt);
|
|
203
|
+
return level;
|
|
204
|
+
})
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function reviveTemplateDates(template) {
|
|
208
|
+
return { ...template, createdAt: new Date(template.createdAt) };
|
|
209
|
+
}
|
|
210
|
+
function readPath(data, path) {
|
|
211
|
+
return path.split(".").reduce((obj, key) => {
|
|
212
|
+
if (obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
213
|
+
return obj[key];
|
|
214
|
+
}
|
|
215
|
+
return void 0;
|
|
216
|
+
}, data);
|
|
217
|
+
}
|
|
218
|
+
function deepEquals(a, b) {
|
|
219
|
+
if (Object.is(a, b)) return true;
|
|
220
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
221
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
222
|
+
const aKeys = Object.keys(a);
|
|
223
|
+
const bKeys = Object.keys(b);
|
|
224
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
225
|
+
return aKeys.every(
|
|
226
|
+
(k) => Object.prototype.hasOwnProperty.call(b, k) && deepEquals(a[k], b[k])
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
function applyFilter(instance, filter) {
|
|
230
|
+
if (filter.status && instance.status !== filter.status) return false;
|
|
231
|
+
if (filter.documentType && instance.documentType !== filter.documentType) return false;
|
|
232
|
+
if (filter.submittedBy && instance.submittedBy !== filter.submittedBy) return false;
|
|
233
|
+
if (filter.templateName && instance.templateName !== filter.templateName) return false;
|
|
234
|
+
if (filter.fromDate && new Date(instance.createdAt) < filter.fromDate) return false;
|
|
235
|
+
if (filter.toDate && new Date(instance.createdAt) > filter.toDate) return false;
|
|
236
|
+
if (filter.data) {
|
|
237
|
+
for (const [path, expected] of Object.entries(filter.data)) {
|
|
238
|
+
if (!deepEquals(readPath(instance.data ?? {}, path), expected)) return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
var MemoryAdapter = class {
|
|
244
|
+
constructor() {
|
|
245
|
+
// keyed by `${tenantId}:${template.name}`
|
|
246
|
+
this.templates = /* @__PURE__ */ new Map();
|
|
247
|
+
// keyed by `${tenantId}:${instance.id}`
|
|
248
|
+
this.instances = /* @__PURE__ */ new Map();
|
|
249
|
+
}
|
|
250
|
+
async saveTemplate(template) {
|
|
251
|
+
this.templates.set(`${template.tenantId}:${template.name}`, deepClone(template));
|
|
252
|
+
}
|
|
253
|
+
async getTemplate(tenantId, name) {
|
|
254
|
+
const template = this.templates.get(`${tenantId}:${name}`);
|
|
255
|
+
return template ? reviveTemplateDates(deepClone(template)) : null;
|
|
256
|
+
}
|
|
257
|
+
async listTemplates(tenantId) {
|
|
258
|
+
const result = [];
|
|
259
|
+
for (const [key, template] of this.templates) {
|
|
260
|
+
if (key.startsWith(`${tenantId}:`)) {
|
|
261
|
+
result.push(reviveTemplateDates(deepClone(template)));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
async saveInstance(instance) {
|
|
267
|
+
this.instances.set(`${instance.tenantId}:${instance.id}`, deepClone(instance));
|
|
268
|
+
}
|
|
269
|
+
async updateInstance(instance, expectedVersion) {
|
|
270
|
+
const key = `${instance.tenantId}:${instance.id}`;
|
|
271
|
+
const stored = this.instances.get(key);
|
|
272
|
+
if (!stored) throw new ApprovalConflictError(instance.id);
|
|
273
|
+
if (stored.version !== expectedVersion) throw new ApprovalConflictError(instance.id);
|
|
274
|
+
const updated = deepClone(instance);
|
|
275
|
+
updated.version = expectedVersion + 1;
|
|
276
|
+
this.instances.set(key, updated);
|
|
277
|
+
}
|
|
278
|
+
async getInstance(tenantId, id) {
|
|
279
|
+
const raw = this.instances.get(`${tenantId}:${id}`);
|
|
280
|
+
if (!raw) return null;
|
|
281
|
+
return reviveDates(deepClone(raw));
|
|
282
|
+
}
|
|
283
|
+
async getInstancesByApprover(tenantId, approverId, opts) {
|
|
284
|
+
const all = [...this.instances.values()].filter((i) => {
|
|
285
|
+
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
286
|
+
const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
|
|
287
|
+
return currentLevel?.approverIds.includes(approverId) ?? false;
|
|
288
|
+
});
|
|
289
|
+
return paginate(
|
|
290
|
+
all.map((i) => reviveDates(deepClone(i))),
|
|
291
|
+
opts
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
async getInstancesByFilter(tenantId, filter, opts) {
|
|
295
|
+
const all = [...this.instances.values()].filter(
|
|
296
|
+
(i) => i.tenantId === tenantId && applyFilter(i, filter)
|
|
297
|
+
);
|
|
298
|
+
return paginate(
|
|
299
|
+
all.map((i) => reviveDates(deepClone(i))),
|
|
300
|
+
opts
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
async getOverdueInstances(tenantId, asOf, filter = {}) {
|
|
304
|
+
return [...this.instances.values()].filter((i) => {
|
|
305
|
+
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
306
|
+
if (filter.documentType && i.documentType !== filter.documentType) return false;
|
|
307
|
+
if (filter.submittedBy && i.submittedBy !== filter.submittedBy) return false;
|
|
308
|
+
const hasOverdueEscalation = i.levels.some(
|
|
309
|
+
(l) => l.escalationDueAt != null && new Date(l.escalationDueAt) <= asOf
|
|
310
|
+
);
|
|
311
|
+
const isExpired = i.expiresAt != null && new Date(i.expiresAt) <= asOf;
|
|
312
|
+
const hasSLABreach = i.slaDeadlineAt != null && new Date(i.slaDeadlineAt) <= asOf && !i.slaBreachedAt;
|
|
313
|
+
const hasDelegationExpiry = i.levels.some(
|
|
314
|
+
(l) => l.status === "pending" && l.delegatedUntil != null && new Date(l.delegatedUntil) <= asOf && l.delegatedFrom != null
|
|
315
|
+
);
|
|
316
|
+
const hasDueReminder = i.levels.some(
|
|
317
|
+
(l) => l.status === "pending" && l.reminderDueAt != null && new Date(l.reminderDueAt) <= asOf
|
|
318
|
+
);
|
|
319
|
+
return hasOverdueEscalation || isExpired || hasSLABreach || hasDelegationExpiry || hasDueReminder;
|
|
320
|
+
}).map((i) => reviveDates(deepClone(i)));
|
|
321
|
+
}
|
|
322
|
+
async deleteInstance(tenantId, id) {
|
|
323
|
+
const key = `${tenantId}:${id}`;
|
|
324
|
+
return this.instances.delete(key);
|
|
325
|
+
}
|
|
326
|
+
async countInstances(tenantId, filter) {
|
|
327
|
+
let count = 0;
|
|
328
|
+
for (const instance of this.instances.values()) {
|
|
329
|
+
if (instance.tenantId === tenantId && applyFilter(instance, filter)) count++;
|
|
330
|
+
}
|
|
331
|
+
return count;
|
|
332
|
+
}
|
|
333
|
+
async getInstancesByCursor(tenantId, filter, opts) {
|
|
334
|
+
const all = [...this.instances.values()].filter((i) => i.tenantId === tenantId && applyFilter(i, filter)).map((i) => reviveDates(deepClone(i))).sort((a, b) => {
|
|
335
|
+
const ta = a.updatedAt.getTime();
|
|
336
|
+
const tb = b.updatedAt.getTime();
|
|
337
|
+
return ta !== tb ? ta - tb : a.id.localeCompare(b.id);
|
|
338
|
+
});
|
|
339
|
+
const { cursor, limit, direction = "forward" } = opts;
|
|
340
|
+
let startIdx = 0;
|
|
341
|
+
if (cursor) {
|
|
342
|
+
const [ts, id] = decodeCursor(cursor);
|
|
343
|
+
const idx = all.findIndex(
|
|
344
|
+
(i) => i.updatedAt.getTime() > ts || i.updatedAt.getTime() === ts && i.id > id
|
|
345
|
+
);
|
|
346
|
+
startIdx = idx === -1 ? all.length : idx;
|
|
347
|
+
}
|
|
348
|
+
if (direction === "backward" && startIdx > 0) {
|
|
349
|
+
startIdx = Math.max(0, startIdx - limit - 1);
|
|
350
|
+
}
|
|
351
|
+
const slice = all.slice(startIdx, startIdx + limit);
|
|
352
|
+
const hasMore = startIdx + limit < all.length;
|
|
353
|
+
const nextCursor = hasMore ? encodeCursor(slice[slice.length - 1]) : void 0;
|
|
354
|
+
const prevCursor = startIdx > 0 ? encodeCursor(all[startIdx - 1]) : void 0;
|
|
355
|
+
return { items: slice, nextCursor, prevCursor, hasMore };
|
|
356
|
+
}
|
|
357
|
+
async getIdempotentInstance(tenantId, idempotencyKey) {
|
|
358
|
+
for (const instance of this.instances.values()) {
|
|
359
|
+
if (instance.tenantId === tenantId && instance.idempotencyKey === idempotencyKey) {
|
|
360
|
+
return reviveDates(deepClone(instance));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
async appendAuditEntry(_tenantId, _instanceId, _entry) {
|
|
366
|
+
}
|
|
367
|
+
/** Test helper — total stored instances across all tenants. */
|
|
368
|
+
get size() {
|
|
369
|
+
return this.instances.size;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
function paginate(items, opts) {
|
|
373
|
+
const total = items.length;
|
|
374
|
+
if (!opts) return { items, total };
|
|
375
|
+
return { items: items.slice(opts.offset, opts.offset + opts.limit), total };
|
|
376
|
+
}
|
|
377
|
+
function encodeCursor(instance) {
|
|
378
|
+
return Buffer.from(`${instance.updatedAt.getTime()}|${instance.id}`).toString("base64");
|
|
379
|
+
}
|
|
380
|
+
function decodeCursor(cursor) {
|
|
381
|
+
const decoded = Buffer.from(cursor, "base64").toString("utf8");
|
|
382
|
+
const pipeIdx = decoded.indexOf("|");
|
|
383
|
+
return [Number(decoded.slice(0, pipeIdx)), decoded.slice(pipeIdx + 1)];
|
|
384
|
+
}
|
|
125
385
|
var EventBus = class {
|
|
126
386
|
constructor() {
|
|
127
387
|
this.emitter = new EventEmitter__default.default();
|
|
@@ -179,89 +439,33 @@ var EventBus = class {
|
|
|
179
439
|
return wrapped;
|
|
180
440
|
}
|
|
181
441
|
forget(event, listener) {
|
|
182
|
-
const perEvent = this.wrappers.get(event);
|
|
183
|
-
if (!perEvent) return;
|
|
184
|
-
perEvent.delete(listener);
|
|
185
|
-
if (perEvent.size === 0) this.wrappers.delete(event);
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// src/utils/Logger.ts
|
|
190
|
-
var noopLogger = {
|
|
191
|
-
info: () => {
|
|
192
|
-
},
|
|
193
|
-
warn: () => {
|
|
194
|
-
},
|
|
195
|
-
error: () => {
|
|
196
|
-
},
|
|
197
|
-
fatal: () => {
|
|
198
|
-
},
|
|
199
|
-
debug: () => {
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
// src/utils/Clock.ts
|
|
204
|
-
var systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
205
|
-
|
|
206
|
-
// src/utils/IdGenerator.ts
|
|
207
|
-
var defaultIdGenerator = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
208
|
-
|
|
209
|
-
// src/errors.ts
|
|
210
|
-
var ApprovalError = class extends Error {
|
|
211
|
-
constructor(message, code) {
|
|
212
|
-
super(message);
|
|
213
|
-
this.code = code;
|
|
214
|
-
this.name = "ApprovalError";
|
|
215
|
-
}
|
|
216
|
-
toJSON() {
|
|
217
|
-
return { code: this.code, message: this.message, name: this.name };
|
|
218
|
-
}
|
|
219
|
-
toHttpStatus() {
|
|
220
|
-
const map = {
|
|
221
|
-
NOT_FOUND: 404,
|
|
222
|
-
CONFLICT: 409,
|
|
223
|
-
FORBIDDEN: 403,
|
|
224
|
-
VALIDATION: 422,
|
|
225
|
-
TEMPLATE_NOT_FOUND: 404
|
|
226
|
-
};
|
|
227
|
-
return map[this.code] ?? 500;
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
var ApprovalNotFoundError = class extends ApprovalError {
|
|
231
|
-
constructor(resource, id) {
|
|
232
|
-
super(`${resource} "${id}" not found.`, "NOT_FOUND");
|
|
233
|
-
this.name = "ApprovalNotFoundError";
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
var ApprovalConflictError = class extends ApprovalError {
|
|
237
|
-
constructor(instanceId) {
|
|
238
|
-
super(
|
|
239
|
-
`Concurrent modification detected on instance "${instanceId}". The record was updated by another process. Please retry.`,
|
|
240
|
-
"CONFLICT"
|
|
241
|
-
);
|
|
242
|
-
this.name = "ApprovalConflictError";
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
var ApprovalForbiddenError = class extends ApprovalError {
|
|
246
|
-
constructor(message) {
|
|
247
|
-
super(message, "FORBIDDEN");
|
|
248
|
-
this.name = "ApprovalForbiddenError";
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
var ApprovalValidationError = class extends ApprovalError {
|
|
252
|
-
constructor(message, cause) {
|
|
253
|
-
super(message, "VALIDATION");
|
|
254
|
-
this.cause = cause;
|
|
255
|
-
this.name = "ApprovalValidationError";
|
|
442
|
+
const perEvent = this.wrappers.get(event);
|
|
443
|
+
if (!perEvent) return;
|
|
444
|
+
perEvent.delete(listener);
|
|
445
|
+
if (perEvent.size === 0) this.wrappers.delete(event);
|
|
256
446
|
}
|
|
257
447
|
};
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
448
|
+
|
|
449
|
+
// src/utils/Logger.ts
|
|
450
|
+
var noopLogger = {
|
|
451
|
+
info: () => {
|
|
452
|
+
},
|
|
453
|
+
warn: () => {
|
|
454
|
+
},
|
|
455
|
+
error: () => {
|
|
456
|
+
},
|
|
457
|
+
fatal: () => {
|
|
458
|
+
},
|
|
459
|
+
debug: () => {
|
|
262
460
|
}
|
|
263
461
|
};
|
|
264
462
|
|
|
463
|
+
// src/utils/Clock.ts
|
|
464
|
+
var systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
465
|
+
|
|
466
|
+
// src/utils/IdGenerator.ts
|
|
467
|
+
var defaultIdGenerator = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
468
|
+
|
|
265
469
|
// src/engine/TemplateRegistry.ts
|
|
266
470
|
var TemplateRegistry = class {
|
|
267
471
|
constructor(adapter, tenantId, opts) {
|
|
@@ -389,6 +593,17 @@ var LevelResolver = class {
|
|
|
389
593
|
registerApproverType(typeName, fn) {
|
|
390
594
|
this.approverTypes.set(typeName, fn);
|
|
391
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* Copy every custom resolver and approver type onto another resolver.
|
|
598
|
+
*
|
|
599
|
+
* Used when the engine builds a throwaway copy of itself for a simulation: a
|
|
600
|
+
* simulation that could not resolve the caller's own `dynamic` approvers
|
|
601
|
+
* would answer a different question from the one asked.
|
|
602
|
+
*/
|
|
603
|
+
copyRegistrationsTo(target) {
|
|
604
|
+
for (const [name, fn] of this.resolvers) target.register(name, fn);
|
|
605
|
+
for (const [name, fn] of this.approverTypes) target.registerApproverType(name, fn);
|
|
606
|
+
}
|
|
392
607
|
async resolveApprovers(approvers, submittedBy, data, orgProvider, outOfOffice, at) {
|
|
393
608
|
const resolved = [];
|
|
394
609
|
for (const approver of approvers) {
|
|
@@ -2837,6 +3052,112 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2837
3052
|
})).sort((a, b) => a.level - b.level);
|
|
2838
3053
|
return { templateName: template.name, levels, skipped, rules };
|
|
2839
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Run a document through a template against scripted decisions, without
|
|
3057
|
+
* persisting anything.
|
|
3058
|
+
*
|
|
3059
|
+
* `explainChain()` says what the chain will be; this says what happens to it —
|
|
3060
|
+
* "if the CFO rejects at level 3, does it go back to the submitter or die?" —
|
|
3061
|
+
* which previously meant submitting a real approval into a real store and
|
|
3062
|
+
* cleaning it up afterwards, or reasoning about the state machine by hand.
|
|
3063
|
+
*
|
|
3064
|
+
* The run executes against a private in-memory store seeded with a copy of
|
|
3065
|
+
* the template, so the caller's storage is untouched and no events reach the
|
|
3066
|
+
* caller's notification adapters. Custom resolvers and approver types are
|
|
3067
|
+
* copied across, because a simulation that could not resolve the caller's own
|
|
3068
|
+
* `dynamic` approvers would answer a different question from the one asked.
|
|
3069
|
+
*
|
|
3070
|
+
* A refused decision — wrong approver, wrong level, already acted — stops the
|
|
3071
|
+
* run and is reported in the transcript rather than thrown: the refusal is
|
|
3072
|
+
* usually the answer the caller was looking for.
|
|
3073
|
+
*
|
|
3074
|
+
* @param opts - Template, document data, submitter and the decisions to play.
|
|
3075
|
+
*/
|
|
3076
|
+
async simulate(opts) {
|
|
3077
|
+
const template = await this.registry.get(opts.templateName);
|
|
3078
|
+
const scratch = new _ApprovalEngine({
|
|
3079
|
+
...this.opts,
|
|
3080
|
+
adapter: new MemoryAdapter(),
|
|
3081
|
+
// A simulation must not page anybody, write anybody's audit log, or move
|
|
3082
|
+
// anybody's metrics.
|
|
3083
|
+
notificationAdapter: void 0,
|
|
3084
|
+
auditAdapter: void 0,
|
|
3085
|
+
metricsAdapter: void 0,
|
|
3086
|
+
authorizationPolicy: void 0,
|
|
3087
|
+
middleware: void 0,
|
|
3088
|
+
escalationPollIntervalMs: void 0
|
|
3089
|
+
});
|
|
3090
|
+
this.resolver.copyRegistrationsTo(scratch.resolver);
|
|
3091
|
+
try {
|
|
3092
|
+
const {
|
|
3093
|
+
id: _id,
|
|
3094
|
+
tenantId: _t,
|
|
3095
|
+
createdAt: _c,
|
|
3096
|
+
version: _v,
|
|
3097
|
+
previousVersionId: _p,
|
|
3098
|
+
...config
|
|
3099
|
+
} = template;
|
|
3100
|
+
await scratch.registry.define(config);
|
|
3101
|
+
const instance = await scratch.submit({
|
|
3102
|
+
templateName: opts.templateName,
|
|
3103
|
+
documentId: `simulation-${opts.templateName}`,
|
|
3104
|
+
documentType: template.documentType,
|
|
3105
|
+
submittedBy: opts.submittedBy,
|
|
3106
|
+
data: opts.data,
|
|
3107
|
+
metadata: {}
|
|
3108
|
+
});
|
|
3109
|
+
const transcript = [];
|
|
3110
|
+
let current = instance;
|
|
3111
|
+
for (const [i, decision] of (opts.decisions ?? []).entries()) {
|
|
3112
|
+
const isApprove = "approve" in decision;
|
|
3113
|
+
const actorId = isApprove ? decision.approve : decision.reject;
|
|
3114
|
+
try {
|
|
3115
|
+
current = isApprove ? await scratch.approve(current.id, {
|
|
3116
|
+
approverId: actorId,
|
|
3117
|
+
level: decision.level,
|
|
3118
|
+
comment: decision.comment
|
|
3119
|
+
}) : await scratch.reject(current.id, {
|
|
3120
|
+
approverId: actorId,
|
|
3121
|
+
level: decision.level,
|
|
3122
|
+
reason: decision.reason ?? "simulated rejection"
|
|
3123
|
+
});
|
|
3124
|
+
transcript.push({
|
|
3125
|
+
step: i + 1,
|
|
3126
|
+
action: isApprove ? "approve" : "reject",
|
|
3127
|
+
actorId,
|
|
3128
|
+
level: decision.level,
|
|
3129
|
+
status: current.status
|
|
3130
|
+
});
|
|
3131
|
+
} catch (err) {
|
|
3132
|
+
transcript.push({
|
|
3133
|
+
step: i + 1,
|
|
3134
|
+
action: isApprove ? "approve" : "reject",
|
|
3135
|
+
actorId,
|
|
3136
|
+
level: decision.level,
|
|
3137
|
+
status: current.status,
|
|
3138
|
+
error: err.message
|
|
3139
|
+
});
|
|
3140
|
+
break;
|
|
3141
|
+
}
|
|
3142
|
+
if (TERMINAL_STATUSES.has(current.status)) break;
|
|
3143
|
+
}
|
|
3144
|
+
const finalInstance = await scratch.getInstance(current.id);
|
|
3145
|
+
return {
|
|
3146
|
+
finalStatus: finalInstance.status,
|
|
3147
|
+
levels: finalInstance.levels.map((l) => ({
|
|
3148
|
+
level: l.level,
|
|
3149
|
+
name: l.name,
|
|
3150
|
+
status: l.status,
|
|
3151
|
+
approvers: l.approverIds
|
|
3152
|
+
})),
|
|
3153
|
+
transcript,
|
|
3154
|
+
unreachedLevels: finalInstance.levels.filter((l) => l.status === "waiting").map((l) => l.level),
|
|
3155
|
+
incomplete: finalInstance.status === "pending"
|
|
3156
|
+
};
|
|
3157
|
+
} finally {
|
|
3158
|
+
await scratch.shutdown();
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
2840
3161
|
async canApprove(instanceId, userId) {
|
|
2841
3162
|
let instance;
|
|
2842
3163
|
try {
|
|
@@ -4307,210 +4628,6 @@ function parseOrThrow(fn) {
|
|
|
4307
4628
|
}
|
|
4308
4629
|
}
|
|
4309
4630
|
|
|
4310
|
-
// src/adapters/MemoryAdapter.ts
|
|
4311
|
-
function deepClone(value) {
|
|
4312
|
-
return JSON.parse(JSON.stringify(value));
|
|
4313
|
-
}
|
|
4314
|
-
function reviveDates(instance) {
|
|
4315
|
-
return {
|
|
4316
|
-
...instance,
|
|
4317
|
-
createdAt: new Date(instance.createdAt),
|
|
4318
|
-
updatedAt: new Date(instance.updatedAt),
|
|
4319
|
-
expiresAt: instance.expiresAt ? new Date(instance.expiresAt) : void 0,
|
|
4320
|
-
slaDeadlineAt: instance.slaDeadlineAt ? new Date(instance.slaDeadlineAt) : void 0,
|
|
4321
|
-
slaBreachedAt: instance.slaBreachedAt ? new Date(instance.slaBreachedAt) : void 0,
|
|
4322
|
-
auditLog: instance.auditLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })),
|
|
4323
|
-
infoRequest: instance.infoRequest ? { ...instance.infoRequest, askedAt: new Date(instance.infoRequest.askedAt) } : void 0,
|
|
4324
|
-
attachments: instance.attachments ? instance.attachments.map((a) => ({ ...a, addedAt: new Date(a.addedAt) })) : void 0,
|
|
4325
|
-
comments: instance.comments ? instance.comments.map((c) => ({ ...c, createdAt: new Date(c.createdAt) })) : void 0,
|
|
4326
|
-
levels: instance.levels.map((l) => {
|
|
4327
|
-
const level = { ...l };
|
|
4328
|
-
if (l.escalationDueAt) level.escalationDueAt = new Date(l.escalationDueAt);
|
|
4329
|
-
if (l.delegatedUntil) level.delegatedUntil = new Date(l.delegatedUntil);
|
|
4330
|
-
if (l.reminderDueAt) level.reminderDueAt = new Date(l.reminderDueAt);
|
|
4331
|
-
return level;
|
|
4332
|
-
})
|
|
4333
|
-
};
|
|
4334
|
-
}
|
|
4335
|
-
function reviveTemplateDates(template) {
|
|
4336
|
-
return { ...template, createdAt: new Date(template.createdAt) };
|
|
4337
|
-
}
|
|
4338
|
-
function readPath(data, path) {
|
|
4339
|
-
return path.split(".").reduce((obj, key) => {
|
|
4340
|
-
if (obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
4341
|
-
return obj[key];
|
|
4342
|
-
}
|
|
4343
|
-
return void 0;
|
|
4344
|
-
}, data);
|
|
4345
|
-
}
|
|
4346
|
-
function deepEquals(a, b) {
|
|
4347
|
-
if (Object.is(a, b)) return true;
|
|
4348
|
-
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
4349
|
-
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
4350
|
-
const aKeys = Object.keys(a);
|
|
4351
|
-
const bKeys = Object.keys(b);
|
|
4352
|
-
if (aKeys.length !== bKeys.length) return false;
|
|
4353
|
-
return aKeys.every(
|
|
4354
|
-
(k) => Object.prototype.hasOwnProperty.call(b, k) && deepEquals(a[k], b[k])
|
|
4355
|
-
);
|
|
4356
|
-
}
|
|
4357
|
-
function applyFilter(instance, filter) {
|
|
4358
|
-
if (filter.status && instance.status !== filter.status) return false;
|
|
4359
|
-
if (filter.documentType && instance.documentType !== filter.documentType) return false;
|
|
4360
|
-
if (filter.submittedBy && instance.submittedBy !== filter.submittedBy) return false;
|
|
4361
|
-
if (filter.templateName && instance.templateName !== filter.templateName) return false;
|
|
4362
|
-
if (filter.fromDate && new Date(instance.createdAt) < filter.fromDate) return false;
|
|
4363
|
-
if (filter.toDate && new Date(instance.createdAt) > filter.toDate) return false;
|
|
4364
|
-
if (filter.data) {
|
|
4365
|
-
for (const [path, expected] of Object.entries(filter.data)) {
|
|
4366
|
-
if (!deepEquals(readPath(instance.data ?? {}, path), expected)) return false;
|
|
4367
|
-
}
|
|
4368
|
-
}
|
|
4369
|
-
return true;
|
|
4370
|
-
}
|
|
4371
|
-
var MemoryAdapter = class {
|
|
4372
|
-
constructor() {
|
|
4373
|
-
// keyed by `${tenantId}:${template.name}`
|
|
4374
|
-
this.templates = /* @__PURE__ */ new Map();
|
|
4375
|
-
// keyed by `${tenantId}:${instance.id}`
|
|
4376
|
-
this.instances = /* @__PURE__ */ new Map();
|
|
4377
|
-
}
|
|
4378
|
-
async saveTemplate(template) {
|
|
4379
|
-
this.templates.set(`${template.tenantId}:${template.name}`, deepClone(template));
|
|
4380
|
-
}
|
|
4381
|
-
async getTemplate(tenantId, name) {
|
|
4382
|
-
const template = this.templates.get(`${tenantId}:${name}`);
|
|
4383
|
-
return template ? reviveTemplateDates(deepClone(template)) : null;
|
|
4384
|
-
}
|
|
4385
|
-
async listTemplates(tenantId) {
|
|
4386
|
-
const result = [];
|
|
4387
|
-
for (const [key, template] of this.templates) {
|
|
4388
|
-
if (key.startsWith(`${tenantId}:`)) {
|
|
4389
|
-
result.push(reviveTemplateDates(deepClone(template)));
|
|
4390
|
-
}
|
|
4391
|
-
}
|
|
4392
|
-
return result;
|
|
4393
|
-
}
|
|
4394
|
-
async saveInstance(instance) {
|
|
4395
|
-
this.instances.set(`${instance.tenantId}:${instance.id}`, deepClone(instance));
|
|
4396
|
-
}
|
|
4397
|
-
async updateInstance(instance, expectedVersion) {
|
|
4398
|
-
const key = `${instance.tenantId}:${instance.id}`;
|
|
4399
|
-
const stored = this.instances.get(key);
|
|
4400
|
-
if (!stored) throw new ApprovalConflictError(instance.id);
|
|
4401
|
-
if (stored.version !== expectedVersion) throw new ApprovalConflictError(instance.id);
|
|
4402
|
-
const updated = deepClone(instance);
|
|
4403
|
-
updated.version = expectedVersion + 1;
|
|
4404
|
-
this.instances.set(key, updated);
|
|
4405
|
-
}
|
|
4406
|
-
async getInstance(tenantId, id) {
|
|
4407
|
-
const raw = this.instances.get(`${tenantId}:${id}`);
|
|
4408
|
-
if (!raw) return null;
|
|
4409
|
-
return reviveDates(deepClone(raw));
|
|
4410
|
-
}
|
|
4411
|
-
async getInstancesByApprover(tenantId, approverId, opts) {
|
|
4412
|
-
const all = [...this.instances.values()].filter((i) => {
|
|
4413
|
-
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
4414
|
-
const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
|
|
4415
|
-
return currentLevel?.approverIds.includes(approverId) ?? false;
|
|
4416
|
-
});
|
|
4417
|
-
return paginate(
|
|
4418
|
-
all.map((i) => reviveDates(deepClone(i))),
|
|
4419
|
-
opts
|
|
4420
|
-
);
|
|
4421
|
-
}
|
|
4422
|
-
async getInstancesByFilter(tenantId, filter, opts) {
|
|
4423
|
-
const all = [...this.instances.values()].filter(
|
|
4424
|
-
(i) => i.tenantId === tenantId && applyFilter(i, filter)
|
|
4425
|
-
);
|
|
4426
|
-
return paginate(
|
|
4427
|
-
all.map((i) => reviveDates(deepClone(i))),
|
|
4428
|
-
opts
|
|
4429
|
-
);
|
|
4430
|
-
}
|
|
4431
|
-
async getOverdueInstances(tenantId, asOf, filter = {}) {
|
|
4432
|
-
return [...this.instances.values()].filter((i) => {
|
|
4433
|
-
if (i.tenantId !== tenantId || i.status !== "pending") return false;
|
|
4434
|
-
if (filter.documentType && i.documentType !== filter.documentType) return false;
|
|
4435
|
-
if (filter.submittedBy && i.submittedBy !== filter.submittedBy) return false;
|
|
4436
|
-
const hasOverdueEscalation = i.levels.some(
|
|
4437
|
-
(l) => l.escalationDueAt != null && new Date(l.escalationDueAt) <= asOf
|
|
4438
|
-
);
|
|
4439
|
-
const isExpired = i.expiresAt != null && new Date(i.expiresAt) <= asOf;
|
|
4440
|
-
const hasSLABreach = i.slaDeadlineAt != null && new Date(i.slaDeadlineAt) <= asOf && !i.slaBreachedAt;
|
|
4441
|
-
const hasDelegationExpiry = i.levels.some(
|
|
4442
|
-
(l) => l.status === "pending" && l.delegatedUntil != null && new Date(l.delegatedUntil) <= asOf && l.delegatedFrom != null
|
|
4443
|
-
);
|
|
4444
|
-
const hasDueReminder = i.levels.some(
|
|
4445
|
-
(l) => l.status === "pending" && l.reminderDueAt != null && new Date(l.reminderDueAt) <= asOf
|
|
4446
|
-
);
|
|
4447
|
-
return hasOverdueEscalation || isExpired || hasSLABreach || hasDelegationExpiry || hasDueReminder;
|
|
4448
|
-
}).map((i) => reviveDates(deepClone(i)));
|
|
4449
|
-
}
|
|
4450
|
-
async deleteInstance(tenantId, id) {
|
|
4451
|
-
const key = `${tenantId}:${id}`;
|
|
4452
|
-
return this.instances.delete(key);
|
|
4453
|
-
}
|
|
4454
|
-
async countInstances(tenantId, filter) {
|
|
4455
|
-
let count = 0;
|
|
4456
|
-
for (const instance of this.instances.values()) {
|
|
4457
|
-
if (instance.tenantId === tenantId && applyFilter(instance, filter)) count++;
|
|
4458
|
-
}
|
|
4459
|
-
return count;
|
|
4460
|
-
}
|
|
4461
|
-
async getInstancesByCursor(tenantId, filter, opts) {
|
|
4462
|
-
const all = [...this.instances.values()].filter((i) => i.tenantId === tenantId && applyFilter(i, filter)).map((i) => reviveDates(deepClone(i))).sort((a, b) => {
|
|
4463
|
-
const ta = a.updatedAt.getTime();
|
|
4464
|
-
const tb = b.updatedAt.getTime();
|
|
4465
|
-
return ta !== tb ? ta - tb : a.id.localeCompare(b.id);
|
|
4466
|
-
});
|
|
4467
|
-
const { cursor, limit, direction = "forward" } = opts;
|
|
4468
|
-
let startIdx = 0;
|
|
4469
|
-
if (cursor) {
|
|
4470
|
-
const [ts, id] = decodeCursor(cursor);
|
|
4471
|
-
const idx = all.findIndex(
|
|
4472
|
-
(i) => i.updatedAt.getTime() > ts || i.updatedAt.getTime() === ts && i.id > id
|
|
4473
|
-
);
|
|
4474
|
-
startIdx = idx === -1 ? all.length : idx;
|
|
4475
|
-
}
|
|
4476
|
-
if (direction === "backward" && startIdx > 0) {
|
|
4477
|
-
startIdx = Math.max(0, startIdx - limit - 1);
|
|
4478
|
-
}
|
|
4479
|
-
const slice = all.slice(startIdx, startIdx + limit);
|
|
4480
|
-
const hasMore = startIdx + limit < all.length;
|
|
4481
|
-
const nextCursor = hasMore ? encodeCursor(slice[slice.length - 1]) : void 0;
|
|
4482
|
-
const prevCursor = startIdx > 0 ? encodeCursor(all[startIdx - 1]) : void 0;
|
|
4483
|
-
return { items: slice, nextCursor, prevCursor, hasMore };
|
|
4484
|
-
}
|
|
4485
|
-
async getIdempotentInstance(tenantId, idempotencyKey) {
|
|
4486
|
-
for (const instance of this.instances.values()) {
|
|
4487
|
-
if (instance.tenantId === tenantId && instance.idempotencyKey === idempotencyKey) {
|
|
4488
|
-
return reviveDates(deepClone(instance));
|
|
4489
|
-
}
|
|
4490
|
-
}
|
|
4491
|
-
return null;
|
|
4492
|
-
}
|
|
4493
|
-
async appendAuditEntry(_tenantId, _instanceId, _entry) {
|
|
4494
|
-
}
|
|
4495
|
-
/** Test helper — total stored instances across all tenants. */
|
|
4496
|
-
get size() {
|
|
4497
|
-
return this.instances.size;
|
|
4498
|
-
}
|
|
4499
|
-
};
|
|
4500
|
-
function paginate(items, opts) {
|
|
4501
|
-
const total = items.length;
|
|
4502
|
-
if (!opts) return { items, total };
|
|
4503
|
-
return { items: items.slice(opts.offset, opts.offset + opts.limit), total };
|
|
4504
|
-
}
|
|
4505
|
-
function encodeCursor(instance) {
|
|
4506
|
-
return Buffer.from(`${instance.updatedAt.getTime()}|${instance.id}`).toString("base64");
|
|
4507
|
-
}
|
|
4508
|
-
function decodeCursor(cursor) {
|
|
4509
|
-
const decoded = Buffer.from(cursor, "base64").toString("utf8");
|
|
4510
|
-
const pipeIdx = decoded.indexOf("|");
|
|
4511
|
-
return [Number(decoded.slice(0, pipeIdx)), decoded.slice(pipeIdx + 1)];
|
|
4512
|
-
}
|
|
4513
|
-
|
|
4514
4631
|
// src/utils/BusinessCalendar.ts
|
|
4515
4632
|
var DAY_MS = 864e5;
|
|
4516
4633
|
function dayKey(d) {
|