@rytass/bpm-core-nestjs-module 0.22.0 → 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 (136) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/package.json +2 -2
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js +1 -0
  5. package/src/index.js.map +1 -1
  6. package/src/lib/bpm/bpm-root-options.d.ts +57 -0
  7. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  8. package/src/lib/bpm/bpm-root.module.d.ts +21 -0
  9. package/src/lib/bpm/bpm-root.module.js +6 -0
  10. package/src/lib/bpm/bpm-root.module.js.map +1 -1
  11. package/src/lib/form/dto/form-definition.input.d.ts +5 -0
  12. package/src/lib/form/dto/form-definition.input.js.map +1 -1
  13. package/src/lib/form/form-definition.entity.d.ts +5 -0
  14. package/src/lib/form/form-definition.entity.js +7 -0
  15. package/src/lib/form/form-definition.entity.js.map +1 -1
  16. package/src/lib/form/form-schema.validator.js +17 -5
  17. package/src/lib/form/form-schema.validator.js.map +1 -1
  18. package/src/lib/form/form.queries.d.ts +2 -2
  19. package/src/lib/form/form.queries.js +17 -5
  20. package/src/lib/form/form.queries.js.map +1 -1
  21. package/src/lib/form/form.service.d.ts +2 -1
  22. package/src/lib/form/form.service.js +10 -5
  23. package/src/lib/form/form.service.js.map +1 -1
  24. package/src/lib/form-data-source/form-data-source-value-resolver.service.js +22 -2
  25. package/src/lib/form-data-source/form-data-source-value-resolver.service.js.map +1 -1
  26. package/src/lib/form-data-source/form-data-source.errors.d.ts +11 -0
  27. package/src/lib/form-data-source/form-data-source.errors.js +14 -1
  28. package/src/lib/form-data-source/form-data-source.errors.js.map +1 -1
  29. package/src/lib/form-data-source/form-data-source.service.d.ts +6 -0
  30. package/src/lib/form-data-source/form-data-source.service.js +37 -3
  31. package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
  32. package/src/lib/instance-event/approval-instance-event.entity.d.ts +27 -0
  33. package/src/lib/instance-event/approval-instance-event.entity.js +93 -0
  34. package/src/lib/instance-event/approval-instance-event.entity.js.map +1 -0
  35. package/src/lib/instance-event/index.d.ts +13 -0
  36. package/src/lib/instance-event/index.js +17 -0
  37. package/src/lib/instance-event/index.js.map +1 -0
  38. package/src/lib/instance-event/instance-event.delivery-scheduler.service.d.ts +21 -0
  39. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js +81 -0
  40. package/src/lib/instance-event/instance-event.delivery-scheduler.service.js.map +1 -0
  41. package/src/lib/instance-event/instance-event.delivery.service.d.ts +69 -0
  42. package/src/lib/instance-event/instance-event.delivery.service.js +259 -0
  43. package/src/lib/instance-event/instance-event.delivery.service.js.map +1 -0
  44. package/src/lib/instance-event/instance-event.delivery.subscriber.d.ts +24 -0
  45. package/src/lib/instance-event/instance-event.delivery.subscriber.js +85 -0
  46. package/src/lib/instance-event/instance-event.delivery.subscriber.js.map +1 -0
  47. package/src/lib/instance-event/instance-event.enums.d.ts +12 -0
  48. package/src/lib/instance-event/instance-event.enums.js +28 -0
  49. package/src/lib/instance-event/instance-event.enums.js.map +1 -0
  50. package/src/lib/instance-event/instance-event.module.d.ts +13 -0
  51. package/src/lib/instance-event/instance-event.module.js +52 -0
  52. package/src/lib/instance-event/instance-event.module.js.map +1 -0
  53. package/src/lib/instance-event/instance-event.object.d.ts +24 -0
  54. package/src/lib/instance-event/instance-event.object.js +73 -0
  55. package/src/lib/instance-event/instance-event.object.js.map +1 -0
  56. package/src/lib/instance-event/instance-event.options.d.ts +26 -0
  57. package/src/lib/instance-event/instance-event.options.js +35 -0
  58. package/src/lib/instance-event/instance-event.options.js.map +1 -0
  59. package/src/lib/instance-event/instance-event.resolver.d.ts +10 -0
  60. package/src/lib/instance-event/instance-event.resolver.js +75 -0
  61. package/src/lib/instance-event/instance-event.resolver.js.map +1 -0
  62. package/src/lib/instance-event/instance-event.service.d.ts +18 -0
  63. package/src/lib/instance-event/instance-event.service.js +37 -0
  64. package/src/lib/instance-event/instance-event.service.js.map +1 -0
  65. package/src/lib/instance-event/instance-event.token.d.ts +19 -0
  66. package/src/lib/instance-event/instance-event.token.js +9 -0
  67. package/src/lib/instance-event/instance-event.token.js.map +1 -0
  68. package/src/lib/instance-event/instance-event.types.d.ts +67 -0
  69. package/src/lib/instance-event/instance-event.types.js +9 -0
  70. package/src/lib/instance-event/instance-event.types.js.map +1 -0
  71. package/src/lib/instance-event/instance-event.writer.d.ts +21 -0
  72. package/src/lib/instance-event/instance-event.writer.js +45 -0
  73. package/src/lib/instance-event/instance-event.writer.js.map +1 -0
  74. package/src/lib/migrations/0000000032000-host-integration-round.d.ts +25 -0
  75. package/src/lib/migrations/0000000032000-host-integration-round.js +104 -0
  76. package/src/lib/migrations/0000000032000-host-integration-round.js.map +1 -0
  77. package/src/lib/migrations/index.d.ts +1 -0
  78. package/src/lib/migrations/index.js +3 -0
  79. package/src/lib/migrations/index.js.map +1 -1
  80. package/src/lib/notification/notification.service.js +50 -9
  81. package/src/lib/notification/notification.service.js.map +1 -1
  82. package/src/lib/template/approval-template-version.entity.d.ts +9 -1
  83. package/src/lib/template/approval-template-version.entity.js +11 -1
  84. package/src/lib/template/approval-template-version.entity.js.map +1 -1
  85. package/src/lib/template/approval-template.entity.d.ts +18 -0
  86. package/src/lib/template/approval-template.entity.js +23 -0
  87. package/src/lib/template/approval-template.entity.js.map +1 -1
  88. package/src/lib/template/dto/approval-template.input.d.ts +21 -0
  89. package/src/lib/template/dto/approval-template.input.js +38 -0
  90. package/src/lib/template/dto/approval-template.input.js.map +1 -1
  91. package/src/lib/template/dto/compose-approval-template.input.d.ts +23 -0
  92. package/src/lib/template/dto/compose-approval-template.input.js +37 -0
  93. package/src/lib/template/dto/compose-approval-template.input.js.map +1 -1
  94. package/src/lib/template/template-key.d.ts +26 -0
  95. package/src/lib/template/template-key.js +67 -0
  96. package/src/lib/template/template-key.js.map +1 -0
  97. package/src/lib/template/template.enums.d.ts +11 -0
  98. package/src/lib/template/template.enums.js +17 -1
  99. package/src/lib/template/template.enums.js.map +1 -1
  100. package/src/lib/template/template.queries.d.ts +3 -2
  101. package/src/lib/template/template.queries.js +29 -4
  102. package/src/lib/template/template.queries.js.map +1 -1
  103. package/src/lib/template/template.service.d.ts +8 -1
  104. package/src/lib/template/template.service.js +94 -17
  105. package/src/lib/template/template.service.js.map +1 -1
  106. package/src/lib/workflow-engine/approver-filled-table-columns.d.ts +91 -0
  107. package/src/lib/workflow-engine/approver-filled-table-columns.js +301 -0
  108. package/src/lib/workflow-engine/approver-filled-table-columns.js.map +1 -0
  109. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.d.ts +7 -0
  110. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js +9 -0
  111. package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js.map +1 -1
  112. package/src/lib/workflow-engine/index.d.ts +1 -0
  113. package/src/lib/workflow-engine/index.js +1 -0
  114. package/src/lib/workflow-engine/index.js.map +1 -1
  115. package/src/lib/workflow-engine/instance-guard.runner.d.ts +19 -0
  116. package/src/lib/workflow-engine/instance-guard.runner.js +102 -0
  117. package/src/lib/workflow-engine/instance-guard.runner.js.map +1 -0
  118. package/src/lib/workflow-engine/instance-guard.token.d.ts +101 -0
  119. package/src/lib/workflow-engine/instance-guard.token.js +9 -0
  120. package/src/lib/workflow-engine/instance-guard.token.js.map +1 -0
  121. package/src/lib/workflow-engine/missing-form-fields.d.ts +12 -0
  122. package/src/lib/workflow-engine/missing-form-fields.js +27 -0
  123. package/src/lib/workflow-engine/missing-form-fields.js.map +1 -0
  124. package/src/lib/workflow-engine/workflow-engine.module.d.ts +2 -0
  125. package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
  126. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  127. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +1 -1
  128. package/src/lib/workflow-engine/workflow-engine.mutations.js +8 -3
  129. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  130. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +7 -7
  131. package/src/lib/workflow-engine/workflow-engine.queries.js +97 -28
  132. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  133. package/src/lib/workflow-engine/workflow-engine.service.d.ts +141 -7
  134. package/src/lib/workflow-engine/workflow-engine.service.js +653 -75
  135. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  136. 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,6 +236,7 @@ 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);
213
241
  /** In-flight inbox reads by member; see `readSharedOpenInboxTasks`. */
214
242
  this.openInboxReadsInFlight = new Map();
@@ -236,7 +264,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
236
264
  injectedInitiatorCustomFieldsResolver ??
237
265
  rootOptions?.initiatorCustomFieldsResolver ??
238
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;
239
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;
240
329
  }
241
330
  /**
242
331
  * Today's date as the organization sees it (`organizationTimeZone`), for
@@ -308,6 +397,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
308
397
  input.submittedByMemberId.trim() === '')) {
309
398
  throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
310
399
  }
400
+ const hostCustomFields = readHostInitiatorCustomFields(input);
311
401
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
312
402
  const businessKey = normalizeBusinessKey(input.businessKey);
313
403
  const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
@@ -319,6 +409,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
319
409
  throw new common_1.ConflictException('Approval template does not have a published version');
320
410
  }
321
411
  const templateVersion = await this.getPublishedTemplateVersionOrThrow(template.currentVersionId);
412
+ if (input.endUserSubmission === true) {
413
+ assertEndUserMayLaunch(templateVersion);
414
+ }
322
415
  const formDefinitionVersion = await this.getPublishedFormVersionOrThrow(templateVersion.formDefinitionVersionId);
323
416
  // The fields an approver fills in belong to the approval, not the
324
417
  // application: an end user's value for one is dropped (it would otherwise
@@ -327,13 +420,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
327
420
  // checked here; required and row minimums wait for the node that fills
328
421
  // them. Read off the version the instance is bound to, not the template's
329
422
  // latest, so a publish in between cannot change what is stripped.
330
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(templateVersion.workflowDefinition);
423
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(templateVersion.workflowDefinition);
331
424
  const formData = input.endUserSubmission === true
332
- ? (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)
333
426
  : submittedFormData;
334
- validateSubmittedFormData(formDefinitionVersion.schema, formData, {
335
- deferredFieldKeys,
336
- });
427
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(formDefinitionVersion.schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
337
428
  // Checked against the bound version's schema before any data source is
338
429
  // called, so a bad value is refused cheaply. The transaction below
339
430
  // confirms under the template lock that this version is still current
@@ -347,7 +438,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
347
438
  });
348
439
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
349
440
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
350
- : await this.buildInitiatorMetadataSnapshot({
441
+ : mergeHostInitiatorCustomFields(await this.buildInitiatorMetadataSnapshot({
351
442
  authContext: submitAuthContext,
352
443
  memberId: input.initiatorMemberId,
353
444
  submission: {
@@ -355,7 +446,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
355
446
  templateId: template.id,
356
447
  templateVersionId: templateVersion.id,
357
448
  },
358
- });
449
+ }), hostCustomFields);
359
450
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
360
451
  // The policy is written in its version's dialect (ADR 22 §3.3).
361
452
  readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
@@ -396,7 +487,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
396
487
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
397
488
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
398
489
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
399
- const instance = await instanceRepository.save(instanceRepository.create({
490
+ const instanceDraft = instanceRepository.create({
400
491
  completedAt: null,
401
492
  formData,
402
493
  formDataOptionSnapshot,
@@ -416,7 +507,24 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
416
507
  templateVersionId: templateVersion.id,
417
508
  title: input.title?.trim() || template.name,
418
509
  workflowSnapshot: templateVersion.workflowDefinition,
419
- }));
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);
420
528
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
421
529
  formData,
422
530
  instanceId: instance.id,
@@ -454,6 +562,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
454
562
  taskId: null,
455
563
  }),
456
564
  ]);
565
+ await this.recordInstanceEvent(manager, instance, instance.state, 'INSTANCE_SUBMITTED', { actorMemberId: submittedByMemberId });
457
566
  await this.processRunningInstance(manager, instance);
458
567
  return instance;
459
568
  }).catch((error) =>
@@ -494,6 +603,66 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
494
603
  schema,
495
604
  });
496
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
+ }
497
666
  async prepareResubmitFormDataOptions(input, submittedFormData, authContext) {
498
667
  const instance = await this.approvalInstanceRepository.findOne({
499
668
  where: { id: input.instanceId },
@@ -512,13 +681,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
512
681
  throw new common_1.ConflictException('Approval template is deactivated');
513
682
  }
514
683
  const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
515
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
684
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
516
685
  // Same overlay as inside the lock, so the options resolved here are for
517
686
  // the form data that is actually saved.
518
687
  const formData = input.endUserSubmission === true
519
- ? (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)
520
689
  : submittedFormData;
521
- validateSubmittedFormData(schema, formData, { deferredFieldKeys });
690
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
522
691
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
523
692
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
524
693
  authContext,
@@ -556,6 +725,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
556
725
  await this.processRunningInstance(manager, instance);
557
726
  });
558
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
+ }
559
748
  async decideTask(input, options = {}) {
560
749
  return this.runEngineTransaction(async (manager) => {
561
750
  const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
@@ -719,9 +908,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
719
908
  if (filledFormData !== null && task.isAdhoc) {
720
909
  throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
721
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);
722
915
  if (filledFormData !== null) {
723
916
  const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
724
- 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));
725
919
  if (refusedFieldKeys.length > 0) {
726
920
  throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not let approvers fill in: ${refusedFieldKeys.join(', ')}`);
727
921
  }
@@ -732,35 +926,134 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
732
926
  (0, form_data_fingerprint_1.computeFormDataFingerprint)(instance.formData)) {
733
927
  throw new common_1.ConflictException(`Approval instance ${instance.id} form data changed; refresh and retry`);
734
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);
735
935
  // Only keys whose value really changes are written and reported. A key
736
936
  // the form data does not have yet counts as changed even when the new
737
937
  // value is null.
738
- const changedFieldKeys = filledFormData === null
938
+ const changedFieldKeys = settledFormData === null
739
939
  ? []
740
- : Object.keys(filledFormData)
940
+ : Object.keys(settledFormData)
741
941
  .filter((fieldKey) => !Object.prototype.hasOwnProperty.call(instance.formData, fieldKey) ||
742
- (0, form_data_fingerprint_1.computeStableValueFingerprint)(filledFormData[fieldKey]) !==
942
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(settledFormData[fieldKey]) !==
743
943
  (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formData[fieldKey]))
744
944
  .sort();
745
- 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
+ }
746
952
  // Every approval at a node that lets its approver fill in fields is
747
953
  // held to those fields, whether or not this request sent any: the host
748
- // may have pre-filled a table the approver then leaves as is.
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.
749
957
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc) {
750
- 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
+ ]);
751
962
  if (nodeFillableFieldKeys.size > 0) {
752
- 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));
753
964
  }
754
965
  }
755
- 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) {
756
1045
  await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
1046
+ }
1047
+ if (formDataChanged || formDataOptionSnapshotChanged) {
757
1048
  // Merged into the shared `instance` before it is signed and before
758
1049
  // the token moves on: the signature must cover the filled values, a
759
1050
  // gateway after this node must route on them, and an end node saves
760
- // 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.
761
1053
  const savedInstance = await instanceRepository.save({
762
1054
  ...instance,
763
1055
  formData: mergedFormData,
1056
+ formDataOptionSnapshot: nextFormDataOptionSnapshot,
764
1057
  });
765
1058
  Object.assign(instance, savedInstance);
766
1059
  }
@@ -879,7 +1172,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
879
1172
  // (rejected / returned, or an approval that completed it), every other
880
1173
  // candidate's notification is superseded too; a non-completing approval
881
1174
  // on a multi-approver task leaves the remaining approvers' open.
882
- const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED || shouldCompleteTask;
1175
+ const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED ||
1176
+ shouldCompleteTask;
883
1177
  await this.notificationService.resolveTaskNotifications({
884
1178
  actingMemberId: input.decidedByMemberId,
885
1179
  manager,
@@ -930,13 +1224,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
930
1224
  return decision;
931
1225
  }
932
1226
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED) {
933
- await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt);
1227
+ await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId);
934
1228
  return decision;
935
1229
  }
936
1230
  if (await this.handleAdhocPreApprovalRejection(manager, instance, completedTask, decidedAt, decisionComment)) {
937
1231
  return decision;
938
1232
  }
939
- await this.rejectInstance(manager, instance, completedTask, decidedAt);
1233
+ await this.rejectInstance(manager, instance, completedTask, decidedAt, input.decidedByMemberId);
940
1234
  return decision;
941
1235
  });
942
1236
  }
@@ -970,6 +1264,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
970
1264
  },
971
1265
  taskId: null,
972
1266
  }));
1267
+ await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: input.cancelledByMemberId });
973
1268
  return cancelledInstance;
974
1269
  });
975
1270
  }
@@ -1007,6 +1302,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1007
1302
  },
1008
1303
  taskId: input.taskId ?? null,
1009
1304
  }));
1305
+ await this.recordInstanceEvent(manager, expiredInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED, 'INSTANCE_EXPIRED', {});
1010
1306
  return expiredInstance;
1011
1307
  });
1012
1308
  }
@@ -1036,6 +1332,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1036
1332
  }
1037
1333
  async resubmitApprovalInstance(input, authContext) {
1038
1334
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
1335
+ if (input.endUserSubmission === true) {
1336
+ await this.assertEndUserMayResubmit(input.instanceId);
1337
+ }
1039
1338
  const preparation = this.formDataSourceValueResolver
1040
1339
  ? await this.prepareResubmitFormDataOptions(input, submittedFormData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
1041
1340
  : null;
@@ -1065,20 +1364,37 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1065
1364
  throw new common_1.ConflictException('Approval instance changed while options were resolving; refresh and retry');
1066
1365
  }
1067
1366
  const resubmitSchema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
1068
- const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
1367
+ const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
1069
1368
  // An end user gets back the value an approver already filled in, and
1070
1369
  // loses a key the stored data never had: the renderer seeds `false`,
1071
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.
1072
1372
  const formData = input.endUserSubmission === true
1073
- ? (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)
1074
1374
  : submittedFormData;
1075
- validateSubmittedFormData(resubmitSchema, formData, {
1076
- deferredFieldKeys,
1077
- });
1375
+ validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(resubmitSchema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
1078
1376
  await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
1079
1377
  if (!preparation) {
1080
1378
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
1081
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
+ }
1082
1398
  const resubmittedInstance = await instanceRepository.save({
1083
1399
  ...instance,
1084
1400
  completedAt: null,
@@ -1091,6 +1407,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1091
1407
  state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
1092
1408
  title: input.title?.trim() || instance.title,
1093
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 });
1094
1413
  await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
1095
1414
  formData,
1096
1415
  instanceId: instance.id,
@@ -1864,11 +2183,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1864
2183
  input.workflowDefinitionJson,
1865
2184
  input.formDataJson,
1866
2185
  input.initiatorMetadataSnapshotJson ?? '',
2186
+ input.formSchemaJson ?? '',
1867
2187
  ].some((json) => json.length > MAX_DRY_RUN_INPUT_LENGTH)) {
1868
2188
  return createFailedDryRunResult('試跑資料過大,請縮小流程或測試資料後再試');
1869
2189
  }
1870
2190
  const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
1871
2191
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
2192
+ const formSchema = readDryRunFormSchema(input.formSchemaJson);
1872
2193
  // The snapshot here is the caller's own sample (the designer always sends
1873
2194
  // one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
1874
2195
  // submission builds, and a dry run has no template version to hand the
@@ -1885,6 +2206,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1885
2206
  return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunResultObject(), {
1886
2207
  errors: [],
1887
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,
1888
2212
  formData,
1889
2213
  initiatorMemberId: input.initiatorMemberId,
1890
2214
  initiatorMetadataSnapshot,
@@ -2189,6 +2513,51 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2189
2513
  }
2190
2514
  return instance;
2191
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
+ }
2192
2561
  /**
2193
2562
  * ADR 25 §6: a value written to a filterable field must fit its type, on
2194
2563
  * every path that writes form data. The fields checked are the ones the
@@ -2274,7 +2643,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2274
2643
  totalPages,
2275
2644
  };
2276
2645
  }
2277
- async createFilteredApprovalInstanceQuery(scope, options = {}) {
2646
+ async createFilteredApprovalInstanceQuery(scope, requested = {}) {
2647
+ const options = await this.resolveApprovalInstanceTemplateKey(requested);
2648
+ if (!options) {
2649
+ return null;
2650
+ }
2278
2651
  const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
2279
2652
  const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
2280
2653
  if (!hasReadableInstances) {
@@ -2502,9 +2875,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2502
2875
  memberId,
2503
2876
  submission: null,
2504
2877
  });
2505
- return templates.filter((template) => this.isInitiatorPolicyMet(template, template.currentVersionId
2506
- ? (versionById.get(template.currentVersionId) ?? null)
2507
- : null, initiatorMetadataSnapshot, 'launchable templates'));
2878
+ return templates.filter((template) => {
2879
+ const version = template.currentVersionId
2880
+ ? (versionById.get(template.currentVersionId) ?? null)
2881
+ : null;
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'));
2886
+ });
2508
2887
  }
2509
2888
  /**
2510
2889
  * The published version whose form `memberId` may read data-source options
@@ -2605,7 +2984,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2605
2984
  * given its candidate summaries.
2606
2985
  */
2607
2986
  async listInboxTasks(assigneeMemberId, options = {}) {
2608
- const openTasks = await this.readSharedOpenInboxTasks(assigneeMemberId);
2987
+ const openTasks = await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), options);
2609
2988
  if (!isTaskListPaged(options)) {
2610
2989
  return this.attachTaskCandidateSummaries(openTasks);
2611
2990
  }
@@ -2613,8 +2992,77 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2613
2992
  return this.attachTaskCandidateSummaries(openTasks.slice(offset, offset + normalizePageSize(options.pageSize)));
2614
2993
  }
2615
2994
  /** How many tasks {@link listInboxTasks} pages through. */
2616
- async countInboxTasks(assigneeMemberId) {
2617
- return (await this.readSharedOpenInboxTasks(assigneeMemberId)).length;
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));
2618
3066
  }
2619
3067
  /**
2620
3068
  * {@link readOpenInboxTasks} for the inbox list and its count, joining a
@@ -2711,7 +3159,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2711
3159
  */
2712
3160
  async listApprovalHistoryTasks(assigneeMemberId, options = {}) {
2713
3161
  const paged = isTaskListPaged(options);
2714
- const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState)
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)
2715
3167
  .orderBy(this.readApprovalHistoryActedAtExpression(), 'DESC')
2716
3168
  .addOrderBy('"task"."id"', 'DESC')
2717
3169
  .offset(paged ? readPageOffset(options.page, options.pageSize) : 0)
@@ -2723,10 +3175,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2723
3175
  }
2724
3176
  /** How many tasks {@link listApprovalHistoryTasks} pages through. */
2725
3177
  async countApprovalHistoryTasks(assigneeMemberId, options = {}) {
2726
- return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState).getCount();
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();
2727
3183
  }
2728
3184
  /** The rows of {@link listApprovalHistoryTasks}, unordered and unpaged. */
2729
- createApprovalHistoryTaskQuery(assigneeMemberId, instanceState) {
3185
+ createApprovalHistoryTaskQuery(assigneeMemberId, instanceState, templateIds = null) {
2730
3186
  const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
2731
3187
  const queryBuilder = this.taskRepository
2732
3188
  .createQueryBuilder('task')
@@ -2759,6 +3215,16 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2759
3215
  )
2760
3216
  `, { historyInstanceState: instanceState });
2761
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
+ }
2762
3228
  return queryBuilder;
2763
3229
  }
2764
3230
  /** When the member acted on a history task; see {@link listApprovalHistoryTasks}. */
@@ -2845,7 +3311,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2845
3311
  * snapshot without a `nodes` array, and a blank or non-string label all
2846
3312
  * leave the task without a label, as `readWorkflowNodeLabel` does. Only the
2847
3313
  * node's `fieldAccess` leaves the database for the fill flag, which
2848
- * `readNodeFillableFieldKeys` then reads as tolerantly as the engine does.
3314
+ * `readNodeFillGrantFieldKeys` then reads as tolerantly as the engine does.
2849
3315
  */
2850
3316
  async readTaskNodeSummaries(tasks) {
2851
3317
  // The table as the entity reads it, so a configured `schema` is honoured
@@ -2880,7 +3346,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2880
3346
  readNodeLabelKey(row.instanceId, row.nodeId),
2881
3347
  {
2882
3348
  hasFillableFields: row.nodeType === 'userTask' &&
2883
- (0, bpm_core_shared_1.readNodeFillableFieldKeys)({
3349
+ (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)({
2884
3350
  data: { fieldAccess: row.fieldAccess },
2885
3351
  }).size > 0,
2886
3352
  label: typeof row.label === 'string' && row.label.trim()
@@ -3169,6 +3635,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3169
3635
  });
3170
3636
  await this.dispatchAdhocCompletionNotifications(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED);
3171
3637
  await this.cancelPendingAdhocDirectives(manager, instance.id, null);
3638
+ await this.recordInstanceEvent(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED, 'INSTANCE_APPROVED', {});
3172
3639
  }
3173
3640
  async isNodeEntryReady(manager, instance, token, node) {
3174
3641
  if (node.type === 'startEvent') {
@@ -3234,7 +3701,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3234
3701
  }
3235
3702
  isNodeEntryConditionMet(instance, node) {
3236
3703
  const entryCondition = readNodeEntryCondition(node);
3237
- 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));
3238
3705
  }
3239
3706
  async skipNodeByEntryCondition(manager, instance, token, node) {
3240
3707
  await this.skipNode(manager, instance, token, node, {
@@ -3308,7 +3775,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3308
3775
  });
3309
3776
  }
3310
3777
  const context = {
3311
- ...buildWorkflowExpressionContext(instance),
3778
+ ...this.buildExpressionContext(instance),
3312
3779
  lastDecision: await this.readLastDecisionContext(manager, instance.id, node.id),
3313
3780
  };
3314
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) }));
@@ -3431,6 +3898,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3431
3898
  // worse — an `endState: 'REJECTED'` row overwritten with `APPROVED`
3432
3899
  // immediately after it had been committed.
3433
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
+ }
3434
3907
  if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED) {
3435
3908
  await this.notificationService.createInstanceCompletedNotification({
3436
3909
  instance: completedInstance,
@@ -3532,7 +4005,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3532
4005
  const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
3533
4006
  const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
3534
4007
  task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
3535
- task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(node).size > 0;
4008
+ task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)(node).size > 0;
3536
4009
  await tokenRepository.save({
3537
4010
  ...token,
3538
4011
  status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
@@ -3559,6 +4032,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3559
4032
  },
3560
4033
  taskId: task.id,
3561
4034
  }));
4035
+ await this.recordInstanceEvent(manager, instance, instance.state, 'NODE_ACTIVATED', { nodeId: node.id, taskIds: [task.id] });
3562
4036
  await openCandidates.reduce(async (previous, candidate) => {
3563
4037
  await previous;
3564
4038
  await this.notificationService.createTaskAssignedNotification({
@@ -4618,9 +5092,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4618
5092
  skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
4619
5093
  };
4620
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
+ }
4621
5108
  buildDelegationContext(instance, nodeId) {
4622
5109
  return {
4623
- formData: instance.formData,
5110
+ formData: this.readExpressionFormData(instance),
4624
5111
  initiatorMemberId: instance.initiatorMemberId,
4625
5112
  initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
4626
5113
  instanceId: instance.id,
@@ -4714,7 +5201,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4714
5201
  return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`);
4715
5202
  }
4716
5203
  if (resolver.type === 'EXPRESSION') {
4717
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
5204
+ return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, this.buildExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
4718
5205
  }
4719
5206
  if (resolver.type === 'POSITION') {
4720
5207
  return this.resolvePositionCandidates(manager, resolver.positionId, label, subject);
@@ -4844,7 +5331,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4844
5331
  */
4845
5332
  async readNodeActorApprovals(manager, instance, nodeIds) {
4846
5333
  const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
4847
- 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
+ },
4848
5339
  });
4849
5340
  if (tasks.length === 0) {
4850
5341
  return [];
@@ -4860,7 +5351,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4860
5351
  }
4861
5352
  const [decisions, decidedLogs] = await Promise.all([
4862
5353
  manager.getRepository(task_decision_entity_1.TaskDecisionEntity).find({
4863
- 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
+ },
4864
5358
  }),
4865
5359
  manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
4866
5360
  where: {
@@ -5322,11 +5816,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5322
5816
  const action = node.data.action;
5323
5817
  if (action.type === 'WEBHOOK') {
5324
5818
  const payload = typeof action.payload === 'string' && action.payload.trim()
5325
- ? 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`, {
5326
5820
  dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot),
5327
5821
  })
5328
5822
  : {
5329
- instance: buildWorkflowExpressionContext(instance).instance,
5823
+ instance: this.buildExpressionContext(instance).instance,
5330
5824
  triggeredAt: new Date().toISOString(),
5331
5825
  };
5332
5826
  await this.dispatchWebhookAfterCommit(manager, {
@@ -5354,7 +5848,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5354
5848
  return;
5355
5849
  }
5356
5850
  if (action.type === 'SET_FORM_FIELD') {
5357
- const value = this.conditionService.evaluateValue(action.value, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
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) });
5358
5852
  assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
5359
5853
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
5360
5854
  await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
@@ -5416,8 +5910,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5416
5910
  // §3.5). Without the webhook module there is nothing to queue into; the
5417
5911
  // publish lint already refuses a template that references an endpoint
5418
5912
  // in that case.
5419
- const webhookDeliveryIds = this.webhookDeliveryService &&
5420
- (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5913
+ const webhookDeliveryIds = this.webhookDeliveryService && (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5421
5914
  ? await this.webhookDeliveryService.enqueueNotifyWebhooks(manager, {
5422
5915
  instance: {
5423
5916
  formData: instance.formData,
@@ -5447,7 +5940,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5447
5940
  taskId: null,
5448
5941
  }));
5449
5942
  }
5450
- async rejectInstance(manager, instance, task, rejectedAt) {
5943
+ async rejectInstance(manager, instance, task, rejectedAt, decidedByMemberId) {
5451
5944
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
5452
5945
  // Dispatch pending ad-hoc notifications for the rejecting node before the
5453
5946
  // remaining directives are cancelled by the terminal cleanup below.
@@ -5478,8 +5971,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5478
5971
  },
5479
5972
  taskId: task.id,
5480
5973
  }));
5974
+ await this.recordInstanceEvent(manager, rejectedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED, 'INSTANCE_REJECTED', { actorMemberId: decidedByMemberId });
5481
5975
  }
5482
- async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt) {
5976
+ async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt, decidedByMemberId) {
5483
5977
  if (!returnToNodeId) {
5484
5978
  throw new common_1.ConflictException('Return target node is required');
5485
5979
  }
@@ -5537,6 +6031,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5537
6031
  taskId: null,
5538
6032
  }),
5539
6033
  ]);
6034
+ await this.recordInstanceEvent(manager, returnedInstance, returnedInstance.state, 'INSTANCE_RETURNED', { actorMemberId: decidedByMemberId, nodeId: task.nodeId });
5540
6035
  if (returnedInstance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
5541
6036
  await this.processRunningInstance(manager, returnedInstance);
5542
6037
  }
@@ -5738,6 +6233,9 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5738
6233
  tslib_1.__param(20, (0, common_1.Optional)()),
5739
6234
  tslib_1.__param(21, (0, common_1.Optional)()),
5740
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)()),
5741
6239
  tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
5742
6240
  typeorm_2.Repository,
5743
6241
  typeorm_2.Repository,
@@ -5754,7 +6252,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5754
6252
  delegation_service_1.DelegationService,
5755
6253
  notification_service_1.NotificationService,
5756
6254
  signature_service_1.SignatureService,
5757
- 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])
5758
6256
  ], WorkflowEngineService);
5759
6257
  function parseJsonObject(value, label) {
5760
6258
  try {
@@ -5777,6 +6275,67 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5777
6275
  memberId,
5778
6276
  };
5779
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
+ }
5780
6339
  function cloneJsonObject(value) {
5781
6340
  return JSON.parse(JSON.stringify(value));
5782
6341
  }
@@ -5861,7 +6420,8 @@ function createWorkflowAuthContext(memberId) {
5861
6420
  };
5862
6421
  }
5863
6422
  function isSameApprovalInstanceRevision(left, right) {
5864
- return (readApprovalInstanceRevision(left) === readApprovalInstanceRevision(right) &&
6423
+ return (readApprovalInstanceRevision(left) ===
6424
+ readApprovalInstanceRevision(right) &&
5865
6425
  JSON.stringify(left.formData) === JSON.stringify(right.formData));
5866
6426
  }
5867
6427
  /**
@@ -5891,7 +6451,9 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
5891
6451
  // A corrupt snapshot can hold non-object entries: read them defensively
5892
6452
  // rather than letting a service task throw.
5893
6453
  const field = schema.fields.find((candidate) => isRecord(candidate) && candidate.fieldKey === fieldKey);
5894
- return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
6454
+ return field && (0, form_1.isFormDataSourceFieldDefinition)(field)
6455
+ ? field.fieldKey
6456
+ : null;
5895
6457
  }
5896
6458
  /**
5897
6459
  * Refuses form data whose filterable fields hold a value of the wrong type
@@ -5979,6 +6541,13 @@ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
5979
6541
  function readSnapshotWithoutField(snapshots, fieldKey) {
5980
6542
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
5981
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
+ }
5982
6551
  /**
5983
6552
  * Keeps only the snapshots whose field still carries a value, so clearing a
5984
6553
  * dynamic option also removes the label recorded for it.
@@ -6056,13 +6625,13 @@ function buildInitiatorPolicyContext(initiatorMetadataSnapshot) {
6056
6625
  subject: initiatorMetadataSnapshot,
6057
6626
  };
6058
6627
  }
6059
- function buildWorkflowExpressionContext(instance) {
6628
+ function buildWorkflowExpressionContext(instance, formData) {
6060
6629
  return {
6061
6630
  env: {
6062
6631
  now: new Date().toISOString(),
6063
6632
  },
6064
- form: instance.formData,
6065
- formData: instance.formData,
6633
+ form: formData,
6634
+ formData,
6066
6635
  initiator: {
6067
6636
  ...instance.initiatorMetadataSnapshot,
6068
6637
  memberId: instance.initiatorMemberId,
@@ -6106,8 +6675,8 @@ function buildDryRunExpressionContext(input, lastDecision) {
6106
6675
  env: {
6107
6676
  now: new Date().toISOString(),
6108
6677
  },
6109
- form: input.formData,
6110
- formData: input.formData,
6678
+ form: input.expressionFormData,
6679
+ formData: input.expressionFormData,
6111
6680
  initiator: {
6112
6681
  ...input.initiatorMetadataSnapshot,
6113
6682
  memberId: input.initiatorMemberId,
@@ -6263,8 +6832,7 @@ function readDefaultReturnTargetNodeId(workflow, currentNode) {
6263
6832
  }
6264
6833
  function isReturnCommentRequired(workflow, nodeId) {
6265
6834
  const node = readWorkflowNodeOrThrow(workflow, nodeId);
6266
- return (node.type === 'userTask' &&
6267
- node.data.returnBehavior.requireComment === true);
6835
+ return (node.type === 'userTask' && node.data.returnBehavior.requireComment === true);
6268
6836
  }
6269
6837
  function readReturnResubmitStrategy(task, instance) {
6270
6838
  const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
@@ -6843,9 +7411,7 @@ function readAdhocTargetValue(directive) {
6843
7411
  webhookHeaders: isRecord(value.webhookHeaders)
6844
7412
  ? Object.entries(value.webhookHeaders).reduce((accumulator, [key, headerValue]) => ({
6845
7413
  ...accumulator,
6846
- ...(typeof headerValue === 'string'
6847
- ? { [key]: headerValue }
6848
- : {}),
7414
+ ...(typeof headerValue === 'string' ? { [key]: headerValue } : {}),
6849
7415
  }), {})
6850
7416
  : undefined,
6851
7417
  webhookUrl: typeof value.webhookUrl === 'string' ? value.webhookUrl : '',
@@ -6961,10 +7527,7 @@ function compareManagerResolution(left, right, orgUnitDepths) {
6961
7527
  return right.effectiveFrom.localeCompare(left.effectiveFrom);
6962
7528
  }
6963
7529
  function readOrgUnitDepthMap(orgUnits) {
6964
- return new Map(orgUnits.map((orgUnit) => [
6965
- orgUnit.id,
6966
- orgUnit.path.split('.').length,
6967
- ]));
7530
+ return new Map(orgUnits.map((orgUnit) => [orgUnit.id, orgUnit.path.split('.').length]));
6968
7531
  }
6969
7532
  function readManagerResolutionScopeRank(scopeType) {
6970
7533
  const ranks = {
@@ -7303,7 +7866,9 @@ function readTableMinRows(field) {
7303
7866
  return typeof field.minRows === 'number' ? field.minRows : 0;
7304
7867
  }
7305
7868
  function readTableMaxRows(field) {
7306
- 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;
7307
7872
  }
7308
7873
  function readTableRequiredRowCount(field) {
7309
7874
  return Math.max(readTableMinRows(field), 1);
@@ -7663,4 +8228,17 @@ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
7663
8228
  }
7664
8229
  throw error;
7665
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
+ }
7666
8244
  //# sourceMappingURL=workflow-engine.service.js.map