hierarchical-approval 2.8.0 → 3.0.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 +84 -0
- package/README.md +36 -0
- package/dist/{ApprovalEngine-D6BmYI8B.d.cts → ApprovalEngine-Bfs5BnMm.d.cts} +102 -2
- package/dist/{ApprovalEngine-DKn7KymS.d.ts → ApprovalEngine-CCOwhMrs.d.ts} +102 -2
- package/dist/index.cjs +511 -315
- 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 +511 -315
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +504 -104
- 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 +504 -104
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +511 -315
- 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 +511 -315
- 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();
|
|
@@ -206,62 +466,6 @@ var systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
|
206
466
|
// src/utils/IdGenerator.ts
|
|
207
467
|
var defaultIdGenerator = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
208
468
|
|
|
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";
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
var ApprovalTemplateNotFoundError = class extends ApprovalError {
|
|
259
|
-
constructor(name) {
|
|
260
|
-
super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
|
|
261
|
-
this.name = "ApprovalTemplateNotFoundError";
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
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) {
|
|
@@ -1260,33 +1475,14 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1260
1475
|
const instanceId = this.generateId("inst");
|
|
1261
1476
|
const firstCfg = allLevelCfgs[0];
|
|
1262
1477
|
const firstGroupKey = firstCfg ? _ApprovalEngine.groupKeyOf(firstCfg) : null;
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
approverIds: [],
|
|
1272
|
-
approvedBy: [],
|
|
1273
|
-
rejectedBy: [],
|
|
1274
|
-
status: inFirstGroup ? "pending" : "waiting",
|
|
1275
|
-
minApprovals: cfg.minApprovals,
|
|
1276
|
-
threshold: cfg.threshold,
|
|
1277
|
-
weights: cfg.weights,
|
|
1278
|
-
escalationAfterDays: cfg.escalationAfterDays,
|
|
1279
|
-
escalationAfterHours: cfg.escalationAfterHours,
|
|
1280
|
-
escalationStep: 0,
|
|
1281
|
-
escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
|
|
1282
|
-
subWorkflowTemplate: cfg.subWorkflow?.templateName,
|
|
1283
|
-
reminderAfterDays: cfg.reminderAfterDays,
|
|
1284
|
-
reminderEveryDays: cfg.reminderEveryDays,
|
|
1285
|
-
maxReminders: cfg.maxReminders,
|
|
1286
|
-
remindersSent: 0,
|
|
1287
|
-
reminderDueAt: inFirstGroup && cfg.reminderAfterDays ? this.deadlineFrom(now, cfg.reminderAfterDays) : void 0
|
|
1288
|
-
};
|
|
1289
|
-
});
|
|
1478
|
+
const firstRung = this.firstRungOf(template.escalationSteps);
|
|
1479
|
+
const levels = allLevelCfgs.map(
|
|
1480
|
+
(cfg) => this.buildLevelInstance(cfg, {
|
|
1481
|
+
open: _ApprovalEngine.groupKeyOf(cfg) === firstGroupKey,
|
|
1482
|
+
now,
|
|
1483
|
+
firstRung
|
|
1484
|
+
})
|
|
1485
|
+
);
|
|
1290
1486
|
for (const lvl of levels.filter((l) => l.status === "pending")) {
|
|
1291
1487
|
if (lvl.subWorkflowTemplate) {
|
|
1292
1488
|
lvl.approverIds = [];
|
|
@@ -1961,7 +2157,10 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1961
2157
|
cancelled = instance;
|
|
1962
2158
|
return instance;
|
|
1963
2159
|
});
|
|
1964
|
-
if (cancelled)
|
|
2160
|
+
if (cancelled) {
|
|
2161
|
+
await this.cancelOrphanedChildren(cancelled);
|
|
2162
|
+
await this.propagateToParent(cancelled);
|
|
2163
|
+
}
|
|
1965
2164
|
return result;
|
|
1966
2165
|
}
|
|
1967
2166
|
async escalate(instanceId, raw, auditCtx) {
|
|
@@ -2114,20 +2313,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2114
2313
|
const rebuiltFuture = futureCfgs.map((cfg) => {
|
|
2115
2314
|
const kept = existingFuture.get(cfg.level);
|
|
2116
2315
|
if (kept) return kept;
|
|
2117
|
-
return {
|
|
2118
|
-
level: cfg.level,
|
|
2119
|
-
name: cfg.name,
|
|
2120
|
-
mode: cfg.mode,
|
|
2121
|
-
approverConfigs: cfg.approvers,
|
|
2122
|
-
approverIds: [],
|
|
2123
|
-
approvedBy: [],
|
|
2124
|
-
rejectedBy: [],
|
|
2125
|
-
status: "waiting",
|
|
2126
|
-
minApprovals: cfg.minApprovals,
|
|
2127
|
-
threshold: cfg.threshold,
|
|
2128
|
-
weights: cfg.weights,
|
|
2129
|
-
escalationAfterDays: cfg.escalationAfterDays
|
|
2130
|
-
};
|
|
2316
|
+
return this.buildLevelInstance(cfg, { open: false, now: this.clock.now() });
|
|
2131
2317
|
});
|
|
2132
2318
|
instance.levels = [...frozen, ...rebuiltFuture];
|
|
2133
2319
|
return { addedLevels: addedLevels.sort((a, b) => a - b), removedLevels };
|
|
@@ -2828,14 +3014,120 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2828
3014
|
} catch (err) {
|
|
2829
3015
|
base.resolutionError = err.message;
|
|
2830
3016
|
}
|
|
2831
|
-
levels.push(base);
|
|
3017
|
+
levels.push(base);
|
|
3018
|
+
}
|
|
3019
|
+
const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
|
|
3020
|
+
level: l.level,
|
|
3021
|
+
name: l.name,
|
|
3022
|
+
skippedByRule: skippedBy.get(l.level) ?? -1
|
|
3023
|
+
})).sort((a, b) => a.level - b.level);
|
|
3024
|
+
return { templateName: template.name, levels, skipped, rules };
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Run a document through a template against scripted decisions, without
|
|
3028
|
+
* persisting anything.
|
|
3029
|
+
*
|
|
3030
|
+
* `explainChain()` says what the chain will be; this says what happens to it —
|
|
3031
|
+
* "if the CFO rejects at level 3, does it go back to the submitter or die?" —
|
|
3032
|
+
* which previously meant submitting a real approval into a real store and
|
|
3033
|
+
* cleaning it up afterwards, or reasoning about the state machine by hand.
|
|
3034
|
+
*
|
|
3035
|
+
* The run executes against a private in-memory store seeded with a copy of
|
|
3036
|
+
* the template, so the caller's storage is untouched and no events reach the
|
|
3037
|
+
* caller's notification adapters. Custom resolvers and approver types are
|
|
3038
|
+
* copied across, because a simulation that could not resolve the caller's own
|
|
3039
|
+
* `dynamic` approvers would answer a different question from the one asked.
|
|
3040
|
+
*
|
|
3041
|
+
* A refused decision — wrong approver, wrong level, already acted — stops the
|
|
3042
|
+
* run and is reported in the transcript rather than thrown: the refusal is
|
|
3043
|
+
* usually the answer the caller was looking for.
|
|
3044
|
+
*
|
|
3045
|
+
* @param opts - Template, document data, submitter and the decisions to play.
|
|
3046
|
+
*/
|
|
3047
|
+
async simulate(opts) {
|
|
3048
|
+
const template = await this.registry.get(opts.templateName);
|
|
3049
|
+
const scratch = new _ApprovalEngine({
|
|
3050
|
+
...this.opts,
|
|
3051
|
+
adapter: new MemoryAdapter(),
|
|
3052
|
+
// A simulation must not page anybody, write anybody's audit log, or move
|
|
3053
|
+
// anybody's metrics.
|
|
3054
|
+
notificationAdapter: void 0,
|
|
3055
|
+
auditAdapter: void 0,
|
|
3056
|
+
metricsAdapter: void 0,
|
|
3057
|
+
authorizationPolicy: void 0,
|
|
3058
|
+
middleware: void 0,
|
|
3059
|
+
escalationPollIntervalMs: void 0
|
|
3060
|
+
});
|
|
3061
|
+
this.resolver.copyRegistrationsTo(scratch.resolver);
|
|
3062
|
+
try {
|
|
3063
|
+
const {
|
|
3064
|
+
id: _id,
|
|
3065
|
+
tenantId: _t,
|
|
3066
|
+
createdAt: _c,
|
|
3067
|
+
version: _v,
|
|
3068
|
+
previousVersionId: _p,
|
|
3069
|
+
...config
|
|
3070
|
+
} = template;
|
|
3071
|
+
await scratch.registry.define(config);
|
|
3072
|
+
const instance = await scratch.submit({
|
|
3073
|
+
templateName: opts.templateName,
|
|
3074
|
+
documentId: `simulation-${opts.templateName}`,
|
|
3075
|
+
documentType: template.documentType,
|
|
3076
|
+
submittedBy: opts.submittedBy,
|
|
3077
|
+
data: opts.data,
|
|
3078
|
+
metadata: {}
|
|
3079
|
+
});
|
|
3080
|
+
const transcript = [];
|
|
3081
|
+
let current = instance;
|
|
3082
|
+
for (const [i, decision] of (opts.decisions ?? []).entries()) {
|
|
3083
|
+
const isApprove = "approve" in decision;
|
|
3084
|
+
const actorId = isApprove ? decision.approve : decision.reject;
|
|
3085
|
+
try {
|
|
3086
|
+
current = isApprove ? await scratch.approve(current.id, {
|
|
3087
|
+
approverId: actorId,
|
|
3088
|
+
level: decision.level,
|
|
3089
|
+
comment: decision.comment
|
|
3090
|
+
}) : await scratch.reject(current.id, {
|
|
3091
|
+
approverId: actorId,
|
|
3092
|
+
level: decision.level,
|
|
3093
|
+
reason: decision.reason ?? "simulated rejection"
|
|
3094
|
+
});
|
|
3095
|
+
transcript.push({
|
|
3096
|
+
step: i + 1,
|
|
3097
|
+
action: isApprove ? "approve" : "reject",
|
|
3098
|
+
actorId,
|
|
3099
|
+
level: decision.level,
|
|
3100
|
+
status: current.status
|
|
3101
|
+
});
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
transcript.push({
|
|
3104
|
+
step: i + 1,
|
|
3105
|
+
action: isApprove ? "approve" : "reject",
|
|
3106
|
+
actorId,
|
|
3107
|
+
level: decision.level,
|
|
3108
|
+
status: current.status,
|
|
3109
|
+
error: err.message
|
|
3110
|
+
});
|
|
3111
|
+
break;
|
|
3112
|
+
}
|
|
3113
|
+
if (TERMINAL_STATUSES.has(current.status)) break;
|
|
3114
|
+
}
|
|
3115
|
+
const finalInstance = await scratch.getInstance(current.id);
|
|
3116
|
+
return {
|
|
3117
|
+
finalStatus: finalInstance.status,
|
|
3118
|
+
levels: finalInstance.levels.map((l) => ({
|
|
3119
|
+
level: l.level,
|
|
3120
|
+
name: l.name,
|
|
3121
|
+
status: l.status,
|
|
3122
|
+
approvers: l.approverIds
|
|
3123
|
+
})),
|
|
3124
|
+
transcript,
|
|
3125
|
+
unreachedLevels: finalInstance.levels.filter((l) => l.status === "waiting").map((l) => l.level),
|
|
3126
|
+
incomplete: finalInstance.status === "pending"
|
|
3127
|
+
};
|
|
3128
|
+
} finally {
|
|
3129
|
+
await scratch.shutdown();
|
|
2832
3130
|
}
|
|
2833
|
-
const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
|
|
2834
|
-
level: l.level,
|
|
2835
|
-
name: l.name,
|
|
2836
|
-
skippedByRule: skippedBy.get(l.level) ?? -1
|
|
2837
|
-
})).sort((a, b) => a.level - b.level);
|
|
2838
|
-
return { templateName: template.name, levels, skipped, rules };
|
|
2839
3131
|
}
|
|
2840
3132
|
async canApprove(instanceId, userId) {
|
|
2841
3133
|
let instance;
|
|
@@ -3338,6 +3630,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3338
3630
|
);
|
|
3339
3631
|
}
|
|
3340
3632
|
const result = { purged: [], scanned: 0, dryRun };
|
|
3633
|
+
const handled = /* @__PURE__ */ new Set();
|
|
3341
3634
|
for (const status of requested) {
|
|
3342
3635
|
if (result.purged.length >= limit) break;
|
|
3343
3636
|
const page = await this.opts.adapter.getInstancesByFilter(
|
|
@@ -3351,15 +3644,21 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3351
3644
|
);
|
|
3352
3645
|
for (const instance of page.items) {
|
|
3353
3646
|
if (result.purged.length >= limit) break;
|
|
3647
|
+
if (handled.has(instance.id)) continue;
|
|
3354
3648
|
result.scanned++;
|
|
3355
3649
|
if (!TERMINAL_STATUSES.has(instance.status)) continue;
|
|
3356
3650
|
if (new Date(instance.createdAt) > opts.olderThan) continue;
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3651
|
+
const family = await this.collectSubWorkflowFamily(instance);
|
|
3652
|
+
for (const member of family) {
|
|
3653
|
+
if (handled.has(member.id)) continue;
|
|
3654
|
+
handled.add(member.id);
|
|
3655
|
+
if (!dryRun) await deleteInstance(this.tenantId, member.id);
|
|
3656
|
+
result.purged.push({
|
|
3657
|
+
instanceId: member.id,
|
|
3658
|
+
documentId: member.documentId,
|
|
3659
|
+
status: member.status
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3363
3662
|
}
|
|
3364
3663
|
}
|
|
3365
3664
|
this.logger.info("purgeInstances: sweep complete", {
|
|
@@ -3735,6 +4034,47 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3735
4034
|
const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
|
|
3736
4035
|
return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
|
|
3737
4036
|
}
|
|
4037
|
+
/**
|
|
4038
|
+
* Build a level instance from its template config.
|
|
4039
|
+
*
|
|
4040
|
+
* The single place a level is constructed. It previously happened twice — in
|
|
4041
|
+
* `submit()` and again in `recomputeFutureChain()` — and the second copy was
|
|
4042
|
+
* missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
|
|
4043
|
+
* reminder fields, so a level added by a condition during `updateData()` came
|
|
4044
|
+
* out silently different from the same level created at submit.
|
|
4045
|
+
*
|
|
4046
|
+
* @param cfg - The template's configuration for this level.
|
|
4047
|
+
* @param opts - `open` activates the level now, computing deadlines from `now`.
|
|
4048
|
+
*/
|
|
4049
|
+
buildLevelInstance(cfg, opts) {
|
|
4050
|
+
const level = {
|
|
4051
|
+
level: cfg.level,
|
|
4052
|
+
name: cfg.name,
|
|
4053
|
+
group: cfg.group,
|
|
4054
|
+
mode: cfg.mode,
|
|
4055
|
+
approverConfigs: cfg.approvers,
|
|
4056
|
+
approverIds: [],
|
|
4057
|
+
approvedBy: [],
|
|
4058
|
+
rejectedBy: [],
|
|
4059
|
+
status: opts.open ? "pending" : "waiting",
|
|
4060
|
+
minApprovals: cfg.minApprovals,
|
|
4061
|
+
threshold: cfg.threshold,
|
|
4062
|
+
weights: cfg.weights,
|
|
4063
|
+
subWorkflowTemplate: cfg.subWorkflow?.templateName,
|
|
4064
|
+
escalationAfterDays: cfg.escalationAfterDays,
|
|
4065
|
+
escalationAfterHours: cfg.escalationAfterHours,
|
|
4066
|
+
escalationStep: 0,
|
|
4067
|
+
reminderAfterDays: cfg.reminderAfterDays,
|
|
4068
|
+
reminderEveryDays: cfg.reminderEveryDays,
|
|
4069
|
+
maxReminders: cfg.maxReminders,
|
|
4070
|
+
remindersSent: 0
|
|
4071
|
+
};
|
|
4072
|
+
if (opts.open) {
|
|
4073
|
+
level.escalationDueAt = this.levelEscalationDue(opts.now, cfg, opts.firstRung);
|
|
4074
|
+
this.scheduleReminder(level, opts.now);
|
|
4075
|
+
}
|
|
4076
|
+
return level;
|
|
4077
|
+
}
|
|
3738
4078
|
/** Level deadline from whichever of days/hours the template configured. */
|
|
3739
4079
|
levelEscalationDue(from, level, firstRung) {
|
|
3740
4080
|
if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
|
|
@@ -4186,8 +4526,68 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
4186
4526
|
* about them, so a slow child template would surface as a spurious conflict
|
|
4187
4527
|
* on the decision the user just made.
|
|
4188
4528
|
*/
|
|
4529
|
+
/**
|
|
4530
|
+
* An instance and every sub-workflow descendant beneath it, parents first.
|
|
4531
|
+
*
|
|
4532
|
+
* A child is only reachable through its parent's `childInstanceId`, so a
|
|
4533
|
+
* purge that removed the parent alone would strand the rest of the tree.
|
|
4534
|
+
* Depth is bounded by the same cap that limits spawning, and an already-seen
|
|
4535
|
+
* id is skipped so a corrupted link cannot loop.
|
|
4536
|
+
*/
|
|
4537
|
+
async collectSubWorkflowFamily(root) {
|
|
4538
|
+
const family = [root];
|
|
4539
|
+
const seen = /* @__PURE__ */ new Set([root.id]);
|
|
4540
|
+
const queue = [root];
|
|
4541
|
+
for (let depth = 0; queue.length > 0 && depth <= MAX_SUBWORKFLOW_DEPTH; depth++) {
|
|
4542
|
+
const generation = queue.splice(0, queue.length);
|
|
4543
|
+
for (const parent of generation) {
|
|
4544
|
+
for (const level of parent.levels) {
|
|
4545
|
+
const childId = level.childInstanceId;
|
|
4546
|
+
if (!childId || seen.has(childId)) continue;
|
|
4547
|
+
seen.add(childId);
|
|
4548
|
+
const child = await this.opts.adapter.getInstance(this.tenantId, childId);
|
|
4549
|
+
if (!child) continue;
|
|
4550
|
+
family.push(child);
|
|
4551
|
+
queue.push(child);
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
return family;
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* Cancel sub-workflow children whose parent has finished.
|
|
4559
|
+
*
|
|
4560
|
+
* A child outlives its parent otherwise: it stays pending, keeps notifying
|
|
4561
|
+
* and escalating, and keeps appearing in {@link getWorkload} — asking people
|
|
4562
|
+
* to decide something whose outcome nobody will ever read, because
|
|
4563
|
+
* {@link propagateToParent} ignores a parent that is no longer pending.
|
|
4564
|
+
*
|
|
4565
|
+
* Cancelling rather than deleting keeps the child's own audit trail intact:
|
|
4566
|
+
* the people who were asked, and why the request stopped, stay on the record.
|
|
4567
|
+
*/
|
|
4568
|
+
async cancelOrphanedChildren(parent) {
|
|
4569
|
+
const childIds = parent.levels.map((l) => l.childInstanceId).filter((id) => typeof id === "string");
|
|
4570
|
+
if (childIds.length === 0) return;
|
|
4571
|
+
for (const childId of childIds) {
|
|
4572
|
+
try {
|
|
4573
|
+
const child = await this.opts.adapter.getInstance(this.tenantId, childId);
|
|
4574
|
+
if (!child || TERMINAL_STATUSES.has(child.status)) continue;
|
|
4575
|
+
await this.cancel(childId, {
|
|
4576
|
+
cancelledBy: "system",
|
|
4577
|
+
reason: `Parent approval ${parent.id} ended as "${parent.status}".`
|
|
4578
|
+
});
|
|
4579
|
+
} catch (err) {
|
|
4580
|
+
this.logger.error("subWorkflow: failed to cancel orphaned child", err, {
|
|
4581
|
+
tenantId: this.tenantId,
|
|
4582
|
+
parentInstanceId: parent.id,
|
|
4583
|
+
childInstanceId: childId
|
|
4584
|
+
});
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4189
4588
|
async afterDecision(instance) {
|
|
4190
4589
|
if (TERMINAL_STATUSES.has(instance.status)) {
|
|
4590
|
+
await this.cancelOrphanedChildren(instance);
|
|
4191
4591
|
await this.propagateToParent(instance);
|
|
4192
4592
|
return;
|
|
4193
4593
|
}
|
|
@@ -4307,210 +4707,6 @@ function parseOrThrow(fn) {
|
|
|
4307
4707
|
}
|
|
4308
4708
|
}
|
|
4309
4709
|
|
|
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
4710
|
// src/utils/BusinessCalendar.ts
|
|
4515
4711
|
var DAY_MS = 864e5;
|
|
4516
4712
|
function dayKey(d) {
|