hierarchical-approval 2.0.0 → 2.1.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 +41 -0
- package/README.md +34 -0
- package/dist/{ApprovalEngine-B8bPZq01.d.ts → ApprovalEngine-CJwnpnpL.d.ts} +52 -7
- package/dist/{ApprovalEngine-C7p9cOA3.d.cts → ApprovalEngine-DqRIadYg.d.cts} +52 -7
- package/dist/{IAuditAdapter-UJNdJanq.d.cts → IAuditAdapter-BHVKMMv6.d.cts} +1 -1
- package/dist/{IAuditAdapter-DDV4Rf9F.d.ts → IAuditAdapter-C4OASS6u.d.ts} +1 -1
- package/dist/{IAuthorizationPolicy-CFJ-gXKl.d.ts → IAuthorizationPolicy-BA_m2Dg9.d.ts} +1 -1
- package/dist/{IAuthorizationPolicy-CN6LAaKg.d.cts → IAuthorizationPolicy-CtLkwQ8I.d.cts} +1 -1
- package/dist/{INotificationAdapter-Dv_MuYwS.d.ts → INotificationAdapter-BdgfUrIn.d.ts} +12 -2
- package/dist/{INotificationAdapter-Dy2d0JKy.d.cts → INotificationAdapter-JJAiMyMd.d.cts} +12 -2
- package/dist/{IOperationMiddleware-DWYXVuJD.d.ts → IOperationMiddleware-BGYrAqep.d.ts} +1 -1
- package/dist/{IOperationMiddleware-DGG-guxK.d.cts → IOperationMiddleware-D0ksiFM3.d.cts} +1 -1
- package/dist/{IStorageAdapter-Bgd6p8XL.d.cts → IStorageAdapter-Bk7ybd3z.d.cts} +1 -1
- package/dist/{IStorageAdapter-B8aRYeGI.d.ts → IStorageAdapter-DjRvHUF0.d.ts} +1 -1
- package/dist/adapters/MemoryAdapter.d.cts +2 -2
- package/dist/adapters/MemoryAdapter.d.ts +2 -2
- package/dist/adapters/PostgresAdapter.d.cts +2 -2
- package/dist/adapters/PostgresAdapter.d.ts +2 -2
- package/dist/index.cjs +271 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +271 -13
- package/dist/index.js.map +1 -1
- package/dist/{instance-BE0uJmg3.d.cts → instance-DUJY_Axf.d.cts} +41 -3
- package/dist/{instance-BE0uJmg3.d.ts → instance-DUJY_Axf.d.ts} +41 -3
- package/dist/nestjs.cjs +271 -13
- package/dist/nestjs.cjs.map +1 -1
- package/dist/nestjs.d.cts +7 -7
- package/dist/nestjs.d.ts +7 -7
- package/dist/nestjs.js +271 -13
- package/dist/nestjs.js.map +1 -1
- package/dist/plugins/audit.d.cts +2 -2
- package/dist/plugins/audit.d.ts +2 -2
- package/dist/plugins/notify.d.cts +2 -2
- package/dist/plugins/notify.d.ts +2 -2
- package/dist/plugins/resilience.d.cts +3 -3
- package/dist/plugins/resilience.d.ts +3 -3
- package/dist/plugins/tracing.d.cts +2 -2
- package/dist/plugins/tracing.d.ts +2 -2
- package/dist/plugins/webhook.d.cts +2 -2
- package/dist/plugins/webhook.d.ts +2 -2
- package/dist/testing.cjs +271 -13
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +7 -7
- package/dist/testing.d.ts +7 -7
- package/dist/testing.js +271 -13
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -914,6 +914,7 @@ function computeTimingStats(samples) {
|
|
|
914
914
|
}
|
|
915
915
|
|
|
916
916
|
// src/engine/ApprovalEngine.ts
|
|
917
|
+
var MAX_SUBWORKFLOW_DEPTH = 5;
|
|
917
918
|
var DEFAULT_MAX_REMINDERS = 3;
|
|
918
919
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
919
920
|
var DEFAULT_BASE_DELAY_MS = 50;
|
|
@@ -1018,12 +1019,32 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1018
1019
|
});
|
|
1019
1020
|
}
|
|
1020
1021
|
levelNums.add(l.level);
|
|
1021
|
-
if (!l.approvers || l.approvers.length === 0) {
|
|
1022
|
+
if (!l.subWorkflow && (!l.approvers || l.approvers.length === 0)) {
|
|
1022
1023
|
errors.push({
|
|
1023
1024
|
field: `levels[${i}].approvers`,
|
|
1024
1025
|
message: `Level ${l.level} must have at least one approver.`
|
|
1025
1026
|
});
|
|
1026
1027
|
}
|
|
1028
|
+
if (l.subWorkflow) {
|
|
1029
|
+
if (!l.subWorkflow.templateName) {
|
|
1030
|
+
errors.push({
|
|
1031
|
+
field: `levels[${i}].subWorkflow.templateName`,
|
|
1032
|
+
message: `Level ${l.level} declares a subWorkflow without a templateName.`
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
if (l.subWorkflow.templateName === config.name) {
|
|
1036
|
+
errors.push({
|
|
1037
|
+
field: `levels[${i}].subWorkflow.templateName`,
|
|
1038
|
+
message: `Level ${l.level} would spawn a sub-workflow of its own template ("${config.name}"), which cannot terminate.`
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
if (l.approvers && l.approvers.length > 0) {
|
|
1042
|
+
errors.push({
|
|
1043
|
+
field: `levels[${i}].approvers`,
|
|
1044
|
+
message: `Level ${l.level} sets both approvers and subWorkflow; a sub-workflow level is decided by its child approval, so its approvers would never be asked.`
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1027
1048
|
if (l.reminderAfterDays !== void 0 && l.reminderAfterDays <= 0) {
|
|
1028
1049
|
errors.push({
|
|
1029
1050
|
field: `levels[${i}].reminderAfterDays`,
|
|
@@ -1166,7 +1187,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1166
1187
|
return this.registry.list();
|
|
1167
1188
|
}
|
|
1168
1189
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
1169
|
-
async submit(raw, auditCtx) {
|
|
1190
|
+
async submit(raw, auditCtx, link) {
|
|
1170
1191
|
const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
|
|
1171
1192
|
const startMs = this.clock.now().getTime();
|
|
1172
1193
|
const template = await this.registry.get(opts.templateName);
|
|
@@ -1226,6 +1247,7 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1226
1247
|
weights: cfg.weights,
|
|
1227
1248
|
escalationAfterDays: cfg.escalationAfterDays,
|
|
1228
1249
|
escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
|
|
1250
|
+
subWorkflowTemplate: cfg.subWorkflow?.templateName,
|
|
1229
1251
|
reminderAfterDays: cfg.reminderAfterDays,
|
|
1230
1252
|
reminderEveryDays: cfg.reminderEveryDays,
|
|
1231
1253
|
maxReminders: cfg.maxReminders,
|
|
@@ -1234,6 +1256,10 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1234
1256
|
};
|
|
1235
1257
|
});
|
|
1236
1258
|
for (const lvl of levels.filter((l) => l.status === "pending")) {
|
|
1259
|
+
if (lvl.subWorkflowTemplate) {
|
|
1260
|
+
lvl.approverIds = [];
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1237
1263
|
lvl.approverIds = await this.resolver.resolveApprovers(
|
|
1238
1264
|
lvl.approverConfigs,
|
|
1239
1265
|
opts.submittedBy,
|
|
@@ -1254,6 +1280,9 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1254
1280
|
const instance = {
|
|
1255
1281
|
id: instanceId,
|
|
1256
1282
|
tenantId: this.tenantId,
|
|
1283
|
+
parentInstanceId: link?.parentInstanceId,
|
|
1284
|
+
parentLevel: link?.parentLevel,
|
|
1285
|
+
subWorkflowDepth: link?.depth,
|
|
1257
1286
|
templateId: template.id,
|
|
1258
1287
|
templateName: template.name,
|
|
1259
1288
|
documentId: opts.documentId,
|
|
@@ -1318,12 +1347,13 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1318
1347
|
{ operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts },
|
|
1319
1348
|
instance
|
|
1320
1349
|
);
|
|
1350
|
+
await this.startSubWorkflows(instance);
|
|
1321
1351
|
return instance;
|
|
1322
1352
|
}
|
|
1323
1353
|
async approve(instanceId, raw, auditCtx) {
|
|
1324
1354
|
const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
|
|
1325
1355
|
const startMs = this.clock.now().getTime();
|
|
1326
|
-
|
|
1356
|
+
const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1327
1357
|
assertStatus(instance, "pending");
|
|
1328
1358
|
if (opts.approverId === instance.submittedBy) {
|
|
1329
1359
|
throw new ApprovalForbiddenError(
|
|
@@ -1536,10 +1566,12 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1536
1566
|
}
|
|
1537
1567
|
return instance;
|
|
1538
1568
|
});
|
|
1569
|
+
await this.afterDecision(decided);
|
|
1570
|
+
return decided;
|
|
1539
1571
|
}
|
|
1540
1572
|
async reject(instanceId, raw, auditCtx) {
|
|
1541
1573
|
const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
|
|
1542
|
-
|
|
1574
|
+
const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1543
1575
|
assertStatus(instance, "pending");
|
|
1544
1576
|
if (opts.approverId === instance.submittedBy) {
|
|
1545
1577
|
throw new ApprovalForbiddenError(
|
|
@@ -1658,6 +1690,8 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1658
1690
|
}
|
|
1659
1691
|
return instance;
|
|
1660
1692
|
});
|
|
1693
|
+
await this.afterDecision(decided);
|
|
1694
|
+
return decided;
|
|
1661
1695
|
}
|
|
1662
1696
|
async delegate(instanceId, raw, auditCtx) {
|
|
1663
1697
|
const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
|
|
@@ -1835,7 +1869,8 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1835
1869
|
}
|
|
1836
1870
|
async cancel(instanceId, raw, auditCtx) {
|
|
1837
1871
|
const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
|
|
1838
|
-
|
|
1872
|
+
let cancelled = null;
|
|
1873
|
+
const result = await this.withOptimisticRetry(instanceId, async (instance) => {
|
|
1839
1874
|
if (instance.status === "approved" || instance.status === "rejected") {
|
|
1840
1875
|
throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
|
|
1841
1876
|
}
|
|
@@ -1889,8 +1924,11 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
1889
1924
|
},
|
|
1890
1925
|
instance
|
|
1891
1926
|
);
|
|
1927
|
+
cancelled = instance;
|
|
1892
1928
|
return instance;
|
|
1893
1929
|
});
|
|
1930
|
+
if (cancelled) await this.propagateToParent(cancelled);
|
|
1931
|
+
return result;
|
|
1894
1932
|
}
|
|
1895
1933
|
async escalate(instanceId, raw, auditCtx) {
|
|
1896
1934
|
parseOrThrow(() => EscalateOptionsSchema.parse(raw));
|
|
@@ -3407,14 +3445,18 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3407
3445
|
/** Resolve approvers for a group, set its deadlines, and mark it pending. */
|
|
3408
3446
|
async activateGroup(instance, group, now) {
|
|
3409
3447
|
for (const lvl of group) {
|
|
3410
|
-
lvl.
|
|
3411
|
-
lvl.
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3448
|
+
if (lvl.subWorkflowTemplate) {
|
|
3449
|
+
lvl.approverIds = [];
|
|
3450
|
+
} else {
|
|
3451
|
+
lvl.approverIds = await this.resolver.resolveApprovers(
|
|
3452
|
+
lvl.approverConfigs,
|
|
3453
|
+
instance.submittedBy,
|
|
3454
|
+
instance.data,
|
|
3455
|
+
this.opts.orgProvider,
|
|
3456
|
+
this.opts.outOfOfficeProvider,
|
|
3457
|
+
now
|
|
3458
|
+
);
|
|
3459
|
+
}
|
|
3418
3460
|
if (lvl.escalationAfterDays) {
|
|
3419
3461
|
lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
|
|
3420
3462
|
}
|
|
@@ -3501,6 +3543,222 @@ var ApprovalEngine = class _ApprovalEngine {
|
|
|
3501
3543
|
this.logger.error("reminder: failed to send", err, { tenantId: this.tenantId, instanceId });
|
|
3502
3544
|
}
|
|
3503
3545
|
}
|
|
3546
|
+
/**
|
|
3547
|
+
* Start child approvals for any open level that delegates to a sub-workflow.
|
|
3548
|
+
*
|
|
3549
|
+
* Run after the parent has been persisted, never inside the same optimistic
|
|
3550
|
+
* write: the child's own `submit()` performs its own reads and writes, and
|
|
3551
|
+
* nesting them under the parent's compare-and-set would make a slow child
|
|
3552
|
+
* template a source of spurious version conflicts on the parent.
|
|
3553
|
+
*/
|
|
3554
|
+
async startSubWorkflows(instance) {
|
|
3555
|
+
const pendingSubs = instance.levels.filter(
|
|
3556
|
+
(l) => l.status === "pending" && l.subWorkflowTemplate && !l.childInstanceId
|
|
3557
|
+
);
|
|
3558
|
+
if (pendingSubs.length === 0) return;
|
|
3559
|
+
const depth = (instance.subWorkflowDepth ?? 0) + 1;
|
|
3560
|
+
if (depth > MAX_SUBWORKFLOW_DEPTH) {
|
|
3561
|
+
throw new ApprovalValidationError(
|
|
3562
|
+
`Sub-workflow nesting exceeded ${MAX_SUBWORKFLOW_DEPTH} levels at template "${instance.templateName}". Check for a template that reaches itself.`
|
|
3563
|
+
);
|
|
3564
|
+
}
|
|
3565
|
+
for (const level of pendingSubs) {
|
|
3566
|
+
const templateName = level.subWorkflowTemplate;
|
|
3567
|
+
const childTemplate = await this.registry.get(templateName);
|
|
3568
|
+
const child = await this.submit(
|
|
3569
|
+
{
|
|
3570
|
+
templateName,
|
|
3571
|
+
// Unique per parent level, so a resubmitted parent does not collide
|
|
3572
|
+
// with the child it spawned last time.
|
|
3573
|
+
documentId: `${instance.documentId}#L${level.level}`,
|
|
3574
|
+
documentType: childTemplate.documentType,
|
|
3575
|
+
submittedBy: instance.submittedBy,
|
|
3576
|
+
data: instance.data,
|
|
3577
|
+
metadata: { ...instance.metadata, parentInstanceId: instance.id }
|
|
3578
|
+
},
|
|
3579
|
+
void 0,
|
|
3580
|
+
{ parentInstanceId: instance.id, parentLevel: level.level, depth }
|
|
3581
|
+
);
|
|
3582
|
+
await this.withOptimisticRetry(instance.id, async (parent) => {
|
|
3583
|
+
const lvl = parent.levels.find((l) => l.level === level.level);
|
|
3584
|
+
if (!lvl || lvl.childInstanceId) return parent;
|
|
3585
|
+
lvl.childInstanceId = child.id;
|
|
3586
|
+
parent.updatedAt = this.clock.now();
|
|
3587
|
+
await this.opts.adapter.updateInstance(parent, parent.version);
|
|
3588
|
+
return parent;
|
|
3589
|
+
});
|
|
3590
|
+
const payload = {
|
|
3591
|
+
instanceId: instance.id,
|
|
3592
|
+
documentId: instance.documentId,
|
|
3593
|
+
documentType: instance.documentType,
|
|
3594
|
+
timestamp: this.clock.now(),
|
|
3595
|
+
level: level.level,
|
|
3596
|
+
childInstanceId: child.id,
|
|
3597
|
+
childTemplateName: templateName
|
|
3598
|
+
};
|
|
3599
|
+
this.bus.emit("approval:subworkflow_started", payload);
|
|
3600
|
+
await this.notifyAdapters("approval:subworkflow_started", instance, payload);
|
|
3601
|
+
this.logger.info("subWorkflow: child approval started", {
|
|
3602
|
+
tenantId: this.tenantId,
|
|
3603
|
+
instanceId: instance.id,
|
|
3604
|
+
level: level.level,
|
|
3605
|
+
childInstanceId: child.id
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Return a finished child's outcome to the parent level that is waiting on it.
|
|
3611
|
+
*
|
|
3612
|
+
* An approved child approves its parent level and lets the chain advance; any
|
|
3613
|
+
* other terminal outcome — rejected, cancelled, expired — rejects the parent,
|
|
3614
|
+
* because the approval the parent was waiting for did not happen. Collapsing
|
|
3615
|
+
* those into one rejection is deliberate: a parent that treated a cancelled
|
|
3616
|
+
* child as "carry on" would advance past a gate nobody cleared.
|
|
3617
|
+
*/
|
|
3618
|
+
async propagateToParent(child) {
|
|
3619
|
+
if (!child.parentInstanceId || child.parentLevel === void 0) return;
|
|
3620
|
+
const outcome = child.status;
|
|
3621
|
+
const parentId = child.parentInstanceId;
|
|
3622
|
+
const parentLevelNumber = child.parentLevel;
|
|
3623
|
+
try {
|
|
3624
|
+
const parent = await this.opts.adapter.getInstance(this.tenantId, parentId);
|
|
3625
|
+
if (!parent || parent.status !== "pending") return;
|
|
3626
|
+
const payload = {
|
|
3627
|
+
instanceId: parentId,
|
|
3628
|
+
documentId: parent.documentId,
|
|
3629
|
+
documentType: parent.documentType,
|
|
3630
|
+
timestamp: this.clock.now(),
|
|
3631
|
+
level: parentLevelNumber,
|
|
3632
|
+
childInstanceId: child.id,
|
|
3633
|
+
childTemplateName: child.templateName,
|
|
3634
|
+
outcome
|
|
3635
|
+
};
|
|
3636
|
+
if (outcome === "approved") {
|
|
3637
|
+
await this.completeSubWorkflowLevel(parentId, parentLevelNumber, child);
|
|
3638
|
+
} else {
|
|
3639
|
+
await this.rejectFromSubWorkflow(parentId, parentLevelNumber, child);
|
|
3640
|
+
}
|
|
3641
|
+
const refreshed = await this.opts.adapter.getInstance(this.tenantId, parentId);
|
|
3642
|
+
this.bus.emit("approval:subworkflow_completed", payload);
|
|
3643
|
+
if (refreshed) {
|
|
3644
|
+
await this.notifyAdapters("approval:subworkflow_completed", refreshed, payload);
|
|
3645
|
+
}
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
this.logger.error("subWorkflow: failed to propagate outcome to parent", err, {
|
|
3648
|
+
tenantId: this.tenantId,
|
|
3649
|
+
childInstanceId: child.id,
|
|
3650
|
+
parentInstanceId: parentId
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
/** Mark a sub-workflow level approved and advance the parent chain. */
|
|
3655
|
+
async completeSubWorkflowLevel(parentId, levelNumber, child) {
|
|
3656
|
+
let advanced = null;
|
|
3657
|
+
await this.withOptimisticRetry(parentId, async (parent) => {
|
|
3658
|
+
const level = parent.levels.find((l) => l.level === levelNumber);
|
|
3659
|
+
if (!level || level.status !== "pending") return parent;
|
|
3660
|
+
const now = this.clock.now();
|
|
3661
|
+
level.status = "approved";
|
|
3662
|
+
level.reminderDueAt = void 0;
|
|
3663
|
+
level.escalationDueAt = void 0;
|
|
3664
|
+
const auditEntry = {
|
|
3665
|
+
action: "subworkflow_completed",
|
|
3666
|
+
actorId: "system",
|
|
3667
|
+
level: levelNumber,
|
|
3668
|
+
timestamp: now,
|
|
3669
|
+
newValue: { childInstanceId: child.id, outcome: "approved" }
|
|
3670
|
+
};
|
|
3671
|
+
parent.auditLog.push(auditEntry);
|
|
3672
|
+
parent.updatedAt = now;
|
|
3673
|
+
const siblingsOpen = this.groupMembers(parent, level).some(
|
|
3674
|
+
(l) => l.status === "pending" || l.status === "waiting"
|
|
3675
|
+
);
|
|
3676
|
+
if (!siblingsOpen) {
|
|
3677
|
+
const nextGroup = this.findNextGroup(parent);
|
|
3678
|
+
if (nextGroup.length === 0) {
|
|
3679
|
+
parent.status = "approved";
|
|
3680
|
+
} else {
|
|
3681
|
+
await this.activateGroup(parent, nextGroup, now);
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
await this.opts.adapter.updateInstance(parent, parent.version);
|
|
3685
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
|
|
3686
|
+
await this.runExternalAudit(parent, auditEntry);
|
|
3687
|
+
advanced = parent;
|
|
3688
|
+
return parent;
|
|
3689
|
+
});
|
|
3690
|
+
if (advanced) {
|
|
3691
|
+
const parent = advanced;
|
|
3692
|
+
if (parent.status === "approved") {
|
|
3693
|
+
this.bus.emit("approval:completed", parent);
|
|
3694
|
+
await this.notifyAdapters("approval:completed", parent, parent);
|
|
3695
|
+
await this.propagateToParent(parent);
|
|
3696
|
+
} else {
|
|
3697
|
+
await this.startSubWorkflows(parent);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
/** Reject a parent because the child approval it was waiting on did not succeed. */
|
|
3702
|
+
async rejectFromSubWorkflow(parentId, levelNumber, child) {
|
|
3703
|
+
let rejected = null;
|
|
3704
|
+
await this.withOptimisticRetry(parentId, async (parent) => {
|
|
3705
|
+
const level = parent.levels.find((l) => l.level === levelNumber);
|
|
3706
|
+
if (!level || level.status !== "pending") return parent;
|
|
3707
|
+
const now = this.clock.now();
|
|
3708
|
+
level.status = "rejected";
|
|
3709
|
+
level.reminderDueAt = void 0;
|
|
3710
|
+
level.escalationDueAt = void 0;
|
|
3711
|
+
parent.status = "rejected";
|
|
3712
|
+
parent.updatedAt = now;
|
|
3713
|
+
const auditEntry = {
|
|
3714
|
+
action: "subworkflow_completed",
|
|
3715
|
+
actorId: "system",
|
|
3716
|
+
level: levelNumber,
|
|
3717
|
+
timestamp: now,
|
|
3718
|
+
reason: `Child approval ${child.id} ended as "${child.status}".`,
|
|
3719
|
+
newValue: { childInstanceId: child.id, outcome: child.status }
|
|
3720
|
+
};
|
|
3721
|
+
parent.auditLog.push(auditEntry);
|
|
3722
|
+
await this.opts.adapter.updateInstance(parent, parent.version);
|
|
3723
|
+
await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
|
|
3724
|
+
await this.runExternalAudit(parent, auditEntry);
|
|
3725
|
+
rejected = parent;
|
|
3726
|
+
return parent;
|
|
3727
|
+
});
|
|
3728
|
+
if (rejected) {
|
|
3729
|
+
const parent = rejected;
|
|
3730
|
+
const payload = {
|
|
3731
|
+
instanceId: parent.id,
|
|
3732
|
+
documentId: parent.documentId,
|
|
3733
|
+
documentType: parent.documentType,
|
|
3734
|
+
timestamp: this.clock.now(),
|
|
3735
|
+
approverId: "system",
|
|
3736
|
+
level: levelNumber,
|
|
3737
|
+
reason: `Child approval ${child.id} ended as "${child.status}".`,
|
|
3738
|
+
returnTo: null
|
|
3739
|
+
};
|
|
3740
|
+
this.bus.emit("approval:rejected", payload);
|
|
3741
|
+
await this.notifyAdapters("approval:rejected", parent, payload);
|
|
3742
|
+
await this.propagateToParent(parent);
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Work that must happen after a decision is durably recorded, not inside it.
|
|
3747
|
+
*
|
|
3748
|
+
* Both branches touch other instances — a newly opened level may spawn a
|
|
3749
|
+
* child approval, and a finished instance may be a child that owes its
|
|
3750
|
+
* outcome to a parent. Doing either inside the deciding instance's
|
|
3751
|
+
* compare-and-set would nest writes under a version guard that knows nothing
|
|
3752
|
+
* about them, so a slow child template would surface as a spurious conflict
|
|
3753
|
+
* on the decision the user just made.
|
|
3754
|
+
*/
|
|
3755
|
+
async afterDecision(instance) {
|
|
3756
|
+
if (TERMINAL_STATUSES.has(instance.status)) {
|
|
3757
|
+
await this.propagateToParent(instance);
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
await this.startSubWorkflows(instance);
|
|
3761
|
+
}
|
|
3504
3762
|
findNextLevel(instance) {
|
|
3505
3763
|
return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
|
|
3506
3764
|
}
|