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/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
- var ApprovalTemplateNotFoundError = class extends ApprovalError {
259
- constructor(name) {
260
- super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
261
- this.name = "ApprovalTemplateNotFoundError";
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) {