@rytass/bpm-core-nestjs-module 0.22.0 → 0.24.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +68 -1
  3. package/package.json +2 -2
  4. package/src/index.d.ts +1 -0
  5. package/src/index.js +1 -0
  6. package/src/index.js.map +1 -1
  7. package/src/lib/bpm/bpm-root-options.d.ts +57 -0
  8. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  9. package/src/lib/bpm/bpm-root.module.d.ts +21 -0
  10. package/src/lib/bpm/bpm-root.module.js +6 -0
  11. package/src/lib/bpm/bpm-root.module.js.map +1 -1
  12. package/src/lib/form/dto/form-definition.input.d.ts +5 -0
  13. package/src/lib/form/dto/form-definition.input.js.map +1 -1
  14. package/src/lib/form/form-definition.entity.d.ts +5 -0
  15. package/src/lib/form/form-definition.entity.js +7 -0
  16. package/src/lib/form/form-definition.entity.js.map +1 -1
  17. package/src/lib/form/form-schema.validator.js +17 -5
  18. package/src/lib/form/form-schema.validator.js.map +1 -1
  19. package/src/lib/form/form.queries.d.ts +2 -2
  20. package/src/lib/form/form.queries.js +17 -5
  21. package/src/lib/form/form.queries.js.map +1 -1
  22. package/src/lib/form/form.service.d.ts +2 -1
  23. package/src/lib/form/form.service.js +10 -5
  24. package/src/lib/form/form.service.js.map +1 -1
  25. package/src/lib/form-data-source/form-data-source-value-resolver.service.js +22 -2
  26. package/src/lib/form-data-source/form-data-source-value-resolver.service.js.map +1 -1
  27. package/src/lib/form-data-source/form-data-source.errors.d.ts +11 -0
  28. package/src/lib/form-data-source/form-data-source.errors.js +14 -1
  29. package/src/lib/form-data-source/form-data-source.errors.js.map +1 -1
  30. package/src/lib/form-data-source/form-data-source.service.d.ts +6 -0
  31. package/src/lib/form-data-source/form-data-source.service.js +37 -3
  32. package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
  33. package/src/lib/instance-event/approval-instance-event.entity.d.ts +27 -0
  34. package/src/lib/instance-event/approval-instance-event.entity.js +93 -0
  35. package/src/lib/instance-event/approval-instance-event.entity.js.map +1 -0
  36. package/src/lib/instance-event/index.d.ts +13 -0
  37. package/src/lib/instance-event/index.js +17 -0
  38. package/src/lib/instance-event/index.js.map +1 -0
  39. package/src/lib/instance-event/instance-event.delivery-scheduler.service.d.ts +21 -0
  40. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js +81 -0
  41. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js.map +1 -0
  42. package/src/lib/instance-event/instance-event.delivery.service.d.ts +69 -0
  43. package/src/lib/instance-event/instance-event.delivery.service.js +259 -0
  44. package/src/lib/instance-event/instance-event.delivery.service.js.map +1 -0
  45. package/src/lib/instance-event/instance-event.delivery.subscriber.d.ts +24 -0
  46. package/src/lib/instance-event/instance-event.delivery.subscriber.js +85 -0
  47. package/src/lib/instance-event/instance-event.delivery.subscriber.js.map +1 -0
  48. package/src/lib/instance-event/instance-event.enums.d.ts +12 -0
  49. package/src/lib/instance-event/instance-event.enums.js +28 -0
  50. package/src/lib/instance-event/instance-event.enums.js.map +1 -0
  51. package/src/lib/instance-event/instance-event.module.d.ts +13 -0
  52. package/src/lib/instance-event/instance-event.module.js +52 -0
  53. package/src/lib/instance-event/instance-event.module.js.map +1 -0
  54. package/src/lib/instance-event/instance-event.object.d.ts +24 -0
  55. package/src/lib/instance-event/instance-event.object.js +73 -0
  56. package/src/lib/instance-event/instance-event.object.js.map +1 -0
  57. package/src/lib/instance-event/instance-event.options.d.ts +26 -0
  58. package/src/lib/instance-event/instance-event.options.js +35 -0
  59. package/src/lib/instance-event/instance-event.options.js.map +1 -0
  60. package/src/lib/instance-event/instance-event.resolver.d.ts +10 -0
  61. package/src/lib/instance-event/instance-event.resolver.js +75 -0
  62. package/src/lib/instance-event/instance-event.resolver.js.map +1 -0
  63. package/src/lib/instance-event/instance-event.service.d.ts +18 -0
  64. package/src/lib/instance-event/instance-event.service.js +37 -0
  65. package/src/lib/instance-event/instance-event.service.js.map +1 -0
  66. package/src/lib/instance-event/instance-event.token.d.ts +19 -0
  67. package/src/lib/instance-event/instance-event.token.js +9 -0
  68. package/src/lib/instance-event/instance-event.token.js.map +1 -0
  69. package/src/lib/instance-event/instance-event.types.d.ts +67 -0
  70. package/src/lib/instance-event/instance-event.types.js +9 -0
  71. package/src/lib/instance-event/instance-event.types.js.map +1 -0
  72. package/src/lib/instance-event/instance-event.writer.d.ts +21 -0
  73. package/src/lib/instance-event/instance-event.writer.js +45 -0
  74. package/src/lib/instance-event/instance-event.writer.js.map +1 -0
  75. package/src/lib/migrations/0000000032000-host-integration-round.d.ts +25 -0
  76. package/src/lib/migrations/0000000032000-host-integration-round.js +104 -0
  77. package/src/lib/migrations/0000000032000-host-integration-round.js.map +1 -0
  78. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.d.ts +35 -0
  79. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js +51 -0
  80. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js.map +1 -0
  81. package/src/lib/migrations/index.d.ts +2 -0
  82. package/src/lib/migrations/index.js +6 -0
  83. package/src/lib/migrations/index.js.map +1 -1
  84. package/src/lib/notification/notification.service.d.ts +3 -2
  85. package/src/lib/notification/notification.service.js +52 -10
  86. package/src/lib/notification/notification.service.js.map +1 -1
  87. package/src/lib/template/approval-template-version.entity.d.ts +9 -1
  88. package/src/lib/template/approval-template-version.entity.js +11 -1
  89. package/src/lib/template/approval-template-version.entity.js.map +1 -1
  90. package/src/lib/template/approval-template.entity.d.ts +18 -0
  91. package/src/lib/template/approval-template.entity.js +23 -0
  92. package/src/lib/template/approval-template.entity.js.map +1 -1
  93. package/src/lib/template/dto/approval-template.input.d.ts +21 -0
  94. package/src/lib/template/dto/approval-template.input.js +38 -0
  95. package/src/lib/template/dto/approval-template.input.js.map +1 -1
  96. package/src/lib/template/dto/compose-approval-template.input.d.ts +23 -0
  97. package/src/lib/template/dto/compose-approval-template.input.js +37 -0
  98. package/src/lib/template/dto/compose-approval-template.input.js.map +1 -1
  99. package/src/lib/template/template-key.d.ts +26 -0
  100. package/src/lib/template/template-key.js +67 -0
  101. package/src/lib/template/template-key.js.map +1 -0
  102. package/src/lib/template/template.enums.d.ts +11 -0
  103. package/src/lib/template/template.enums.js +17 -1
  104. package/src/lib/template/template.enums.js.map +1 -1
  105. package/src/lib/template/template.queries.d.ts +3 -2
  106. package/src/lib/template/template.queries.js +29 -4
  107. package/src/lib/template/template.queries.js.map +1 -1
  108. package/src/lib/template/template.service.d.ts +8 -1
  109. package/src/lib/template/template.service.js +111 -17
  110. package/src/lib/template/template.service.js.map +1 -1
  111. package/src/lib/template/workflow-definition.validator.js +12 -6
  112. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  113. package/src/lib/workflow-engine/approver-filled-table-columns.d.ts +91 -0
  114. package/src/lib/workflow-engine/approver-filled-table-columns.js +301 -0
  115. package/src/lib/workflow-engine/approver-filled-table-columns.js.map +1 -0
  116. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.d.ts +9 -0
  117. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js +24 -0
  118. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js.map +1 -0
  119. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.d.ts +7 -0
  120. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js +9 -0
  121. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js.map +1 -1
  122. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +2 -0
  123. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +8 -0
  124. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -1
  125. package/src/lib/workflow-engine/index.d.ts +3 -0
  126. package/src/lib/workflow-engine/index.js +3 -0
  127. package/src/lib/workflow-engine/index.js.map +1 -1
  128. package/src/lib/workflow-engine/instance-advance-failure.d.ts +28 -0
  129. package/src/lib/workflow-engine/instance-advance-failure.js +20 -0
  130. package/src/lib/workflow-engine/instance-advance-failure.js.map +1 -0
  131. package/src/lib/workflow-engine/instance-guard.runner.d.ts +19 -0
  132. package/src/lib/workflow-engine/instance-guard.runner.js +102 -0
  133. package/src/lib/workflow-engine/instance-guard.runner.js.map +1 -0
  134. package/src/lib/workflow-engine/instance-guard.token.d.ts +101 -0
  135. package/src/lib/workflow-engine/instance-guard.token.js +9 -0
  136. package/src/lib/workflow-engine/instance-guard.token.js.map +1 -0
  137. package/src/lib/workflow-engine/missing-form-fields.d.ts +12 -0
  138. package/src/lib/workflow-engine/missing-form-fields.js +27 -0
  139. package/src/lib/workflow-engine/missing-form-fields.js.map +1 -0
  140. package/src/lib/workflow-engine/stuck-approval-instance.object.d.ts +22 -0
  141. package/src/lib/workflow-engine/stuck-approval-instance.object.js +107 -0
  142. package/src/lib/workflow-engine/stuck-approval-instance.object.js.map +1 -0
  143. package/src/lib/workflow-engine/stuck-approval-instance.query.d.ts +22 -0
  144. package/src/lib/workflow-engine/stuck-approval-instance.query.js +139 -0
  145. package/src/lib/workflow-engine/stuck-approval-instance.query.js.map +1 -0
  146. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +11 -0
  147. package/src/lib/workflow-engine/workflow-engine.enums.js +16 -1
  148. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  149. package/src/lib/workflow-engine/workflow-engine.module.d.ts +2 -0
  150. package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
  151. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  152. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +3 -1
  153. package/src/lib/workflow-engine/workflow-engine.mutations.js +39 -6
  154. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  155. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +10 -8
  156. package/src/lib/workflow-engine/workflow-engine.queries.js +131 -28
  157. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  158. package/src/lib/workflow-engine/workflow-engine.service.d.ts +348 -10
  159. package/src/lib/workflow-engine/workflow-engine.service.js +1396 -118
  160. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  161. package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +5 -0
@@ -36,7 +36,9 @@ const organization_enums_1 = require("../organization/organization.enums");
36
36
  const organization_time_zone_1 = require("../organization/organization-time-zone");
37
37
  const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
38
38
  const approval_template_entity_1 = require("../template/approval-template.entity");
39
+ const approver_filled_table_columns_1 = require("./approver-filled-table-columns");
39
40
  const template_enums_1 = require("../template/template.enums");
41
+ const missing_form_fields_1 = require("./missing-form-fields");
40
42
  const activity_log_entity_1 = require("./activity-log.entity");
41
43
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
42
44
  const adhoc_enums_1 = require("./adhoc.enums");
@@ -45,10 +47,12 @@ const approval_instance_field_filter_compiler_1 = require("./approval-instance-f
45
47
  const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
46
48
  const text_field_pattern_test_1 = require("./text-field-pattern-test");
47
49
  const form_data_fingerprint_1 = require("./form-data-fingerprint");
50
+ const instance_advance_failure_1 = require("./instance-advance-failure");
48
51
  const instance_submitter_1 = require("./instance-submitter");
49
52
  const task_decision_entity_1 = require("./task-decision.entity");
50
53
  const task_candidate_entity_1 = require("./task-candidate.entity");
51
54
  const task_entity_1 = require("./task.entity");
55
+ const stuck_approval_instance_query_1 = require("./stuck-approval-instance.query");
52
56
  const workflow_engine_enums_1 = require("./workflow-engine.enums");
53
57
  const workflow_condition_evaluator_1 = require("./workflow-condition-evaluator");
54
58
  const workflow_form_value_1 = require("./workflow-form-value");
@@ -58,6 +62,9 @@ const expression_budget_1 = require("../condition/expression-budget");
58
62
  const expression_errors_1 = require("../condition/expression-errors");
59
63
  const expression_dialect_1 = require("../condition/expression-dialect");
60
64
  const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
65
+ const instance_event_writer_1 = require("../instance-event/instance-event.writer");
66
+ const instance_guard_token_1 = require("./instance-guard.token");
67
+ const instance_guard_runner_1 = require("./instance-guard.runner");
61
68
  const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
62
69
  const workflow_token_entity_1 = require("./workflow-token.entity");
63
70
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
@@ -182,6 +189,44 @@ const INACTIVE_MEMBER_NAME_LOCALE = 'zh-Hant-TW';
182
189
  * every load of the member screen.
183
190
  */
184
191
  const INACTIVE_MEMBER_COUNT_CONCURRENCY = 5;
192
+ /**
193
+ * How many times an `ADVANCE_FAILED` row is tried before giving up. A missed
194
+ * row hides the instance from the stuck list, and a second try is safe: the
195
+ * duplicate check runs under the instance lock.
196
+ */
197
+ const ADVANCE_FAILURE_RECORD_ATTEMPTS = 2;
198
+ // What each way of being skipped writes into the `TOKEN_ADVANCED` payload. The
199
+ // two chain settings that can skip a node share one key.
200
+ const SKIPPED_RUNTIME_TASK_PAYLOADS = {
201
+ INITIATOR: { skippedByInitiatorExclusion: true },
202
+ MANAGER_CHAIN_REACHED: { skippedByManagerChainReached: true },
203
+ NODE_ACTORS: { skippedByNodeActorExclusion: true },
204
+ };
205
+ // Why a chain that continues an earlier one, or skips when its start already
206
+ // qualifies, is refused anywhere task creation did not work its context out.
207
+ const ORG_MANAGER_CHAIN_CONTEXT_REFUSAL = '接續前一段主管鏈、或「起點本人已達簽到職等時略過」,只能在建立簽核任務時使用,此處不支援。';
208
+ /**
209
+ * ADR 27 §4.2: an approver-filled value the provider no longer offers answers
210
+ * 400 with `formDataErrors` naming the field or table cell, `code` kept. Other
211
+ * failures pass through unchanged.
212
+ */
213
+ async function answerValueNotResolved(run) {
214
+ try {
215
+ return await run();
216
+ }
217
+ catch (error) {
218
+ if (error instanceof form_data_source_errors_1.BPMFormDataSourceValueNotResolvedException) {
219
+ const issue = {
220
+ ...(0, form_data_issue_1.createFormDataIssue)(error.location, 'VALUE_NOT_RESOLVED', '的選項已不在可選範圍內,請重新選擇'),
221
+ code: form_data_source_errors_1.BPM_FORM_DATA_SOURCE_ERROR_CODES.VALUE_NOT_RESOLVED,
222
+ };
223
+ throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)([issue]), [
224
+ issue,
225
+ ]);
226
+ }
227
+ throw error;
228
+ }
229
+ }
185
230
  const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
186
231
  'bpm:*',
187
232
  'bpm.workflow.read_all',
@@ -189,7 +234,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
189
234
  'workflow.read_all',
190
235
  ]);
191
236
  let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
192
- constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver) {
237
+ constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver, injectedInstanceGuard, instanceEventWriter) {
193
238
  this.approvalInstanceRepository = approvalInstanceRepository;
194
239
  this.workflowTokenRepository = workflowTokenRepository;
195
240
  this.taskRepository = taskRepository;
@@ -209,6 +254,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
209
254
  this.slaScheduleService = slaScheduleService;
210
255
  this.formDataSourceValueResolver = formDataSourceValueResolver;
211
256
  this.webhookDeliveryService = webhookDeliveryService;
257
+ this.instanceEventWriter = instanceEventWriter;
212
258
  this.logger = new common_1.Logger(WorkflowEngineService_1.name);
213
259
  /** In-flight inbox reads by member; see `readSharedOpenInboxTasks`. */
214
260
  this.openInboxReadsInFlight = new Map();
@@ -236,7 +282,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
236
282
  injectedInitiatorCustomFieldsResolver ??
237
283
  rootOptions?.initiatorCustomFieldsResolver ??
238
284
  null;
285
+ // Same three-way order as the initiator resolver: a host binding under the
286
+ // token, then the runtime instance, then no guard at all.
287
+ this.instanceGuard =
288
+ injectedInstanceGuard ?? rootOptions?.instanceGuard ?? null;
289
+ this.instanceGuardTimeoutMs =
290
+ rootOptions?.instanceGuardTimeoutMs ??
291
+ instance_guard_runner_1.BPM_INSTANCE_GUARD_DEFAULT_TIMEOUT_MS;
239
292
  this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
293
+ this.expressionMissingFormFieldsAsNull =
294
+ rootOptions?.expressionMissingFormFieldsAsNull === true;
295
+ }
296
+ /**
297
+ * Runs the bound guard for one engine path inside its transaction. A no-op
298
+ * when no guard is bound, so a host without one sees no behavior change.
299
+ */
300
+ async guardInstanceChange(input) {
301
+ if (this.instanceGuard === null) {
302
+ return;
303
+ }
304
+ await (0, instance_guard_runner_1.runInstanceGuard)(this.instanceGuard, input, this.instanceGuardTimeoutMs);
305
+ }
306
+ /**
307
+ * Writes one case-lifecycle event (ADR 27 §1) in the caller's transaction.
308
+ * Returns before any lookup when no observer is bound, so a host without
309
+ * one pays nothing. `instanceState` is the state the change commits, not the
310
+ * state the instance held before it.
311
+ */
312
+ async recordInstanceEvent(manager, instance, instanceState, type, fields) {
313
+ if (this.instanceEventWriter?.enabled !== true) {
314
+ return;
315
+ }
316
+ const template = await manager
317
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
318
+ .findOne({
319
+ select: { id: true, key: true },
320
+ where: { id: instance.templateId },
321
+ withDeleted: true,
322
+ });
323
+ const snapshot = {
324
+ instanceState,
325
+ templateId: instance.templateId,
326
+ templateKey: template?.key ?? null,
327
+ templateVersionId: instance.templateVersionId,
328
+ };
329
+ await this.instanceEventWriter.record(manager, instance.id, snapshot, type, fields);
330
+ }
331
+ /**
332
+ * Reads the template version a case runs on, inside the engine transaction,
333
+ * so the guard sees the same snapshot as the decision. A missing row fails
334
+ * the change rather than handing the guard a partial input.
335
+ */
336
+ async readBoundTemplateVersionOrThrow(manager, instance) {
337
+ const version = await manager
338
+ .getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
339
+ .findOne({ where: { id: instance.templateVersionId } });
340
+ if (!version) {
341
+ throw new common_1.ConflictException({
342
+ code: 'TEMPLATE_VERSION_NOT_FOUND',
343
+ message: 'The template version of this instance no longer exists.',
344
+ });
345
+ }
346
+ return version;
240
347
  }
241
348
  /**
242
349
  * Today's date as the organization sees it (`organizationTimeZone`), for
@@ -272,6 +379,151 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
272
379
  }, Promise.resolve());
273
380
  return result;
274
381
  }
382
+ /**
383
+ * Runs a public operation whose engine transaction may fail with an
384
+ * {@link InstanceAdvanceFailure}. The transaction has rolled back by the time
385
+ * it lands here, so the record is written in a transaction of its own, and
386
+ * the caller then gets the original error: same class, same message, as if
387
+ * none of this existed. The wrapper type never leaves this method.
388
+ */
389
+ async withAdvanceFailureRecorded(run) {
390
+ try {
391
+ return await run();
392
+ }
393
+ catch (error) {
394
+ if (error instanceof instance_advance_failure_1.InstanceAdvanceFailure) {
395
+ await this.recordAdvanceFailure(error);
396
+ throw error.originalError;
397
+ }
398
+ throw error;
399
+ }
400
+ }
401
+ /**
402
+ * Writes the `ADVANCE_FAILED` row for a rolled-back attempt, trying again
403
+ * once if the write fails. If every try fails the failure is logged as an
404
+ * error with what the row would have held, and swallowed: the caller must
405
+ * see the error that stopped the case, not the one that stopped its record.
406
+ * Such an instance is missing from the stuck list, so the log is the only
407
+ * trace of it.
408
+ */
409
+ async recordAdvanceFailure(failure, attempt = 1) {
410
+ const { context, message } = failure;
411
+ try {
412
+ await this.writeAdvanceFailure(failure);
413
+ }
414
+ catch (recordError) {
415
+ const reason = recordError instanceof Error ? recordError.message : 'unknown error';
416
+ if (attempt < ADVANCE_FAILURE_RECORD_ATTEMPTS) {
417
+ this.logger.warn(`Could not record ADVANCE_FAILED for instance ${context.instanceId} (attempt ${attempt}), trying again: ${reason}`);
418
+ return this.recordAdvanceFailure(failure, attempt + 1);
419
+ }
420
+ this.logger.error(`Could not record ADVANCE_FAILED for instance ${context.instanceId}; it will not appear in the stuck list: ${reason}`, JSON.stringify({
421
+ action: context.action,
422
+ actorMemberId: context.actorMemberId,
423
+ automatic: context.automatic,
424
+ kind: context.kind,
425
+ message,
426
+ nodeId: context.nodeId,
427
+ taskId: context.taskId,
428
+ }));
429
+ }
430
+ }
431
+ /** One try of {@link recordAdvanceFailure}, in a transaction of its own. */
432
+ async writeAdvanceFailure(failure) {
433
+ const { context, message } = failure;
434
+ await this.approvalInstanceRepository.manager.transaction(async (manager) => {
435
+ // The same per-instance lock every decision takes, so two attempts
436
+ // failing together cannot both pass the check below and write twice.
437
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
438
+ context.instanceId,
439
+ ]);
440
+ const unresolved = await this.readUnresolvedAdvanceFailures(manager, context.instanceId);
441
+ if (unresolved.some((logged) => isSameAdvanceFailure(logged, context, message))) {
442
+ return;
443
+ }
444
+ const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
445
+ await activityRepository.save(activityRepository.create({
446
+ actorMemberId: context.actorMemberId,
447
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED,
448
+ instanceId: context.instanceId,
449
+ nodeId: context.nodeId,
450
+ payload: {
451
+ action: context.action,
452
+ automatic: context.automatic,
453
+ kind: context.kind,
454
+ message,
455
+ nodeId: context.nodeId,
456
+ taskId: context.taskId,
457
+ },
458
+ taskId: context.taskId,
459
+ }));
460
+ });
461
+ }
462
+ /**
463
+ * The `ADVANCE_FAILED` rows of one instance that still describe how it is
464
+ * stuck, oldest first. Whether a decision went through is not read from
465
+ * `TASK_DECIDED`: a countersign leaves one behind from an earlier voter while
466
+ * the last vote is the one that failed. What counts is whether the situation
467
+ * the row describes still holds.
468
+ *
469
+ * - `DECISION`: the task is still open and the instance is still RUNNING.
470
+ * Once the task is done, cancelled, transferred or the instance has ended,
471
+ * the case has moved on or been taken over.
472
+ * - `RESUBMIT`: the instance is still RETURNED and no later
473
+ * `INSTANCE_RESUBMITTED` exists.
474
+ *
475
+ * The rule itself is `readUnresolvedInstanceFailures` in
476
+ * `stuck-approval-instance.query.ts`, which also holds the SQL the stuck list
477
+ * pages with; the two must agree. This is the duplicate check's side.
478
+ */
479
+ async readUnresolvedAdvanceFailures(manager, instanceId) {
480
+ const instance = await manager
481
+ .getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
482
+ .findOne({ where: { id: instanceId } });
483
+ if (!instance) {
484
+ return [];
485
+ }
486
+ const byInstance = await this.readUnresolvedAdvanceFailuresByInstance(manager, [instance]);
487
+ return byInstance.get(instance.id) ?? [];
488
+ }
489
+ /**
490
+ * {@link readUnresolvedAdvanceFailures} for many instances at once, in two
491
+ * queries whatever their number: one for their activity rows, one for the
492
+ * tasks those rows name. Instances with nothing unresolved are absent.
493
+ */
494
+ async readUnresolvedAdvanceFailuresByInstance(manager, instances) {
495
+ if (instances.length === 0) {
496
+ return new Map();
497
+ }
498
+ const logs = await manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
499
+ order: { seq: 'ASC' },
500
+ where: {
501
+ eventType: (0, typeorm_2.In)([
502
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED,
503
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_RESUBMITTED,
504
+ ]),
505
+ instanceId: (0, typeorm_2.In)(instances.map((instance) => instance.id)),
506
+ },
507
+ });
508
+ const decidedTaskIds = [
509
+ ...new Set(logs.flatMap((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED &&
510
+ log.taskId
511
+ ? [log.taskId]
512
+ : [])),
513
+ ];
514
+ const openTaskIds = new Set(decidedTaskIds.length === 0
515
+ ? []
516
+ : (await manager.getRepository(task_entity_1.TaskEntity).find({
517
+ where: {
518
+ id: (0, typeorm_2.In)(decidedTaskIds),
519
+ status: (0, typeorm_2.In)([...OPEN_TASK_STATUSES]),
520
+ },
521
+ })).map((task) => task.id));
522
+ return new Map(instances.flatMap((instance) => {
523
+ const unresolved = (0, stuck_approval_instance_query_1.readUnresolvedInstanceFailures)(instance, logs.filter((log) => log.instanceId === instance.id), openTaskIds);
524
+ return unresolved.length === 0 ? [] : [[instance.id, unresolved]];
525
+ }));
526
+ }
275
527
  /**
276
528
  * Queues a webhook for after the commit. A manager this service did not open
277
529
  * has no queue and no commit it can see, so that webhook goes out on the spot
@@ -308,6 +560,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
308
560
  input.submittedByMemberId.trim() === '')) {
309
561
  throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
310
562
  }
563
+ const hostCustomFields = readHostInitiatorCustomFields(input);
311
564
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
312
565
  const businessKey = normalizeBusinessKey(input.businessKey);
313
566
  const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
@@ -319,6 +572,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
319
572
  throw new common_1.ConflictException('Approval template does not have a published version');
320
573
  }
321
574
  const templateVersion = await this.getPublishedTemplateVersionOrThrow(template.currentVersionId);
575
+ if (input.endUserSubmission === true) {
576
+ assertEndUserMayLaunch(templateVersion);
577
+ }
322
578
  const formDefinitionVersion = await this.getPublishedFormVersionOrThrow(templateVersion.formDefinitionVersionId);
323
579
  // The fields an approver fills in belong to the approval, not the
324
580
  // application: an end user's value for one is dropped (it would otherwise
@@ -327,13 +583,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
327
583
  // checked here; required and row minimums wait for the node that fills
328
584
  // them. Read off the version the instance is bound to, not the template's
329
585
  // latest, so a publish in between cannot change what is stripped.
330
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(templateVersion.workflowDefinition);
586
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(templateVersion.workflowDefinition);
331
587
  const formData = input.endUserSubmission === true
332
- ? (0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferredFieldKeys)
588
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferral.wholeKeys), null, deferral)
333
589
  : submittedFormData;
334
- validateSubmittedFormData(formDefinitionVersion.schema, formData, {
335
- deferredFieldKeys,
336
- });
590
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(formDefinitionVersion.schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
337
591
  // Checked against the bound version's schema before any data source is
338
592
  // called, so a bad value is refused cheaply. The transaction below
339
593
  // confirms under the template lock that this version is still current
@@ -347,7 +601,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
347
601
  });
348
602
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
349
603
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
350
- : await this.buildInitiatorMetadataSnapshot({
604
+ : mergeHostInitiatorCustomFields(await this.buildInitiatorMetadataSnapshot({
351
605
  authContext: submitAuthContext,
352
606
  memberId: input.initiatorMemberId,
353
607
  submission: {
@@ -355,7 +609,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
355
609
  templateId: template.id,
356
610
  templateVersionId: templateVersion.id,
357
611
  },
358
- });
612
+ }), hostCustomFields);
359
613
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
360
614
  // The policy is written in its version's dialect (ADR 22 §3.3).
361
615
  readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
@@ -396,7 +650,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
396
650
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
397
651
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
398
652
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
399
- const instance = await instanceRepository.save(instanceRepository.create({
653
+ const instanceDraft = instanceRepository.create({
400
654
  completedAt: null,
401
655
  formData,
402
656
  formDataOptionSnapshot,
@@ -416,7 +670,24 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
416
670
  templateVersionId: templateVersion.id,
417
671
  title: input.title?.trim() || template.name,
418
672
  workflowSnapshot: templateVersion.workflowDefinition,
419
- }));
673
+ });
674
+ // ADR 27 §2: the host guard sees the case as it is about to be saved,
675
+ // under the template lock. A refusal throws here, before any insert.
676
+ await this.guardInstanceChange({
677
+ action: null,
678
+ actorMemberId: submittedByMemberId,
679
+ authContext: authContext ?? null,
680
+ automatic: null,
681
+ changedFieldKeys: Object.keys(formData),
682
+ formData,
683
+ instance: instanceDraft,
684
+ kind: 'SUBMIT',
685
+ manager,
686
+ node: null,
687
+ task: null,
688
+ templateVersion,
689
+ });
690
+ const instance = await instanceRepository.save(instanceDraft);
420
691
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
421
692
  formData,
422
693
  instanceId: instance.id,
@@ -454,6 +725,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
454
725
  taskId: null,
455
726
  }),
456
727
  ]);
728
+ await this.recordInstanceEvent(manager, instance, instance.state, 'INSTANCE_SUBMITTED', { actorMemberId: submittedByMemberId });
457
729
  await this.processRunningInstance(manager, instance);
458
730
  return instance;
459
731
  }).catch((error) =>
@@ -494,6 +766,66 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
494
766
  schema,
495
767
  });
496
768
  }
769
+ /**
770
+ * ADR 27 §4(b): asks the data-source providers again, under the decision
771
+ * lock and as the approver, for the options of the approver-fillable fields.
772
+ * A value that is no longer among the options is refused by the resolver
773
+ * (`VALUE_NOT_RESOLVED`). Returns snapshots for the scoped fields only; the
774
+ * caller swaps them in for the stored ones.
775
+ */
776
+ async refreshApproverFilledOptionSnapshots({ approverAuthContext, fieldKeys, formData, instance, }) {
777
+ const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
778
+ return this.resolveFormDataOptionSnapshots({
779
+ authContext: approverAuthContext,
780
+ formData,
781
+ previousFormData: instance.formData,
782
+ previousSnapshots: readSnapshotWithoutFields(instance.formDataOptionSnapshot, fieldKeys),
783
+ revalidateAll: true,
784
+ schema: {
785
+ ...schema,
786
+ fields: schema.fields.filter((field) => fieldKeys.has(field.fieldKey)),
787
+ },
788
+ });
789
+ }
790
+ /**
791
+ * ADR 27 §4(b): asks the data-source providers again, as the approver, for
792
+ * the open data-source cells of column-mode tables. `revalidateAll` false lets
793
+ * the resolver reuse the stored options of cells whose value and bindings did
794
+ * not move. Returns snapshots for the cells of `cellSchema` only.
795
+ */
796
+ async refreshApproverFilledOpenCellSnapshots({ approverAuthContext, cellSchema, formData, instance, revalidateAll, }) {
797
+ return this.resolveFormDataOptionSnapshots({
798
+ authContext: approverAuthContext,
799
+ formData,
800
+ previousFormData: instance.formData,
801
+ previousSnapshots: instance.formDataOptionSnapshot ?? {},
802
+ revalidateAll,
803
+ schema: cellSchema,
804
+ });
805
+ }
806
+ /**
807
+ * Refuses an end user's resubmission of an instance bound to a `HOST_ONLY`
808
+ * version (ADR 27 §5). Checked before any data source is called and outside
809
+ * the lock: an instance never changes version and a published version never
810
+ * changes `launchMode`, so the answer cannot go stale. An instance that does
811
+ * not exist is left to the locked read, which reports it.
812
+ */
813
+ async assertEndUserMayResubmit(instanceId) {
814
+ const instance = await this.approvalInstanceRepository.findOne({
815
+ select: { id: true, templateVersionId: true },
816
+ where: { id: instanceId },
817
+ });
818
+ if (!instance) {
819
+ return;
820
+ }
821
+ const version = await this.approvalTemplateVersionRepository.findOne({
822
+ select: { id: true, launchMode: true },
823
+ where: { id: instance.templateVersionId },
824
+ });
825
+ if (version) {
826
+ assertEndUserMayLaunch(version);
827
+ }
828
+ }
497
829
  async prepareResubmitFormDataOptions(input, submittedFormData, authContext) {
498
830
  const instance = await this.approvalInstanceRepository.findOne({
499
831
  where: { id: input.instanceId },
@@ -512,13 +844,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
512
844
  throw new common_1.ConflictException('Approval template is deactivated');
513
845
  }
514
846
  const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
515
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
847
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
516
848
  // Same overlay as inside the lock, so the options resolved here are for
517
849
  // the form data that is actually saved.
518
850
  const formData = input.endUserSubmission === true
519
- ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
851
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
520
852
  : submittedFormData;
521
- validateSubmittedFormData(schema, formData, { deferredFieldKeys });
853
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
522
854
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
523
855
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
524
856
  authContext,
@@ -556,7 +888,30 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
556
888
  await this.processRunningInstance(manager, instance);
557
889
  });
558
890
  }
891
+ /**
892
+ * Records `TASK_AUTO_DECISION_FAILED` (ADR 27 §3) in its own transaction.
893
+ * Used after an automatic decision was refused by the instance guard: the
894
+ * decision's transaction has already rolled back, so the event cannot ride
895
+ * on it. Returns before any query when no observer is bound.
896
+ */
897
+ async recordTaskAutoDecisionFailed(input) {
898
+ if (this.instanceEventWriter?.enabled !== true) {
899
+ return;
900
+ }
901
+ await this.runEngineTransaction(async (manager) => {
902
+ const instance = await manager
903
+ .getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
904
+ .findOne({ where: { id: input.instanceId } });
905
+ if (!instance) {
906
+ return;
907
+ }
908
+ await this.recordInstanceEvent(manager, instance, instance.state, 'TASK_AUTO_DECISION_FAILED', { reason: input.reason, taskId: input.taskId });
909
+ });
910
+ }
559
911
  async decideTask(input, options = {}) {
912
+ return this.withAdvanceFailureRecorded(() => this.decideTaskInTransaction(input, options));
913
+ }
914
+ async decideTaskInTransaction(input, options) {
560
915
  return this.runEngineTransaction(async (manager) => {
561
916
  const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
562
917
  const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
@@ -719,9 +1074,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
719
1074
  if (filledFormData !== null && task.isAdhoc) {
720
1075
  throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
721
1076
  }
1077
+ // ADR 27 §4(a): column-mode tables are accepted here and settled
1078
+ // cell by cell below. Their open data-source columns are re-resolved per
1079
+ // row as the approver (ADR 27 §4(b)).
1080
+ const nodeColumnFillMap = (0, approver_filled_table_columns_1.readNodeColumnFillMap)(taskNode);
722
1081
  if (filledFormData !== null) {
723
1082
  const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
724
- const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey));
1083
+ const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey) &&
1084
+ !nodeColumnFillMap.has(fieldKey));
725
1085
  if (refusedFieldKeys.length > 0) {
726
1086
  throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not let approvers fill in: ${refusedFieldKeys.join(', ')}`);
727
1087
  }
@@ -732,35 +1092,134 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
732
1092
  (0, form_data_fingerprint_1.computeFormDataFingerprint)(instance.formData)) {
733
1093
  throw new common_1.ConflictException(`Approval instance ${instance.id} form data changed; refresh and retry`);
734
1094
  }
1095
+ // ADR 27 §4(a): each column-mode table is merged per cell against the
1096
+ // stored rows. A changed row count, row order or applicant cell is
1097
+ // refused before anything below sees the payload.
1098
+ const settledFormData = filledFormData === null
1099
+ ? null
1100
+ : (0, approver_filled_table_columns_1.settleColumnFillPayload)(filledFormData, instance.formData, nodeColumnFillMap);
735
1101
  // Only keys whose value really changes are written and reported. A key
736
1102
  // the form data does not have yet counts as changed even when the new
737
1103
  // value is null.
738
- const changedFieldKeys = filledFormData === null
1104
+ const changedFieldKeys = settledFormData === null
739
1105
  ? []
740
- : Object.keys(filledFormData)
1106
+ : Object.keys(settledFormData)
741
1107
  .filter((fieldKey) => !Object.prototype.hasOwnProperty.call(instance.formData, fieldKey) ||
742
- (0, form_data_fingerprint_1.computeStableValueFingerprint)(filledFormData[fieldKey]) !==
1108
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(settledFormData[fieldKey]) !==
743
1109
  (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formData[fieldKey]))
744
1110
  .sort();
745
- const mergedFormData = { ...instance.formData, ...filledFormData };
1111
+ const mergedFormData = { ...instance.formData, ...settledFormData };
1112
+ // ADR 27 §4.3: an approver may change a column only if no column this
1113
+ // approver may not fill reads it. Such a dependent keeps its stored value
1114
+ // here, so a changed source would leave it stale. Fail closed.
1115
+ if (nodeColumnFillMap.size > 0) {
1116
+ (0, approver_filled_table_columns_1.assertDependentColumnsFrozen)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), taskNode, instance.formData, mergedFormData);
1117
+ }
746
1118
  // Every approval at a node that lets its approver fill in fields is
747
1119
  // held to those fields, whether or not this request sent any: the host
748
- // may have pre-filled a table the approver then leaves as is.
1120
+ // may have pre-filled a table the approver then leaves as is. A column
1121
+ // this node grants REQUIRED is required in the check, not only in the
1122
+ // template.
749
1123
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc) {
750
- const nodeFillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
1124
+ const nodeFillableFieldKeys = new Set([
1125
+ ...(0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode),
1126
+ ...nodeColumnFillMap.keys(),
1127
+ ]);
751
1128
  if (nodeFillableFieldKeys.size > 0) {
752
- validateFilledFormFields(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
1129
+ validateFilledFormFields((0, approver_filled_table_columns_1.projectNodeOpenColumnsRequired)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
753
1130
  }
754
1131
  }
755
- if (filledFormData !== null && changedFieldKeys.length > 0) {
1132
+ // ADR 27 §4(b): an approver-filled data-source field is judged as the
1133
+ // approver's own pick. The provider is asked again under the lock with
1134
+ // the approver's identity, so a value outside the current options is
1135
+ // refused and the recorded label is refreshed before anything is
1136
+ // written. Table data-source columns resolve per row (ROW_FIELD).
1137
+ const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
1138
+ const dataSourceRefreshKeys = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc
1139
+ ? fillableFieldKeys
1140
+ : new Set(changedFieldKeys.filter((fieldKey) => fillableFieldKeys.has(fieldKey)));
1141
+ const approverAuthContext = options.approverAuthContext ??
1142
+ createWorkflowAuthContext(input.decidedByMemberId);
1143
+ const refreshedFormDataOptionSnapshots = dataSourceRefreshKeys.size === 0
1144
+ ? null
1145
+ : await answerValueNotResolved(() => this.refreshApproverFilledOptionSnapshots({
1146
+ approverAuthContext,
1147
+ fieldKeys: dataSourceRefreshKeys,
1148
+ formData: mergedFormData,
1149
+ instance,
1150
+ }));
1151
+ const wholeFieldOptionSnapshot = refreshedFormDataOptionSnapshots === null
1152
+ ? (instance.formDataOptionSnapshot ?? {})
1153
+ : {
1154
+ ...readSnapshotWithoutFields(instance.formDataOptionSnapshot, dataSourceRefreshKeys),
1155
+ ...refreshedFormDataOptionSnapshots,
1156
+ };
1157
+ // ADR 27 §4(b): the open data-source cells of column-mode tables. An
1158
+ // APPROVED decision re-resolves all of them; any other decision only
1159
+ // when a column-mode table's cells changed. The form schema is read
1160
+ // only for a node that has column-mode fills, so other nodes never
1161
+ // touch the snapshot's schema.
1162
+ const openCellSchema = nodeColumnFillMap.size === 0
1163
+ ? null
1164
+ : (0, approver_filled_table_columns_1.readOpenDataSourceCellSchema)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap);
1165
+ const openCellTableKeys = new Set((openCellSchema?.fields ?? []).map((field) => field.fieldKey));
1166
+ const revalidateAllOpenCells = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc;
1167
+ const hasChangedOpenCellTable = changedFieldKeys.some((fieldKey) => openCellTableKeys.has(fieldKey));
1168
+ const refreshedOpenCellSnapshots = openCellSchema === null ||
1169
+ openCellSchema.fields.length === 0 ||
1170
+ (!revalidateAllOpenCells && !hasChangedOpenCellTable)
1171
+ ? null
1172
+ : await answerValueNotResolved(() => this.refreshApproverFilledOpenCellSnapshots({
1173
+ approverAuthContext,
1174
+ cellSchema: openCellSchema,
1175
+ formData: mergedFormData,
1176
+ instance,
1177
+ revalidateAll: revalidateAllOpenCells,
1178
+ }));
1179
+ const nextFormDataOptionSnapshot = refreshedOpenCellSnapshots === null || openCellSchema === null
1180
+ ? wholeFieldOptionSnapshot
1181
+ : {
1182
+ ...(0, approver_filled_table_columns_1.readSnapshotWithoutOpenCells)(wholeFieldOptionSnapshot, openCellSchema),
1183
+ ...(0, approver_filled_table_columns_1.readOpenCellSnapshots)(refreshedOpenCellSnapshots, openCellSchema),
1184
+ };
1185
+ // ADR 27 §2: the host guard judges the decision as it will be
1186
+ // committed, after the form checks and before anything is written.
1187
+ // The version is read only when a guard is bound, so an engine
1188
+ // without one issues exactly the queries it did before.
1189
+ if (this.instanceGuard !== null) {
1190
+ await this.guardInstanceChange({
1191
+ action: input.action,
1192
+ actorMemberId: input.decidedByMemberId,
1193
+ // The approver's own context when the GraphQL mutation passed one;
1194
+ // never the member-id stub the data-source path falls back to.
1195
+ authContext: options.approverAuthContext ?? null,
1196
+ automatic: options.automatic ?? null,
1197
+ changedFieldKeys,
1198
+ formData: mergedFormData,
1199
+ instance,
1200
+ kind: 'DECIDE',
1201
+ manager,
1202
+ node: taskNode,
1203
+ task,
1204
+ templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
1205
+ });
1206
+ }
1207
+ const formDataChanged = settledFormData !== null && changedFieldKeys.length > 0;
1208
+ const formDataOptionSnapshotChanged = (0, form_data_fingerprint_1.computeStableValueFingerprint)(nextFormDataOptionSnapshot) !==
1209
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formDataOptionSnapshot ?? {});
1210
+ if (formDataChanged) {
756
1211
  await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
1212
+ }
1213
+ if (formDataChanged || formDataOptionSnapshotChanged) {
757
1214
  // Merged into the shared `instance` before it is signed and before
758
1215
  // the token moves on: the signature must cover the filled values, a
759
1216
  // gateway after this node must route on them, and an end node saves
760
- // this same object, which would write the old values back.
1217
+ // this same object, which would write the old values back. The
1218
+ // refreshed option labels go in the same write.
761
1219
  const savedInstance = await instanceRepository.save({
762
1220
  ...instance,
763
1221
  formData: mergedFormData,
1222
+ formDataOptionSnapshot: nextFormDataOptionSnapshot,
764
1223
  });
765
1224
  Object.assign(instance, savedInstance);
766
1225
  }
@@ -874,12 +1333,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
874
1333
  },
875
1334
  taskId: completedTask.id,
876
1335
  }));
1336
+ // What an ADVANCE_FAILED row says if the move to the next stage fails.
1337
+ // An SLA timeout acts as the member `resolveTaskActorMemberId` picked,
1338
+ // who never pressed anything, so `automatic` is what tells them apart.
1339
+ const advanceContext = (action) => ({
1340
+ action,
1341
+ actorMemberId: input.decidedByMemberId,
1342
+ automatic: options.automatic ?? null,
1343
+ instanceId: instance.id,
1344
+ kind: 'DECISION',
1345
+ nodeId: claimedTask.nodeId,
1346
+ taskId: claimedTask.id,
1347
+ });
877
1348
  // Resolve the recipient's "待簽" notification so its inline 同意/拒絕
878
1349
  // actions disappear once the task is decided. When the task has ended
879
1350
  // (rejected / returned, or an approval that completed it), every other
880
1351
  // candidate's notification is superseded too; a non-completing approval
881
1352
  // on a multi-approver task leaves the remaining approvers' open.
882
- const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED || shouldCompleteTask;
1353
+ const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED ||
1354
+ shouldCompleteTask;
883
1355
  await this.notificationService.resolveTaskNotifications({
884
1356
  actingMemberId: input.decidedByMemberId,
885
1357
  manager,
@@ -907,69 +1379,151 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
907
1379
  if (await this.hasOpenTasksForTokenNode(manager, claimedTask.tokenId, claimedTask.nodeId)) {
908
1380
  return decision;
909
1381
  }
910
- const token = await tokenRepository.findOne({
911
- where: { id: claimedTask.tokenId },
912
- });
913
- if (!token) {
914
- throw new common_1.NotFoundException(`Workflow token ${claimedTask.tokenId} was not found`);
915
- }
916
- if (token.status !== workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING) {
917
- throw new common_1.ConflictException(`Workflow token ${token.id} is not waiting for a task decision`);
918
- }
919
- const activeToken = await tokenRepository.save({
920
- ...token,
921
- consumedAt: null,
922
- status: workflow_engine_enums_1.WorkflowTokenStatusEnum.ACTIVE,
1382
+ // Everything from here to the next stage is the part that leaves the
1383
+ // case stuck when it fails: the decision rolls back with it.
1384
+ await runInstanceAdvanceStep(advanceContext('APPROVED'), async () => {
1385
+ const token = await tokenRepository.findOne({
1386
+ where: { id: claimedTask.tokenId },
1387
+ });
1388
+ if (!token) {
1389
+ throw new common_1.NotFoundException(`Workflow token ${claimedTask.tokenId} was not found`);
1390
+ }
1391
+ if (token.status !== workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING) {
1392
+ throw new common_1.ConflictException(`Workflow token ${token.id} is not waiting for a task decision`);
1393
+ }
1394
+ const activeToken = await tokenRepository.save({
1395
+ ...token,
1396
+ consumedAt: null,
1397
+ status: workflow_engine_enums_1.WorkflowTokenStatusEnum.ACTIVE,
1398
+ });
1399
+ await this.dispatchAdhocStageNotifications(manager, instance, claimedTask.nodeId, 'APPROVED');
1400
+ await this.advanceTokenToOutgoingNodes(manager, instance, activeToken, readWorkflowNodeOrThrow(instance.workflowSnapshot, claimedTask.nodeId));
1401
+ await this.processRunningInstance(manager, instance);
923
1402
  });
924
- await this.dispatchAdhocStageNotifications(manager, instance, claimedTask.nodeId, 'APPROVED');
925
- await this.advanceTokenToOutgoingNodes(manager, instance, activeToken, readWorkflowNodeOrThrow(instance.workflowSnapshot, claimedTask.nodeId));
926
- await this.processRunningInstance(manager, instance);
927
1403
  return decision;
928
1404
  }
929
1405
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
930
1406
  return decision;
931
1407
  }
932
1408
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED) {
933
- await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt);
1409
+ await runInstanceAdvanceStep(advanceContext('RETURNED'), () => this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId));
934
1410
  return decision;
935
1411
  }
936
1412
  if (await this.handleAdhocPreApprovalRejection(manager, instance, completedTask, decidedAt, decisionComment)) {
937
1413
  return decision;
938
1414
  }
939
- await this.rejectInstance(manager, instance, completedTask, decidedAt);
1415
+ await this.rejectInstance(manager, instance, completedTask, decidedAt, input.decidedByMemberId);
940
1416
  return decision;
941
1417
  });
942
1418
  }
943
1419
  async cancelApprovalInstance(input) {
1420
+ return this.cancelApprovalInstanceInTransaction({
1421
+ actorMemberId: input.cancelledByMemberId,
1422
+ authorize: (instance) => {
1423
+ if (instance.initiatorMemberId !== input.cancelledByMemberId) {
1424
+ throw new common_1.ConflictException(`Approval instance ${instance.id} can only be cancelled by its initiator`);
1425
+ }
1426
+ },
1427
+ instanceId: input.instanceId,
1428
+ payload: {
1429
+ comment: input.comment?.trim() || null,
1430
+ },
1431
+ });
1432
+ }
1433
+ /**
1434
+ * Administrator: ends a running or returned instance on the initiator's
1435
+ * behalf, whoever started it. The reason is required; it is recorded as the
1436
+ * comment of the INSTANCE_CANCELLED activity (flagged
1437
+ * `cancelledByAdministrator`, so a host can tell this from the initiator
1438
+ * withdrawing) and sent to the initiator as an in-app notification. An
1439
+ * administrator ending their own instance is not told.
1440
+ *
1441
+ * The notification is written only after the cancellation has committed,
1442
+ * so nobody is told of a cancellation that then rolled back. A failure to
1443
+ * write it is logged and does not undo the cancellation.
1444
+ *
1445
+ * Like the other administrator operations of this service it does not check
1446
+ * who `cancelledByMemberId` is: the GraphQL mutation is guarded by
1447
+ * `@BPMAdminOnly()`, and a host calling this directly must check first. That
1448
+ * member is recorded as the actor of the INSTANCE_CANCELLED activity.
1449
+ */
1450
+ async adminCancelApprovalInstance(input) {
1451
+ const reason = input.reason.trim();
1452
+ // Refused before anything is read, so an empty reason is never mistaken
1453
+ // for a missing instance.
1454
+ if (!reason) {
1455
+ throw new common_1.BadRequestException('A reason is required to cancel an approval instance as an administrator');
1456
+ }
1457
+ const cancelled = await this.cancelApprovalInstanceInTransaction({
1458
+ actorMemberId: input.cancelledByMemberId,
1459
+ authorize: () => undefined,
1460
+ instanceId: input.instanceId,
1461
+ payload: {
1462
+ cancelledByAdministrator: true,
1463
+ comment: reason,
1464
+ },
1465
+ });
1466
+ if (cancelled.initiatorMemberId !== input.cancelledByMemberId) {
1467
+ await this.notifyInitiatorOfAdminCancel(cancelled, reason);
1468
+ }
1469
+ return cancelled;
1470
+ }
1471
+ /**
1472
+ * Tells the initiator, after the commit, that an administrator ended their
1473
+ * instance. Written outside any transaction, so external channels go out
1474
+ * straight away and an observer is told the rows are committed.
1475
+ */
1476
+ async notifyInitiatorOfAdminCancel(instance, reason) {
1477
+ try {
1478
+ await this.notificationService.createAdhocWorkflowNotifications({
1479
+ channels: null,
1480
+ instance,
1481
+ message: `案件「${instance.title}」已由管理員撤銷:${reason}`,
1482
+ payload: {
1483
+ nodeLabel: '管理員撤銷',
1484
+ reason,
1485
+ type: 'INSTANCE_CANCELLED_BY_ADMINISTRATOR',
1486
+ },
1487
+ recipientMemberIds: [instance.initiatorMemberId],
1488
+ });
1489
+ }
1490
+ catch (error) {
1491
+ this.logger.error(`Instance ${instance.id} was cancelled by an administrator, but its initiator ${instance.initiatorMemberId} could not be notified: ${error instanceof Error ? error.message : 'unknown error'}`);
1492
+ }
1493
+ }
1494
+ /**
1495
+ * The cancel transaction both paths share. The order is part of the
1496
+ * contract: read, authorize, check the state, close, record. A non-initiator who tries to withdraw an already
1497
+ * approved instance must keep getting the "initiator" refusal, not
1498
+ * "not cancellable".
1499
+ */
1500
+ async cancelApprovalInstanceInTransaction(cancellation) {
944
1501
  return this.runEngineTransaction(async (manager) => {
945
1502
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
946
- input.instanceId,
1503
+ cancellation.instanceId,
947
1504
  ]);
948
1505
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
949
1506
  const instance = await instanceRepository.findOne({
950
- where: { id: input.instanceId },
1507
+ where: { id: cancellation.instanceId },
951
1508
  });
952
1509
  if (!instance) {
953
- throw new common_1.NotFoundException(`Approval instance ${input.instanceId} was not found`);
954
- }
955
- if (instance.initiatorMemberId !== input.cancelledByMemberId) {
956
- throw new common_1.ConflictException(`Approval instance ${instance.id} can only be cancelled by its initiator`);
1510
+ throw new common_1.NotFoundException(`Approval instance ${cancellation.instanceId} was not found`);
957
1511
  }
1512
+ cancellation.authorize(instance);
958
1513
  if (instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING &&
959
1514
  instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RETURNED) {
960
1515
  throw new common_1.ConflictException(`Approval instance ${instance.id} is not cancellable`);
961
1516
  }
962
1517
  const cancelledInstance = await this.closeInstance(manager, instance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED);
963
1518
  await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
964
- actorMemberId: input.cancelledByMemberId,
1519
+ actorMemberId: cancellation.actorMemberId,
965
1520
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_CANCELLED,
966
1521
  instanceId: instance.id,
967
1522
  nodeId: null,
968
- payload: {
969
- comment: input.comment?.trim() || null,
970
- },
1523
+ payload: cancellation.payload,
971
1524
  taskId: null,
972
1525
  }));
1526
+ await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: cancellation.actorMemberId });
973
1527
  return cancelledInstance;
974
1528
  });
975
1529
  }
@@ -1007,6 +1561,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1007
1561
  },
1008
1562
  taskId: input.taskId ?? null,
1009
1563
  }));
1564
+ await this.recordInstanceEvent(manager, expiredInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED, 'INSTANCE_EXPIRED', {});
1010
1565
  return expiredInstance;
1011
1566
  });
1012
1567
  }
@@ -1035,7 +1590,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1035
1590
  return closedInstance;
1036
1591
  }
1037
1592
  async resubmitApprovalInstance(input, authContext) {
1593
+ return this.withAdvanceFailureRecorded(() => this.resubmitApprovalInstanceInTransaction(input, authContext));
1594
+ }
1595
+ async resubmitApprovalInstanceInTransaction(input, authContext) {
1038
1596
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
1597
+ if (input.endUserSubmission === true) {
1598
+ await this.assertEndUserMayResubmit(input.instanceId);
1599
+ }
1039
1600
  const preparation = this.formDataSourceValueResolver
1040
1601
  ? await this.prepareResubmitFormDataOptions(input, submittedFormData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
1041
1602
  : null;
@@ -1065,20 +1626,37 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1065
1626
  throw new common_1.ConflictException('Approval instance changed while options were resolving; refresh and retry');
1066
1627
  }
1067
1628
  const resubmitSchema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
1068
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
1629
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
1069
1630
  // An end user gets back the value an approver already filled in, and
1070
1631
  // loses a key the stored data never had: the renderer seeds `false`,
1071
1632
  // `[]` and empty rows for every field, none of which an approver wrote.
1633
+ // An open cell of a table is settled per row the same way.
1072
1634
  const formData = input.endUserSubmission === true
1073
- ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
1635
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
1074
1636
  : submittedFormData;
1075
- validateSubmittedFormData(resubmitSchema, formData, {
1076
- deferredFieldKeys,
1077
- });
1637
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(resubmitSchema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
1078
1638
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
1079
1639
  if (!preparation) {
1080
1640
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
1081
1641
  }
1642
+ // ADR 27 §2: the host guard judges the resubmission as it will be
1643
+ // committed, after every form check and before the write.
1644
+ if (this.instanceGuard !== null) {
1645
+ await this.guardInstanceChange({
1646
+ action: null,
1647
+ actorMemberId: input.initiatorMemberId,
1648
+ authContext: authContext ?? null,
1649
+ automatic: null,
1650
+ changedFieldKeys: listChangedFormDataKeys(instance.formData, formData),
1651
+ formData,
1652
+ instance,
1653
+ kind: 'RESUBMIT',
1654
+ manager,
1655
+ node: null,
1656
+ task: null,
1657
+ templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
1658
+ });
1659
+ }
1082
1660
  const resubmittedInstance = await instanceRepository.save({
1083
1661
  ...instance,
1084
1662
  completedAt: null,
@@ -1091,6 +1669,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1091
1669
  state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
1092
1670
  title: input.title?.trim() || instance.title,
1093
1671
  });
1672
+ // Written before the resume below, so RESUBMITTED precedes any
1673
+ // NODE_ACTIVATED that the resume produces in the same transaction.
1674
+ await this.recordInstanceEvent(manager, resubmittedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING, 'INSTANCE_RESUBMITTED', { actorMemberId: input.initiatorMemberId });
1094
1675
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
1095
1676
  formData,
1096
1677
  instanceId: instance.id,
@@ -1118,7 +1699,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1118
1699
  },
1119
1700
  taskId: null,
1120
1701
  }));
1121
- await this.processRunningInstance(manager, runtimeInstance);
1702
+ await runInstanceAdvanceStep({
1703
+ action: null,
1704
+ actorMemberId: input.initiatorMemberId,
1705
+ automatic: null,
1706
+ instanceId: instance.id,
1707
+ kind: 'RESUBMIT',
1708
+ nodeId: null,
1709
+ taskId: null,
1710
+ }, () => this.processRunningInstance(manager, runtimeInstance));
1122
1711
  const refreshedInstance = await instanceRepository.findOne({
1123
1712
  where: { id: instance.id },
1124
1713
  });
@@ -1404,7 +1993,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1404
1993
  }
1405
1994
  async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
1406
1995
  const exclusions = await this.readNodeExclusions(manager, instance, node);
1407
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds);
1996
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds, null);
1408
1997
  // Ad-hoc approvers are picked by a person, not walked up a manager chain,
1409
1998
  // so the chain's grade rule does not apply to them.
1410
1999
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
@@ -1812,7 +2401,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1812
2401
  return;
1813
2402
  }
1814
2403
  try {
1815
- const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
2404
+ const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象', [], null);
1816
2405
  await this.notificationService.createAdhocWorkflowNotifications({
1817
2406
  channels: directive.channels,
1818
2407
  instance,
@@ -1864,11 +2453,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1864
2453
  input.workflowDefinitionJson,
1865
2454
  input.formDataJson,
1866
2455
  input.initiatorMetadataSnapshotJson ?? '',
2456
+ input.formSchemaJson ?? '',
1867
2457
  ].some((json) => json.length > MAX_DRY_RUN_INPUT_LENGTH)) {
1868
2458
  return createFailedDryRunResult('試跑資料過大,請縮小流程或測試資料後再試');
1869
2459
  }
1870
2460
  const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
1871
2461
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
2462
+ const formSchema = readDryRunFormSchema(input.formSchemaJson);
1872
2463
  // The snapshot here is the caller's own sample (the designer always sends
1873
2464
  // one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
1874
2465
  // submission builds, and a dry run has no template version to hand the
@@ -1885,6 +2476,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1885
2476
  return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunResultObject(), {
1886
2477
  errors: [],
1887
2478
  steps: (0, expression_budget_1.runExpressionOperation)(() => (0, time_budget_1.runWithinTimeBudget)(() => this.simulateWorkflow({
2479
+ expressionFormData: this.expressionMissingFormFieldsAsNull && formSchema
2480
+ ? (0, missing_form_fields_1.fillMissingFormFields)(formData, formSchema)
2481
+ : formData,
1888
2482
  formData,
1889
2483
  initiatorMemberId: input.initiatorMemberId,
1890
2484
  initiatorMetadataSnapshot,
@@ -2189,6 +2783,51 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2189
2783
  }
2190
2784
  return instance;
2191
2785
  }
2786
+ /**
2787
+ * ADR 27 §4.2 (gap 1): the fill units an approver may use to resolve runtime
2788
+ * data-source options on a RUNNING instance. Only tasks the member can act on
2789
+ * count: the member is the assignee or a PENDING/CLAIMED candidate of a
2790
+ * PENDING or IN_PROGRESS, non-ad-hoc task. A task whose candidate row is
2791
+ * WAITING or already decided grants nothing, even though its status is open.
2792
+ * A field counts only when its node lists it as SHOW; a HIDE entry never
2793
+ * grants, whatever its fill is. Returns `[]` when the instance is not RUNNING.
2794
+ */
2795
+ async listApproverFillUnits(instanceId, memberId) {
2796
+ const instance = await this.approvalInstanceRepository.findOne({
2797
+ where: { id: instanceId },
2798
+ });
2799
+ if (!instance ||
2800
+ instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
2801
+ return [];
2802
+ }
2803
+ const tasks = await this.taskRepository.find({
2804
+ where: {
2805
+ instanceId,
2806
+ isAdhoc: false,
2807
+ status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
2808
+ },
2809
+ });
2810
+ const unitsPerTask = await Promise.all(tasks.map(async (task) => {
2811
+ const node = instance.workflowSnapshot.nodes.find((candidate) => candidate.type === 'userTask' && candidate.id === task.nodeId);
2812
+ if (!node) {
2813
+ return [];
2814
+ }
2815
+ try {
2816
+ await this.readTaskActorContext(this.taskRepository.manager, task, memberId);
2817
+ }
2818
+ catch (error) {
2819
+ if (error instanceof common_1.ConflictException) {
2820
+ return [];
2821
+ }
2822
+ throw error;
2823
+ }
2824
+ const shown = new Set((0, bpm_core_shared_1.readNodeFieldAccess)(node)
2825
+ .filter((entry) => entry.display === 'SHOW')
2826
+ .map((entry) => entry.fieldKey));
2827
+ return (0, bpm_core_shared_1.readNodeFillUnits)(node).filter((unit) => shown.has(unit.fieldKey));
2828
+ }));
2829
+ return unitsPerTask.flat();
2830
+ }
2192
2831
  /**
2193
2832
  * ADR 25 §6: a value written to a filterable field must fit its type, on
2194
2833
  * every path that writes form data. The fields checked are the ones the
@@ -2274,7 +2913,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2274
2913
  totalPages,
2275
2914
  };
2276
2915
  }
2277
- async createFilteredApprovalInstanceQuery(scope, options = {}) {
2916
+ async createFilteredApprovalInstanceQuery(scope, requested = {}) {
2917
+ const options = await this.resolveApprovalInstanceTemplateKey(requested);
2918
+ if (!options) {
2919
+ return null;
2920
+ }
2278
2921
  const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
2279
2922
  const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
2280
2923
  if (!hasReadableInstances) {
@@ -2502,9 +3145,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2502
3145
  memberId,
2503
3146
  submission: null,
2504
3147
  });
2505
- return templates.filter((template) => this.isInitiatorPolicyMet(template, template.currentVersionId
2506
- ? (versionById.get(template.currentVersionId) ?? null)
2507
- : null, initiatorMetadataSnapshot, 'launchable templates'));
3148
+ return templates.filter((template) => {
3149
+ const version = template.currentVersionId
3150
+ ? (versionById.get(template.currentVersionId) ?? null)
3151
+ : null;
3152
+ // A HOST_ONLY template is started by the host's backend, never from the
3153
+ // launch page (ADR 27 §5), so it is not offered there at all.
3154
+ return (version?.launchMode !== template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY &&
3155
+ this.isInitiatorPolicyMet(template, version, initiatorMetadataSnapshot, 'launchable templates'));
3156
+ });
2508
3157
  }
2509
3158
  /**
2510
3159
  * The published version whose form `memberId` may read data-source options
@@ -2605,7 +3254,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2605
3254
  * given its candidate summaries.
2606
3255
  */
2607
3256
  async listInboxTasks(assigneeMemberId, options = {}) {
2608
- const openTasks = await this.readSharedOpenInboxTasks(assigneeMemberId);
3257
+ const openTasks = await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), options);
2609
3258
  if (!isTaskListPaged(options)) {
2610
3259
  return this.attachTaskCandidateSummaries(openTasks);
2611
3260
  }
@@ -2613,8 +3262,77 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2613
3262
  return this.attachTaskCandidateSummaries(openTasks.slice(offset, offset + normalizePageSize(options.pageSize)));
2614
3263
  }
2615
3264
  /** How many tasks {@link listInboxTasks} pages through. */
2616
- async countInboxTasks(assigneeMemberId) {
2617
- return (await this.readSharedOpenInboxTasks(assigneeMemberId)).length;
3265
+ async countInboxTasks(assigneeMemberId, filter = {}) {
3266
+ return (await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), filter)).length;
3267
+ }
3268
+ /**
3269
+ * The template ids a {@link TaskTemplateFilter} allows, or `null` when it
3270
+ * does not filter. A `templateKey` that names no template allows nothing.
3271
+ */
3272
+ async readTaskTemplateFilterIds(filter) {
3273
+ // Trimmed, and a blank key does not filter, as on the instance list.
3274
+ const key = normalizeText(filter.templateKey);
3275
+ const byKey = key ? await this.readTemplateIdsByKey(key) : null;
3276
+ if (filter.templateIds === undefined) {
3277
+ return byKey;
3278
+ }
3279
+ if (byKey === null) {
3280
+ return filter.templateIds;
3281
+ }
3282
+ return filter.templateIds.filter((id) => byKey.includes(id));
3283
+ }
3284
+ /**
3285
+ * Turns `templateKey` into `templateId` (ADR 27 §6), or `null` when the
3286
+ * options can match nothing: an unknown key, or a key naming another
3287
+ * template than `templateId` does.
3288
+ */
3289
+ async resolveApprovalInstanceTemplateKey(options) {
3290
+ const key = normalizeText(options.templateKey);
3291
+ if (!key) {
3292
+ return options;
3293
+ }
3294
+ const [templateId] = await this.readTemplateIdsByKey(key);
3295
+ const requestedId = normalizeText(options.templateId);
3296
+ if (!templateId || (requestedId && requestedId !== templateId)) {
3297
+ return null;
3298
+ }
3299
+ return { ...options, templateId };
3300
+ }
3301
+ /**
3302
+ * The id of the live template carrying `key`, as a list so a key that names
3303
+ * nothing filters everything out. At most one live template holds a key.
3304
+ */
3305
+ async readTemplateIdsByKey(key) {
3306
+ const template = await this.approvalTemplateRepository.findOne({
3307
+ select: { id: true },
3308
+ where: { key },
3309
+ });
3310
+ return template ? [template.id] : [];
3311
+ }
3312
+ /**
3313
+ * Keeps the tasks whose instance belongs to the filter's templates. The open
3314
+ * task read is shared with the dashboard counters, so the filter is applied
3315
+ * to its result rather than pushed into it.
3316
+ */
3317
+ async filterTasksByTemplate(tasks, filter) {
3318
+ const templateIds = await this.readTaskTemplateFilterIds(filter);
3319
+ if (templateIds === null) {
3320
+ return tasks;
3321
+ }
3322
+ if (!templateIds.length || !tasks.length) {
3323
+ return [];
3324
+ }
3325
+ const instances = await this.approvalInstanceRepository.find({
3326
+ select: { id: true },
3327
+ where: {
3328
+ id: (0, typeorm_2.In)([
3329
+ ...new Set(tasks.map((task) => task.instanceId)),
3330
+ ]),
3331
+ templateId: (0, typeorm_2.In)([...templateIds]),
3332
+ },
3333
+ });
3334
+ const instanceIds = new Set(instances.map((instance) => instance.id));
3335
+ return tasks.filter((task) => instanceIds.has(task.instanceId));
2618
3336
  }
2619
3337
  /**
2620
3338
  * {@link readOpenInboxTasks} for the inbox list and its count, joining a
@@ -2711,7 +3429,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2711
3429
  */
2712
3430
  async listApprovalHistoryTasks(assigneeMemberId, options = {}) {
2713
3431
  const paged = isTaskListPaged(options);
2714
- const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState)
3432
+ const templateIds = await this.readTaskTemplateFilterIds(options);
3433
+ if (templateIds?.length === 0) {
3434
+ return [];
3435
+ }
3436
+ const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds)
2715
3437
  .orderBy(this.readApprovalHistoryActedAtExpression(), 'DESC')
2716
3438
  .addOrderBy('"task"."id"', 'DESC')
2717
3439
  .offset(paged ? readPageOffset(options.page, options.pageSize) : 0)
@@ -2723,10 +3445,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2723
3445
  }
2724
3446
  /** How many tasks {@link listApprovalHistoryTasks} pages through. */
2725
3447
  async countApprovalHistoryTasks(assigneeMemberId, options = {}) {
2726
- return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState).getCount();
3448
+ const templateIds = await this.readTaskTemplateFilterIds(options);
3449
+ if (templateIds?.length === 0) {
3450
+ return 0;
3451
+ }
3452
+ return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds).getCount();
2727
3453
  }
2728
3454
  /** The rows of {@link listApprovalHistoryTasks}, unordered and unpaged. */
2729
- createApprovalHistoryTaskQuery(assigneeMemberId, instanceState) {
3455
+ createApprovalHistoryTaskQuery(assigneeMemberId, instanceState, templateIds = null) {
2730
3456
  const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
2731
3457
  const queryBuilder = this.taskRepository
2732
3458
  .createQueryBuilder('task')
@@ -2759,6 +3485,16 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2759
3485
  )
2760
3486
  `, { historyInstanceState: instanceState });
2761
3487
  }
3488
+ if (templateIds) {
3489
+ queryBuilder.andWhere(`
3490
+ EXISTS (
3491
+ SELECT 1
3492
+ FROM ${this.readQualifiedTableName(this.approvalInstanceRepository)} "historyTemplateInstance"
3493
+ WHERE "historyTemplateInstance"."id" = "task"."instance_id"
3494
+ AND "historyTemplateInstance"."template_id" IN (:...historyTemplateIds)
3495
+ )
3496
+ `, { historyTemplateIds: [...templateIds] });
3497
+ }
2762
3498
  return queryBuilder;
2763
3499
  }
2764
3500
  /** When the member acted on a history task; see {@link listApprovalHistoryTasks}. */
@@ -2845,7 +3581,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2845
3581
  * snapshot without a `nodes` array, and a blank or non-string label all
2846
3582
  * leave the task without a label, as `readWorkflowNodeLabel` does. Only the
2847
3583
  * node's `fieldAccess` leaves the database for the fill flag, which
2848
- * `readNodeFillableFieldKeys` then reads as tolerantly as the engine does.
3584
+ * `readNodeFillGrantFieldKeys` then reads as tolerantly as the engine does.
2849
3585
  */
2850
3586
  async readTaskNodeSummaries(tasks) {
2851
3587
  // The table as the entity reads it, so a configured `schema` is honoured
@@ -2880,7 +3616,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2880
3616
  readNodeLabelKey(row.instanceId, row.nodeId),
2881
3617
  {
2882
3618
  hasFillableFields: row.nodeType === 'userTask' &&
2883
- (0, bpm_core_shared_1.readNodeFillableFieldKeys)({
3619
+ (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)({
2884
3620
  data: { fieldAccess: row.fieldAccess },
2885
3621
  }).size > 0,
2886
3622
  label: typeof row.label === 'string' && row.label.trim()
@@ -3169,6 +3905,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3169
3905
  });
3170
3906
  await this.dispatchAdhocCompletionNotifications(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED);
3171
3907
  await this.cancelPendingAdhocDirectives(manager, instance.id, null);
3908
+ await this.recordInstanceEvent(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED, 'INSTANCE_APPROVED', {});
3172
3909
  }
3173
3910
  async isNodeEntryReady(manager, instance, token, node) {
3174
3911
  if (node.type === 'startEvent') {
@@ -3234,7 +3971,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3234
3971
  }
3235
3972
  isNodeEntryConditionMet(instance, node) {
3236
3973
  const entryCondition = readNodeEntryCondition(node);
3237
- return this.conditionService.evaluateBoolean(entryCondition, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`, readExpressionDialectOptions(entryCondition, instance.workflowSnapshot));
3974
+ return this.conditionService.evaluateBoolean(entryCondition, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`, readExpressionDialectOptions(entryCondition, instance.workflowSnapshot));
3238
3975
  }
3239
3976
  async skipNodeByEntryCondition(manager, instance, token, node) {
3240
3977
  await this.skipNode(manager, instance, token, node, {
@@ -3242,9 +3979,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3242
3979
  });
3243
3980
  }
3244
3981
  async skipNodeByInitiatorExclusion(manager, instance, token, node, skippedBy = 'INITIATOR') {
3245
- await this.skipNode(manager, instance, token, node, skippedBy === 'NODE_ACTORS'
3246
- ? { skippedByNodeActorExclusion: true }
3247
- : { skippedByInitiatorExclusion: true });
3982
+ await this.skipNode(manager, instance, token, node, SKIPPED_RUNTIME_TASK_PAYLOADS[skippedBy]);
3248
3983
  }
3249
3984
  async skipNode(manager, instance, token, node, reason) {
3250
3985
  // A NOTIFY node is an asynchronous branch that may not have an outgoing
@@ -3308,7 +4043,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3308
4043
  });
3309
4044
  }
3310
4045
  const context = {
3311
- ...buildWorkflowExpressionContext(instance),
4046
+ ...this.buildExpressionContext(instance),
3312
4047
  lastDecision: await this.readLastDecisionContext(manager, instance.id, node.id),
3313
4048
  };
3314
4049
  return evaluateWithStructuredFallback(edge, instance.formData, (message) => this.logger.warn(`instance=${instance.id} ${message}`), () => this.conditionService.evaluateBoolean(expression, context, `workflow.edges.${edge.id}.data.condition`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }));
@@ -3431,6 +4166,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3431
4166
  // worse — an `endState: 'REJECTED'` row overwritten with `APPROVED`
3432
4167
  // immediately after it had been committed.
3433
4168
  const completedInstance = await instanceRepository.save(Object.assign(instance, { completedAt, state: instanceState }));
4169
+ if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED) {
4170
+ await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_REJECTED', { actorMemberId: null });
4171
+ }
4172
+ else {
4173
+ await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_APPROVED', {});
4174
+ }
3434
4175
  if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED) {
3435
4176
  await this.notificationService.createInstanceCompletedNotification({
3436
4177
  instance: completedInstance,
@@ -3532,7 +4273,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3532
4273
  const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
3533
4274
  const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
3534
4275
  task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
3535
- task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(node).size > 0;
4276
+ task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)(node).size > 0;
3536
4277
  await tokenRepository.save({
3537
4278
  ...token,
3538
4279
  status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
@@ -3559,6 +4300,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3559
4300
  },
3560
4301
  taskId: task.id,
3561
4302
  }));
4303
+ await this.recordInstanceEvent(manager, instance, instance.state, 'NODE_ACTIVATED', { nodeId: node.id, taskIds: [task.id] });
3562
4304
  await openCandidates.reduce(async (previous, candidate) => {
3563
4305
  await previous;
3564
4306
  await this.notificationService.createTaskAssignedNotification({
@@ -3949,9 +4691,23 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3949
4691
  * its own transaction, and reports one result per task. A failing task does
3950
4692
  * not stop the others: a refusal is a REJECTED result, and an unexpected
3951
4693
  * error is reported as REJECTED with UNEXPECTED_ERROR for that task alone.
4694
+ *
4695
+ * `taskIds` narrows the batch to those tasks. Omitted or null keeps every
4696
+ * open task. An empty list throws a BadRequestException before anything is
4697
+ * read: it must never be taken for "all", the write is not reversible. An id
4698
+ * the member does not hold (already moved, instance finished, unknown) yields
4699
+ * no result row and no error; the others still run.
3952
4700
  */
3953
4701
  async reassignMemberTasks(options) {
3954
- const held = await this.readRunningMemberTasks(options.fromMemberId);
4702
+ const { taskIds } = options;
4703
+ if (taskIds && taskIds.length === 0) {
4704
+ throw new common_1.BadRequestException('taskIds must not be empty: omit it to reassign every open task');
4705
+ }
4706
+ const wanted = taskIds ? new Set(taskIds) : null;
4707
+ const allHeld = await this.readRunningMemberTasks(options.fromMemberId);
4708
+ const held = wanted
4709
+ ? allHeld.filter(({ task }) => wanted.has(task.id))
4710
+ : allHeld;
3955
4711
  return held.reduce(async (previous, { task }) => {
3956
4712
  const results = await previous;
3957
4713
  const result = await this.executeTaskReassignment(task, options).catch((error) => {
@@ -4020,6 +4776,103 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4020
4776
  };
4021
4777
  });
4022
4778
  }
4779
+ /**
4780
+ * The instances that could not move on and still cannot, newest failure
4781
+ * first, one row each, described by their latest unresolved `ADVANCE_FAILED`.
4782
+ *
4783
+ * Which failures are unresolved, the order and the page are decided by the
4784
+ * database (`readStuckFailurePage`, the SQL form of the rule the duplicate
4785
+ * check applies in {@link readUnresolvedAdvanceFailures}); the stuck set is
4786
+ * still evaluated whole before LIMIT applies, but only the page's rows leave
4787
+ * the database. This decorates that page.
4788
+ *
4789
+ * Who has to press again is read now, not copied from the failure: a task
4790
+ * the administrator has reassigned since shows its new holder.
4791
+ * - `DECISION`: the candidates whose seat is PENDING or CLAIMED. A WAITING
4792
+ * seat of a sequential task is not that member's turn yet. A task without
4793
+ * a single candidate row is a direct task, held by its assignee.
4794
+ * - `RESUBMIT`: the initiator.
4795
+ */
4796
+ async listStuckApprovalInstances(options = {}) {
4797
+ return this.approvalInstanceRepository.manager.transaction(async (manager) => {
4798
+ const failureIds = await (0, stuck_approval_instance_query_1.readStuckFailurePage)(manager, OPEN_TASK_STATUSES, {
4799
+ limit: normalizePageSize(options.pageSize),
4800
+ offset: readPageOffset(options.page, options.pageSize),
4801
+ });
4802
+ const failures = failureIds.length
4803
+ ? await manager
4804
+ .getRepository(activity_log_entity_1.ActivityLogEntity)
4805
+ .find({ where: { id: (0, typeorm_2.In)([...failureIds]) } })
4806
+ : [];
4807
+ const instances = failures.length
4808
+ ? await manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity).find({
4809
+ where: {
4810
+ id: (0, typeorm_2.In)([
4811
+ ...new Set(failures.map((failure) => failure.instanceId)),
4812
+ ]),
4813
+ },
4814
+ })
4815
+ : [];
4816
+ const failureById = new Map(failures.map((failure) => [
4817
+ failure.id,
4818
+ failure,
4819
+ ]));
4820
+ const instanceById = new Map(instances.map((instance) => [
4821
+ instance.id,
4822
+ instance,
4823
+ ]));
4824
+ // `find` does not keep the order of the ids; the page's order is the
4825
+ // database's.
4826
+ const stuck = failureIds.flatMap((failureId) => {
4827
+ const failure = failureById.get(failureId);
4828
+ const instance = failure && instanceById.get(failure.instanceId);
4829
+ return failure && instance ? [{ failure, instance }] : [];
4830
+ });
4831
+ if (stuck.length === 0) {
4832
+ return [];
4833
+ }
4834
+ const taskIds = [
4835
+ ...new Set(stuck.flatMap(({ failure }) => failure.taskId ? [failure.taskId] : [])),
4836
+ ];
4837
+ const tasks = taskIds.length
4838
+ ? await manager
4839
+ .getRepository(task_entity_1.TaskEntity)
4840
+ .find({ where: { id: (0, typeorm_2.In)(taskIds) } })
4841
+ : [];
4842
+ const candidates = taskIds.length
4843
+ ? await manager.getRepository(task_candidate_entity_1.TaskCandidateEntity).find({
4844
+ order: {
4845
+ sequence: { direction: 'ASC', nulls: 'LAST' },
4846
+ createdAt: 'ASC',
4847
+ id: 'ASC',
4848
+ },
4849
+ where: { taskId: (0, typeorm_2.In)(taskIds) },
4850
+ })
4851
+ : [];
4852
+ const templates = await manager
4853
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
4854
+ .find({
4855
+ where: {
4856
+ id: (0, typeorm_2.In)([
4857
+ ...new Set(stuck.map(({ instance }) => instance.templateId)),
4858
+ ]),
4859
+ },
4860
+ });
4861
+ const templateNameById = new Map(templates.map((template) => [
4862
+ template.id,
4863
+ template.name,
4864
+ ]));
4865
+ return stuck.map((candidate) => toStuckApprovalInstanceRecord(candidate, {
4866
+ candidates,
4867
+ tasks,
4868
+ templateNameById,
4869
+ }));
4870
+ });
4871
+ }
4872
+ /** How many instances {@link listStuckApprovalInstances} pages through. */
4873
+ async countStuckApprovalInstances() {
4874
+ return this.approvalInstanceRepository.manager.transaction(async (manager) => (0, stuck_approval_instance_query_1.countStuckInstances)(manager, OPEN_TASK_STATUSES));
4875
+ }
4023
4876
  /**
4024
4877
  * Deactivated members who still hold open tasks, so an administrator can find
4025
4878
  * them: a deactivated member no longer shows up in the member search.
@@ -4447,7 +5300,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4447
5300
  * Resolves who gets the node's task. A {@link SkippedRuntimeTask} means the
4448
5301
  * node asked to be skipped (`excludeInitiator.whenEmpty` or
4449
5302
  * `excludeNodeActors.whenEmpty` = SKIP_NODE) because everyone left was kept
4450
- * out: the sole caller creates no task and advances the token instead.
5303
+ * out, or its manager chain found the level it asks for already reached
5304
+ * (`skipWhenStartReached`, or an earlier stage it continues): the sole
5305
+ * caller creates no task and advances the token instead.
4451
5306
  *
4452
5307
  * With both exclusions set the initiator rule runs first. When it alone
4453
5308
  * leaves nobody, `excludeInitiator.whenEmpty` decides; when it leaves
@@ -4456,8 +5311,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4456
5311
  */
4457
5312
  async resolveRuntimeTaskCandidates(manager, instance, node) {
4458
5313
  const label = `簽核節點「${node.data.label}」`;
5314
+ // Worked out before anything else because it can end the node: a chain
5315
+ // that needs nobody more is skipped without resolving or excluding anyone.
5316
+ const chainContext = await this.resolveOrgManagerChainContext(manager, instance, node, label);
5317
+ if (chainContext?.skipped) {
5318
+ return { skippedBy: 'MANAGER_CHAIN_REACHED' };
5319
+ }
4459
5320
  const exclusions = await this.readNodeExclusions(manager, instance, node);
4460
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds);
5321
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds, chainContext);
4461
5322
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
4462
5323
  const hasExclusion = hasSeatExclusion(node);
4463
5324
  const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
@@ -4497,6 +5358,136 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4497
5358
  ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
4498
5359
  : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
4499
5360
  }
5361
+ /**
5362
+ * What the walk of `node`'s manager chain has to know first, or `null`
5363
+ * for a node that is not a manager chain using one of the two settings that
5364
+ * need it (continuing an earlier chain, or skipping when the start already
5365
+ * qualifies); such a node is resolved exactly as before.
5366
+ *
5367
+ * The settings are checked again here, since the instance runs on its own
5368
+ * snapshot of the definition and a definition from another build can carry
5369
+ * a shape this one never published. A setting that does not hold throws a
5370
+ * {@link ConflictException} naming the node.
5371
+ *
5372
+ * `skipped` is set when the level the chain asks for is already there: the
5373
+ * start person's own level for `skipWhenStartReached`, the level the earlier
5374
+ * stage got a signature from for a continuation.
5375
+ */
5376
+ async resolveOrgManagerChainContext(manager, instance, node, label) {
5377
+ const resolver = node.data.approverResolver;
5378
+ if (resolver.type !== 'ORG_MANAGER_CHAIN' ||
5379
+ !usesOrgManagerChainSettings(resolver)) {
5380
+ return null;
5381
+ }
5382
+ const shapeIssue = (0, workflow_graph_1.readOrgManagerChainIssue)(resolver, '簽核節點');
5383
+ if (shapeIssue !== null) {
5384
+ throw new common_1.ConflictException(`${label} 無法建立待簽任務:${shapeIssue}`);
5385
+ }
5386
+ if (!(0, workflow_1.isOrgManagerChainContinuation)(resolver)) {
5387
+ return {
5388
+ continuation: null,
5389
+ skipped: await this.isOrgManagerChainStartLevelReached(manager, instance, resolver, label),
5390
+ };
5391
+ }
5392
+ const found = readChainContinuationSource(instance.workflowSnapshot, node.id, resolver);
5393
+ if ('issue' in found) {
5394
+ throw new common_1.ConflictException(`${label} 無法建立待簽任務:${found.issue}`);
5395
+ }
5396
+ // The earlier stage's own start, so both stages climb the same line.
5397
+ const start = await this.resolveOrgManagerChainStart(manager, instance, found.source, label, '簽核人');
5398
+ const reachedLevel = await this.readChainStageReachedLevel(manager, instance, found.sourceId);
5399
+ return {
5400
+ continuation: { reachedLevel, start },
5401
+ skipped: reachedLevel !== null && reachedLevel >= resolver.minPositionLevel,
5402
+ };
5403
+ }
5404
+ /**
5405
+ * `skipWhenStartReached`: whether the person the chain starts from already
5406
+ * holds a position level of `minPositionLevel` or more today. No start
5407
+ * person, or one without a graded position, reads as "not reached" so the
5408
+ * chain is walked as usual.
5409
+ */
5410
+ async isOrgManagerChainStartLevelReached(manager, instance, resolver, label) {
5411
+ const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, '簽核人');
5412
+ if (start.memberId === null) {
5413
+ return false;
5414
+ }
5415
+ const level = await this.readMemberPositionLevel(manager, start.memberId, this.readOrganizationToday());
5416
+ return level !== null && level >= resolver.minPositionLevel;
5417
+ }
5418
+ /**
5419
+ * The position level the manager-chain stage `stageNodeId` actually got a
5420
+ * signature from in its latest round (`T`), or `null` when none counts.
5421
+ *
5422
+ * - A stage whose latest round was a skip for `skippedByManagerChainReached`
5423
+ * got nobody to sign, so its older approvals do not count: `null`.
5424
+ * - Otherwise the people who really approved its latest batch
5425
+ * ({@link readNodeActorApprovals}: no ad-hoc countersign, no SLA
5426
+ * auto-approval), each at the level they held on the day they approved.
5427
+ * `Position.level` has no history, but memberships are dated, so a
5428
+ * promotion that took effect after the approval does not raise `T`. The day
5429
+ * is the organisation's, the same one "today" is.
5430
+ *
5431
+ * The people counted are those who pressed approve, delegates and transfer
5432
+ * receivers included, not the seat they sat in.
5433
+ */
5434
+ async readChainStageReachedLevel(manager, instance, stageNodeId) {
5435
+ if (await this.wasChainStageSkippedLastRound(manager, instance, stageNodeId)) {
5436
+ return null;
5437
+ }
5438
+ const approvals = (await this.readNodeActorApprovals(manager, instance, [stageNodeId])).map((decision) => ({
5439
+ date: (0, organization_time_zone_1.formatDateOnlyInTimeZone)(decision.decidedAt, this.organizationTimeZone),
5440
+ memberId: decision.decidedByMemberId,
5441
+ }));
5442
+ const levelGroups = await Promise.all(uniqueTexts(approvals.map((approval) => approval.date)).map(async (date) => [
5443
+ ...(await this.readMemberPositionLevels(manager, approvals
5444
+ .filter((approval) => approval.date === date)
5445
+ .map((approval) => approval.memberId), date)).values(),
5446
+ ]));
5447
+ const levels = levelGroups
5448
+ .flat()
5449
+ .filter((level) => level !== null);
5450
+ return levels.length === 0 ? null : Math.max(...levels);
5451
+ }
5452
+ /**
5453
+ * Whether the latest thing that happened to the stage `stageNodeId` was a
5454
+ * skip for `skippedByManagerChainReached`, rather than a task being made for
5455
+ * it: the newest such `TOKEN_ADVANCED` is newer than the newest
5456
+ * `TASK_CREATED` of one of the stage's own (non-ad-hoc) tasks. Newer is
5457
+ * decided by the log's `seq` (the order the database wrote the rows in;
5458
+ * `created_at` is the transaction start and ties within one transaction).
5459
+ *
5460
+ * The `TASK_CREATED` payload does not say whether its task is ad-hoc, so the
5461
+ * task is looked up by the id the log carries.
5462
+ */
5463
+ async wasChainStageSkippedLastRound(manager, instance, stageNodeId) {
5464
+ const [logs, stageTasks] = await Promise.all([
5465
+ manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
5466
+ where: {
5467
+ eventType: (0, typeorm_2.In)([
5468
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
5469
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
5470
+ ]),
5471
+ instanceId: instance.id,
5472
+ nodeId: stageNodeId,
5473
+ },
5474
+ }),
5475
+ manager.getRepository(task_entity_1.TaskEntity).find({
5476
+ where: { instanceId: instance.id, isAdhoc: false, nodeId: stageNodeId },
5477
+ }),
5478
+ ]);
5479
+ const stageTaskIds = new Set(stageTasks.map((task) => task.id));
5480
+ const newestSeq = (matching) => matching.reduce((newest, log) => {
5481
+ const seq = BigInt(log.seq);
5482
+ return newest === null || seq > newest ? seq : newest;
5483
+ }, null);
5484
+ const skippedSeq = newestSeq(logs.filter((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED &&
5485
+ log.payload['skippedByManagerChainReached'] === true));
5486
+ const createdSeq = newestSeq(logs.filter((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED &&
5487
+ log.taskId !== null &&
5488
+ stageTaskIds.has(log.taskId)));
5489
+ return (skippedSeq !== null && (createdSeq === null || skippedSeq > createdSeq));
5490
+ }
4500
5491
  /**
4501
5492
  * Everyone `node` keeps out of its approvers, per rule: the initiator and
4502
5493
  * the submitter for `excludeInitiator`, and everyone who actually approved
@@ -4618,9 +5609,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4618
5609
  skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
4619
5610
  };
4620
5611
  }
5612
+ /**
5613
+ * The context workflow expressions on `instance` read. Its `form` is the
5614
+ * stored data, or with `expressionMissingFormFieldsAsNull` on, a copy with
5615
+ * every unfilled top-level field present (ADR 27 §3).
5616
+ */
5617
+ buildExpressionContext(instance) {
5618
+ return buildWorkflowExpressionContext(instance, this.readExpressionFormData(instance));
5619
+ }
5620
+ readExpressionFormData(instance) {
5621
+ return this.expressionMissingFormFieldsAsNull
5622
+ ? (0, missing_form_fields_1.fillMissingFormFields)(instance.formData, readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot))
5623
+ : instance.formData;
5624
+ }
4621
5625
  buildDelegationContext(instance, nodeId) {
4622
5626
  return {
4623
- formData: instance.formData,
5627
+ formData: this.readExpressionFormData(instance),
4624
5628
  initiatorMemberId: instance.initiatorMemberId,
4625
5629
  initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
4626
5630
  instanceId: instance.id,
@@ -4698,15 +5702,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4698
5702
  ? requiredLevel
4699
5703
  : null;
4700
5704
  }
4701
- async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
4702
- const memberIds = await this.resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds);
5705
+ async resolveApproverResolver(manager, instance, resolver, label, subject, skippedChainMemberIds,
5706
+ // Required, so a new entry point has to say it has none: only task
5707
+ // creation works one out, and a chain that needs it refuses without it.
5708
+ chainContext) {
5709
+ const memberIds = await this.resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext);
4703
5710
  return memberIds.map((memberId) => ({
4704
5711
  memberId,
4705
5712
  sourceType: resolver.type,
4706
5713
  }));
4707
5714
  }
4708
5715
  /** The member ids `resolver` routes to; every source type, one branch each. */
4709
- async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds) {
5716
+ async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext) {
4710
5717
  if (resolver.type === 'DIRECT') {
4711
5718
  return readMemberIdsFromValue(resolver.memberIds, label, subject);
4712
5719
  }
@@ -4714,7 +5721,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4714
5721
  return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`);
4715
5722
  }
4716
5723
  if (resolver.type === 'EXPRESSION') {
4717
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
5724
+ return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, this.buildExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
4718
5725
  }
4719
5726
  if (resolver.type === 'POSITION') {
4720
5727
  return this.resolvePositionCandidates(manager, resolver.positionId, label, subject);
@@ -4729,7 +5736,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4729
5736
  return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
4730
5737
  }
4731
5738
  if (resolver.type === 'ORG_MANAGER_CHAIN') {
4732
- return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
5739
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext);
4733
5740
  }
4734
5741
  if (resolver.type === 'ORG_MANAGER') {
4735
5742
  return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
@@ -4844,7 +5851,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4844
5851
  */
4845
5852
  async readNodeActorApprovals(manager, instance, nodeIds) {
4846
5853
  const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
4847
- where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
5854
+ where: {
5855
+ instanceId: instance.id,
5856
+ isAdhoc: false,
5857
+ nodeId: (0, typeorm_2.In)([...nodeIds]),
5858
+ },
4848
5859
  });
4849
5860
  if (tasks.length === 0) {
4850
5861
  return [];
@@ -4860,7 +5871,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4860
5871
  }
4861
5872
  const [decisions, decidedLogs] = await Promise.all([
4862
5873
  manager.getRepository(task_decision_entity_1.TaskDecisionEntity).find({
4863
- where: { action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED, taskId: (0, typeorm_2.In)(batchTaskIds) },
5874
+ where: {
5875
+ action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
5876
+ taskId: (0, typeorm_2.In)(batchTaskIds),
5877
+ },
4864
5878
  }),
4865
5879
  manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
4866
5880
  where: {
@@ -4922,11 +5936,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4922
5936
  * past without joining the chain or counting as the stopping point, and so
4923
5937
  * are managers below `startPositionLevel`, until the first one at or above
4924
5938
  * it starts the chain.
5939
+ *
5940
+ * A chain that continues an earlier stage climbs from that stage's start and
5941
+ * walks past every manager whose level is not above the one the stage got a
5942
+ * signature from, as if that level were the last one already added. Both it
5943
+ * and a chain that skips when its start already qualifies are only walked
5944
+ * with the `chainContext` task creation worked out; without one they are
5945
+ * refused, which keeps every other entry point (a notify node's recipients,
5946
+ * for one) from reading them as an ordinary chain.
4925
5947
  */
4926
- async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds) {
5948
+ async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds, chainContext) {
4927
5949
  const { fallback, minPositionLevel } = resolver;
4928
5950
  const startPositionLevel = (0, workflow_1.readOrgManagerChainStartPositionLevel)(resolver);
4929
- const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject);
5951
+ const continuation = chainContext?.continuation ?? null;
5952
+ if (usesOrgManagerChainSettings(resolver) &&
5953
+ (!chainContext ||
5954
+ ((0, workflow_1.isOrgManagerChainContinuation)(resolver) && continuation === null))) {
5955
+ throw new common_1.ConflictException(`${label} ${subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務'}:${ORG_MANAGER_CHAIN_CONTEXT_REFUSAL}`);
5956
+ }
5957
+ const start = continuation?.start ??
5958
+ (await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject));
4930
5959
  // Nobody to climb from: the same "no manager" path as a chain whose walk
4931
5960
  // finds nobody, so the fallback decides.
4932
5961
  if (start.memberId === null) {
@@ -4936,7 +5965,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4936
5965
  const visitedMemberIds = new Set([start.memberId]);
4937
5966
  const chain = [];
4938
5967
  let currentMemberId = start.memberId;
4939
- let lastAddedLevel = null;
5968
+ // A continuation starts out as if the level its earlier stage got a
5969
+ // signature from were the last one added: managers at or below it are
5970
+ // walked past, so only those above it are asked.
5971
+ let lastAddedLevel = continuation?.reachedLevel ?? null;
4940
5972
  for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
4941
5973
  // A chain is a single path, so it always uses closest-org-unit semantics
4942
5974
  // (merging ancestor-unit rules could only produce ties). The member is
@@ -5021,6 +6053,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5021
6053
  if (subject === '通知對象') {
5022
6054
  throw new common_1.ConflictException(`${label} ${failure}:知會節點的主管鏈只能從發起人開始。`);
5023
6055
  }
6056
+ // The start of a continuation is its earlier stage's, which task creation
6057
+ // hands over already resolved; asking for it here is a mistake of the
6058
+ // caller, not a start to climb from.
6059
+ if (startFrom.type === 'CHAIN_CONTINUATION') {
6060
+ throw new common_1.ConflictException(`${label} ${failure}:${ORG_MANAGER_CHAIN_CONTEXT_REFUSAL}`);
6061
+ }
5024
6062
  if (startFrom.type === 'FORM_MEMBER') {
5025
6063
  const source = `依表單欄位「${startFrom.formPath}」`;
5026
6064
  const pickedMemberIds = findMemberIdsInValue(readValueAtPath(instance.formData, startFrom.formPath));
@@ -5322,11 +6360,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5322
6360
  const action = node.data.action;
5323
6361
  if (action.type === 'WEBHOOK') {
5324
6362
  const payload = typeof action.payload === 'string' && action.payload.trim()
5325
- ? this.conditionService.evaluateValue(action.payload, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.payload`, {
6363
+ ? this.conditionService.evaluateValue(action.payload, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.action.payload`, {
5326
6364
  dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot),
5327
6365
  })
5328
6366
  : {
5329
- instance: buildWorkflowExpressionContext(instance).instance,
6367
+ instance: this.buildExpressionContext(instance).instance,
5330
6368
  triggeredAt: new Date().toISOString(),
5331
6369
  };
5332
6370
  await this.dispatchWebhookAfterCommit(manager, {
@@ -5354,7 +6392,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5354
6392
  return;
5355
6393
  }
5356
6394
  if (action.type === 'SET_FORM_FIELD') {
5357
- const value = this.conditionService.evaluateValue(action.value, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
6395
+ const value = this.conditionService.evaluateValue(action.value, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
5358
6396
  assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
5359
6397
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
5360
6398
  await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
@@ -5404,7 +6442,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5404
6442
  const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
5405
6443
  (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5406
6444
  ? []
5407
- : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
6445
+ : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象', [],
6446
+ // A notify node has no task to build the context: a chain that
6447
+ // needs one is refused (publish lint forbids it here already).
6448
+ null);
5408
6449
  const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
5409
6450
  await this.notificationService.createServiceTaskNotifications({
5410
6451
  instance,
@@ -5416,8 +6457,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5416
6457
  // §3.5). Without the webhook module there is nothing to queue into; the
5417
6458
  // publish lint already refuses a template that references an endpoint
5418
6459
  // in that case.
5419
- const webhookDeliveryIds = this.webhookDeliveryService &&
5420
- (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
6460
+ const webhookDeliveryIds = this.webhookDeliveryService && (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5421
6461
  ? await this.webhookDeliveryService.enqueueNotifyWebhooks(manager, {
5422
6462
  instance: {
5423
6463
  formData: instance.formData,
@@ -5447,7 +6487,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5447
6487
  taskId: null,
5448
6488
  }));
5449
6489
  }
5450
- async rejectInstance(manager, instance, task, rejectedAt) {
6490
+ async rejectInstance(manager, instance, task, rejectedAt, decidedByMemberId) {
5451
6491
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
5452
6492
  // Dispatch pending ad-hoc notifications for the rejecting node before the
5453
6493
  // remaining directives are cancelled by the terminal cleanup below.
@@ -5478,8 +6518,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5478
6518
  },
5479
6519
  taskId: task.id,
5480
6520
  }));
6521
+ await this.recordInstanceEvent(manager, rejectedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED, 'INSTANCE_REJECTED', { actorMemberId: decidedByMemberId });
5481
6522
  }
5482
- async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt) {
6523
+ async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt, decidedByMemberId) {
5483
6524
  if (!returnToNodeId) {
5484
6525
  throw new common_1.ConflictException('Return target node is required');
5485
6526
  }
@@ -5537,6 +6578,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5537
6578
  taskId: null,
5538
6579
  }),
5539
6580
  ]);
6581
+ await this.recordInstanceEvent(manager, returnedInstance, returnedInstance.state, 'INSTANCE_RETURNED', { actorMemberId: decidedByMemberId, nodeId: task.nodeId });
5540
6582
  if (returnedInstance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
5541
6583
  await this.processRunningInstance(manager, returnedInstance);
5542
6584
  }
@@ -5738,6 +6780,9 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5738
6780
  tslib_1.__param(20, (0, common_1.Optional)()),
5739
6781
  tslib_1.__param(21, (0, common_1.Optional)()),
5740
6782
  tslib_1.__param(21, (0, common_1.Inject)(initiator_custom_fields_resolver_token_1.BPM_INITIATOR_CUSTOM_FIELDS_RESOLVER)),
6783
+ tslib_1.__param(22, (0, common_1.Optional)()),
6784
+ tslib_1.__param(22, (0, common_1.Inject)(instance_guard_token_1.BPM_INSTANCE_GUARD)),
6785
+ tslib_1.__param(23, (0, common_1.Optional)()),
5741
6786
  tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
5742
6787
  typeorm_2.Repository,
5743
6788
  typeorm_2.Repository,
@@ -5754,7 +6799,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5754
6799
  delegation_service_1.DelegationService,
5755
6800
  notification_service_1.NotificationService,
5756
6801
  signature_service_1.SignatureService,
5757
- sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object])
6802
+ sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object, Object, instance_event_writer_1.InstanceEventWriter])
5758
6803
  ], WorkflowEngineService);
5759
6804
  function parseJsonObject(value, label) {
5760
6805
  try {
@@ -5777,6 +6822,67 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5777
6822
  memberId,
5778
6823
  };
5779
6824
  }
6825
+ /**
6826
+ * The form schema a dry run was given to fill unfilled fields with (ADR 27
6827
+ * §3), or `null` when it was given none. A schema that does not parse is a
6828
+ * 400, like the other inputs.
6829
+ */
6830
+ function readDryRunFormSchema(json) {
6831
+ if (!json) {
6832
+ return null;
6833
+ }
6834
+ const schema = parseJsonObject(json, 'formSchemaJson');
6835
+ if (!isFormDefinitionSchema(schema)) {
6836
+ throw new common_1.BadRequestException('formSchemaJson is not a form schema');
6837
+ }
6838
+ return schema;
6839
+ }
6840
+ /**
6841
+ * Refuses a submission or resubmission through GraphQL on a `HOST_ONLY`
6842
+ * version (ADR 27 §5). Only the host's backend, calling the service, may start
6843
+ * or resubmit such a case.
6844
+ */
6845
+ function assertEndUserMayLaunch(version) {
6846
+ if (version.launchMode === template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY) {
6847
+ throw new common_1.ForbiddenException('This approval template can only be submitted by the host system');
6848
+ }
6849
+ }
6850
+ /**
6851
+ * Reads the service-only `initiatorCustomFields` of a submission (ADR 27 §5):
6852
+ * `null` when absent, otherwise a JSON copy. Refused with 400 when it is not a
6853
+ * plain object or comes together with a whole `initiatorMetadataSnapshotJson`,
6854
+ * which would leave it unclear which one decides.
6855
+ */
6856
+ function readHostInitiatorCustomFields(input) {
6857
+ const fields = input.initiatorCustomFields;
6858
+ if (fields === undefined || fields === null) {
6859
+ return null;
6860
+ }
6861
+ if (input.initiatorMetadataSnapshotJson) {
6862
+ throw new common_1.BadRequestException('initiatorCustomFields cannot be combined with initiatorMetadataSnapshotJson');
6863
+ }
6864
+ if (typeof fields !== 'object' || Array.isArray(fields)) {
6865
+ throw new common_1.BadRequestException('initiatorCustomFields must be an object');
6866
+ }
6867
+ return cloneJsonObject(fields);
6868
+ }
6869
+ /**
6870
+ * Lays the host's `initiatorCustomFields` over the `customFields` of a built
6871
+ * snapshot; a key the host gave wins over the resolver's.
6872
+ */
6873
+ function mergeHostInitiatorCustomFields(snapshot, hostCustomFields) {
6874
+ if (hostCustomFields === null) {
6875
+ return snapshot;
6876
+ }
6877
+ const current = snapshot['customFields'];
6878
+ return {
6879
+ ...snapshot,
6880
+ customFields: {
6881
+ ...(isRecord(current) ? current : {}),
6882
+ ...hostCustomFields,
6883
+ },
6884
+ };
6885
+ }
5780
6886
  function cloneJsonObject(value) {
5781
6887
  return JSON.parse(JSON.stringify(value));
5782
6888
  }
@@ -5861,7 +6967,8 @@ function createWorkflowAuthContext(memberId) {
5861
6967
  };
5862
6968
  }
5863
6969
  function isSameApprovalInstanceRevision(left, right) {
5864
- return (readApprovalInstanceRevision(left) === readApprovalInstanceRevision(right) &&
6970
+ return (readApprovalInstanceRevision(left) ===
6971
+ readApprovalInstanceRevision(right) &&
5865
6972
  JSON.stringify(left.formData) === JSON.stringify(right.formData));
5866
6973
  }
5867
6974
  /**
@@ -5891,7 +6998,9 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
5891
6998
  // A corrupt snapshot can hold non-object entries: read them defensively
5892
6999
  // rather than letting a service task throw.
5893
7000
  const field = schema.fields.find((candidate) => isRecord(candidate) && candidate.fieldKey === fieldKey);
5894
- return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
7001
+ return field && (0, form_1.isFormDataSourceFieldDefinition)(field)
7002
+ ? field.fieldKey
7003
+ : null;
5895
7004
  }
5896
7005
  /**
5897
7006
  * Refuses form data whose filterable fields hold a value of the wrong type
@@ -5979,6 +7088,13 @@ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
5979
7088
  function readSnapshotWithoutField(snapshots, fieldKey) {
5980
7089
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
5981
7090
  }
7091
+ /**
7092
+ * Drops every snapshot that belongs to one of the fields, table cells included
7093
+ * (`items[0].col` belongs to `items`).
7094
+ */
7095
+ function readSnapshotWithoutFields(snapshots, fieldKeys) {
7096
+ return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => !fieldKeys.has((0, bpm_core_shared_1.readTopLevelFieldKey)(key))));
7097
+ }
5982
7098
  /**
5983
7099
  * Keeps only the snapshots whose field still carries a value, so clearing a
5984
7100
  * dynamic option also removes the label recorded for it.
@@ -6056,13 +7172,13 @@ function buildInitiatorPolicyContext(initiatorMetadataSnapshot) {
6056
7172
  subject: initiatorMetadataSnapshot,
6057
7173
  };
6058
7174
  }
6059
- function buildWorkflowExpressionContext(instance) {
7175
+ function buildWorkflowExpressionContext(instance, formData) {
6060
7176
  return {
6061
7177
  env: {
6062
7178
  now: new Date().toISOString(),
6063
7179
  },
6064
- form: instance.formData,
6065
- formData: instance.formData,
7180
+ form: formData,
7181
+ formData,
6066
7182
  initiator: {
6067
7183
  ...instance.initiatorMetadataSnapshot,
6068
7184
  memberId: instance.initiatorMemberId,
@@ -6106,8 +7222,8 @@ function buildDryRunExpressionContext(input, lastDecision) {
6106
7222
  env: {
6107
7223
  now: new Date().toISOString(),
6108
7224
  },
6109
- form: input.formData,
6110
- formData: input.formData,
7225
+ form: input.expressionFormData,
7226
+ formData: input.expressionFormData,
6111
7227
  initiator: {
6112
7228
  ...input.initiatorMetadataSnapshot,
6113
7229
  memberId: input.initiatorMemberId,
@@ -6263,8 +7379,7 @@ function readDefaultReturnTargetNodeId(workflow, currentNode) {
6263
7379
  }
6264
7380
  function isReturnCommentRequired(workflow, nodeId) {
6265
7381
  const node = readWorkflowNodeOrThrow(workflow, nodeId);
6266
- return (node.type === 'userTask' &&
6267
- node.data.returnBehavior.requireComment === true);
7382
+ return (node.type === 'userTask' && node.data.returnBehavior.requireComment === true);
6268
7383
  }
6269
7384
  function readReturnResubmitStrategy(task, instance) {
6270
7385
  const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
@@ -6278,6 +7393,92 @@ function readReturnResubmitStrategyFromPayload(payload) {
6278
7393
  ? 'FROM_RETURN_POINT'
6279
7394
  : 'RESTART';
6280
7395
  }
7396
+ /**
7397
+ * Whether a recorded failure is the one a new attempt would write again: same
7398
+ * kind and key, same reason. A decision is keyed by task, actor and action, so
7399
+ * the votes of a countersign each keep their own row; a resubmission has one
7400
+ * key per instance. The same actor failing for a different reason is news, so
7401
+ * the message is part of the match.
7402
+ */
7403
+ function isSameAdvanceFailure(logged, context, message) {
7404
+ if (readStringPayloadValue(logged.payload, 'kind') !== context.kind ||
7405
+ logged.payload['message'] !== message) {
7406
+ return false;
7407
+ }
7408
+ return (context.kind === 'RESUBMIT' ||
7409
+ (logged.taskId === context.taskId &&
7410
+ logged.actorMemberId === context.actorMemberId &&
7411
+ readStringPayloadValue(logged.payload, 'action') === context.action));
7412
+ }
7413
+ /**
7414
+ * Runs the step that moves an instance on and, when it fails with an error an
7415
+ * operator is meant to read (an `HttpException` below 500), hands it on as an
7416
+ * {@link InstanceAdvanceFailure} so the public method can record it once the
7417
+ * transaction has rolled back. Everything else passes through untouched: a 5xx
7418
+ * is usually a hiccup that retrying clears, and a plain `Error` may carry SQL.
7419
+ *
7420
+ * Used at the three call sites only. `processRunningInstance` and
7421
+ * `returnInstanceToNode` stay unwrapped because submit and the administrator's
7422
+ * re-run call them too, and they must keep throwing the original class.
7423
+ */
7424
+ async function runInstanceAdvanceStep(context, step) {
7425
+ try {
7426
+ return await step();
7427
+ }
7428
+ catch (error) {
7429
+ if (error instanceof common_1.HttpException && error.getStatus() < 500) {
7430
+ throw new instance_advance_failure_1.InstanceAdvanceFailure(error, context);
7431
+ }
7432
+ throw error;
7433
+ }
7434
+ }
7435
+ function toStuckApprovalInstanceRecord({ failure, instance }, lookup) {
7436
+ const isResubmit = readStringPayloadValue(failure.payload, 'kind') === 'RESUBMIT';
7437
+ const taskId = isResubmit ? null : failure.taskId;
7438
+ const nodeId = isResubmit ? null : failure.nodeId;
7439
+ const message = failure.payload['message'];
7440
+ return {
7441
+ automatic: failure.payload['automatic'] === SLA_AUTO_APPROVE_MARK
7442
+ ? SLA_AUTO_APPROVE_MARK
7443
+ : null,
7444
+ failedActorMemberId: failure.actorMemberId,
7445
+ failedAt: failure.createdAt,
7446
+ initiatorMemberId: instance.initiatorMemberId,
7447
+ instanceId: instance.id,
7448
+ instanceTitle: instance.title,
7449
+ kind: isResubmit
7450
+ ? workflow_engine_enums_1.StuckApprovalInstanceKindEnum.RESUBMIT
7451
+ : workflow_engine_enums_1.StuckApprovalInstanceKindEnum.DECISION,
7452
+ message: typeof message === 'string' ? message : '',
7453
+ nodeId,
7454
+ nodeLabel: nodeId
7455
+ ? readWorkflowNodeLabel(instance.workflowSnapshot, nodeId)
7456
+ : null,
7457
+ pendingActorMemberIds: taskId === null
7458
+ ? [instance.initiatorMemberId]
7459
+ : readTaskHolderMemberIds(lookup.tasks.find((task) => task.id === taskId) ?? null, lookup.candidates.filter((candidate) => candidate.taskId === taskId)),
7460
+ taskId,
7461
+ templateId: instance.templateId,
7462
+ templateName: lookup.templateNameById.get(instance.templateId) ?? null,
7463
+ };
7464
+ }
7465
+ /**
7466
+ * The members who can press a task's button now: a PENDING or CLAIMED seat,
7467
+ * each member once. A WAITING seat of a sequential task is not that member's
7468
+ * turn yet. A task with no candidate row at all is a direct task, held by its
7469
+ * assignee, as the reassignment check reads it.
7470
+ */
7471
+ function readTaskHolderMemberIds(task, candidates) {
7472
+ if (candidates.length === 0) {
7473
+ return task?.assigneeMemberId ? [task.assigneeMemberId] : [];
7474
+ }
7475
+ return [
7476
+ ...new Set(candidates
7477
+ .filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
7478
+ candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)
7479
+ .map((candidate) => candidate.memberId)),
7480
+ ];
7481
+ }
6281
7482
  function readStringPayloadValue(payload, key) {
6282
7483
  const value = payload[key];
6283
7484
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -6430,6 +7631,73 @@ function readInitiatorAndSubmitter(instance) {
6430
7631
  function isOrgManagerChainNode(node) {
6431
7632
  return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
6432
7633
  }
7634
+ /**
7635
+ * Whether a manager chain uses one of the two settings that need the context
7636
+ * task creation works out first: it continues an earlier chain, or
7637
+ * `skipWhenStartReached` is anything but absent, `null` or `false` (a value
7638
+ * that is not a boolean is "used" too, so the shape check can refuse it
7639
+ * instead of the chain being walked as if it were absent).
7640
+ */
7641
+ function usesOrgManagerChainSettings(resolver) {
7642
+ const skipWhenStartReached = resolver.skipWhenStartReached;
7643
+ return ((0, workflow_1.isOrgManagerChainContinuation)(resolver) ||
7644
+ (skipWhenStartReached !== undefined &&
7645
+ skipWhenStartReached !== null &&
7646
+ skipWhenStartReached !== false));
7647
+ }
7648
+ /**
7649
+ * The manager-chain stage a continuing chain (`nodeId`, whose resolver is
7650
+ * `resolver`) continues, or the first reason it cannot: it is missing, not a
7651
+ * user task, not a manager chain, itself a continuation (stages are not
7652
+ * chained more than once), started from something that can change while the
7653
+ * stage's approvals stay (a form field, or a start this build does not know),
7654
+ * or asks for a level at or below this chain's own, which would skip this
7655
+ * chain every time. The same rules the publish lint applies to the stage;
7656
+ * where the stage sits in the graph is only checked there.
7657
+ */
7658
+ function readChainContinuationSource(workflow, nodeId, resolver) {
7659
+ const sourceId = resolver.startFrom.nodeId;
7660
+ const source = workflow.nodes.find((candidate) => candidate.id === sourceId);
7661
+ const nameOf = (id) => `「${readWorkflowNodeLabel(workflow, id) ?? id}」`;
7662
+ if (source === undefined) {
7663
+ return { issue: `接續的前一段主管鏈「${sourceId}」不存在。` };
7664
+ }
7665
+ if (source.type !== 'userTask') {
7666
+ return { issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 不是簽核關卡。` };
7667
+ }
7668
+ const sourceResolver = source.data.approverResolver;
7669
+ if (sourceResolver?.type !== 'ORG_MANAGER_CHAIN') {
7670
+ return {
7671
+ issue: `接續的前一段 ${nameOf(sourceId)} 的簽核來源不是「沿主管鏈簽核」。`,
7672
+ };
7673
+ }
7674
+ if ((0, workflow_1.isOrgManagerChainContinuation)(sourceResolver)) {
7675
+ return {
7676
+ issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 本身也是接續型主管鏈,不支援多段串接。`,
7677
+ };
7678
+ }
7679
+ const sourceStart = sourceResolver.startFrom;
7680
+ const sourceStartType = typeof sourceStart === 'object' && sourceStart !== null
7681
+ ? sourceStart.type
7682
+ : undefined;
7683
+ if (sourceStart !== undefined &&
7684
+ sourceStart !== null &&
7685
+ sourceStartType !== 'INITIATOR' &&
7686
+ sourceStartType !== 'NODE_ACTOR') {
7687
+ return {
7688
+ issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 的起點只能是申請人或前面關卡的簽核者,不能是表單欄位或其他起點。`,
7689
+ };
7690
+ }
7691
+ const sourceLevel = sourceResolver.minPositionLevel;
7692
+ if (typeof sourceLevel === 'number' &&
7693
+ Number.isInteger(sourceLevel) &&
7694
+ resolver.minPositionLevel <= sourceLevel) {
7695
+ return {
7696
+ issue: `接續關卡 ${nameOf(nodeId)} 的職等門檻(${resolver.minPositionLevel})必須高於前一段主管鏈 ${nameOf(sourceId)} 的職等門檻(${sourceLevel});否則前一段只要簽到自己的職等,這一關就一定被略過。`,
7697
+ };
7698
+ }
7699
+ return { source: sourceResolver, sourceId };
7700
+ }
6433
7701
  // Candidates of a manager-chain node that came from the chain (or replaced the
6434
7702
  // excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
6435
7703
  function isGradeGatedCandidate(node, sourceType) {
@@ -6843,9 +8111,7 @@ function readAdhocTargetValue(directive) {
6843
8111
  webhookHeaders: isRecord(value.webhookHeaders)
6844
8112
  ? Object.entries(value.webhookHeaders).reduce((accumulator, [key, headerValue]) => ({
6845
8113
  ...accumulator,
6846
- ...(typeof headerValue === 'string'
6847
- ? { [key]: headerValue }
6848
- : {}),
8114
+ ...(typeof headerValue === 'string' ? { [key]: headerValue } : {}),
6849
8115
  }), {})
6850
8116
  : undefined,
6851
8117
  webhookUrl: typeof value.webhookUrl === 'string' ? value.webhookUrl : '',
@@ -6961,10 +8227,7 @@ function compareManagerResolution(left, right, orgUnitDepths) {
6961
8227
  return right.effectiveFrom.localeCompare(left.effectiveFrom);
6962
8228
  }
6963
8229
  function readOrgUnitDepthMap(orgUnits) {
6964
- return new Map(orgUnits.map((orgUnit) => [
6965
- orgUnit.id,
6966
- orgUnit.path.split('.').length,
6967
- ]));
8230
+ return new Map(orgUnits.map((orgUnit) => [orgUnit.id, orgUnit.path.split('.').length]));
6968
8231
  }
6969
8232
  function readManagerResolutionScopeRank(scopeType) {
6970
8233
  const ranks = {
@@ -7303,7 +8566,9 @@ function readTableMinRows(field) {
7303
8566
  return typeof field.minRows === 'number' ? field.minRows : 0;
7304
8567
  }
7305
8568
  function readTableMaxRows(field) {
7306
- return typeof field.maxRows === 'number' ? field.maxRows : form_1.FORM_TABLE_MAX_ROWS;
8569
+ return typeof field.maxRows === 'number'
8570
+ ? field.maxRows
8571
+ : form_1.FORM_TABLE_MAX_ROWS;
7307
8572
  }
7308
8573
  function readTableRequiredRowCount(field) {
7309
8574
  return Math.max(readTableMinRows(field), 1);
@@ -7663,4 +8928,17 @@ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
7663
8928
  }
7664
8929
  throw error;
7665
8930
  }
8931
+ /**
8932
+ * The keys whose value differs between the stored form data and the data a
8933
+ * resubmission replaces it with, sorted. A key present on only one side counts.
8934
+ */
8935
+ function listChangedFormDataKeys(previous, next) {
8936
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
8937
+ return [...keys]
8938
+ .filter((fieldKey) => Object.prototype.hasOwnProperty.call(previous, fieldKey) !==
8939
+ Object.prototype.hasOwnProperty.call(next, fieldKey) ||
8940
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(previous[fieldKey]) !==
8941
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(next[fieldKey]))
8942
+ .sort();
8943
+ }
7666
8944
  //# sourceMappingURL=workflow-engine.service.js.map