@rytass/bpm-core-nestjs-module 0.21.4 → 0.23.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 (143) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +9 -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 +41 -15
  32. package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
  33. package/src/lib/identity/identity-options.d.ts +1 -1
  34. package/src/lib/identity/identity.queries.d.ts +0 -2
  35. package/src/lib/identity/identity.queries.js +0 -11
  36. package/src/lib/identity/identity.queries.js.map +1 -1
  37. package/src/lib/instance-event/approval-instance-event.entity.d.ts +27 -0
  38. package/src/lib/instance-event/approval-instance-event.entity.js +93 -0
  39. package/src/lib/instance-event/approval-instance-event.entity.js.map +1 -0
  40. package/src/lib/instance-event/index.d.ts +13 -0
  41. package/src/lib/instance-event/index.js +17 -0
  42. package/src/lib/instance-event/index.js.map +1 -0
  43. package/src/lib/instance-event/instance-event.delivery-scheduler.service.d.ts +21 -0
  44. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js +81 -0
  45. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js.map +1 -0
  46. package/src/lib/instance-event/instance-event.delivery.service.d.ts +69 -0
  47. package/src/lib/instance-event/instance-event.delivery.service.js +259 -0
  48. package/src/lib/instance-event/instance-event.delivery.service.js.map +1 -0
  49. package/src/lib/instance-event/instance-event.delivery.subscriber.d.ts +24 -0
  50. package/src/lib/instance-event/instance-event.delivery.subscriber.js +85 -0
  51. package/src/lib/instance-event/instance-event.delivery.subscriber.js.map +1 -0
  52. package/src/lib/instance-event/instance-event.enums.d.ts +12 -0
  53. package/src/lib/instance-event/instance-event.enums.js +28 -0
  54. package/src/lib/instance-event/instance-event.enums.js.map +1 -0
  55. package/src/lib/instance-event/instance-event.module.d.ts +13 -0
  56. package/src/lib/instance-event/instance-event.module.js +52 -0
  57. package/src/lib/instance-event/instance-event.module.js.map +1 -0
  58. package/src/lib/instance-event/instance-event.object.d.ts +24 -0
  59. package/src/lib/instance-event/instance-event.object.js +73 -0
  60. package/src/lib/instance-event/instance-event.object.js.map +1 -0
  61. package/src/lib/instance-event/instance-event.options.d.ts +26 -0
  62. package/src/lib/instance-event/instance-event.options.js +35 -0
  63. package/src/lib/instance-event/instance-event.options.js.map +1 -0
  64. package/src/lib/instance-event/instance-event.resolver.d.ts +10 -0
  65. package/src/lib/instance-event/instance-event.resolver.js +75 -0
  66. package/src/lib/instance-event/instance-event.resolver.js.map +1 -0
  67. package/src/lib/instance-event/instance-event.service.d.ts +18 -0
  68. package/src/lib/instance-event/instance-event.service.js +37 -0
  69. package/src/lib/instance-event/instance-event.service.js.map +1 -0
  70. package/src/lib/instance-event/instance-event.token.d.ts +19 -0
  71. package/src/lib/instance-event/instance-event.token.js +9 -0
  72. package/src/lib/instance-event/instance-event.token.js.map +1 -0
  73. package/src/lib/instance-event/instance-event.types.d.ts +67 -0
  74. package/src/lib/instance-event/instance-event.types.js +9 -0
  75. package/src/lib/instance-event/instance-event.types.js.map +1 -0
  76. package/src/lib/instance-event/instance-event.writer.d.ts +21 -0
  77. package/src/lib/instance-event/instance-event.writer.js +45 -0
  78. package/src/lib/instance-event/instance-event.writer.js.map +1 -0
  79. package/src/lib/migrations/0000000032000-host-integration-round.d.ts +25 -0
  80. package/src/lib/migrations/0000000032000-host-integration-round.js +104 -0
  81. package/src/lib/migrations/0000000032000-host-integration-round.js.map +1 -0
  82. package/src/lib/migrations/index.d.ts +1 -0
  83. package/src/lib/migrations/index.js +3 -0
  84. package/src/lib/migrations/index.js.map +1 -1
  85. package/src/lib/notification/notification.service.js +50 -9
  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 +94 -17
  110. package/src/lib/template/template.service.js.map +1 -1
  111. package/src/lib/workflow-engine/approver-filled-table-columns.d.ts +91 -0
  112. package/src/lib/workflow-engine/approver-filled-table-columns.js +301 -0
  113. package/src/lib/workflow-engine/approver-filled-table-columns.js.map +1 -0
  114. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.d.ts +7 -0
  115. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js +9 -0
  116. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js.map +1 -1
  117. package/src/lib/workflow-engine/index.d.ts +1 -0
  118. package/src/lib/workflow-engine/index.js +1 -0
  119. package/src/lib/workflow-engine/index.js.map +1 -1
  120. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.d.ts +31 -11
  121. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js.map +1 -1
  122. package/src/lib/workflow-engine/instance-guard.runner.d.ts +19 -0
  123. package/src/lib/workflow-engine/instance-guard.runner.js +102 -0
  124. package/src/lib/workflow-engine/instance-guard.runner.js.map +1 -0
  125. package/src/lib/workflow-engine/instance-guard.token.d.ts +101 -0
  126. package/src/lib/workflow-engine/instance-guard.token.js +9 -0
  127. package/src/lib/workflow-engine/instance-guard.token.js.map +1 -0
  128. package/src/lib/workflow-engine/missing-form-fields.d.ts +12 -0
  129. package/src/lib/workflow-engine/missing-form-fields.js +27 -0
  130. package/src/lib/workflow-engine/missing-form-fields.js.map +1 -0
  131. package/src/lib/workflow-engine/workflow-engine.module.d.ts +2 -0
  132. package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
  133. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  134. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +1 -1
  135. package/src/lib/workflow-engine/workflow-engine.mutations.js +8 -3
  136. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  137. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +7 -5
  138. package/src/lib/workflow-engine/workflow-engine.queries.js +136 -24
  139. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  140. package/src/lib/workflow-engine/workflow-engine.service.d.ts +216 -7
  141. package/src/lib/workflow-engine/workflow-engine.service.js +864 -131
  142. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  143. 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");
@@ -58,6 +60,9 @@ const expression_budget_1 = require("../condition/expression-budget");
58
60
  const expression_errors_1 = require("../condition/expression-errors");
59
61
  const expression_dialect_1 = require("../condition/expression-dialect");
60
62
  const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
63
+ const instance_event_writer_1 = require("../instance-event/instance-event.writer");
64
+ const instance_guard_token_1 = require("./instance-guard.token");
65
+ const instance_guard_runner_1 = require("./instance-guard.runner");
61
66
  const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
62
67
  const workflow_token_entity_1 = require("./workflow-token.entity");
63
68
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
@@ -182,6 +187,28 @@ const INACTIVE_MEMBER_NAME_LOCALE = 'zh-Hant-TW';
182
187
  * every load of the member screen.
183
188
  */
184
189
  const INACTIVE_MEMBER_COUNT_CONCURRENCY = 5;
190
+ /**
191
+ * ADR 27 §4.2: an approver-filled value the provider no longer offers answers
192
+ * 400 with `formDataErrors` naming the field or table cell, `code` kept. Other
193
+ * failures pass through unchanged.
194
+ */
195
+ async function answerValueNotResolved(run) {
196
+ try {
197
+ return await run();
198
+ }
199
+ catch (error) {
200
+ if (error instanceof form_data_source_errors_1.BPMFormDataSourceValueNotResolvedException) {
201
+ const issue = {
202
+ ...(0, form_data_issue_1.createFormDataIssue)(error.location, 'VALUE_NOT_RESOLVED', '的選項已不在可選範圍內,請重新選擇'),
203
+ code: form_data_source_errors_1.BPM_FORM_DATA_SOURCE_ERROR_CODES.VALUE_NOT_RESOLVED,
204
+ };
205
+ throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)([issue]), [
206
+ issue,
207
+ ]);
208
+ }
209
+ throw error;
210
+ }
211
+ }
185
212
  const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
186
213
  'bpm:*',
187
214
  'bpm.workflow.read_all',
@@ -189,7 +216,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
189
216
  'workflow.read_all',
190
217
  ]);
191
218
  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) {
219
+ 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
220
  this.approvalInstanceRepository = approvalInstanceRepository;
194
221
  this.workflowTokenRepository = workflowTokenRepository;
195
222
  this.taskRepository = taskRepository;
@@ -209,7 +236,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
209
236
  this.slaScheduleService = slaScheduleService;
210
237
  this.formDataSourceValueResolver = formDataSourceValueResolver;
211
238
  this.webhookDeliveryService = webhookDeliveryService;
239
+ this.instanceEventWriter = instanceEventWriter;
212
240
  this.logger = new common_1.Logger(WorkflowEngineService_1.name);
241
+ /** In-flight inbox reads by member; see `readSharedOpenInboxTasks`. */
242
+ this.openInboxReadsInFlight = new Map();
213
243
  this.approvalInstanceFieldFilterCompiler = new approval_instance_field_filter_compiler_1.ApprovalInstanceFieldFilterCompiler();
214
244
  this.approvalInstanceFilterableFieldPolicy = new approval_instance_filterable_field_policy_1.ApprovalInstanceFilterableFieldPolicy();
215
245
  /** Webhooks each open engine transaction will send once it has committed. */
@@ -234,7 +264,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
234
264
  injectedInitiatorCustomFieldsResolver ??
235
265
  rootOptions?.initiatorCustomFieldsResolver ??
236
266
  null;
267
+ // Same three-way order as the initiator resolver: a host binding under the
268
+ // token, then the runtime instance, then no guard at all.
269
+ this.instanceGuard =
270
+ injectedInstanceGuard ?? rootOptions?.instanceGuard ?? null;
271
+ this.instanceGuardTimeoutMs =
272
+ rootOptions?.instanceGuardTimeoutMs ??
273
+ instance_guard_runner_1.BPM_INSTANCE_GUARD_DEFAULT_TIMEOUT_MS;
237
274
  this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
275
+ this.expressionMissingFormFieldsAsNull =
276
+ rootOptions?.expressionMissingFormFieldsAsNull === true;
277
+ }
278
+ /**
279
+ * Runs the bound guard for one engine path inside its transaction. A no-op
280
+ * when no guard is bound, so a host without one sees no behavior change.
281
+ */
282
+ async guardInstanceChange(input) {
283
+ if (this.instanceGuard === null) {
284
+ return;
285
+ }
286
+ await (0, instance_guard_runner_1.runInstanceGuard)(this.instanceGuard, input, this.instanceGuardTimeoutMs);
287
+ }
288
+ /**
289
+ * Writes one case-lifecycle event (ADR 27 §1) in the caller's transaction.
290
+ * Returns before any lookup when no observer is bound, so a host without
291
+ * one pays nothing. `instanceState` is the state the change commits, not the
292
+ * state the instance held before it.
293
+ */
294
+ async recordInstanceEvent(manager, instance, instanceState, type, fields) {
295
+ if (this.instanceEventWriter?.enabled !== true) {
296
+ return;
297
+ }
298
+ const template = await manager
299
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
300
+ .findOne({
301
+ select: { id: true, key: true },
302
+ where: { id: instance.templateId },
303
+ withDeleted: true,
304
+ });
305
+ const snapshot = {
306
+ instanceState,
307
+ templateId: instance.templateId,
308
+ templateKey: template?.key ?? null,
309
+ templateVersionId: instance.templateVersionId,
310
+ };
311
+ await this.instanceEventWriter.record(manager, instance.id, snapshot, type, fields);
312
+ }
313
+ /**
314
+ * Reads the template version a case runs on, inside the engine transaction,
315
+ * so the guard sees the same snapshot as the decision. A missing row fails
316
+ * the change rather than handing the guard a partial input.
317
+ */
318
+ async readBoundTemplateVersionOrThrow(manager, instance) {
319
+ const version = await manager
320
+ .getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
321
+ .findOne({ where: { id: instance.templateVersionId } });
322
+ if (!version) {
323
+ throw new common_1.ConflictException({
324
+ code: 'TEMPLATE_VERSION_NOT_FOUND',
325
+ message: 'The template version of this instance no longer exists.',
326
+ });
327
+ }
328
+ return version;
238
329
  }
239
330
  /**
240
331
  * Today's date as the organization sees it (`organizationTimeZone`), for
@@ -306,6 +397,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
306
397
  input.submittedByMemberId.trim() === '')) {
307
398
  throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
308
399
  }
400
+ const hostCustomFields = readHostInitiatorCustomFields(input);
309
401
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
310
402
  const businessKey = normalizeBusinessKey(input.businessKey);
311
403
  const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
@@ -317,6 +409,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
317
409
  throw new common_1.ConflictException('Approval template does not have a published version');
318
410
  }
319
411
  const templateVersion = await this.getPublishedTemplateVersionOrThrow(template.currentVersionId);
412
+ if (input.endUserSubmission === true) {
413
+ assertEndUserMayLaunch(templateVersion);
414
+ }
320
415
  const formDefinitionVersion = await this.getPublishedFormVersionOrThrow(templateVersion.formDefinitionVersionId);
321
416
  // The fields an approver fills in belong to the approval, not the
322
417
  // application: an end user's value for one is dropped (it would otherwise
@@ -325,13 +420,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
325
420
  // checked here; required and row minimums wait for the node that fills
326
421
  // them. Read off the version the instance is bound to, not the template's
327
422
  // latest, so a publish in between cannot change what is stripped.
328
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(templateVersion.workflowDefinition);
423
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(templateVersion.workflowDefinition);
329
424
  const formData = input.endUserSubmission === true
330
- ? (0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferredFieldKeys)
425
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferral.wholeKeys), null, deferral)
331
426
  : submittedFormData;
332
- validateSubmittedFormData(formDefinitionVersion.schema, formData, {
333
- deferredFieldKeys,
334
- });
427
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(formDefinitionVersion.schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
335
428
  // Checked against the bound version's schema before any data source is
336
429
  // called, so a bad value is refused cheaply. The transaction below
337
430
  // confirms under the template lock that this version is still current
@@ -345,7 +438,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
345
438
  });
346
439
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
347
440
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
348
- : await this.buildInitiatorMetadataSnapshot({
441
+ : mergeHostInitiatorCustomFields(await this.buildInitiatorMetadataSnapshot({
349
442
  authContext: submitAuthContext,
350
443
  memberId: input.initiatorMemberId,
351
444
  submission: {
@@ -353,7 +446,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
353
446
  templateId: template.id,
354
447
  templateVersionId: templateVersion.id,
355
448
  },
356
- });
449
+ }), hostCustomFields);
357
450
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
358
451
  // The policy is written in its version's dialect (ADR 22 §3.3).
359
452
  readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
@@ -394,7 +487,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
394
487
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
395
488
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
396
489
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
397
- const instance = await instanceRepository.save(instanceRepository.create({
490
+ const instanceDraft = instanceRepository.create({
398
491
  completedAt: null,
399
492
  formData,
400
493
  formDataOptionSnapshot,
@@ -414,7 +507,24 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
414
507
  templateVersionId: templateVersion.id,
415
508
  title: input.title?.trim() || template.name,
416
509
  workflowSnapshot: templateVersion.workflowDefinition,
417
- }));
510
+ });
511
+ // ADR 27 §2: the host guard sees the case as it is about to be saved,
512
+ // under the template lock. A refusal throws here, before any insert.
513
+ await this.guardInstanceChange({
514
+ action: null,
515
+ actorMemberId: submittedByMemberId,
516
+ authContext: authContext ?? null,
517
+ automatic: null,
518
+ changedFieldKeys: Object.keys(formData),
519
+ formData,
520
+ instance: instanceDraft,
521
+ kind: 'SUBMIT',
522
+ manager,
523
+ node: null,
524
+ task: null,
525
+ templateVersion,
526
+ });
527
+ const instance = await instanceRepository.save(instanceDraft);
418
528
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
419
529
  formData,
420
530
  instanceId: instance.id,
@@ -452,6 +562,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
452
562
  taskId: null,
453
563
  }),
454
564
  ]);
565
+ await this.recordInstanceEvent(manager, instance, instance.state, 'INSTANCE_SUBMITTED', { actorMemberId: submittedByMemberId });
455
566
  await this.processRunningInstance(manager, instance);
456
567
  return instance;
457
568
  }).catch((error) =>
@@ -492,6 +603,66 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
492
603
  schema,
493
604
  });
494
605
  }
606
+ /**
607
+ * ADR 27 §4(b): asks the data-source providers again, under the decision
608
+ * lock and as the approver, for the options of the approver-fillable fields.
609
+ * A value that is no longer among the options is refused by the resolver
610
+ * (`VALUE_NOT_RESOLVED`). Returns snapshots for the scoped fields only; the
611
+ * caller swaps them in for the stored ones.
612
+ */
613
+ async refreshApproverFilledOptionSnapshots({ approverAuthContext, fieldKeys, formData, instance, }) {
614
+ const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
615
+ return this.resolveFormDataOptionSnapshots({
616
+ authContext: approverAuthContext,
617
+ formData,
618
+ previousFormData: instance.formData,
619
+ previousSnapshots: readSnapshotWithoutFields(instance.formDataOptionSnapshot, fieldKeys),
620
+ revalidateAll: true,
621
+ schema: {
622
+ ...schema,
623
+ fields: schema.fields.filter((field) => fieldKeys.has(field.fieldKey)),
624
+ },
625
+ });
626
+ }
627
+ /**
628
+ * ADR 27 §4(b): asks the data-source providers again, as the approver, for
629
+ * the open data-source cells of column-mode tables. `revalidateAll` false lets
630
+ * the resolver reuse the stored options of cells whose value and bindings did
631
+ * not move. Returns snapshots for the cells of `cellSchema` only.
632
+ */
633
+ async refreshApproverFilledOpenCellSnapshots({ approverAuthContext, cellSchema, formData, instance, revalidateAll, }) {
634
+ return this.resolveFormDataOptionSnapshots({
635
+ authContext: approverAuthContext,
636
+ formData,
637
+ previousFormData: instance.formData,
638
+ previousSnapshots: instance.formDataOptionSnapshot ?? {},
639
+ revalidateAll,
640
+ schema: cellSchema,
641
+ });
642
+ }
643
+ /**
644
+ * Refuses an end user's resubmission of an instance bound to a `HOST_ONLY`
645
+ * version (ADR 27 §5). Checked before any data source is called and outside
646
+ * the lock: an instance never changes version and a published version never
647
+ * changes `launchMode`, so the answer cannot go stale. An instance that does
648
+ * not exist is left to the locked read, which reports it.
649
+ */
650
+ async assertEndUserMayResubmit(instanceId) {
651
+ const instance = await this.approvalInstanceRepository.findOne({
652
+ select: { id: true, templateVersionId: true },
653
+ where: { id: instanceId },
654
+ });
655
+ if (!instance) {
656
+ return;
657
+ }
658
+ const version = await this.approvalTemplateVersionRepository.findOne({
659
+ select: { id: true, launchMode: true },
660
+ where: { id: instance.templateVersionId },
661
+ });
662
+ if (version) {
663
+ assertEndUserMayLaunch(version);
664
+ }
665
+ }
495
666
  async prepareResubmitFormDataOptions(input, submittedFormData, authContext) {
496
667
  const instance = await this.approvalInstanceRepository.findOne({
497
668
  where: { id: input.instanceId },
@@ -510,13 +681,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
510
681
  throw new common_1.ConflictException('Approval template is deactivated');
511
682
  }
512
683
  const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
513
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
684
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
514
685
  // Same overlay as inside the lock, so the options resolved here are for
515
686
  // the form data that is actually saved.
516
687
  const formData = input.endUserSubmission === true
517
- ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
688
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
518
689
  : submittedFormData;
519
- validateSubmittedFormData(schema, formData, { deferredFieldKeys });
690
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
520
691
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
521
692
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
522
693
  authContext,
@@ -554,6 +725,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
554
725
  await this.processRunningInstance(manager, instance);
555
726
  });
556
727
  }
728
+ /**
729
+ * Records `TASK_AUTO_DECISION_FAILED` (ADR 27 §3) in its own transaction.
730
+ * Used after an automatic decision was refused by the instance guard: the
731
+ * decision's transaction has already rolled back, so the event cannot ride
732
+ * on it. Returns before any query when no observer is bound.
733
+ */
734
+ async recordTaskAutoDecisionFailed(input) {
735
+ if (this.instanceEventWriter?.enabled !== true) {
736
+ return;
737
+ }
738
+ await this.runEngineTransaction(async (manager) => {
739
+ const instance = await manager
740
+ .getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
741
+ .findOne({ where: { id: input.instanceId } });
742
+ if (!instance) {
743
+ return;
744
+ }
745
+ await this.recordInstanceEvent(manager, instance, instance.state, 'TASK_AUTO_DECISION_FAILED', { reason: input.reason, taskId: input.taskId });
746
+ });
747
+ }
557
748
  async decideTask(input, options = {}) {
558
749
  return this.runEngineTransaction(async (manager) => {
559
750
  const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
@@ -717,9 +908,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
717
908
  if (filledFormData !== null && task.isAdhoc) {
718
909
  throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
719
910
  }
911
+ // ADR 27 §4(a): column-mode tables are accepted here and settled
912
+ // cell by cell below. Their open data-source columns are re-resolved per
913
+ // row as the approver (ADR 27 §4(b)).
914
+ const nodeColumnFillMap = (0, approver_filled_table_columns_1.readNodeColumnFillMap)(taskNode);
720
915
  if (filledFormData !== null) {
721
916
  const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
722
- const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey));
917
+ const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey) &&
918
+ !nodeColumnFillMap.has(fieldKey));
723
919
  if (refusedFieldKeys.length > 0) {
724
920
  throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not let approvers fill in: ${refusedFieldKeys.join(', ')}`);
725
921
  }
@@ -730,35 +926,134 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
730
926
  (0, form_data_fingerprint_1.computeFormDataFingerprint)(instance.formData)) {
731
927
  throw new common_1.ConflictException(`Approval instance ${instance.id} form data changed; refresh and retry`);
732
928
  }
929
+ // ADR 27 §4(a): each column-mode table is merged per cell against the
930
+ // stored rows. A changed row count, row order or applicant cell is
931
+ // refused before anything below sees the payload.
932
+ const settledFormData = filledFormData === null
933
+ ? null
934
+ : (0, approver_filled_table_columns_1.settleColumnFillPayload)(filledFormData, instance.formData, nodeColumnFillMap);
733
935
  // Only keys whose value really changes are written and reported. A key
734
936
  // the form data does not have yet counts as changed even when the new
735
937
  // value is null.
736
- const changedFieldKeys = filledFormData === null
938
+ const changedFieldKeys = settledFormData === null
737
939
  ? []
738
- : Object.keys(filledFormData)
940
+ : Object.keys(settledFormData)
739
941
  .filter((fieldKey) => !Object.prototype.hasOwnProperty.call(instance.formData, fieldKey) ||
740
- (0, form_data_fingerprint_1.computeStableValueFingerprint)(filledFormData[fieldKey]) !==
942
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(settledFormData[fieldKey]) !==
741
943
  (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formData[fieldKey]))
742
944
  .sort();
743
- const mergedFormData = { ...instance.formData, ...filledFormData };
945
+ const mergedFormData = { ...instance.formData, ...settledFormData };
946
+ // ADR 27 §4.3: an approver may change a column only if no column this
947
+ // approver may not fill reads it. Such a dependent keeps its stored value
948
+ // here, so a changed source would leave it stale. Fail closed.
949
+ if (nodeColumnFillMap.size > 0) {
950
+ (0, approver_filled_table_columns_1.assertDependentColumnsFrozen)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), taskNode, instance.formData, mergedFormData);
951
+ }
744
952
  // Every approval at a node that lets its approver fill in fields is
745
953
  // held to those fields, whether or not this request sent any: the host
746
- // may have pre-filled a table the approver then leaves as is.
954
+ // may have pre-filled a table the approver then leaves as is. A column
955
+ // this node grants REQUIRED is required in the check, not only in the
956
+ // template.
747
957
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc) {
748
- const nodeFillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
958
+ const nodeFillableFieldKeys = new Set([
959
+ ...(0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode),
960
+ ...nodeColumnFillMap.keys(),
961
+ ]);
749
962
  if (nodeFillableFieldKeys.size > 0) {
750
- validateFilledFormFields(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
963
+ validateFilledFormFields((0, approver_filled_table_columns_1.projectNodeOpenColumnsRequired)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
751
964
  }
752
965
  }
753
- if (filledFormData !== null && changedFieldKeys.length > 0) {
966
+ // ADR 27 §4(b): an approver-filled data-source field is judged as the
967
+ // approver's own pick. The provider is asked again under the lock with
968
+ // the approver's identity, so a value outside the current options is
969
+ // refused and the recorded label is refreshed before anything is
970
+ // written. Table data-source columns resolve per row (ROW_FIELD).
971
+ const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
972
+ const dataSourceRefreshKeys = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc
973
+ ? fillableFieldKeys
974
+ : new Set(changedFieldKeys.filter((fieldKey) => fillableFieldKeys.has(fieldKey)));
975
+ const approverAuthContext = options.approverAuthContext ??
976
+ createWorkflowAuthContext(input.decidedByMemberId);
977
+ const refreshedFormDataOptionSnapshots = dataSourceRefreshKeys.size === 0
978
+ ? null
979
+ : await answerValueNotResolved(() => this.refreshApproverFilledOptionSnapshots({
980
+ approverAuthContext,
981
+ fieldKeys: dataSourceRefreshKeys,
982
+ formData: mergedFormData,
983
+ instance,
984
+ }));
985
+ const wholeFieldOptionSnapshot = refreshedFormDataOptionSnapshots === null
986
+ ? (instance.formDataOptionSnapshot ?? {})
987
+ : {
988
+ ...readSnapshotWithoutFields(instance.formDataOptionSnapshot, dataSourceRefreshKeys),
989
+ ...refreshedFormDataOptionSnapshots,
990
+ };
991
+ // ADR 27 §4(b): the open data-source cells of column-mode tables. An
992
+ // APPROVED decision re-resolves all of them; any other decision only
993
+ // when a column-mode table's cells changed. The form schema is read
994
+ // only for a node that has column-mode fills, so other nodes never
995
+ // touch the snapshot's schema.
996
+ const openCellSchema = nodeColumnFillMap.size === 0
997
+ ? null
998
+ : (0, approver_filled_table_columns_1.readOpenDataSourceCellSchema)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap);
999
+ const openCellTableKeys = new Set((openCellSchema?.fields ?? []).map((field) => field.fieldKey));
1000
+ const revalidateAllOpenCells = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc;
1001
+ const hasChangedOpenCellTable = changedFieldKeys.some((fieldKey) => openCellTableKeys.has(fieldKey));
1002
+ const refreshedOpenCellSnapshots = openCellSchema === null ||
1003
+ openCellSchema.fields.length === 0 ||
1004
+ (!revalidateAllOpenCells && !hasChangedOpenCellTable)
1005
+ ? null
1006
+ : await answerValueNotResolved(() => this.refreshApproverFilledOpenCellSnapshots({
1007
+ approverAuthContext,
1008
+ cellSchema: openCellSchema,
1009
+ formData: mergedFormData,
1010
+ instance,
1011
+ revalidateAll: revalidateAllOpenCells,
1012
+ }));
1013
+ const nextFormDataOptionSnapshot = refreshedOpenCellSnapshots === null || openCellSchema === null
1014
+ ? wholeFieldOptionSnapshot
1015
+ : {
1016
+ ...(0, approver_filled_table_columns_1.readSnapshotWithoutOpenCells)(wholeFieldOptionSnapshot, openCellSchema),
1017
+ ...(0, approver_filled_table_columns_1.readOpenCellSnapshots)(refreshedOpenCellSnapshots, openCellSchema),
1018
+ };
1019
+ // ADR 27 §2: the host guard judges the decision as it will be
1020
+ // committed, after the form checks and before anything is written.
1021
+ // The version is read only when a guard is bound, so an engine
1022
+ // without one issues exactly the queries it did before.
1023
+ if (this.instanceGuard !== null) {
1024
+ await this.guardInstanceChange({
1025
+ action: input.action,
1026
+ actorMemberId: input.decidedByMemberId,
1027
+ // The approver's own context when the GraphQL mutation passed one;
1028
+ // never the member-id stub the data-source path falls back to.
1029
+ authContext: options.approverAuthContext ?? null,
1030
+ automatic: options.automatic ?? null,
1031
+ changedFieldKeys,
1032
+ formData: mergedFormData,
1033
+ instance,
1034
+ kind: 'DECIDE',
1035
+ manager,
1036
+ node: taskNode,
1037
+ task,
1038
+ templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
1039
+ });
1040
+ }
1041
+ const formDataChanged = settledFormData !== null && changedFieldKeys.length > 0;
1042
+ const formDataOptionSnapshotChanged = (0, form_data_fingerprint_1.computeStableValueFingerprint)(nextFormDataOptionSnapshot) !==
1043
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formDataOptionSnapshot ?? {});
1044
+ if (formDataChanged) {
754
1045
  await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
1046
+ }
1047
+ if (formDataChanged || formDataOptionSnapshotChanged) {
755
1048
  // Merged into the shared `instance` before it is signed and before
756
1049
  // the token moves on: the signature must cover the filled values, a
757
1050
  // gateway after this node must route on them, and an end node saves
758
- // this same object, which would write the old values back.
1051
+ // this same object, which would write the old values back. The
1052
+ // refreshed option labels go in the same write.
759
1053
  const savedInstance = await instanceRepository.save({
760
1054
  ...instance,
761
1055
  formData: mergedFormData,
1056
+ formDataOptionSnapshot: nextFormDataOptionSnapshot,
762
1057
  });
763
1058
  Object.assign(instance, savedInstance);
764
1059
  }
@@ -877,7 +1172,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
877
1172
  // (rejected / returned, or an approval that completed it), every other
878
1173
  // candidate's notification is superseded too; a non-completing approval
879
1174
  // on a multi-approver task leaves the remaining approvers' open.
880
- const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED || shouldCompleteTask;
1175
+ const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED ||
1176
+ shouldCompleteTask;
881
1177
  await this.notificationService.resolveTaskNotifications({
882
1178
  actingMemberId: input.decidedByMemberId,
883
1179
  manager,
@@ -928,13 +1224,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
928
1224
  return decision;
929
1225
  }
930
1226
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED) {
931
- await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt);
1227
+ await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId);
932
1228
  return decision;
933
1229
  }
934
1230
  if (await this.handleAdhocPreApprovalRejection(manager, instance, completedTask, decidedAt, decisionComment)) {
935
1231
  return decision;
936
1232
  }
937
- await this.rejectInstance(manager, instance, completedTask, decidedAt);
1233
+ await this.rejectInstance(manager, instance, completedTask, decidedAt, input.decidedByMemberId);
938
1234
  return decision;
939
1235
  });
940
1236
  }
@@ -968,6 +1264,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
968
1264
  },
969
1265
  taskId: null,
970
1266
  }));
1267
+ await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: input.cancelledByMemberId });
971
1268
  return cancelledInstance;
972
1269
  });
973
1270
  }
@@ -1005,6 +1302,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1005
1302
  },
1006
1303
  taskId: input.taskId ?? null,
1007
1304
  }));
1305
+ await this.recordInstanceEvent(manager, expiredInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED, 'INSTANCE_EXPIRED', {});
1008
1306
  return expiredInstance;
1009
1307
  });
1010
1308
  }
@@ -1034,6 +1332,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1034
1332
  }
1035
1333
  async resubmitApprovalInstance(input, authContext) {
1036
1334
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
1335
+ if (input.endUserSubmission === true) {
1336
+ await this.assertEndUserMayResubmit(input.instanceId);
1337
+ }
1037
1338
  const preparation = this.formDataSourceValueResolver
1038
1339
  ? await this.prepareResubmitFormDataOptions(input, submittedFormData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
1039
1340
  : null;
@@ -1063,20 +1364,37 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1063
1364
  throw new common_1.ConflictException('Approval instance changed while options were resolving; refresh and retry');
1064
1365
  }
1065
1366
  const resubmitSchema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
1066
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
1367
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
1067
1368
  // An end user gets back the value an approver already filled in, and
1068
1369
  // loses a key the stored data never had: the renderer seeds `false`,
1069
1370
  // `[]` and empty rows for every field, none of which an approver wrote.
1371
+ // An open cell of a table is settled per row the same way.
1070
1372
  const formData = input.endUserSubmission === true
1071
- ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
1373
+ ? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
1072
1374
  : submittedFormData;
1073
- validateSubmittedFormData(resubmitSchema, formData, {
1074
- deferredFieldKeys,
1075
- });
1375
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(resubmitSchema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
1076
1376
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
1077
1377
  if (!preparation) {
1078
1378
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
1079
1379
  }
1380
+ // ADR 27 §2: the host guard judges the resubmission as it will be
1381
+ // committed, after every form check and before the write.
1382
+ if (this.instanceGuard !== null) {
1383
+ await this.guardInstanceChange({
1384
+ action: null,
1385
+ actorMemberId: input.initiatorMemberId,
1386
+ authContext: authContext ?? null,
1387
+ automatic: null,
1388
+ changedFieldKeys: listChangedFormDataKeys(instance.formData, formData),
1389
+ formData,
1390
+ instance,
1391
+ kind: 'RESUBMIT',
1392
+ manager,
1393
+ node: null,
1394
+ task: null,
1395
+ templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
1396
+ });
1397
+ }
1080
1398
  const resubmittedInstance = await instanceRepository.save({
1081
1399
  ...instance,
1082
1400
  completedAt: null,
@@ -1089,6 +1407,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1089
1407
  state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
1090
1408
  title: input.title?.trim() || instance.title,
1091
1409
  });
1410
+ // Written before the resume below, so RESUBMITTED precedes any
1411
+ // NODE_ACTIVATED that the resume produces in the same transaction.
1412
+ await this.recordInstanceEvent(manager, resubmittedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING, 'INSTANCE_RESUBMITTED', { actorMemberId: input.initiatorMemberId });
1092
1413
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
1093
1414
  formData,
1094
1415
  instanceId: instance.id,
@@ -1862,11 +2183,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1862
2183
  input.workflowDefinitionJson,
1863
2184
  input.formDataJson,
1864
2185
  input.initiatorMetadataSnapshotJson ?? '',
2186
+ input.formSchemaJson ?? '',
1865
2187
  ].some((json) => json.length > MAX_DRY_RUN_INPUT_LENGTH)) {
1866
2188
  return createFailedDryRunResult('試跑資料過大,請縮小流程或測試資料後再試');
1867
2189
  }
1868
2190
  const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
1869
2191
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
2192
+ const formSchema = readDryRunFormSchema(input.formSchemaJson);
1870
2193
  // The snapshot here is the caller's own sample (the designer always sends
1871
2194
  // one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
1872
2195
  // submission builds, and a dry run has no template version to hand the
@@ -1883,6 +2206,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1883
2206
  return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunResultObject(), {
1884
2207
  errors: [],
1885
2208
  steps: (0, expression_budget_1.runExpressionOperation)(() => (0, time_budget_1.runWithinTimeBudget)(() => this.simulateWorkflow({
2209
+ expressionFormData: this.expressionMissingFormFieldsAsNull && formSchema
2210
+ ? (0, missing_form_fields_1.fillMissingFormFields)(formData, formSchema)
2211
+ : formData,
1886
2212
  formData,
1887
2213
  initiatorMemberId: input.initiatorMemberId,
1888
2214
  initiatorMetadataSnapshot,
@@ -2187,6 +2513,51 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2187
2513
  }
2188
2514
  return instance;
2189
2515
  }
2516
+ /**
2517
+ * ADR 27 §4.2 (gap 1): the fill units an approver may use to resolve runtime
2518
+ * data-source options on a RUNNING instance. Only tasks the member can act on
2519
+ * count: the member is the assignee or a PENDING/CLAIMED candidate of a
2520
+ * PENDING or IN_PROGRESS, non-ad-hoc task. A task whose candidate row is
2521
+ * WAITING or already decided grants nothing, even though its status is open.
2522
+ * A field counts only when its node lists it as SHOW; a HIDE entry never
2523
+ * grants, whatever its fill is. Returns `[]` when the instance is not RUNNING.
2524
+ */
2525
+ async listApproverFillUnits(instanceId, memberId) {
2526
+ const instance = await this.approvalInstanceRepository.findOne({
2527
+ where: { id: instanceId },
2528
+ });
2529
+ if (!instance ||
2530
+ instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
2531
+ return [];
2532
+ }
2533
+ const tasks = await this.taskRepository.find({
2534
+ where: {
2535
+ instanceId,
2536
+ isAdhoc: false,
2537
+ status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
2538
+ },
2539
+ });
2540
+ const unitsPerTask = await Promise.all(tasks.map(async (task) => {
2541
+ const node = instance.workflowSnapshot.nodes.find((candidate) => candidate.type === 'userTask' && candidate.id === task.nodeId);
2542
+ if (!node) {
2543
+ return [];
2544
+ }
2545
+ try {
2546
+ await this.readTaskActorContext(this.taskRepository.manager, task, memberId);
2547
+ }
2548
+ catch (error) {
2549
+ if (error instanceof common_1.ConflictException) {
2550
+ return [];
2551
+ }
2552
+ throw error;
2553
+ }
2554
+ const shown = new Set((0, bpm_core_shared_1.readNodeFieldAccess)(node)
2555
+ .filter((entry) => entry.display === 'SHOW')
2556
+ .map((entry) => entry.fieldKey));
2557
+ return (0, bpm_core_shared_1.readNodeFillUnits)(node).filter((unit) => shown.has(unit.fieldKey));
2558
+ }));
2559
+ return unitsPerTask.flat();
2560
+ }
2190
2561
  /**
2191
2562
  * ADR 25 §6: a value written to a filterable field must fit its type, on
2192
2563
  * every path that writes form data. The fields checked are the ones the
@@ -2272,7 +2643,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2272
2643
  totalPages,
2273
2644
  };
2274
2645
  }
2275
- async createFilteredApprovalInstanceQuery(scope, options = {}) {
2646
+ async createFilteredApprovalInstanceQuery(scope, requested = {}) {
2647
+ const options = await this.resolveApprovalInstanceTemplateKey(requested);
2648
+ if (!options) {
2649
+ return null;
2650
+ }
2276
2651
  const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
2277
2652
  const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
2278
2653
  if (!hasReadableInstances) {
@@ -2501,37 +2876,92 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2501
2876
  submission: null,
2502
2877
  });
2503
2878
  return templates.filter((template) => {
2504
- const currentVersion = template.currentVersionId
2879
+ const version = template.currentVersionId
2505
2880
  ? (versionById.get(template.currentVersionId) ?? null)
2506
2881
  : null;
2507
- if (!currentVersion?.formDefinitionVersionId) {
2508
- return false;
2509
- }
2510
- // A policy in a dialect this version does not know cannot be evaluated,
2511
- // so the template is not launchable (fail closed), but only that one:
2512
- // publishing does not check the field yet (Phase 23 P2), and one such
2513
- // version must not break the list for every member.
2514
- if (hasExpressionText(currentVersion.initiatorPolicyCel) &&
2515
- !isReadableExpressionDialect(currentVersion.workflowDefinition)) {
2516
- this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of launchable templates: unknown expression dialect`);
2517
- return false;
2518
- }
2519
- // Each template is its own operation, so one slow template cannot use
2520
- // up the allowance of the rest of the list. A policy that throws (for
2521
- // example a missing `initiator.customFields.*` key, because the resolver
2522
- // did not return it, or failed and the list fell back to `{}`) is also
2523
- // only that template's problem: it is left out, and the error code is
2524
- // logged, never the values.
2525
- try {
2526
- return (0, expression_budget_1.runExpressionOperation)(() => this.conditionService.evaluateBoolean(currentVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel', readExpressionDialectOptions(currentVersion.initiatorPolicyCel, currentVersion.workflowDefinition)));
2527
- }
2528
- catch (error) {
2529
- const reason = error instanceof expression_errors_1.ExpressionRuntimeError ? error.code : 'UNEXPECTED';
2530
- this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of launchable templates: initiatorPolicyCel failed (${reason})`);
2531
- return false;
2532
- }
2882
+ // A HOST_ONLY template is started by the host's backend, never from the
2883
+ // launch page (ADR 27 §5), so it is not offered there at all.
2884
+ return (version?.launchMode !== template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY &&
2885
+ this.isInitiatorPolicyMet(template, version, initiatorMetadataSnapshot, 'launchable templates'));
2533
2886
  });
2534
2887
  }
2888
+ /**
2889
+ * The published version whose form `memberId` may read data-source options
2890
+ * for when starting `templateId`, or `null` when they may not.
2891
+ *
2892
+ * It applies the same checks as {@link listLaunchableApprovalTemplates} to
2893
+ * that one template, but asks the host's initiator `customFields` resolver
2894
+ * with `purpose: 'FORM_OPTIONS'` and the template and version ids. A host can
2895
+ * therefore open a template's options to members who may not submit it
2896
+ * themselves — a template only the host's backend submits — without the
2897
+ * template appearing in their launchable list. The snapshot is used for this
2898
+ * judgement only and is never stored. A resolver failure judges against
2899
+ * `customFields: {}`, as the list does, so a policy that reads
2900
+ * `initiator.customFields` then fails closed.
2901
+ */
2902
+ async readFormOptionsTemplateVersion(templateId, memberId, authContext) {
2903
+ const template = await this.approvalTemplateRepository.findOne({
2904
+ where: { id: templateId, isActive: true },
2905
+ });
2906
+ if (!template?.currentVersionId) {
2907
+ return null;
2908
+ }
2909
+ const currentVersion = await this.approvalTemplateVersionRepository.findOne({
2910
+ where: {
2911
+ id: template.currentVersionId,
2912
+ status: template_enums_1.ApprovalTemplateVersionStatusEnum.PUBLISHED,
2913
+ },
2914
+ });
2915
+ if (!currentVersion?.formDefinitionVersionId) {
2916
+ return null;
2917
+ }
2918
+ const initiatorMetadataSnapshot = await this.buildInitiatorMetadataSnapshot({
2919
+ authContext: authContext ?? createWorkflowAuthContext(memberId),
2920
+ formOptions: {
2921
+ templateId: template.id,
2922
+ templateVersionId: currentVersion.id,
2923
+ },
2924
+ memberId,
2925
+ submission: null,
2926
+ });
2927
+ return this.isInitiatorPolicyMet(template, currentVersion, initiatorMetadataSnapshot, 'form options')
2928
+ ? currentVersion
2929
+ : null;
2930
+ }
2931
+ /**
2932
+ * Whether `template`'s current published version lets the member described
2933
+ * by `initiatorMetadataSnapshot` start it: the launchable list and the form
2934
+ * options check share this, so the two cannot drift apart. `scope` only
2935
+ * names the caller in the log.
2936
+ */
2937
+ isInitiatorPolicyMet(template, currentVersion, initiatorMetadataSnapshot, scope) {
2938
+ if (!currentVersion?.formDefinitionVersionId) {
2939
+ return false;
2940
+ }
2941
+ // A policy in a dialect this version does not know cannot be evaluated,
2942
+ // so the template is not launchable (fail closed), but only that one:
2943
+ // publishing does not check the field yet (Phase 23 P2), and one such
2944
+ // version must not break the list for every member.
2945
+ if (hasExpressionText(currentVersion.initiatorPolicyCel) &&
2946
+ !isReadableExpressionDialect(currentVersion.workflowDefinition)) {
2947
+ this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of ${scope}: unknown expression dialect`);
2948
+ return false;
2949
+ }
2950
+ // Each template is its own operation, so one slow template cannot use
2951
+ // up the allowance of the rest of the list. A policy that throws (for
2952
+ // example a missing `initiator.customFields.*` key, because the resolver
2953
+ // did not return it, or failed and the list fell back to `{}`) is also
2954
+ // only that template's problem: it is left out, and the error code is
2955
+ // logged, never the values.
2956
+ try {
2957
+ return (0, expression_budget_1.runExpressionOperation)(() => this.conditionService.evaluateBoolean(currentVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel', readExpressionDialectOptions(currentVersion.initiatorPolicyCel, currentVersion.workflowDefinition)));
2958
+ }
2959
+ catch (error) {
2960
+ const reason = error instanceof expression_errors_1.ExpressionRuntimeError ? error.code : 'UNEXPECTED';
2961
+ this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of ${scope}: initiatorPolicyCel failed (${reason})`);
2962
+ return false;
2963
+ }
2964
+ }
2535
2965
  async listWorkflowTokens(instanceId, scope) {
2536
2966
  await this.getApprovalInstance(instanceId, scope);
2537
2967
  return this.workflowTokenRepository.find({
@@ -2546,8 +2976,112 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2546
2976
  where: { instanceId },
2547
2977
  }));
2548
2978
  }
2549
- async listInboxTasks(assigneeMemberId) {
2550
- return this.attachTaskCandidateSummaries(await this.readOpenInboxTasks(assigneeMemberId));
2979
+ /**
2980
+ * The member's open tasks, newest first. Without `page` / `pageSize` every
2981
+ * one is returned, as before paging existed. The page is cut from the same
2982
+ * {@link readOpenInboxTasks} result the dashboard counters read, so the list,
2983
+ * {@link countInboxTasks} and the counters cannot disagree; only the page is
2984
+ * given its candidate summaries.
2985
+ */
2986
+ async listInboxTasks(assigneeMemberId, options = {}) {
2987
+ const openTasks = await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), options);
2988
+ if (!isTaskListPaged(options)) {
2989
+ return this.attachTaskCandidateSummaries(openTasks);
2990
+ }
2991
+ const offset = readPageOffset(options.page, options.pageSize);
2992
+ return this.attachTaskCandidateSummaries(openTasks.slice(offset, offset + normalizePageSize(options.pageSize)));
2993
+ }
2994
+ /** How many tasks {@link listInboxTasks} pages through. */
2995
+ async countInboxTasks(assigneeMemberId, filter = {}) {
2996
+ return (await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), filter)).length;
2997
+ }
2998
+ /**
2999
+ * The template ids a {@link TaskTemplateFilter} allows, or `null` when it
3000
+ * does not filter. A `templateKey` that names no template allows nothing.
3001
+ */
3002
+ async readTaskTemplateFilterIds(filter) {
3003
+ // Trimmed, and a blank key does not filter, as on the instance list.
3004
+ const key = normalizeText(filter.templateKey);
3005
+ const byKey = key ? await this.readTemplateIdsByKey(key) : null;
3006
+ if (filter.templateIds === undefined) {
3007
+ return byKey;
3008
+ }
3009
+ if (byKey === null) {
3010
+ return filter.templateIds;
3011
+ }
3012
+ return filter.templateIds.filter((id) => byKey.includes(id));
3013
+ }
3014
+ /**
3015
+ * Turns `templateKey` into `templateId` (ADR 27 §6), or `null` when the
3016
+ * options can match nothing: an unknown key, or a key naming another
3017
+ * template than `templateId` does.
3018
+ */
3019
+ async resolveApprovalInstanceTemplateKey(options) {
3020
+ const key = normalizeText(options.templateKey);
3021
+ if (!key) {
3022
+ return options;
3023
+ }
3024
+ const [templateId] = await this.readTemplateIdsByKey(key);
3025
+ const requestedId = normalizeText(options.templateId);
3026
+ if (!templateId || (requestedId && requestedId !== templateId)) {
3027
+ return null;
3028
+ }
3029
+ return { ...options, templateId };
3030
+ }
3031
+ /**
3032
+ * The id of the live template carrying `key`, as a list so a key that names
3033
+ * nothing filters everything out. At most one live template holds a key.
3034
+ */
3035
+ async readTemplateIdsByKey(key) {
3036
+ const template = await this.approvalTemplateRepository.findOne({
3037
+ select: { id: true },
3038
+ where: { key },
3039
+ });
3040
+ return template ? [template.id] : [];
3041
+ }
3042
+ /**
3043
+ * Keeps the tasks whose instance belongs to the filter's templates. The open
3044
+ * task read is shared with the dashboard counters, so the filter is applied
3045
+ * to its result rather than pushed into it.
3046
+ */
3047
+ async filterTasksByTemplate(tasks, filter) {
3048
+ const templateIds = await this.readTaskTemplateFilterIds(filter);
3049
+ if (templateIds === null) {
3050
+ return tasks;
3051
+ }
3052
+ if (!templateIds.length || !tasks.length) {
3053
+ return [];
3054
+ }
3055
+ const instances = await this.approvalInstanceRepository.find({
3056
+ select: { id: true },
3057
+ where: {
3058
+ id: (0, typeorm_2.In)([
3059
+ ...new Set(tasks.map((task) => task.instanceId)),
3060
+ ]),
3061
+ templateId: (0, typeorm_2.In)([...templateIds]),
3062
+ },
3063
+ });
3064
+ const instanceIds = new Set(instances.map((instance) => instance.id));
3065
+ return tasks.filter((task) => instanceIds.has(task.instanceId));
3066
+ }
3067
+ /**
3068
+ * {@link readOpenInboxTasks} for the inbox list and its count, joining a
3069
+ * read for the same member that is still in flight. The client asks for
3070
+ * `inboxTasks` and `inboxTaskCount` in one request and GraphQL resolves the
3071
+ * two side by side, so this turns two identical reads into one. An entry
3072
+ * lives only until its read settles: nothing is cached across requests, and
3073
+ * a read that started earlier is never served once it has finished.
3074
+ */
3075
+ readSharedOpenInboxTasks(assigneeMemberId) {
3076
+ const inFlight = this.openInboxReadsInFlight.get(assigneeMemberId);
3077
+ if (inFlight) {
3078
+ return inFlight;
3079
+ }
3080
+ const read = this.readOpenInboxTasks(assigneeMemberId).finally(() => {
3081
+ this.openInboxReadsInFlight.delete(assigneeMemberId);
3082
+ });
3083
+ this.openInboxReadsInFlight.set(assigneeMemberId, read);
3084
+ return read;
2551
3085
  }
2552
3086
  /**
2553
3087
  * The open tasks that are this member's own to act on — the single
@@ -2609,33 +3143,104 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2609
3143
  const settledTaskIds = new Set(settledCandidateRows.map((candidate) => candidate.taskId));
2610
3144
  return openTasks.filter((task) => !settledTaskIds.has(task.id));
2611
3145
  }
2612
- async listApprovalHistoryTasks(assigneeMemberId) {
2613
- const directTasks = await this.taskRepository.find({
2614
- order: { completedAt: 'DESC', createdAt: 'DESC' },
2615
- take: DEFAULT_APPROVAL_HISTORY_TASK_LIMIT,
2616
- where: { assigneeMemberId, status: workflow_engine_enums_1.TaskStatusEnum.COMPLETED },
2617
- });
2618
- const candidateRows = await this.taskCandidateRepository.find({
2619
- where: {
2620
- memberId: assigneeMemberId,
2621
- status: workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED,
2622
- },
3146
+ /**
3147
+ * The tasks this member has acted on, most recently acted on first: tasks
3148
+ * assigned to them that are COMPLETED, and tasks on which their own
3149
+ * candidate row is COMPLETED. The latter needs no status on the task: a
3150
+ * countersign still waiting on somebody else already carries their vote, and
3151
+ * the inbox has stopped showing it to them.
3152
+ *
3153
+ * "Acted on" is their candidate row's `decided_at`, else the task's
3154
+ * `completed_at`, else its `created_at`; ties fall back to the task id so no
3155
+ * row moves between pages. Paged in SQL. Without `page` / `pageSize` the
3156
+ * latest {@link DEFAULT_APPROVAL_HISTORY_TASK_LIMIT} are returned, as before
3157
+ * paging existed. `instanceState` keeps only tasks of instances in that
3158
+ * state (RUNNING for "acted on, not finished").
3159
+ */
3160
+ async listApprovalHistoryTasks(assigneeMemberId, options = {}) {
3161
+ const paged = isTaskListPaged(options);
3162
+ const templateIds = await this.readTaskTemplateFilterIds(options);
3163
+ if (templateIds?.length === 0) {
3164
+ return [];
3165
+ }
3166
+ const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds)
3167
+ .orderBy(this.readApprovalHistoryActedAtExpression(), 'DESC')
3168
+ .addOrderBy('"task"."id"', 'DESC')
3169
+ .offset(paged ? readPageOffset(options.page, options.pageSize) : 0)
3170
+ .limit(paged
3171
+ ? normalizePageSize(options.pageSize)
3172
+ : DEFAULT_APPROVAL_HISTORY_TASK_LIMIT)
3173
+ .getMany();
3174
+ return this.attachTaskCandidateSummaries(tasks);
3175
+ }
3176
+ /** How many tasks {@link listApprovalHistoryTasks} pages through. */
3177
+ async countApprovalHistoryTasks(assigneeMemberId, options = {}) {
3178
+ const templateIds = await this.readTaskTemplateFilterIds(options);
3179
+ if (templateIds?.length === 0) {
3180
+ return 0;
3181
+ }
3182
+ return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds).getCount();
3183
+ }
3184
+ /** The rows of {@link listApprovalHistoryTasks}, unordered and unpaged. */
3185
+ createApprovalHistoryTaskQuery(assigneeMemberId, instanceState, templateIds = null) {
3186
+ const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
3187
+ const queryBuilder = this.taskRepository
3188
+ .createQueryBuilder('task')
3189
+ .andWhere(`
3190
+ (
3191
+ (
3192
+ "task"."assignee_member_id" = :historyMemberId
3193
+ AND "task"."status" = :historyTaskStatus
3194
+ )
3195
+ OR EXISTS (
3196
+ SELECT 1
3197
+ FROM ${candidateTable} "historyCandidate"
3198
+ WHERE "historyCandidate"."task_id" = "task"."id"
3199
+ AND "historyCandidate"."member_id" = :historyMemberId
3200
+ AND "historyCandidate"."status" = :historyCandidateStatus
3201
+ )
3202
+ )
3203
+ `, {
3204
+ historyCandidateStatus: workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED,
3205
+ historyMemberId: assigneeMemberId,
3206
+ historyTaskStatus: workflow_engine_enums_1.TaskStatusEnum.COMPLETED,
2623
3207
  });
2624
- // No status filter on this branch: the candidate row is already COMPLETED,
2625
- // which means this member has cast their decision. Requiring the task
2626
- // itself to be COMPLETED hid their own vote for as long as a multi-person
2627
- // countersign waited on somebody else — and the inbox has, correctly,
2628
- // stopped showing it to them by then, so it appeared nowhere at all.
2629
- const candidateTasks = candidateRows.length
2630
- ? await this.taskRepository.find({
2631
- order: { completedAt: 'DESC', createdAt: 'DESC' },
2632
- take: DEFAULT_APPROVAL_HISTORY_TASK_LIMIT,
2633
- where: {
2634
- id: (0, typeorm_2.In)(candidateRows.map((candidate) => candidate.taskId)),
2635
- },
2636
- })
2637
- : [];
2638
- return this.attachTaskCandidateSummaries(uniqueTasksById([...directTasks, ...candidateTasks]));
3208
+ if (instanceState) {
3209
+ queryBuilder.andWhere(`
3210
+ EXISTS (
3211
+ SELECT 1
3212
+ FROM ${this.readQualifiedTableName(this.approvalInstanceRepository)} "historyInstance"
3213
+ WHERE "historyInstance"."id" = "task"."instance_id"
3214
+ AND "historyInstance"."state" = :historyInstanceState
3215
+ )
3216
+ `, { historyInstanceState: instanceState });
3217
+ }
3218
+ if (templateIds) {
3219
+ queryBuilder.andWhere(`
3220
+ EXISTS (
3221
+ SELECT 1
3222
+ FROM ${this.readQualifiedTableName(this.approvalInstanceRepository)} "historyTemplateInstance"
3223
+ WHERE "historyTemplateInstance"."id" = "task"."instance_id"
3224
+ AND "historyTemplateInstance"."template_id" IN (:...historyTemplateIds)
3225
+ )
3226
+ `, { historyTemplateIds: [...templateIds] });
3227
+ }
3228
+ return queryBuilder;
3229
+ }
3230
+ /** When the member acted on a history task; see {@link listApprovalHistoryTasks}. */
3231
+ readApprovalHistoryActedAtExpression() {
3232
+ const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
3233
+ return `COALESCE(
3234
+ (
3235
+ SELECT MAX("actedCandidate"."decided_at")
3236
+ FROM ${candidateTable} "actedCandidate"
3237
+ WHERE "actedCandidate"."task_id" = "task"."id"
3238
+ AND "actedCandidate"."member_id" = :historyMemberId
3239
+ AND "actedCandidate"."status" = :historyCandidateStatus
3240
+ ),
3241
+ "task"."completed_at",
3242
+ "task"."created_at"
3243
+ )`;
2639
3244
  }
2640
3245
  async listTaskDecisions(taskId, scope) {
2641
3246
  await this.assertTaskReadable(taskId, scope);
@@ -2706,7 +3311,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2706
3311
  * snapshot without a `nodes` array, and a blank or non-string label all
2707
3312
  * leave the task without a label, as `readWorkflowNodeLabel` does. Only the
2708
3313
  * node's `fieldAccess` leaves the database for the fill flag, which
2709
- * `readNodeFillableFieldKeys` then reads as tolerantly as the engine does.
3314
+ * `readNodeFillGrantFieldKeys` then reads as tolerantly as the engine does.
2710
3315
  */
2711
3316
  async readTaskNodeSummaries(tasks) {
2712
3317
  // The table as the entity reads it, so a configured `schema` is honoured
@@ -2741,7 +3346,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2741
3346
  readNodeLabelKey(row.instanceId, row.nodeId),
2742
3347
  {
2743
3348
  hasFillableFields: row.nodeType === 'userTask' &&
2744
- (0, bpm_core_shared_1.readNodeFillableFieldKeys)({
3349
+ (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)({
2745
3350
  data: { fieldAccess: row.fieldAccess },
2746
3351
  }).size > 0,
2747
3352
  label: typeof row.label === 'string' && row.label.trim()
@@ -2793,26 +3398,30 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2793
3398
  * case on data the host never gave. It runs before the engine
2794
3399
  * transaction, so a failure leaves nothing behind and a slow lookup holds
2795
3400
  * no lock.
2796
- * - The list carries on with `customFields: {}` and logs a warning: one
2797
- * failing lookup must not take the whole list away. That fails closed: a
3401
+ * - The list, and the form options check, carry on with `customFields: {}`
3402
+ * and log a warning: one failing lookup must not take the whole list
3403
+ * away. That fails closed: a
2798
3404
  * policy that reads `initiator.customFields` then throws on the missing key
2799
3405
  * and the list's per-template catch leaves that template out, while a
2800
3406
  * template whose policy does not read it is listed as usual. Submitting is
2801
3407
  * still judged against the resolver's real answer.
2802
3408
  */
2803
- async buildInitiatorMetadataSnapshot({ authContext, memberId, submission, }) {
3409
+ async buildInitiatorMetadataSnapshot({ authContext, formOptions = null, memberId, submission, }) {
2804
3410
  const defaultSnapshot = await this.readDefaultInitiatorMetadataSnapshot(memberId);
2805
3411
  if (!this.initiatorCustomFieldsResolver) {
2806
3412
  return defaultSnapshot;
2807
3413
  }
3414
+ // The template the resolver is asked about: the one being submitted, or
3415
+ // the one whose options are read. The list asks about none.
3416
+ const template = submission ?? formOptions;
2808
3417
  const input = {
2809
3418
  authContext,
2810
3419
  defaultSnapshot: cloneJsonObject(defaultSnapshot),
2811
3420
  formData: submission ? cloneJsonObject(submission.formData) : null,
2812
3421
  memberId,
2813
- purpose: submission ? 'SUBMISSION' : 'LAUNCHABLE_TEMPLATES',
2814
- templateId: submission?.templateId ?? null,
2815
- templateVersionId: submission?.templateVersionId ?? null,
3422
+ purpose: readInitiatorCustomFieldsPurpose({ formOptions, submission }),
3423
+ templateId: template?.templateId ?? null,
3424
+ templateVersionId: template?.templateVersionId ?? null,
2816
3425
  };
2817
3426
  if (submission) {
2818
3427
  return {
@@ -2827,7 +3436,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2827
3436
  };
2828
3437
  }
2829
3438
  catch (error) {
2830
- this.logger.warn(`Launchable templates judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
3439
+ this.logger.warn(`${formOptions
3440
+ ? `Form options for template=${formOptions.templateId}`
3441
+ : 'Launchable templates'} judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
2831
3442
  return defaultSnapshot;
2832
3443
  }
2833
3444
  }
@@ -2848,9 +3459,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2848
3459
  * unhandled.
2849
3460
  */
2850
3461
  async resolveInitiatorCustomFields(resolver, input) {
2851
- const subject = input.purpose === 'SUBMISSION'
2852
- ? `template=${input.templateId} version=${input.templateVersionId}`
2853
- : 'launchable templates';
3462
+ const subject = input.purpose === 'LAUNCHABLE_TEMPLATES'
3463
+ ? 'launchable templates'
3464
+ : `${input.purpose === 'FORM_OPTIONS' ? 'form options ' : ''}template=${input.templateId} version=${input.templateVersionId}`;
2854
3465
  const outcome = await settleWithin(new Promise((resolve) => {
2855
3466
  resolve(resolver.resolveCustomFields(input));
2856
3467
  }), INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS, (late) => {
@@ -3024,6 +3635,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3024
3635
  });
3025
3636
  await this.dispatchAdhocCompletionNotifications(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED);
3026
3637
  await this.cancelPendingAdhocDirectives(manager, instance.id, null);
3638
+ await this.recordInstanceEvent(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED, 'INSTANCE_APPROVED', {});
3027
3639
  }
3028
3640
  async isNodeEntryReady(manager, instance, token, node) {
3029
3641
  if (node.type === 'startEvent') {
@@ -3089,7 +3701,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3089
3701
  }
3090
3702
  isNodeEntryConditionMet(instance, node) {
3091
3703
  const entryCondition = readNodeEntryCondition(node);
3092
- return this.conditionService.evaluateBoolean(entryCondition, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`, readExpressionDialectOptions(entryCondition, instance.workflowSnapshot));
3704
+ return this.conditionService.evaluateBoolean(entryCondition, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`, readExpressionDialectOptions(entryCondition, instance.workflowSnapshot));
3093
3705
  }
3094
3706
  async skipNodeByEntryCondition(manager, instance, token, node) {
3095
3707
  await this.skipNode(manager, instance, token, node, {
@@ -3163,7 +3775,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3163
3775
  });
3164
3776
  }
3165
3777
  const context = {
3166
- ...buildWorkflowExpressionContext(instance),
3778
+ ...this.buildExpressionContext(instance),
3167
3779
  lastDecision: await this.readLastDecisionContext(manager, instance.id, node.id),
3168
3780
  };
3169
3781
  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) }));
@@ -3286,6 +3898,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3286
3898
  // worse — an `endState: 'REJECTED'` row overwritten with `APPROVED`
3287
3899
  // immediately after it had been committed.
3288
3900
  const completedInstance = await instanceRepository.save(Object.assign(instance, { completedAt, state: instanceState }));
3901
+ if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED) {
3902
+ await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_REJECTED', { actorMemberId: null });
3903
+ }
3904
+ else {
3905
+ await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_APPROVED', {});
3906
+ }
3289
3907
  if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED) {
3290
3908
  await this.notificationService.createInstanceCompletedNotification({
3291
3909
  instance: completedInstance,
@@ -3387,7 +4005,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3387
4005
  const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
3388
4006
  const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
3389
4007
  task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
3390
- task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(node).size > 0;
4008
+ task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)(node).size > 0;
3391
4009
  await tokenRepository.save({
3392
4010
  ...token,
3393
4011
  status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
@@ -3414,6 +4032,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3414
4032
  },
3415
4033
  taskId: task.id,
3416
4034
  }));
4035
+ await this.recordInstanceEvent(manager, instance, instance.state, 'NODE_ACTIVATED', { nodeId: node.id, taskIds: [task.id] });
3417
4036
  await openCandidates.reduce(async (previous, candidate) => {
3418
4037
  await previous;
3419
4038
  await this.notificationService.createTaskAssignedNotification({
@@ -4473,9 +5092,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4473
5092
  skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
4474
5093
  };
4475
5094
  }
5095
+ /**
5096
+ * The context workflow expressions on `instance` read. Its `form` is the
5097
+ * stored data, or with `expressionMissingFormFieldsAsNull` on, a copy with
5098
+ * every unfilled top-level field present (ADR 27 §3).
5099
+ */
5100
+ buildExpressionContext(instance) {
5101
+ return buildWorkflowExpressionContext(instance, this.readExpressionFormData(instance));
5102
+ }
5103
+ readExpressionFormData(instance) {
5104
+ return this.expressionMissingFormFieldsAsNull
5105
+ ? (0, missing_form_fields_1.fillMissingFormFields)(instance.formData, readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot))
5106
+ : instance.formData;
5107
+ }
4476
5108
  buildDelegationContext(instance, nodeId) {
4477
5109
  return {
4478
- formData: instance.formData,
5110
+ formData: this.readExpressionFormData(instance),
4479
5111
  initiatorMemberId: instance.initiatorMemberId,
4480
5112
  initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
4481
5113
  instanceId: instance.id,
@@ -4569,7 +5201,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4569
5201
  return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`);
4570
5202
  }
4571
5203
  if (resolver.type === 'EXPRESSION') {
4572
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
5204
+ return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, this.buildExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
4573
5205
  }
4574
5206
  if (resolver.type === 'POSITION') {
4575
5207
  return this.resolvePositionCandidates(manager, resolver.positionId, label, subject);
@@ -4699,7 +5331,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4699
5331
  */
4700
5332
  async readNodeActorApprovals(manager, instance, nodeIds) {
4701
5333
  const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
4702
- where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
5334
+ where: {
5335
+ instanceId: instance.id,
5336
+ isAdhoc: false,
5337
+ nodeId: (0, typeorm_2.In)([...nodeIds]),
5338
+ },
4703
5339
  });
4704
5340
  if (tasks.length === 0) {
4705
5341
  return [];
@@ -4715,7 +5351,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4715
5351
  }
4716
5352
  const [decisions, decidedLogs] = await Promise.all([
4717
5353
  manager.getRepository(task_decision_entity_1.TaskDecisionEntity).find({
4718
- where: { action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED, taskId: (0, typeorm_2.In)(batchTaskIds) },
5354
+ where: {
5355
+ action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
5356
+ taskId: (0, typeorm_2.In)(batchTaskIds),
5357
+ },
4719
5358
  }),
4720
5359
  manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
4721
5360
  where: {
@@ -5177,11 +5816,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5177
5816
  const action = node.data.action;
5178
5817
  if (action.type === 'WEBHOOK') {
5179
5818
  const payload = typeof action.payload === 'string' && action.payload.trim()
5180
- ? this.conditionService.evaluateValue(action.payload, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.payload`, {
5819
+ ? this.conditionService.evaluateValue(action.payload, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.action.payload`, {
5181
5820
  dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot),
5182
5821
  })
5183
5822
  : {
5184
- instance: buildWorkflowExpressionContext(instance).instance,
5823
+ instance: this.buildExpressionContext(instance).instance,
5185
5824
  triggeredAt: new Date().toISOString(),
5186
5825
  };
5187
5826
  await this.dispatchWebhookAfterCommit(manager, {
@@ -5209,7 +5848,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5209
5848
  return;
5210
5849
  }
5211
5850
  if (action.type === 'SET_FORM_FIELD') {
5212
- const value = this.conditionService.evaluateValue(action.value, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
5851
+ 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) });
5213
5852
  assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
5214
5853
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
5215
5854
  await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
@@ -5271,8 +5910,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5271
5910
  // §3.5). Without the webhook module there is nothing to queue into; the
5272
5911
  // publish lint already refuses a template that references an endpoint
5273
5912
  // in that case.
5274
- const webhookDeliveryIds = this.webhookDeliveryService &&
5275
- (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5913
+ const webhookDeliveryIds = this.webhookDeliveryService && (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5276
5914
  ? await this.webhookDeliveryService.enqueueNotifyWebhooks(manager, {
5277
5915
  instance: {
5278
5916
  formData: instance.formData,
@@ -5302,7 +5940,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5302
5940
  taskId: null,
5303
5941
  }));
5304
5942
  }
5305
- async rejectInstance(manager, instance, task, rejectedAt) {
5943
+ async rejectInstance(manager, instance, task, rejectedAt, decidedByMemberId) {
5306
5944
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
5307
5945
  // Dispatch pending ad-hoc notifications for the rejecting node before the
5308
5946
  // remaining directives are cancelled by the terminal cleanup below.
@@ -5333,8 +5971,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5333
5971
  },
5334
5972
  taskId: task.id,
5335
5973
  }));
5974
+ await this.recordInstanceEvent(manager, rejectedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED, 'INSTANCE_REJECTED', { actorMemberId: decidedByMemberId });
5336
5975
  }
5337
- async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt) {
5976
+ async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt, decidedByMemberId) {
5338
5977
  if (!returnToNodeId) {
5339
5978
  throw new common_1.ConflictException('Return target node is required');
5340
5979
  }
@@ -5392,6 +6031,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5392
6031
  taskId: null,
5393
6032
  }),
5394
6033
  ]);
6034
+ await this.recordInstanceEvent(manager, returnedInstance, returnedInstance.state, 'INSTANCE_RETURNED', { actorMemberId: decidedByMemberId, nodeId: task.nodeId });
5395
6035
  if (returnedInstance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
5396
6036
  await this.processRunningInstance(manager, returnedInstance);
5397
6037
  }
@@ -5593,6 +6233,9 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5593
6233
  tslib_1.__param(20, (0, common_1.Optional)()),
5594
6234
  tslib_1.__param(21, (0, common_1.Optional)()),
5595
6235
  tslib_1.__param(21, (0, common_1.Inject)(initiator_custom_fields_resolver_token_1.BPM_INITIATOR_CUSTOM_FIELDS_RESOLVER)),
6236
+ tslib_1.__param(22, (0, common_1.Optional)()),
6237
+ tslib_1.__param(22, (0, common_1.Inject)(instance_guard_token_1.BPM_INSTANCE_GUARD)),
6238
+ tslib_1.__param(23, (0, common_1.Optional)()),
5596
6239
  tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
5597
6240
  typeorm_2.Repository,
5598
6241
  typeorm_2.Repository,
@@ -5609,7 +6252,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5609
6252
  delegation_service_1.DelegationService,
5610
6253
  notification_service_1.NotificationService,
5611
6254
  signature_service_1.SignatureService,
5612
- sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object])
6255
+ sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object, Object, instance_event_writer_1.InstanceEventWriter])
5613
6256
  ], WorkflowEngineService);
5614
6257
  function parseJsonObject(value, label) {
5615
6258
  try {
@@ -5632,9 +6275,77 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5632
6275
  memberId,
5633
6276
  };
5634
6277
  }
6278
+ /**
6279
+ * The form schema a dry run was given to fill unfilled fields with (ADR 27
6280
+ * §3), or `null` when it was given none. A schema that does not parse is a
6281
+ * 400, like the other inputs.
6282
+ */
6283
+ function readDryRunFormSchema(json) {
6284
+ if (!json) {
6285
+ return null;
6286
+ }
6287
+ const schema = parseJsonObject(json, 'formSchemaJson');
6288
+ if (!isFormDefinitionSchema(schema)) {
6289
+ throw new common_1.BadRequestException('formSchemaJson is not a form schema');
6290
+ }
6291
+ return schema;
6292
+ }
6293
+ /**
6294
+ * Refuses a submission or resubmission through GraphQL on a `HOST_ONLY`
6295
+ * version (ADR 27 §5). Only the host's backend, calling the service, may start
6296
+ * or resubmit such a case.
6297
+ */
6298
+ function assertEndUserMayLaunch(version) {
6299
+ if (version.launchMode === template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY) {
6300
+ throw new common_1.ForbiddenException('This approval template can only be submitted by the host system');
6301
+ }
6302
+ }
6303
+ /**
6304
+ * Reads the service-only `initiatorCustomFields` of a submission (ADR 27 §5):
6305
+ * `null` when absent, otherwise a JSON copy. Refused with 400 when it is not a
6306
+ * plain object or comes together with a whole `initiatorMetadataSnapshotJson`,
6307
+ * which would leave it unclear which one decides.
6308
+ */
6309
+ function readHostInitiatorCustomFields(input) {
6310
+ const fields = input.initiatorCustomFields;
6311
+ if (fields === undefined || fields === null) {
6312
+ return null;
6313
+ }
6314
+ if (input.initiatorMetadataSnapshotJson) {
6315
+ throw new common_1.BadRequestException('initiatorCustomFields cannot be combined with initiatorMetadataSnapshotJson');
6316
+ }
6317
+ if (typeof fields !== 'object' || Array.isArray(fields)) {
6318
+ throw new common_1.BadRequestException('initiatorCustomFields must be an object');
6319
+ }
6320
+ return cloneJsonObject(fields);
6321
+ }
6322
+ /**
6323
+ * Lays the host's `initiatorCustomFields` over the `customFields` of a built
6324
+ * snapshot; a key the host gave wins over the resolver's.
6325
+ */
6326
+ function mergeHostInitiatorCustomFields(snapshot, hostCustomFields) {
6327
+ if (hostCustomFields === null) {
6328
+ return snapshot;
6329
+ }
6330
+ const current = snapshot['customFields'];
6331
+ return {
6332
+ ...snapshot,
6333
+ customFields: {
6334
+ ...(isRecord(current) ? current : {}),
6335
+ ...hostCustomFields,
6336
+ },
6337
+ };
6338
+ }
5635
6339
  function cloneJsonObject(value) {
5636
6340
  return JSON.parse(JSON.stringify(value));
5637
6341
  }
6342
+ /** Which caller is building an initiator snapshot, as the resolver is told. */
6343
+ function readInitiatorCustomFieldsPurpose({ formOptions, submission, }) {
6344
+ if (submission) {
6345
+ return 'SUBMISSION';
6346
+ }
6347
+ return formOptions ? 'FORM_OPTIONS' : 'LAUNCHABLE_TEMPLATES';
6348
+ }
5638
6349
  /**
5639
6350
  * What a host's initiator `customFields` resolver returned, as the object the
5640
6351
  * snapshot keeps: a plain object that survives JSON, which is also how it is
@@ -5709,7 +6420,8 @@ function createWorkflowAuthContext(memberId) {
5709
6420
  };
5710
6421
  }
5711
6422
  function isSameApprovalInstanceRevision(left, right) {
5712
- return (readApprovalInstanceRevision(left) === readApprovalInstanceRevision(right) &&
6423
+ return (readApprovalInstanceRevision(left) ===
6424
+ readApprovalInstanceRevision(right) &&
5713
6425
  JSON.stringify(left.formData) === JSON.stringify(right.formData));
5714
6426
  }
5715
6427
  /**
@@ -5739,7 +6451,9 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
5739
6451
  // A corrupt snapshot can hold non-object entries: read them defensively
5740
6452
  // rather than letting a service task throw.
5741
6453
  const field = schema.fields.find((candidate) => isRecord(candidate) && candidate.fieldKey === fieldKey);
5742
- return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
6454
+ return field && (0, form_1.isFormDataSourceFieldDefinition)(field)
6455
+ ? field.fieldKey
6456
+ : null;
5743
6457
  }
5744
6458
  /**
5745
6459
  * Refuses form data whose filterable fields hold a value of the wrong type
@@ -5827,6 +6541,13 @@ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
5827
6541
  function readSnapshotWithoutField(snapshots, fieldKey) {
5828
6542
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
5829
6543
  }
6544
+ /**
6545
+ * Drops every snapshot that belongs to one of the fields, table cells included
6546
+ * (`items[0].col` belongs to `items`).
6547
+ */
6548
+ function readSnapshotWithoutFields(snapshots, fieldKeys) {
6549
+ return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => !fieldKeys.has((0, bpm_core_shared_1.readTopLevelFieldKey)(key))));
6550
+ }
5830
6551
  /**
5831
6552
  * Keeps only the snapshots whose field still carries a value, so clearing a
5832
6553
  * dynamic option also removes the label recorded for it.
@@ -5904,13 +6625,13 @@ function buildInitiatorPolicyContext(initiatorMetadataSnapshot) {
5904
6625
  subject: initiatorMetadataSnapshot,
5905
6626
  };
5906
6627
  }
5907
- function buildWorkflowExpressionContext(instance) {
6628
+ function buildWorkflowExpressionContext(instance, formData) {
5908
6629
  return {
5909
6630
  env: {
5910
6631
  now: new Date().toISOString(),
5911
6632
  },
5912
- form: instance.formData,
5913
- formData: instance.formData,
6633
+ form: formData,
6634
+ formData,
5914
6635
  initiator: {
5915
6636
  ...instance.initiatorMetadataSnapshot,
5916
6637
  memberId: instance.initiatorMemberId,
@@ -5954,8 +6675,8 @@ function buildDryRunExpressionContext(input, lastDecision) {
5954
6675
  env: {
5955
6676
  now: new Date().toISOString(),
5956
6677
  },
5957
- form: input.formData,
5958
- formData: input.formData,
6678
+ form: input.expressionFormData,
6679
+ formData: input.expressionFormData,
5959
6680
  initiator: {
5960
6681
  ...input.initiatorMetadataSnapshot,
5961
6682
  memberId: input.initiatorMemberId,
@@ -6111,8 +6832,7 @@ function readDefaultReturnTargetNodeId(workflow, currentNode) {
6111
6832
  }
6112
6833
  function isReturnCommentRequired(workflow, nodeId) {
6113
6834
  const node = readWorkflowNodeOrThrow(workflow, nodeId);
6114
- return (node.type === 'userTask' &&
6115
- node.data.returnBehavior.requireComment === true);
6835
+ return (node.type === 'userTask' && node.data.returnBehavior.requireComment === true);
6116
6836
  }
6117
6837
  function readReturnResubmitStrategy(task, instance) {
6118
6838
  const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
@@ -6376,6 +7096,9 @@ function readPageOffset(page, pageSize) {
6376
7096
  const normalizedPageSize = normalizePageSize(pageSize);
6377
7097
  return (normalizePage(page) - 1) * normalizedPageSize;
6378
7098
  }
7099
+ function isTaskListPaged(options) {
7100
+ return options.page !== undefined || options.pageSize !== undefined;
7101
+ }
6379
7102
  function shouldPaginateList(options) {
6380
7103
  return (typeof options.page !== 'undefined' ||
6381
7104
  typeof options.pageSize !== 'undefined');
@@ -6688,9 +7411,7 @@ function readAdhocTargetValue(directive) {
6688
7411
  webhookHeaders: isRecord(value.webhookHeaders)
6689
7412
  ? Object.entries(value.webhookHeaders).reduce((accumulator, [key, headerValue]) => ({
6690
7413
  ...accumulator,
6691
- ...(typeof headerValue === 'string'
6692
- ? { [key]: headerValue }
6693
- : {}),
7414
+ ...(typeof headerValue === 'string' ? { [key]: headerValue } : {}),
6694
7415
  }), {})
6695
7416
  : undefined,
6696
7417
  webhookUrl: typeof value.webhookUrl === 'string' ? value.webhookUrl : '',
@@ -6806,10 +7527,7 @@ function compareManagerResolution(left, right, orgUnitDepths) {
6806
7527
  return right.effectiveFrom.localeCompare(left.effectiveFrom);
6807
7528
  }
6808
7529
  function readOrgUnitDepthMap(orgUnits) {
6809
- return new Map(orgUnits.map((orgUnit) => [
6810
- orgUnit.id,
6811
- orgUnit.path.split('.').length,
6812
- ]));
7530
+ return new Map(orgUnits.map((orgUnit) => [orgUnit.id, orgUnit.path.split('.').length]));
6813
7531
  }
6814
7532
  function readManagerResolutionScopeRank(scopeType) {
6815
7533
  const ranks = {
@@ -7148,7 +7866,9 @@ function readTableMinRows(field) {
7148
7866
  return typeof field.minRows === 'number' ? field.minRows : 0;
7149
7867
  }
7150
7868
  function readTableMaxRows(field) {
7151
- return typeof field.maxRows === 'number' ? field.maxRows : form_1.FORM_TABLE_MAX_ROWS;
7869
+ return typeof field.maxRows === 'number'
7870
+ ? field.maxRows
7871
+ : form_1.FORM_TABLE_MAX_ROWS;
7152
7872
  }
7153
7873
  function readTableRequiredRowCount(field) {
7154
7874
  return Math.max(readTableMinRows(field), 1);
@@ -7508,4 +8228,17 @@ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
7508
8228
  }
7509
8229
  throw error;
7510
8230
  }
8231
+ /**
8232
+ * The keys whose value differs between the stored form data and the data a
8233
+ * resubmission replaces it with, sorted. A key present on only one side counts.
8234
+ */
8235
+ function listChangedFormDataKeys(previous, next) {
8236
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
8237
+ return [...keys]
8238
+ .filter((fieldKey) => Object.prototype.hasOwnProperty.call(previous, fieldKey) !==
8239
+ Object.prototype.hasOwnProperty.call(next, fieldKey) ||
8240
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(previous[fieldKey]) !==
8241
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(next[fieldKey]))
8242
+ .sort();
8243
+ }
7511
8244
  //# sourceMappingURL=workflow-engine.service.js.map