hierarchical-approval 2.9.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 +48 -0
- package/README.md +7 -0
- package/dist/{ApprovalEngine-DMM55f5R.d.cts → ApprovalEngine-Bfs5BnMm.d.cts} +34 -0
- package/dist/{ApprovalEngine-B3PVWw05.d.ts → ApprovalEngine-CCOwhMrs.d.ts} +34 -0
- package/dist/index.cjs +127 -48
- 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 +127 -48
- package/dist/index.js.map +1 -1
- package/dist/nestjs.cjs +127 -48
- 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 +127 -48
- package/dist/nestjs.js.map +1 -1
- package/dist/testing.cjs +127 -48
- 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 +127 -48
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
_Nothing yet._
|
|
9
9
|
|
|
10
|
+
## [3.0.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
Three defects found by auditing the interactions between features added across
|
|
13
|
+
2.x, rather than by adding anything new.
|
|
14
|
+
|
|
15
|
+
### Fixed — `updateData()` built condition-added levels wrong
|
|
16
|
+
|
|
17
|
+
- **A level a condition added during `updateData()` silently lost its `group`,
|
|
18
|
+
`subWorkflow`, `escalationAfterHours` and reminder configuration.** Levels
|
|
19
|
+
were constructed in two places — `submit()` and `recomputeFutureChain()` —
|
|
20
|
+
and the second copy had never been updated as fields were added across 1.0.0
|
|
21
|
+
to 2.6.0. The consequences were quiet and serious:
|
|
22
|
+
|
|
23
|
+
- a condition-added **parallel group ran sequentially**, one branch at a time,
|
|
24
|
+
because the levels came back without their `group`;
|
|
25
|
+
- a condition-added **sub-workflow level lost its binding**, then failed with
|
|
26
|
+
"No approvers resolved for this level" when reached — leaving an approval
|
|
27
|
+
that could never advance;
|
|
28
|
+
- hour-based escalation and reminders simply never fired.
|
|
29
|
+
|
|
30
|
+
A level whose configuration was unchanged was carried over intact, so this
|
|
31
|
+
only bit templates whose conditions *add* levels — and it bit them silently.
|
|
32
|
+
|
|
33
|
+
Both paths now go through one `buildLevelInstance()`, so a field cannot be
|
|
34
|
+
added to a level in one place and forgotten in the other. This is the same
|
|
35
|
+
failure the 1.6.0 Postgres column list had, in a different file.
|
|
36
|
+
|
|
37
|
+
### BREAKING — a finished parent no longer leaves its sub-workflow children running
|
|
38
|
+
|
|
39
|
+
- **Cancelling or rejecting a parent left its child approval pending forever.**
|
|
40
|
+
The child kept notifying, kept escalating, and kept appearing in
|
|
41
|
+
`getWorkload()` — asking people to decide something whose outcome nobody would
|
|
42
|
+
ever read, since `propagateToParent()` ignores a parent that is no longer
|
|
43
|
+
pending. Children of a terminal parent are now cancelled, with the reason
|
|
44
|
+
naming the parent, and the child's own audit trail is left intact rather than
|
|
45
|
+
deleted.
|
|
46
|
+
|
|
47
|
+
**Behaviour change:** a child that used to stay open now reaches `cancelled`.
|
|
48
|
+
Anything counting open approvals, or waiting on a child whose parent has
|
|
49
|
+
ended, will see different numbers — correct ones.
|
|
50
|
+
|
|
51
|
+
- **`purgeInstances()` orphaned sub-workflow children.** It removed the parent
|
|
52
|
+
and left the child behind, holding a `parentInstanceId` pointing at a row that
|
|
53
|
+
no longer existed — unreachable, and invisible to a purge scoped by document
|
|
54
|
+
type, since a child usually has a different one. A purge now takes the whole
|
|
55
|
+
sub-workflow family together, parents first, deduplicated so a parent and
|
|
56
|
+
child sharing a terminal status are each reported once.
|
|
57
|
+
|
|
10
58
|
## [2.9.0] - 2026-09-04
|
|
11
59
|
|
|
12
60
|
### Added — `simulate()`
|
package/README.md
CHANGED
|
@@ -689,6 +689,13 @@ Collapsing the non-approved outcomes into one rejection is deliberate: a parent
|
|
|
689
689
|
that treated a cancelled child as "carry on" would advance past a gate nobody
|
|
690
690
|
cleared.
|
|
691
691
|
|
|
692
|
+
**A finished parent ends its children.** If a parent is cancelled or rejected
|
|
693
|
+
while a child is still running, the child is cancelled too, with the reason
|
|
694
|
+
naming the parent — otherwise it would keep asking people to decide something
|
|
695
|
+
whose outcome nobody will read. A child that already finished is left alone.
|
|
696
|
+
`purgeInstances()` likewise removes a whole sub-workflow family together, so a
|
|
697
|
+
child is never orphaned behind a deleted parent.
|
|
698
|
+
|
|
692
699
|
Children link back via `parentInstanceId` and `parentLevel`, and the level
|
|
693
700
|
records `childInstanceId`. Nesting is allowed up to five levels deep, and
|
|
694
701
|
`validateTemplate()` rejects a template that would spawn itself. Emits
|
|
@@ -995,6 +995,19 @@ declare class ApprovalEngine {
|
|
|
995
995
|
* `businessHoursCalendar` to have hours skip evenings and weekends.
|
|
996
996
|
*/
|
|
997
997
|
private deadlineFromHours;
|
|
998
|
+
/**
|
|
999
|
+
* Build a level instance from its template config.
|
|
1000
|
+
*
|
|
1001
|
+
* The single place a level is constructed. It previously happened twice — in
|
|
1002
|
+
* `submit()` and again in `recomputeFutureChain()` — and the second copy was
|
|
1003
|
+
* missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
|
|
1004
|
+
* reminder fields, so a level added by a condition during `updateData()` came
|
|
1005
|
+
* out silently different from the same level created at submit.
|
|
1006
|
+
*
|
|
1007
|
+
* @param cfg - The template's configuration for this level.
|
|
1008
|
+
* @param opts - `open` activates the level now, computing deadlines from `now`.
|
|
1009
|
+
*/
|
|
1010
|
+
private buildLevelInstance;
|
|
998
1011
|
/** Level deadline from whichever of days/hours the template configured. */
|
|
999
1012
|
private levelEscalationDue;
|
|
1000
1013
|
/** First rung of a ladder, sorted by delay, or undefined when there is none. */
|
|
@@ -1098,6 +1111,27 @@ declare class ApprovalEngine {
|
|
|
1098
1111
|
* about them, so a slow child template would surface as a spurious conflict
|
|
1099
1112
|
* on the decision the user just made.
|
|
1100
1113
|
*/
|
|
1114
|
+
/**
|
|
1115
|
+
* An instance and every sub-workflow descendant beneath it, parents first.
|
|
1116
|
+
*
|
|
1117
|
+
* A child is only reachable through its parent's `childInstanceId`, so a
|
|
1118
|
+
* purge that removed the parent alone would strand the rest of the tree.
|
|
1119
|
+
* Depth is bounded by the same cap that limits spawning, and an already-seen
|
|
1120
|
+
* id is skipped so a corrupted link cannot loop.
|
|
1121
|
+
*/
|
|
1122
|
+
private collectSubWorkflowFamily;
|
|
1123
|
+
/**
|
|
1124
|
+
* Cancel sub-workflow children whose parent has finished.
|
|
1125
|
+
*
|
|
1126
|
+
* A child outlives its parent otherwise: it stays pending, keeps notifying
|
|
1127
|
+
* and escalating, and keeps appearing in {@link getWorkload} — asking people
|
|
1128
|
+
* to decide something whose outcome nobody will ever read, because
|
|
1129
|
+
* {@link propagateToParent} ignores a parent that is no longer pending.
|
|
1130
|
+
*
|
|
1131
|
+
* Cancelling rather than deleting keeps the child's own audit trail intact:
|
|
1132
|
+
* the people who were asked, and why the request stopped, stay on the record.
|
|
1133
|
+
*/
|
|
1134
|
+
private cancelOrphanedChildren;
|
|
1101
1135
|
private afterDecision;
|
|
1102
1136
|
private findNextLevel;
|
|
1103
1137
|
private findPreviousLevel;
|
|
@@ -995,6 +995,19 @@ declare class ApprovalEngine {
|
|
|
995
995
|
* `businessHoursCalendar` to have hours skip evenings and weekends.
|
|
996
996
|
*/
|
|
997
997
|
private deadlineFromHours;
|
|
998
|
+
/**
|
|
999
|
+
* Build a level instance from its template config.
|
|
1000
|
+
*
|
|
1001
|
+
* The single place a level is constructed. It previously happened twice — in
|
|
1002
|
+
* `submit()` and again in `recomputeFutureChain()` — and the second copy was
|
|
1003
|
+
* missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
|
|
1004
|
+
* reminder fields, so a level added by a condition during `updateData()` came
|
|
1005
|
+
* out silently different from the same level created at submit.
|
|
1006
|
+
*
|
|
1007
|
+
* @param cfg - The template's configuration for this level.
|
|
1008
|
+
* @param opts - `open` activates the level now, computing deadlines from `now`.
|
|
1009
|
+
*/
|
|
1010
|
+
private buildLevelInstance;
|
|
998
1011
|
/** Level deadline from whichever of days/hours the template configured. */
|
|
999
1012
|
private levelEscalationDue;
|
|
1000
1013
|
/** First rung of a ladder, sorted by delay, or undefined when there is none. */
|
|
@@ -1098,6 +1111,27 @@ declare class ApprovalEngine {
|
|
|
1098
1111
|
* about them, so a slow child template would surface as a spurious conflict
|
|
1099
1112
|
* on the decision the user just made.
|
|
1100
1113
|
*/
|
|
1114
|
+
/**
|
|
1115
|
+
* An instance and every sub-workflow descendant beneath it, parents first.
|
|
1116
|
+
*
|
|
1117
|
+
* A child is only reachable through its parent's `childInstanceId`, so a
|
|
1118
|
+
* purge that removed the parent alone would strand the rest of the tree.
|
|
1119
|
+
* Depth is bounded by the same cap that limits spawning, and an already-seen
|
|
1120
|
+
* id is skipped so a corrupted link cannot loop.
|
|
1121
|
+
*/
|
|
1122
|
+
private collectSubWorkflowFamily;
|
|
1123
|
+
/**
|
|
1124
|
+
* Cancel sub-workflow children whose parent has finished.
|
|
1125
|
+
*
|
|
1126
|
+
* A child outlives its parent otherwise: it stays pending, keeps notifying
|
|
1127
|
+
* and escalating, and keeps appearing in {@link getWorkload} — asking people
|
|
1128
|
+
* to decide something whose outcome nobody will ever read, because
|
|
1129
|
+
* {@link propagateToParent} ignores a parent that is no longer pending.
|
|
1130
|
+
*
|
|
1131
|
+
* Cancelling rather than deleting keeps the child's own audit trail intact:
|
|
1132
|
+
* the people who were asked, and why the request stopped, stay on the record.
|
|
1133
|
+
*/
|
|
1134
|
+
private cancelOrphanedChildren;
|
|
1101
1135
|
private afterDecision;
|
|
1102
1136
|
private findNextLevel;
|
|
1103
1137
|
private findPreviousLevel;
|
package/dist/index.cjs
CHANGED
|
@@ -1475,33 +1475,14 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1475
1475
|
const instanceId = this.generateId("inst");
|
|
1476
1476
|
const firstCfg = allLevelCfgs[0];
|
|
1477
1477
|
const firstGroupKey = firstCfg ? _ApprovalEngine.groupKeyOf(firstCfg) : null;
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
approverIds: [],
|
|
1487
|
-
approvedBy: [],
|
|
1488
|
-
rejectedBy: [],
|
|
1489
|
-
status: inFirstGroup ? "pending" : "waiting",
|
|
1490
|
-
minApprovals: cfg.minApprovals,
|
|
1491
|
-
threshold: cfg.threshold,
|
|
1492
|
-
weights: cfg.weights,
|
|
1493
|
-
escalationAfterDays: cfg.escalationAfterDays,
|
|
1494
|
-
escalationAfterHours: cfg.escalationAfterHours,
|
|
1495
|
-
escalationStep: 0,
|
|
1496
|
-
escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
|
|
1497
|
-
subWorkflowTemplate: cfg.subWorkflow?.templateName,
|
|
1498
|
-
reminderAfterDays: cfg.reminderAfterDays,
|
|
1499
|
-
reminderEveryDays: cfg.reminderEveryDays,
|
|
1500
|
-
maxReminders: cfg.maxReminders,
|
|
1501
|
-
remindersSent: 0,
|
|
1502
|
-
reminderDueAt: inFirstGroup && cfg.reminderAfterDays ? this.deadlineFrom(now, cfg.reminderAfterDays) : void 0
|
|
1503
|
-
};
|
|
1504
|
-
});
|
|
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
|
+
);
|
|
1505
1486
|
for (const lvl of levels.filter((l) => l.status === "pending")) {
|
|
1506
1487
|
if (lvl.subWorkflowTemplate) {
|
|
1507
1488
|
lvl.approverIds = [];
|
|
@@ -2176,7 +2157,10 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2176
2157
|
cancelled = instance;
|
|
2177
2158
|
return instance;
|
|
2178
2159
|
});
|
|
2179
|
-
if (cancelled)
|
|
2160
|
+
if (cancelled) {
|
|
2161
|
+
await this.cancelOrphanedChildren(cancelled);
|
|
2162
|
+
await this.propagateToParent(cancelled);
|
|
2163
|
+
}
|
|
2180
2164
|
return result;
|
|
2181
2165
|
}
|
|
2182
2166
|
async escalate(instanceId, raw, auditCtx) {
|
|
@@ -2329,20 +2313,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
2329
2313
|
const rebuiltFuture = futureCfgs.map((cfg) => {
|
|
2330
2314
|
const kept = existingFuture.get(cfg.level);
|
|
2331
2315
|
if (kept) return kept;
|
|
2332
|
-
return {
|
|
2333
|
-
level: cfg.level,
|
|
2334
|
-
name: cfg.name,
|
|
2335
|
-
mode: cfg.mode,
|
|
2336
|
-
approverConfigs: cfg.approvers,
|
|
2337
|
-
approverIds: [],
|
|
2338
|
-
approvedBy: [],
|
|
2339
|
-
rejectedBy: [],
|
|
2340
|
-
status: "waiting",
|
|
2341
|
-
minApprovals: cfg.minApprovals,
|
|
2342
|
-
threshold: cfg.threshold,
|
|
2343
|
-
weights: cfg.weights,
|
|
2344
|
-
escalationAfterDays: cfg.escalationAfterDays
|
|
2345
|
-
};
|
|
2316
|
+
return this.buildLevelInstance(cfg, { open: false, now: this.clock.now() });
|
|
2346
2317
|
});
|
|
2347
2318
|
instance.levels = [...frozen, ...rebuiltFuture];
|
|
2348
2319
|
return { addedLevels: addedLevels.sort((a, b) => a - b), removedLevels };
|
|
@@ -3659,6 +3630,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3659
3630
|
);
|
|
3660
3631
|
}
|
|
3661
3632
|
const result = { purged: [], scanned: 0, dryRun };
|
|
3633
|
+
const handled = /* @__PURE__ */ new Set();
|
|
3662
3634
|
for (const status of requested) {
|
|
3663
3635
|
if (result.purged.length >= limit) break;
|
|
3664
3636
|
const page = await this.opts.adapter.getInstancesByFilter(
|
|
@@ -3672,15 +3644,21 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3672
3644
|
);
|
|
3673
3645
|
for (const instance of page.items) {
|
|
3674
3646
|
if (result.purged.length >= limit) break;
|
|
3647
|
+
if (handled.has(instance.id)) continue;
|
|
3675
3648
|
result.scanned++;
|
|
3676
3649
|
if (!TERMINAL_STATUSES.has(instance.status)) continue;
|
|
3677
3650
|
if (new Date(instance.createdAt) > opts.olderThan) continue;
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
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
|
+
}
|
|
3684
3662
|
}
|
|
3685
3663
|
}
|
|
3686
3664
|
this.logger.info("purgeInstances: sweep complete", {
|
|
@@ -4056,6 +4034,47 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
4056
4034
|
const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
|
|
4057
4035
|
return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
|
|
4058
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
|
+
}
|
|
4059
4078
|
/** Level deadline from whichever of days/hours the template configured. */
|
|
4060
4079
|
levelEscalationDue(from, level, firstRung) {
|
|
4061
4080
|
if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
|
|
@@ -4507,8 +4526,68 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
4507
4526
|
* about them, so a slow child template would surface as a spurious conflict
|
|
4508
4527
|
* on the decision the user just made.
|
|
4509
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
|
+
}
|
|
4510
4588
|
async afterDecision(instance) {
|
|
4511
4589
|
if (TERMINAL_STATUSES.has(instance.status)) {
|
|
4590
|
+
await this.cancelOrphanedChildren(instance);
|
|
4512
4591
|
await this.propagateToParent(instance);
|
|
4513
4592
|
return;
|
|
4514
4593
|
}
|