hierarchical-approval 1.7.0 → 1.8.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 CHANGED
@@ -7,6 +7,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [1.8.0] - 2026-09-04
11
+
12
+ ### Added — `transferApprovals()`
13
+
14
+ - **Move every pending approval assigned to one person over to another.**
15
+ Someone leaves, changes team, or goes on long-term leave, and their queue has
16
+ to go somewhere. Doing it by hand meant finding every open instance first —
17
+ across parallel branches, where one person can hold several open levels on the
18
+ same document — and missing one left an approval that could never complete.
19
+
20
+ ```ts
21
+ await engine.transferApprovals({
22
+ fromApprover: 'alice',
23
+ toApprover: 'bob',
24
+ transferredBy: 'workflow-admin',
25
+ reason: 'Alice left the company',
26
+ documentType: 'purchase_order', // optional
27
+ dryRun: true, // see what would move first
28
+ });
29
+ ```
30
+
31
+ Each move goes through `reassign()`, so every guard, audit entry, event and
32
+ authorization check that applies to a single reassignment applies here too.
33
+ The sweep is deliberately **not atomic**: it reports per-instance failures
34
+ rather than rolling back, because a partial transfer is the useful outcome —
35
+ what can move should move, and what cannot is named for a human to look at.
36
+
37
+ New exports: `TransferResult`, `TransferApprovalsOptions`.
38
+
39
+ ### Fixed — `delegate()` and `reassign()` could not reach an upper parallel branch
40
+
41
+ - **Both resolved the level via `currentLevelInstance`**, which names only the
42
+ lowest-numbered open level. Inside a parallel group they therefore always
43
+ acted on the lowest branch: an approver could not hand off their own
44
+ upper-branch work, and an administrator reassigning a departing user silently
45
+ moved the wrong branch — or failed, because that person was not an approver on
46
+ the branch being targeted. Introduced with parallel groups in 1.0.0; sequential
47
+ templates were never affected.
48
+
49
+ Both now resolve against the approver actually being moved, and take an
50
+ optional `level` to disambiguate when one person holds more than one open
51
+ branch — matching what `approve()` and `reject()` already did.
52
+
10
53
  ## [1.7.0] - 2026-09-04
11
54
 
12
55
  ### Added — attachment references
package/README.md CHANGED
@@ -399,6 +399,33 @@ engine.registerConditionOperator('between', (actual, expected) => {
399
399
  });
400
400
  ```
401
401
 
402
+ ### Transferring a person's queue
403
+
404
+ Someone leaves, changes team, or goes on long-term leave, and their open
405
+ approvals have to go somewhere:
406
+
407
+ ```ts
408
+ const result = await engine.transferApprovals({
409
+ fromApprover: 'alice',
410
+ toApprover: 'bob',
411
+ transferredBy: 'workflow-admin',
412
+ reason: 'Alice left the company',
413
+ documentType: 'purchase_order', // optional scoping
414
+ dryRun: true, // see what would move first
415
+ });
416
+ // { transferred: [{ instanceId, level, documentId }], failed: [...], scanned, dryRun }
417
+ ```
418
+
419
+ Each move goes through `reassign()`, so every guard, audit entry, event and
420
+ authorization check that applies to a single reassignment applies here too —
421
+ there is no bulk short-cut around them. Inside a parallel group each open branch
422
+ is moved separately, since one person can hold several open levels on the same
423
+ document.
424
+
425
+ The sweep is **not atomic**: it reports per-instance failures rather than rolling
426
+ back. A partial transfer is the useful outcome — the approvals that can move
427
+ should move, and the ones that cannot are named so a human can look at them.
428
+
402
429
  ### Attachments
403
430
 
404
431
  Attach supporting evidence — a quote PDF, a signed contract, a screenshot of a
@@ -42,12 +42,14 @@ declare const DelegateOptionsSchema: z.ZodObject<{
42
42
  toApprover: z.ZodString;
43
43
  reason: z.ZodString;
44
44
  until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
45
+ level: z.ZodOptional<z.ZodNumber>;
45
46
  }, z.core.$strip>;
46
47
  declare const ReassignOptionsSchema: z.ZodObject<{
47
48
  reassignedBy: z.ZodString;
48
49
  fromApprover: z.ZodString;
49
50
  toApprover: z.ZodString;
50
51
  reason: z.ZodString;
52
+ level: z.ZodOptional<z.ZodNumber>;
51
53
  }, z.core.$strip>;
52
54
  declare const CancelOptionsSchema: z.ZodObject<{
53
55
  cancelledBy: z.ZodString;
@@ -90,6 +92,15 @@ declare const RemoveAttachmentOptionsSchema: z.ZodObject<{
90
92
  attachmentId: z.ZodString;
91
93
  reason: z.ZodOptional<z.ZodString>;
92
94
  }, z.core.$strip>;
95
+ declare const TransferApprovalsOptionsSchema: z.ZodObject<{
96
+ fromApprover: z.ZodString;
97
+ toApprover: z.ZodString;
98
+ transferredBy: z.ZodString;
99
+ reason: z.ZodString;
100
+ documentType: z.ZodOptional<z.ZodString>;
101
+ dryRun: z.ZodDefault<z.ZodBoolean>;
102
+ limit: z.ZodDefault<z.ZodNumber>;
103
+ }, z.core.$strip>;
93
104
  declare const UpdateDataOptionsSchema: z.ZodObject<{
94
105
  updatedBy: z.ZodString;
95
106
  data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -115,6 +126,7 @@ type RequestInfoOptions = z.infer<typeof RequestInfoOptionsSchema>;
115
126
  type ProvideInfoOptions = z.infer<typeof ProvideInfoOptionsSchema>;
116
127
  type AddAttachmentOptions = z.infer<typeof AddAttachmentOptionsSchema>;
117
128
  type RemoveAttachmentOptions = z.infer<typeof RemoveAttachmentOptionsSchema>;
129
+ type TransferApprovalsOptions = z.infer<typeof TransferApprovalsOptionsSchema>;
118
130
 
119
131
  /**
120
132
  * Computes deadline dates from a number of days. The default engine behaviour
@@ -254,6 +266,24 @@ interface BulkResult {
254
266
  }>;
255
267
  total: number;
256
268
  }
269
+ /** Outcome of a {@link ApprovalEngine.transferApprovals} sweep. */
270
+ interface TransferResult {
271
+ /** One entry per level actually moved (an instance can hold the approver on several open branches). */
272
+ transferred: Array<{
273
+ instanceId: string;
274
+ level: number;
275
+ documentId: string;
276
+ }>;
277
+ /** Instances that could not be moved, with the reason. */
278
+ failed: Array<{
279
+ instanceId: string;
280
+ error: ApprovalError;
281
+ }>;
282
+ /** Instances examined. */
283
+ scanned: number;
284
+ /** True when nothing was written. */
285
+ dryRun: boolean;
286
+ }
257
287
  interface ApprovalStatistics {
258
288
  /** Total instances matching the filter (across all statuses). */
259
289
  total: number;
@@ -539,6 +569,28 @@ declare class ApprovalEngine {
539
569
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
540
570
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
541
571
  /** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
572
+ /**
573
+ * Move every pending approval assigned to one person over to another.
574
+ *
575
+ * Someone leaves, changes team, or goes on long-term leave, and their queue
576
+ * has to go somewhere. Doing it by hand means finding every open instance
577
+ * first — across parallel branches, where one person can hold several open
578
+ * levels on the same document — and missing one leaves an approval that can
579
+ * never complete.
580
+ *
581
+ * Each move goes through {@link reassign}, so every guard, audit entry, event
582
+ * and authorization check that applies to a single reassignment applies here
583
+ * too. There is no bulk short-cut around them.
584
+ *
585
+ * The sweep is **not atomic**: it reassigns one level at a time and reports
586
+ * per-instance failures rather than rolling back. A partial transfer is the
587
+ * useful outcome — the approvals that could move should move, and the ones
588
+ * that could not are named so a human can look at them.
589
+ *
590
+ * @param raw - Who is moving to whom, and why.
591
+ * @returns What moved, what did not, and why. With `dryRun` nothing is written.
592
+ */
593
+ transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
542
594
  bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
543
595
  /** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
544
596
  bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
@@ -652,4 +704,4 @@ declare class ApprovalEngine {
652
704
  private runExternalAudit;
653
705
  }
654
706
 
655
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type ProvideInfoOptions as n, type RejectOptions as o, type RequestInfoOptions as p, type ResubmitOptions as q, type RetryPolicy as r, defaultIdGenerator as s, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
707
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type TransferResult as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type ProvideInfoOptions as n, type RejectOptions as o, type RequestInfoOptions as p, type ResubmitOptions as q, type RetryPolicy as r, defaultIdGenerator as s, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
@@ -42,12 +42,14 @@ declare const DelegateOptionsSchema: z.ZodObject<{
42
42
  toApprover: z.ZodString;
43
43
  reason: z.ZodString;
44
44
  until: z.ZodOptional<z.ZodCoercedDate<unknown>>;
45
+ level: z.ZodOptional<z.ZodNumber>;
45
46
  }, z.core.$strip>;
46
47
  declare const ReassignOptionsSchema: z.ZodObject<{
47
48
  reassignedBy: z.ZodString;
48
49
  fromApprover: z.ZodString;
49
50
  toApprover: z.ZodString;
50
51
  reason: z.ZodString;
52
+ level: z.ZodOptional<z.ZodNumber>;
51
53
  }, z.core.$strip>;
52
54
  declare const CancelOptionsSchema: z.ZodObject<{
53
55
  cancelledBy: z.ZodString;
@@ -90,6 +92,15 @@ declare const RemoveAttachmentOptionsSchema: z.ZodObject<{
90
92
  attachmentId: z.ZodString;
91
93
  reason: z.ZodOptional<z.ZodString>;
92
94
  }, z.core.$strip>;
95
+ declare const TransferApprovalsOptionsSchema: z.ZodObject<{
96
+ fromApprover: z.ZodString;
97
+ toApprover: z.ZodString;
98
+ transferredBy: z.ZodString;
99
+ reason: z.ZodString;
100
+ documentType: z.ZodOptional<z.ZodString>;
101
+ dryRun: z.ZodDefault<z.ZodBoolean>;
102
+ limit: z.ZodDefault<z.ZodNumber>;
103
+ }, z.core.$strip>;
93
104
  declare const UpdateDataOptionsSchema: z.ZodObject<{
94
105
  updatedBy: z.ZodString;
95
106
  data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -115,6 +126,7 @@ type RequestInfoOptions = z.infer<typeof RequestInfoOptionsSchema>;
115
126
  type ProvideInfoOptions = z.infer<typeof ProvideInfoOptionsSchema>;
116
127
  type AddAttachmentOptions = z.infer<typeof AddAttachmentOptionsSchema>;
117
128
  type RemoveAttachmentOptions = z.infer<typeof RemoveAttachmentOptionsSchema>;
129
+ type TransferApprovalsOptions = z.infer<typeof TransferApprovalsOptionsSchema>;
118
130
 
119
131
  /**
120
132
  * Computes deadline dates from a number of days. The default engine behaviour
@@ -254,6 +266,24 @@ interface BulkResult {
254
266
  }>;
255
267
  total: number;
256
268
  }
269
+ /** Outcome of a {@link ApprovalEngine.transferApprovals} sweep. */
270
+ interface TransferResult {
271
+ /** One entry per level actually moved (an instance can hold the approver on several open branches). */
272
+ transferred: Array<{
273
+ instanceId: string;
274
+ level: number;
275
+ documentId: string;
276
+ }>;
277
+ /** Instances that could not be moved, with the reason. */
278
+ failed: Array<{
279
+ instanceId: string;
280
+ error: ApprovalError;
281
+ }>;
282
+ /** Instances examined. */
283
+ scanned: number;
284
+ /** True when nothing was written. */
285
+ dryRun: boolean;
286
+ }
257
287
  interface ApprovalStatistics {
258
288
  /** Total instances matching the filter (across all statuses). */
259
289
  total: number;
@@ -539,6 +569,28 @@ declare class ApprovalEngine {
539
569
  /** Emergency bypass — completes the instance as 'approved', skipping remaining levels. Requires template.allowOverride = true. */
540
570
  override(instanceId: string, raw: OverrideOptions, auditCtx?: AuditContext): Promise<ApprovalInstance>;
541
571
  /** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
572
+ /**
573
+ * Move every pending approval assigned to one person over to another.
574
+ *
575
+ * Someone leaves, changes team, or goes on long-term leave, and their queue
576
+ * has to go somewhere. Doing it by hand means finding every open instance
577
+ * first — across parallel branches, where one person can hold several open
578
+ * levels on the same document — and missing one leaves an approval that can
579
+ * never complete.
580
+ *
581
+ * Each move goes through {@link reassign}, so every guard, audit entry, event
582
+ * and authorization check that applies to a single reassignment applies here
583
+ * too. There is no bulk short-cut around them.
584
+ *
585
+ * The sweep is **not atomic**: it reassigns one level at a time and reports
586
+ * per-instance failures rather than rolling back. A partial transfer is the
587
+ * useful outcome — the approvals that could move should move, and the ones
588
+ * that could not are named so a human can look at them.
589
+ *
590
+ * @param raw - Who is moving to whom, and why.
591
+ * @returns What moved, what did not, and why. With `dryRun` nothing is written.
592
+ */
593
+ transferApprovals(raw: TransferApprovalsOptions, auditCtx?: AuditContext): Promise<TransferResult>;
542
594
  bulkApprove(instanceIds: string[], raw: ApproveOptions, auditCtx?: AuditContext): Promise<BulkResult>;
543
595
  /** Reject multiple instances in one call. Never throws — failures collected in result.failed. */
544
596
  bulkReject(instanceIds: string[], raw: RejectOptions, auditCtx?: AuditContext): Promise<BulkResult>;
@@ -652,4 +704,4 @@ declare class ApprovalEngine {
652
704
  private runExternalAudit;
653
705
  }
654
706
 
655
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type ProvideInfoOptions as n, type RejectOptions as o, type RequestInfoOptions as p, type ResubmitOptions as q, type RetryPolicy as r, defaultIdGenerator as s, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
707
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type TransferResult as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type BusinessCalendar as f, type CancelOptions as g, type ConditionOperatorFn as h, type CycleTimeStats as i, type IdempotencyKeyFn as j, type OutOfOfficeProvider as k, type OverrideOptions as l, type PreviewResult as m, type ProvideInfoOptions as n, type RejectOptions as o, type RequestInfoOptions as p, type ResubmitOptions as q, type RetryPolicy as r, defaultIdGenerator as s, toComparableNumber as t, validateConditionExpression as v, weekendCalendar as w };
package/dist/index.cjs CHANGED
@@ -39,13 +39,20 @@ var DelegateOptionsSchema = zod.z.object({
39
39
  fromApprover: zod.z.string().min(1),
40
40
  toApprover: zod.z.string().min(1),
41
41
  reason: zod.z.string().min(1),
42
- until: zod.z.coerce.date().optional()
42
+ until: zod.z.coerce.date().optional(),
43
+ /**
44
+ * Which open level to act on. Only needed inside a parallel group, where the
45
+ * approver may hold more than one open branch.
46
+ */
47
+ level: zod.z.number().int().optional()
43
48
  });
44
49
  var ReassignOptionsSchema = zod.z.object({
45
50
  reassignedBy: zod.z.string().min(1),
46
51
  fromApprover: zod.z.string().min(1),
47
52
  toApprover: zod.z.string().min(1),
48
- reason: zod.z.string().min(1)
53
+ reason: zod.z.string().min(1),
54
+ /** Which open level to act on; see {@link DelegateOptionsSchema}. */
55
+ level: zod.z.number().int().optional()
49
56
  });
50
57
  var CancelOptionsSchema = zod.z.object({
51
58
  cancelledBy: zod.z.string().min(1),
@@ -90,6 +97,18 @@ var RemoveAttachmentOptionsSchema = zod.z.object({
90
97
  attachmentId: zod.z.string().min(1),
91
98
  reason: zod.z.string().optional()
92
99
  });
100
+ var TransferApprovalsOptionsSchema = zod.z.object({
101
+ fromApprover: zod.z.string().min(1),
102
+ toApprover: zod.z.string().min(1),
103
+ transferredBy: zod.z.string().min(1),
104
+ reason: zod.z.string().min(1),
105
+ /** Restrict the sweep to one document type. */
106
+ documentType: zod.z.string().optional(),
107
+ /** Report what would move without changing anything. */
108
+ dryRun: zod.z.boolean().default(false),
109
+ /** Safety cap on how many instances one sweep will touch. */
110
+ limit: zod.z.number().int().positive().default(500)
111
+ });
93
112
  var UpdateDataOptionsSchema = zod.z.object({
94
113
  updatedBy: zod.z.string().min(1),
95
114
  data: zod.z.record(zod.z.string(), zod.z.unknown()),
@@ -1647,7 +1666,7 @@ var ApprovalEngine = class _ApprovalEngine {
1647
1666
  if (opts.fromApprover === opts.toApprover) {
1648
1667
  throw new ApprovalForbiddenError("Cannot delegate to yourself.");
1649
1668
  }
1650
- const level = this.currentLevelInstance(instance);
1669
+ const level = this.resolveActorLevel(instance, opts.fromApprover, opts.level);
1651
1670
  await this.runAuthorizationPolicy({
1652
1671
  operation: "delegate",
1653
1672
  actorId: opts.fromApprover,
@@ -1729,7 +1748,7 @@ var ApprovalEngine = class _ApprovalEngine {
1729
1748
  if (opts.fromApprover === opts.toApprover) {
1730
1749
  throw new ApprovalForbiddenError("Cannot reassign an approver to themselves.");
1731
1750
  }
1732
- const level = this.currentLevelInstance(instance);
1751
+ const level = this.resolveActorLevel(instance, opts.fromApprover, opts.level);
1733
1752
  await this.runAuthorizationPolicy({
1734
1753
  operation: "reassign",
1735
1754
  actorId: opts.reassignedBy,
@@ -2365,9 +2384,7 @@ var ApprovalEngine = class _ApprovalEngine {
2365
2384
  input: opts
2366
2385
  });
2367
2386
  const now = this.clock.now();
2368
- instance.attachments = (instance.attachments ?? []).filter(
2369
- (a) => a.id !== opts.attachmentId
2370
- );
2387
+ instance.attachments = (instance.attachments ?? []).filter((a) => a.id !== opts.attachmentId);
2371
2388
  instance.updatedAt = now;
2372
2389
  const auditEntry = {
2373
2390
  action: "attachment_removed",
@@ -2706,6 +2723,95 @@ var ApprovalEngine = class _ApprovalEngine {
2706
2723
  });
2707
2724
  }
2708
2725
  /** Approve multiple instances in one call. Never throws — failures collected in result.failed. */
2726
+ /**
2727
+ * Move every pending approval assigned to one person over to another.
2728
+ *
2729
+ * Someone leaves, changes team, or goes on long-term leave, and their queue
2730
+ * has to go somewhere. Doing it by hand means finding every open instance
2731
+ * first — across parallel branches, where one person can hold several open
2732
+ * levels on the same document — and missing one leaves an approval that can
2733
+ * never complete.
2734
+ *
2735
+ * Each move goes through {@link reassign}, so every guard, audit entry, event
2736
+ * and authorization check that applies to a single reassignment applies here
2737
+ * too. There is no bulk short-cut around them.
2738
+ *
2739
+ * The sweep is **not atomic**: it reassigns one level at a time and reports
2740
+ * per-instance failures rather than rolling back. A partial transfer is the
2741
+ * useful outcome — the approvals that could move should move, and the ones
2742
+ * that could not are named so a human can look at them.
2743
+ *
2744
+ * @param raw - Who is moving to whom, and why.
2745
+ * @returns What moved, what did not, and why. With `dryRun` nothing is written.
2746
+ */
2747
+ async transferApprovals(raw, auditCtx) {
2748
+ const opts = parseOrThrow(() => TransferApprovalsOptionsSchema.parse(raw));
2749
+ if (opts.fromApprover === opts.toApprover) {
2750
+ throw new ApprovalValidationError(
2751
+ "transferApprovals requires different fromApprover and toApprover."
2752
+ );
2753
+ }
2754
+ const queue = await this.opts.adapter.getInstancesByApprover(this.tenantId, opts.fromApprover, {
2755
+ limit: opts.limit,
2756
+ offset: 0
2757
+ });
2758
+ const result = {
2759
+ transferred: [],
2760
+ failed: [],
2761
+ scanned: 0,
2762
+ dryRun: opts.dryRun
2763
+ };
2764
+ for (const instance of queue.items) {
2765
+ if (opts.documentType && instance.documentType !== opts.documentType) continue;
2766
+ result.scanned++;
2767
+ const levels = instance.levels.filter(
2768
+ (l) => l.status === "pending" && l.approverIds.includes(opts.fromApprover)
2769
+ );
2770
+ for (const level of levels) {
2771
+ if (opts.dryRun) {
2772
+ result.transferred.push({
2773
+ instanceId: instance.id,
2774
+ level: level.level,
2775
+ documentId: instance.documentId
2776
+ });
2777
+ continue;
2778
+ }
2779
+ try {
2780
+ await this.reassign(
2781
+ instance.id,
2782
+ {
2783
+ reassignedBy: opts.transferredBy,
2784
+ fromApprover: opts.fromApprover,
2785
+ toApprover: opts.toApprover,
2786
+ reason: opts.reason,
2787
+ level: level.level
2788
+ },
2789
+ auditCtx
2790
+ );
2791
+ result.transferred.push({
2792
+ instanceId: instance.id,
2793
+ level: level.level,
2794
+ documentId: instance.documentId
2795
+ });
2796
+ } catch (err) {
2797
+ result.failed.push({
2798
+ instanceId: instance.id,
2799
+ error: err instanceof ApprovalError ? err : new ApprovalError(String(err), "UNKNOWN")
2800
+ });
2801
+ }
2802
+ }
2803
+ }
2804
+ this.logger.info("transferApprovals: sweep complete", {
2805
+ tenantId: this.tenantId,
2806
+ fromApprover: opts.fromApprover,
2807
+ toApprover: opts.toApprover,
2808
+ scanned: result.scanned,
2809
+ transferred: result.transferred.length,
2810
+ failed: result.failed.length,
2811
+ dryRun: opts.dryRun
2812
+ });
2813
+ return result;
2814
+ }
2709
2815
  async bulkApprove(instanceIds, raw, auditCtx) {
2710
2816
  const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
2711
2817
  this.guardBulkSize(instanceIds);