@rytass/bpm-core-nestjs-module 0.20.1 → 0.21.1

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 (92) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +109 -5
  3. package/package.json +2 -2
  4. package/src/lib/attachment/attachment.service.js +2 -10
  5. package/src/lib/attachment/attachment.service.js.map +1 -1
  6. package/src/lib/attachment/file-upload-value-ids.d.ts +8 -0
  7. package/src/lib/attachment/file-upload-value-ids.js +15 -0
  8. package/src/lib/attachment/file-upload-value-ids.js.map +1 -0
  9. package/src/lib/bpm/bpm-root-options.d.ts +18 -0
  10. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  11. package/src/lib/bpm/bpm-root.module.d.ts +11 -0
  12. package/src/lib/bpm/bpm-root.module.js +9 -4
  13. package/src/lib/bpm/bpm-root.module.js.map +1 -1
  14. package/src/lib/form/form-data-issue.d.ts +34 -0
  15. package/src/lib/form/form-data-issue.js +59 -0
  16. package/src/lib/form/form-data-issue.js.map +1 -0
  17. package/src/lib/form/form-member-value.validator.d.ts +2 -2
  18. package/src/lib/form/form-member-value.validator.js +2 -3
  19. package/src/lib/form/form-member-value.validator.js.map +1 -1
  20. package/src/lib/form/form-org-unit-value.validator.d.ts +2 -2
  21. package/src/lib/form/form-org-unit-value.validator.js +4 -1
  22. package/src/lib/form/form-org-unit-value.validator.js.map +1 -1
  23. package/src/lib/form/form-schema.validator.js +40 -0
  24. package/src/lib/form/form-schema.validator.js.map +1 -1
  25. package/src/lib/form-data-source/form-data-source.module.d.ts +8 -0
  26. package/src/lib/form-data-source/form-data-source.module.js +1 -1
  27. package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
  28. package/src/lib/form-data-source/form-data-source.service.js +1 -1
  29. package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
  30. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.d.ts +29 -0
  31. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js +44 -0
  32. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js.map +1 -0
  33. package/src/lib/migrations/index.d.ts +1 -0
  34. package/src/lib/migrations/index.js +3 -0
  35. package/src/lib/migrations/index.js.map +1 -1
  36. package/src/lib/notification/notification.service.js +6 -0
  37. package/src/lib/notification/notification.service.js.map +1 -1
  38. package/src/lib/organization/organization-time-zone.d.ts +4 -2
  39. package/src/lib/organization/organization-time-zone.js +3 -6
  40. package/src/lib/organization/organization-time-zone.js.map +1 -1
  41. package/src/lib/template/filterable-field-guard.d.ts +49 -0
  42. package/src/lib/template/filterable-field-guard.js +141 -0
  43. package/src/lib/template/filterable-field-guard.js.map +1 -0
  44. package/src/lib/template/filterable-field-issue.object.d.ts +21 -0
  45. package/src/lib/template/filterable-field-issue.object.js +65 -0
  46. package/src/lib/template/filterable-field-issue.object.js.map +1 -0
  47. package/src/lib/template/filterable-field.queries.d.ts +20 -0
  48. package/src/lib/template/filterable-field.queries.js +80 -0
  49. package/src/lib/template/filterable-field.queries.js.map +1 -0
  50. package/src/lib/template/index.d.ts +1 -0
  51. package/src/lib/template/index.js +1 -0
  52. package/src/lib/template/index.js.map +1 -1
  53. package/src/lib/template/template.module.js +2 -0
  54. package/src/lib/template/template.module.js.map +1 -1
  55. package/src/lib/template/template.service.d.ts +39 -0
  56. package/src/lib/template/template.service.js +218 -49
  57. package/src/lib/template/template.service.js.map +1 -1
  58. package/src/lib/template/workflow-definition.validator.js +84 -2
  59. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  60. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.d.ts +49 -0
  61. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js +501 -0
  62. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js.map +1 -0
  63. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.d.ts +15 -0
  64. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js +20 -0
  65. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js.map +1 -0
  66. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.d.ts +15 -0
  67. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js +55 -0
  68. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js.map +1 -0
  69. package/src/lib/workflow-engine/dto/approval-instance-list-args.d.ts +9 -0
  70. package/src/lib/workflow-engine/dto/approval-instance-list-args.js +24 -0
  71. package/src/lib/workflow-engine/dto/approval-instance-list-args.js.map +1 -0
  72. package/src/lib/workflow-engine/dto/approval-instance-sort.input.d.ts +10 -0
  73. package/src/lib/workflow-engine/dto/approval-instance-sort.input.js +32 -0
  74. package/src/lib/workflow-engine/dto/approval-instance-sort.input.js.map +1 -0
  75. package/src/lib/workflow-engine/index.d.ts +1 -0
  76. package/src/lib/workflow-engine/index.js +1 -0
  77. package/src/lib/workflow-engine/index.js.map +1 -1
  78. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.d.ts +108 -0
  79. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js +9 -0
  80. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js.map +1 -0
  81. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +13 -0
  82. package/src/lib/workflow-engine/workflow-engine.enums.js +22 -1
  83. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  84. package/src/lib/workflow-engine/workflow-engine.module.d.ts +2 -0
  85. package/src/lib/workflow-engine/workflow-engine.module.js +9 -3
  86. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  87. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +6 -4
  88. package/src/lib/workflow-engine/workflow-engine.queries.js +35 -12
  89. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  90. package/src/lib/workflow-engine/workflow-engine.service.d.ts +166 -18
  91. package/src/lib/workflow-engine/workflow-engine.service.js +918 -139
  92. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -13,11 +13,13 @@ const common_1 = require("@nestjs/common");
13
13
  const typeorm_1 = require("@nestjs/typeorm");
14
14
  const typeorm_2 = require("typeorm");
15
15
  const attachment_service_1 = require("../attachment/attachment.service");
16
+ const file_upload_value_ids_1 = require("../attachment/file-upload-value-ids");
16
17
  const delegation_service_1 = require("../delegation/delegation.service");
17
18
  const form_definition_version_entity_1 = require("../form/form-definition-version.entity");
18
19
  const form_enums_1 = require("../form/form.enums");
19
20
  const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
20
21
  const form_member_value_validator_1 = require("../form/form-member-value.validator");
22
+ const form_data_issue_1 = require("../form/form-data-issue");
21
23
  const form_org_unit_value_validator_1 = require("../form/form-org-unit-value.validator");
22
24
  const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
23
25
  const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
@@ -39,6 +41,8 @@ const activity_log_entity_1 = require("./activity-log.entity");
39
41
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
40
42
  const adhoc_enums_1 = require("./adhoc.enums");
41
43
  const approval_instance_entity_1 = require("./approval-instance.entity");
44
+ const approval_instance_field_filter_compiler_1 = require("./approval-instance-field-filter.compiler");
45
+ const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
42
46
  const text_field_pattern_test_1 = require("./text-field-pattern-test");
43
47
  const form_data_fingerprint_1 = require("./form-data-fingerprint");
44
48
  const instance_submitter_1 = require("./instance-submitter");
@@ -53,6 +57,7 @@ const time_budget_1 = require("./time-budget");
53
57
  const expression_budget_1 = require("../condition/expression-budget");
54
58
  const expression_errors_1 = require("../condition/expression-errors");
55
59
  const expression_dialect_1 = require("../condition/expression-dialect");
60
+ const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
56
61
  const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
57
62
  const workflow_token_entity_1 = require("./workflow-token.entity");
58
63
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
@@ -67,6 +72,15 @@ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_ke
67
72
  const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
68
73
  const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
69
74
  const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
75
+ // How long a submission waits for the host's initiator `customFields`
76
+ // resolver. The case is not created when it runs out (the snapshot never falls
77
+ // back to `{}`), so this bounds how long a member stares at a spinner when the
78
+ // host's lookup hangs; it is shorter than the 30 s webhook budget because a
79
+ // person is waiting on this one.
80
+ const INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS = 10_000;
81
+ const INITIATOR_CUSTOM_FIELDS_TIMEOUT_MESSAGE = '無法取得發起人的自訂欄位(逾時),這次申請沒有送出;請稍後再試或聯絡系統管理者';
82
+ const INITIATOR_CUSTOM_FIELDS_FAILED_MESSAGE = '無法取得發起人的自訂欄位,這次申請沒有送出;請稍後再試或聯絡系統管理者';
83
+ const INITIATOR_CUSTOM_FIELDS_INVALID_MESSAGE = '無法取得發起人的自訂欄位(回傳格式不正確),這次申請沒有送出;請聯絡系統管理者';
70
84
  // The dry run checks each expression against the roots its use point may read,
71
85
  // as publish does. Keep these in step with `WORKFLOW_EXPRESSION_ROOT_IDENTIFIERS`
72
86
  // in template.service.ts; P2 moves both to one place.
@@ -119,6 +133,7 @@ const TASK_REASSIGNMENT_MESSAGES = {
119
133
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED]: '接手人已對此任務簽過核',
120
134
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK]: '接手人已在此任務的待簽名單上',
121
135
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW]: '接手人職等不足,不能取代此簽核位置',
136
+ [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR]: '此節點不允許與前面關卡的簽核者為同一人,接手人不能是前面關卡的簽核者',
122
137
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR]: '此節點不允許申請人自簽,接手人不能是申請人',
123
138
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER]: '此節點不允許代送人簽核,接手人不能是代為送出此單的人',
124
139
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE]: '接手人與原簽核人相同',
@@ -174,7 +189,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
174
189
  'workflow.read_all',
175
190
  ]);
176
191
  let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
177
- constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService) {
192
+ constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver) {
178
193
  this.approvalInstanceRepository = approvalInstanceRepository;
179
194
  this.workflowTokenRepository = workflowTokenRepository;
180
195
  this.taskRepository = taskRepository;
@@ -195,6 +210,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
195
210
  this.formDataSourceValueResolver = formDataSourceValueResolver;
196
211
  this.webhookDeliveryService = webhookDeliveryService;
197
212
  this.logger = new common_1.Logger(WorkflowEngineService_1.name);
213
+ this.approvalInstanceFieldFilterCompiler = new approval_instance_field_filter_compiler_1.ApprovalInstanceFieldFilterCompiler();
214
+ this.approvalInstanceFilterableFieldPolicy = new approval_instance_filterable_field_policy_1.ApprovalInstanceFilterableFieldPolicy();
198
215
  /** Webhooks each open engine transaction will send once it has committed. */
199
216
  this.deferredWebhookDispatches = new WeakMap();
200
217
  // A binding under the token wins: that is either the host's own provider
@@ -205,12 +222,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
205
222
  // keeps a host that binds the token from its own global module working —
206
223
  // a module-local default would shadow that binding, silently sending
207
224
  // WEBHOOK service tasks through the built-in `fetch` dispatcher instead of
208
- // the host's signing or queueing one. It also reaches every copy of this
209
- // service, which a provider registered on one module does not.
225
+ // the host's signing or queueing one.
210
226
  this.serviceTaskDispatcher =
211
227
  injectedServiceTaskDispatcher ??
212
228
  rootOptions?.workflowServiceTaskDispatcher ??
213
229
  new workflow_service_task_dispatcher_token_1.DefaultWorkflowServiceTaskDispatcher();
230
+ // Resolved like the dispatcher above, for the same reason: a binding the
231
+ // host made under the token wins, then the runtime instance, and with
232
+ // neither the snapshot's `customFields` stays `{}`.
233
+ this.initiatorCustomFieldsResolver =
234
+ injectedInitiatorCustomFieldsResolver ??
235
+ rootOptions?.initiatorCustomFieldsResolver ??
236
+ null;
214
237
  this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
215
238
  }
216
239
  /**
@@ -276,6 +299,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
276
299
  }
277
300
  }
278
301
  async submitApprovalInstance(input, authContext) {
302
+ const submitAuthContext = authContext ?? createWorkflowAuthContext(input.initiatorMemberId);
279
303
  if (input.submittedByMemberId !== undefined &&
280
304
  input.submittedByMemberId !== null &&
281
305
  (typeof input.submittedByMemberId !== 'string' ||
@@ -308,15 +332,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
308
332
  validateSubmittedFormData(formDefinitionVersion.schema, formData, {
309
333
  deferredFieldKeys,
310
334
  });
335
+ // Checked against the bound version's schema before any data source is
336
+ // called, so a bad value is refused cheaply. The transaction below
337
+ // confirms under the template lock that this version is still current
338
+ // (ADR 25 §15), which is what makes this schema the one in force.
339
+ assertFilterableFormDataValues(formDefinitionVersion.schema, formData);
311
340
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
312
- authContext: authContext ?? createWorkflowAuthContext(input.initiatorMemberId),
341
+ authContext: submitAuthContext,
313
342
  formData,
314
343
  revalidateAll: true,
315
344
  schema: formDefinitionVersion.schema,
316
345
  });
317
346
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
318
347
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
319
- : await this.readDefaultInitiatorMetadataSnapshot(input.initiatorMemberId);
348
+ : await this.buildInitiatorMetadataSnapshot({
349
+ authContext: submitAuthContext,
350
+ memberId: input.initiatorMemberId,
351
+ submission: {
352
+ formData,
353
+ templateId: template.id,
354
+ templateVersionId: templateVersion.id,
355
+ },
356
+ });
320
357
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
321
358
  // The policy is written in its version's dialect (ADR 22 §3.3).
322
359
  readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
@@ -334,6 +371,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
334
371
  throw new common_1.BadRequestException('Published workflow does not include a startEvent');
335
372
  }
336
373
  return this.runEngineTransaction(async (manager) => {
374
+ // ADR 25 §6 and §15: take FOR SHARE on the template row, so a publish,
375
+ // deactivation or edit cannot commit between this point and the
376
+ // insert, then re-check under the lock what was read before it. The
377
+ // filterable check above used the version read then; it holds only
378
+ // while that version is still the current one.
379
+ const lockedTemplate = await manager
380
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
381
+ .findOne({
382
+ lock: { mode: 'pessimistic_read' },
383
+ where: { deletedAt: (0, typeorm_2.IsNull)(), id: template.id },
384
+ });
385
+ if (!lockedTemplate) {
386
+ throw new common_1.NotFoundException(`Approval template ${template.id} was not found`);
387
+ }
388
+ if (!lockedTemplate.isActive) {
389
+ throw new common_1.ConflictException('Approval template is deactivated');
390
+ }
391
+ if (lockedTemplate.currentVersionId !== templateVersion.id) {
392
+ throw new common_1.ConflictException('範本在送出期間發布了新版本,這次申請沒有送出;請重新整理頁面後再送出');
393
+ }
337
394
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
338
395
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
339
396
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
@@ -460,6 +517,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
460
517
  ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
461
518
  : submittedFormData;
462
519
  validateSubmittedFormData(schema, formData, { deferredFieldKeys });
520
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
463
521
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
464
522
  authContext,
465
523
  formData,
@@ -611,6 +669,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
611
669
  transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
612
670
  throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
613
671
  }
672
+ // `excludeNodeActors`: the same carve-out, the same refusal. A human
673
+ // may not hand the seat to someone who signed one of the listed stages.
674
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
675
+ options.transferReason === undefined &&
676
+ isNodeActorsExcluded(taskNode) &&
677
+ transferToMemberId !== null &&
678
+ (await this.isExcludedNodeActorTarget(manager, instance, taskNode, transferToMemberId))) {
679
+ throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許與前面關卡的簽核者為同一人,不能將任務轉交給前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(taskNode))}的簽核者`);
680
+ }
614
681
  // Same carve-out: a human picking the target on a manager-chain node
615
682
  // must pick someone at least as senior as the member being replaced.
616
683
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
@@ -634,7 +701,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
634
701
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED &&
635
702
  !decisionComment &&
636
703
  isReturnCommentRequired(instance.workflowSnapshot, task.nodeId)) {
637
- throw new common_1.BadRequestException(`Return decision comment is required by workflow node ${task.nodeId}`);
704
+ throw new common_1.BadRequestException('此節點要求退回時填寫意見,請輸入意見後再退回。');
705
+ }
706
+ // Same snapshot rule as the return check above. Only a person's
707
+ // approval is held to it: an engine-made approval (the SLA
708
+ // `AUTO_APPROVE` timeout) carries `options.automatic` and must never
709
+ // stall waiting for a comment nobody is there to write. A person's
710
+ // approval of an ad-hoc task counts too: it runs on the main node.
711
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
712
+ options.automatic === undefined &&
713
+ !decisionComment &&
714
+ taskNode.data.requireApprovalComment === true) {
715
+ throw new common_1.BadRequestException('此節點要求同意時填寫意見,請輸入意見後再同意。');
638
716
  }
639
717
  if (filledFormData !== null && task.isAdhoc) {
640
718
  throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
@@ -673,6 +751,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
673
751
  }
674
752
  }
675
753
  if (filledFormData !== null && changedFieldKeys.length > 0) {
754
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
676
755
  // Merged into the shared `instance` before it is signed and before
677
756
  // the token moves on: the signature must cover the filled values, a
678
757
  // gateway after this node must route on them, and an end node saves
@@ -994,6 +1073,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
994
1073
  validateSubmittedFormData(resubmitSchema, formData, {
995
1074
  deferredFieldKeys,
996
1075
  });
1076
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
997
1077
  if (!preparation) {
998
1078
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
999
1079
  }
@@ -1056,7 +1136,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1056
1136
  // reached, against that node's own setting. This checks the target
1057
1137
  // against the node being decided now, as an early rejection for the
1058
1138
  // common case; it is not a guarantee about the node it attaches to.
1059
- if (isInitiatorExcluded(node)) {
1139
+ if (hasSeatExclusion(node)) {
1060
1140
  await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
1061
1141
  }
1062
1142
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
@@ -1321,19 +1401,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1321
1401
  return task;
1322
1402
  }
1323
1403
  async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
1324
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
1404
+ const exclusions = await this.readNodeExclusions(manager, instance, node);
1405
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds);
1325
1406
  // Ad-hoc approvers are picked by a person, not walked up a manager chain,
1326
1407
  // so the chain's grade rule does not apply to them.
1327
1408
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
1328
- const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
1409
+ const candidates = selectNodeCandidates(delegatedCandidates, hasSeatExclusion(node), exclusions.memberIds);
1329
1410
  if (candidates.length > 0) {
1330
1411
  return candidates;
1331
1412
  }
1332
- throw new common_1.ConflictException(delegatedCandidates.length > 0
1413
+ if (delegatedCandidates.length === 0) {
1414
+ throw new common_1.ConflictException(`${label}「${node.data.label}」 did not resolve to any member id`);
1415
+ }
1416
+ // The initiator rule runs first: only when it alone left somebody is the
1417
+ // earlier-stage rule the one that emptied the list.
1418
+ throw new common_1.ConflictException(isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions)
1333
1419
  ? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
1334
1420
  ? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
1335
1421
  : `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
1336
- : `${label}「${node.data.label}」 did not resolve to any member id`);
1422
+ : `${label}「${node.data.label}」 無法建立任務:指定對象只有前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人。`);
1337
1423
  }
1338
1424
  async spawnCountersignTasksForNode(manager, instance, token, node, now) {
1339
1425
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
@@ -1781,6 +1867,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1781
1867
  }
1782
1868
  const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
1783
1869
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
1870
+ // The snapshot here is the caller's own sample (the designer always sends
1871
+ // one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
1872
+ // submission builds, and a dry run has no template version to hand the
1873
+ // host's `customFields` resolver, so that resolver is not called.
1784
1874
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
1785
1875
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
1786
1876
  : readDefaultInitiatorMetadataSnapshot(input.initiatorMemberId);
@@ -1979,6 +2069,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1979
2069
  outcome: 'STOPPED',
1980
2070
  };
1981
2071
  }
2072
+ // Same for a manager chain whose start a newer engine wrote.
2073
+ const unsupportedStartType = resolver.type === 'ORG_MANAGER_CHAIN' &&
2074
+ !(0, workflow_1.isOrgManagerChainStartedFromInitiator)(resolver)
2075
+ ? (0, workflow_1.readUnsupportedOrgManagerChainStartType)(resolver.startFrom)
2076
+ : null;
2077
+ if (unsupportedStartType !== null) {
2078
+ return {
2079
+ assigneeMemberId: null,
2080
+ message: `簽核節點「${readDryRunNodeName(node)}」此引擎版本不支援主管鏈起點「${unsupportedStartType}」,請升級 BPMCore 後再試`,
2081
+ outcome: 'STOPPED',
2082
+ };
2083
+ }
1982
2084
  const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
1983
2085
  if (memberIds === null) {
1984
2086
  return {
@@ -2085,22 +2187,76 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2085
2187
  }
2086
2188
  return instance;
2087
2189
  }
2190
+ /**
2191
+ * ADR 25 §6: a value written to a filterable field must fit its type, on
2192
+ * every path that writes form data. The fields checked are the ones the
2193
+ * template's *current* version marks filterable, not the instance's own
2194
+ * snapshot: an older instance can be re-filled, resubmitted or written by a
2195
+ * service task after a newer version enabled filtering on a field, and the
2196
+ * enable check (ADR 25 §5.4) guarantees the type is the same in every version
2197
+ * that holds instances, so the current definition judges the old value too.
2198
+ */
2199
+ async assertCurrentFilterableFormDataValues(templateId, formData, manager, serviceNodeLabel) {
2200
+ const schema = await this.readCurrentFormSchema(templateId, manager);
2201
+ assertFilterableFormDataValues(schema, formData, serviceNodeLabel);
2202
+ }
2203
+ /** The form schema of the template's current version, or `null` when there is none. */
2204
+ async readCurrentFormSchema(templateId, manager) {
2205
+ // Inside a write transaction the template row is taken FOR SHARE (ADR 25
2206
+ // §15): a publish, compose or rollback needs FOR UPDATE on the same row, so
2207
+ // the current version read here cannot change before the write commits.
2208
+ const template = manager
2209
+ ? await manager.getRepository(approval_template_entity_1.ApprovalTemplateEntity).findOne({
2210
+ lock: { mode: 'pessimistic_read' },
2211
+ where: { id: templateId },
2212
+ })
2213
+ : await this.approvalTemplateRepository.findOne({
2214
+ where: { id: templateId },
2215
+ });
2216
+ if (!template?.currentVersionId) {
2217
+ return null;
2218
+ }
2219
+ const versions = manager
2220
+ ? manager.getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
2221
+ : this.approvalTemplateVersionRepository;
2222
+ const version = await versions.findOne({
2223
+ where: { id: template.currentVersionId },
2224
+ });
2225
+ if (!version?.formDefinitionVersionId) {
2226
+ return null;
2227
+ }
2228
+ const formVersions = manager
2229
+ ? manager.getRepository(form_definition_version_entity_1.FormDefinitionVersionEntity)
2230
+ : this.formDefinitionVersionRepository;
2231
+ const formVersion = await formVersions.findOne({
2232
+ where: { id: version.formDefinitionVersionId },
2233
+ });
2234
+ return formVersion?.schema ?? null;
2235
+ }
2088
2236
  async listApprovalInstances(scope, options = {}) {
2089
- const queryBuilder = await this.createFilteredApprovalInstanceQueryBuilder(scope, options);
2090
- if (!queryBuilder) {
2237
+ const query = await this.createFilteredApprovalInstanceQuery(scope, options);
2238
+ if (!query) {
2091
2239
  return [];
2092
2240
  }
2093
- queryBuilder.orderBy('"approvalInstance"."created_at"', 'DESC');
2241
+ const { orderBy, queryBuilder } = query;
2242
+ orderBy.forEach((order, index) => {
2243
+ if (index === 0) {
2244
+ queryBuilder.orderBy(order.sql, order.direction);
2245
+ }
2246
+ else {
2247
+ queryBuilder.addOrderBy(order.sql, order.direction);
2248
+ }
2249
+ });
2094
2250
  if (shouldPaginateList(options)) {
2095
2251
  queryBuilder
2096
- .skip(readPageOffset(options.page, options.pageSize))
2097
- .take(normalizePageSize(options.pageSize));
2252
+ .offset(readPageOffset(options.page, options.pageSize))
2253
+ .limit(normalizePageSize(options.pageSize));
2098
2254
  }
2099
2255
  return queryBuilder.getMany();
2100
2256
  }
2101
2257
  async countApprovalInstances(scope, options = {}) {
2102
- const queryBuilder = await this.createFilteredApprovalInstanceQueryBuilder(scope, options);
2103
- return queryBuilder ? queryBuilder.getCount() : 0;
2258
+ const query = await this.createFilteredApprovalInstanceQuery(scope, options);
2259
+ return query ? query.queryBuilder.getCount() : 0;
2104
2260
  }
2105
2261
  async readApprovalInstancePageInfo(scope, options = {}) {
2106
2262
  const totalCount = await this.countApprovalInstances(scope, options);
@@ -2116,7 +2272,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2116
2272
  totalPages,
2117
2273
  };
2118
2274
  }
2119
- async createFilteredApprovalInstanceQueryBuilder(scope, options = {}) {
2275
+ async createFilteredApprovalInstanceQuery(scope, options = {}) {
2120
2276
  const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
2121
2277
  const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
2122
2278
  if (!hasReadableInstances) {
@@ -2134,7 +2290,72 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2134
2290
  });
2135
2291
  }
2136
2292
  applyApprovalInstanceSearchFilter(queryBuilder, options.searchText);
2137
- return queryBuilder;
2293
+ const compiled = await this.compileApprovalInstanceFieldQuery(scope, templateId, options);
2294
+ compiled.conditions.forEach((condition) => {
2295
+ queryBuilder.andWhere(condition.sql, condition.parameters);
2296
+ });
2297
+ queryBuilder.setParameters(compiled.orderBy.reduce((all, order) => ({ ...all, ...order.parameters }), {}));
2298
+ return { orderBy: compiled.orderBy, queryBuilder };
2299
+ }
2300
+ /**
2301
+ * The WHERE conditions and sort keys of the field filters and sort on the
2302
+ * approval-instance list (ADR 25 §7). The template's current schema is read,
2303
+ * and the static option lists of every version holding instances, only when
2304
+ * a condition or a non-time sort key needs them.
2305
+ */
2306
+ async compileApprovalInstanceFieldQuery(scope, templateId, options) {
2307
+ const fieldFilters = options.fieldFilters ?? [];
2308
+ const sort = options.sort ?? [];
2309
+ const needsSchema = fieldFilters.length > 0 ||
2310
+ sort.some((item) => !(0, bpm_core_shared_1.isApprovalInstanceSortTimeField)(item.field));
2311
+ if (needsSchema && !templateId) {
2312
+ throw new common_1.BadRequestException('篩選或排序表單欄位時需要指定簽核範本');
2313
+ }
2314
+ const schema = needsSchema && templateId
2315
+ ? await this.readCurrentFormSchema(templateId)
2316
+ : null;
2317
+ // Only a name search (CONTAINS) reads option names; other conditions and
2318
+ // sort keys need the current schema alone.
2319
+ const needsOptionNames = fieldFilters.some((filter) => filter.operator === 'CONTAINS');
2320
+ const staticOptionsByPath = needsOptionNames && templateId
2321
+ ? await this.readStaticOptionsByPathForTemplate(templateId, schema)
2322
+ : (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([]);
2323
+ return this.approvalInstanceFieldFilterCompiler.compile({
2324
+ alias: 'approvalInstance',
2325
+ fieldFilters,
2326
+ paths: this.approvalInstanceFilterableFieldPolicy.readFilterableFieldPaths(scope, schema),
2327
+ sort,
2328
+ staticOptionsByPath,
2329
+ });
2330
+ }
2331
+ /**
2332
+ * Static option lists by field path, across the current schema and the form
2333
+ * version of every template version that holds instances (ADR 25 §7.3): a
2334
+ * CONTAINS on a retired option still matches its name.
2335
+ */
2336
+ async readStaticOptionsByPathForTemplate(templateId, currentSchema) {
2337
+ const held = await this.approvalInstanceRepository
2338
+ .createQueryBuilder('approvalInstance')
2339
+ .select('DISTINCT "approvalInstance"."template_version_id"', 'id')
2340
+ .where('"approvalInstance"."template_id" = :templateId', { templateId })
2341
+ .getRawMany();
2342
+ const templateVersions = held.length === 0
2343
+ ? []
2344
+ : await this.approvalTemplateVersionRepository.find({
2345
+ where: { id: (0, typeorm_2.In)(held.map((row) => row.id)) },
2346
+ });
2347
+ const formVersionIds = templateVersions
2348
+ .map((version) => version.formDefinitionVersionId)
2349
+ .filter((id) => id !== null);
2350
+ const formVersions = formVersionIds.length === 0
2351
+ ? []
2352
+ : await this.formDefinitionVersionRepository.find({
2353
+ where: { id: (0, typeorm_2.In)(formVersionIds) },
2354
+ });
2355
+ return (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([
2356
+ currentSchema,
2357
+ ...formVersions.map((version) => version.schema),
2358
+ ]);
2138
2359
  }
2139
2360
  applyReadableInstanceFilter(queryBuilder, scope, options) {
2140
2361
  if (!scope) {
@@ -2242,7 +2463,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2242
2463
  },
2243
2464
  });
2244
2465
  }
2245
- async listLaunchableApprovalTemplates(memberId) {
2466
+ /**
2467
+ * The active templates `memberId` may start now. `authContext` is handed to
2468
+ * the host's initiator `customFields` resolver; a host calling this without
2469
+ * one gets a bare context for the member, as a submission does.
2470
+ */
2471
+ async listLaunchableApprovalTemplates(memberId, authContext) {
2246
2472
  const templates = await this.approvalTemplateRepository.find({
2247
2473
  order: { updatedAt: 'DESC' },
2248
2474
  where: { isActive: true },
@@ -2263,7 +2489,17 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2263
2489
  version.id,
2264
2490
  version,
2265
2491
  ]));
2266
- const initiatorMetadataSnapshot = await this.readDefaultInitiatorMetadataSnapshot(memberId);
2492
+ // Built like a submission's, so a policy that reads
2493
+ // `initiator.customFields` is judged here against what the host's resolver
2494
+ // returns for this member. The resolver depends on the initiator only, so
2495
+ // it is called once for the whole list, not once per template. When it
2496
+ // fails, the list is judged against `customFields: {}` instead of failing
2497
+ // (see `buildInitiatorMetadataSnapshot`).
2498
+ const initiatorMetadataSnapshot = await this.buildInitiatorMetadataSnapshot({
2499
+ authContext: authContext ?? createWorkflowAuthContext(memberId),
2500
+ memberId,
2501
+ submission: null,
2502
+ });
2267
2503
  return templates.filter((template) => {
2268
2504
  const currentVersion = template.currentVersionId
2269
2505
  ? (versionById.get(template.currentVersionId) ?? null)
@@ -2281,8 +2517,19 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2281
2517
  return false;
2282
2518
  }
2283
2519
  // Each template is its own operation, so one slow template cannot use
2284
- // up the allowance of the rest of the list.
2285
- return (0, expression_budget_1.runExpressionOperation)(() => this.conditionService.evaluateBoolean(currentVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel', readExpressionDialectOptions(currentVersion.initiatorPolicyCel, currentVersion.workflowDefinition)));
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
+ }
2286
2533
  });
2287
2534
  }
2288
2535
  async listWorkflowTokens(instanceId, scope) {
@@ -2523,6 +2770,112 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2523
2770
  primaryOrgUnitId: primaryMembership?.orgUnitId ?? null,
2524
2771
  };
2525
2772
  }
2773
+ /**
2774
+ * The initiator snapshot the engine builds itself: what it knows of the
2775
+ * initiator, with `customFields` filled by the host's resolver when there is
2776
+ * one. Without a resolver this is exactly the default snapshot
2777
+ * (`customFields` is `{}`). A submission (`submission` set) stores it and
2778
+ * judges the initiator policy with it; the launchable template list
2779
+ * (`submission: null`) only judges the policies with it, so both see the same
2780
+ * `customFields` for the same member.
2781
+ *
2782
+ * Only `customFields` comes from the host. The other keys are the engine's
2783
+ * and are written back after the resolver returns, so nothing it does can
2784
+ * change who the initiator is. The resolver gets copies of the snapshot and
2785
+ * of the form data for the same reason; `authContext` is the caller's own
2786
+ * object, as every other host hook receives it.
2787
+ *
2788
+ * The two callers treat a resolver failure (an error, a bad value, the
2789
+ * timeout) differently, on purpose:
2790
+ *
2791
+ * - A submission fails, and no case is created. Its snapshot is stored and
2792
+ * routes the case from then on, so carrying on with `{}` would start a
2793
+ * case on data the host never gave. It runs before the engine
2794
+ * transaction, so a failure leaves nothing behind and a slow lookup holds
2795
+ * 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
2798
+ * policy that reads `initiator.customFields` then throws on the missing key
2799
+ * and the list's per-template catch leaves that template out, while a
2800
+ * template whose policy does not read it is listed as usual. Submitting is
2801
+ * still judged against the resolver's real answer.
2802
+ */
2803
+ async buildInitiatorMetadataSnapshot({ authContext, memberId, submission, }) {
2804
+ const defaultSnapshot = await this.readDefaultInitiatorMetadataSnapshot(memberId);
2805
+ if (!this.initiatorCustomFieldsResolver) {
2806
+ return defaultSnapshot;
2807
+ }
2808
+ const input = {
2809
+ authContext,
2810
+ defaultSnapshot: cloneJsonObject(defaultSnapshot),
2811
+ formData: submission ? cloneJsonObject(submission.formData) : null,
2812
+ memberId,
2813
+ purpose: submission ? 'SUBMISSION' : 'LAUNCHABLE_TEMPLATES',
2814
+ templateId: submission?.templateId ?? null,
2815
+ templateVersionId: submission?.templateVersionId ?? null,
2816
+ };
2817
+ if (submission) {
2818
+ return {
2819
+ ...defaultSnapshot,
2820
+ customFields: await this.resolveInitiatorCustomFields(this.initiatorCustomFieldsResolver, input),
2821
+ };
2822
+ }
2823
+ try {
2824
+ return {
2825
+ ...defaultSnapshot,
2826
+ customFields: await this.resolveInitiatorCustomFields(this.initiatorCustomFieldsResolver, input),
2827
+ };
2828
+ }
2829
+ catch (error) {
2830
+ this.logger.warn(`Launchable templates judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
2831
+ return defaultSnapshot;
2832
+ }
2833
+ }
2834
+ /**
2835
+ * Calls the host's initiator `customFields` resolver within
2836
+ * {@link INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} and returns what it
2837
+ * gave as the object the snapshot keeps.
2838
+ *
2839
+ * Every failure becomes an `InternalServerErrorException` with a message
2840
+ * meant for the member, and the cause goes to the log only, so nothing of
2841
+ * the host's lookup reaches the client: running out of time, an error that
2842
+ * is not an `HttpException`, and a value that is not a plain JSON object. An
2843
+ * `HttpException` the resolver throws is passed on as it is, because the
2844
+ * host chose that answer for the member.
2845
+ *
2846
+ * A resolver that settles after the time has run out is not waited for; what
2847
+ * it settles with is logged and dropped, so a late rejection is never left
2848
+ * unhandled.
2849
+ */
2850
+ async resolveInitiatorCustomFields(resolver, input) {
2851
+ const subject = input.purpose === 'SUBMISSION'
2852
+ ? `template=${input.templateId} version=${input.templateVersionId}`
2853
+ : 'launchable templates';
2854
+ const outcome = await settleWithin(new Promise((resolve) => {
2855
+ resolve(resolver.resolveCustomFields(input));
2856
+ }), INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS, (late) => {
2857
+ this.logger.warn(`Initiator customFields resolver settled after the ${INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} ms timeout (${subject}); ${late.status === 'fulfilled'
2858
+ ? 'its value was dropped'
2859
+ : `its error was dropped: ${describeRejection(late.reason)}`}`);
2860
+ });
2861
+ if (outcome.status === 'timeout') {
2862
+ this.logger.error(`Initiator customFields resolver timed out after ${INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} ms (${subject})`);
2863
+ throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_TIMEOUT_MESSAGE);
2864
+ }
2865
+ if (outcome.status === 'rejected') {
2866
+ if (outcome.reason instanceof common_1.HttpException) {
2867
+ throw outcome.reason;
2868
+ }
2869
+ this.logger.error(`Initiator customFields resolver failed (${subject}): ${describeRejection(outcome.reason)}`, outcome.reason instanceof Error ? outcome.reason.stack : undefined);
2870
+ throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_FAILED_MESSAGE);
2871
+ }
2872
+ const customFields = readResolvedInitiatorCustomFields(outcome.value);
2873
+ if (!customFields.ok) {
2874
+ this.logger.error(`Initiator customFields resolver returned an unusable value (${subject}): ${customFields.problem}`);
2875
+ throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_INVALID_MESSAGE);
2876
+ }
2877
+ return customFields.value;
2878
+ }
2526
2879
  async listActivityLogs(instanceId, scope) {
2527
2880
  await this.getApprovalInstance(instanceId, scope);
2528
2881
  return this.activityLogRepository.find({
@@ -2743,10 +3096,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2743
3096
  skippedByEntryCondition: true,
2744
3097
  });
2745
3098
  }
2746
- async skipNodeByInitiatorExclusion(manager, instance, token, node) {
2747
- await this.skipNode(manager, instance, token, node, {
2748
- skippedByInitiatorExclusion: true,
2749
- });
3099
+ async skipNodeByInitiatorExclusion(manager, instance, token, node, skippedBy = 'INITIATOR') {
3100
+ await this.skipNode(manager, instance, token, node, skippedBy === 'NODE_ACTORS'
3101
+ ? { skippedByNodeActorExclusion: true }
3102
+ : { skippedByInitiatorExclusion: true });
2750
3103
  }
2751
3104
  async skipNode(manager, instance, token, node, reason) {
2752
3105
  // A NOTIFY node is an asynchronous branch that may not have an outgoing
@@ -2975,11 +3328,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2975
3328
  return;
2976
3329
  }
2977
3330
  const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
2978
- // The initiator was the only approver and the node asks to be skipped in
2979
- // that case: no task, the token moves on. Pending countersign directives
2980
- // stay pending for the next user task that actually gets created.
2981
- if (resolvedCandidates === null) {
2982
- await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
3331
+ // Everyone left was kept out and the node asks to be skipped in that case:
3332
+ // no task, the token moves on. Pending countersign directives stay
3333
+ // pending for the next user task that actually gets created.
3334
+ if ('skippedBy' in resolvedCandidates) {
3335
+ await this.skipNodeByInitiatorExclusion(manager, instance, token, node, resolvedCandidates.skippedBy);
2983
3336
  return;
2984
3337
  }
2985
3338
  const { candidates, skippedDelegations } = resolvedCandidates;
@@ -3002,7 +3355,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3002
3355
  : workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
3003
3356
  completedAt: null,
3004
3357
  createdAt: now,
3005
- decisionPolicySnapshot: isInitiatorExcluded(node)
3358
+ decisionPolicySnapshot: hasSeatExclusion(node)
3006
3359
  ? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
3007
3360
  : decisionPolicy,
3008
3361
  delegationChain: assignedCandidate?.delegationChain ?? [],
@@ -3100,7 +3453,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3100
3453
  const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
3101
3454
  candidate: nextCandidate,
3102
3455
  candidates,
3103
- excludesInitiator: isInitiatorExcluded(node),
3456
+ excludedMemberIds: (await this.readNodeExclusions(manager, instance, node)).memberIds,
3104
3457
  gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
3105
3458
  instance,
3106
3459
  manager,
@@ -3161,12 +3514,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3161
3514
  * Re-resolves the delegation of a candidate whose turn has come. When the
3162
3515
  * delegate already holds another candidate row on the task, the stored values
3163
3516
  * are kept: (task_id, member_id) is unique, and that member signs from the
3164
- * row they already have. On a node that excludes the initiator the stored
3165
- * values are kept as well when the new delegation path would run through the
3166
- * initiator or the submitter: creation already filtered such paths out, and a
3167
- * delegation added while the candidate waited must not bring them back.
3517
+ * row they already have. On a node that keeps members out (the initiator and
3518
+ * submitter, or the approvers of earlier stages) the stored values are kept
3519
+ * as well when the new delegation path would run through one of them:
3520
+ * creation already filtered such paths out, and a delegation added while the
3521
+ * candidate waited must not bring them back.
3168
3522
  */
3169
- async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
3523
+ async resolveActivationDelegation({ candidate, candidates, excludedMemberIds, gradeGated, instance, manager, nodeId, }) {
3170
3524
  const stored = {
3171
3525
  delegationChain: candidate.delegationChain,
3172
3526
  memberId: candidate.memberId,
@@ -3175,12 +3529,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3175
3529
  const collides = candidates.some((other) => other.id !== candidate.id &&
3176
3530
  other.memberId === resolution.finalAssigneeMemberId);
3177
3531
  if (collides ||
3178
- (excludesInitiator &&
3179
- isExcludedMemberOnCandidatePath({
3180
- delegationChain: resolution.delegationChain,
3181
- memberId: resolution.finalAssigneeMemberId,
3182
- originalMemberId: candidate.originalMemberId,
3183
- }, readInitiatorAndSubmitter(instance)))) {
3532
+ isExcludedMemberOnCandidatePath({
3533
+ delegationChain: resolution.delegationChain,
3534
+ memberId: resolution.finalAssigneeMemberId,
3535
+ originalMemberId: candidate.originalMemberId,
3536
+ }, excludedMemberIds)) {
3184
3537
  // The refusal happened even though the stored values are kept.
3185
3538
  return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
3186
3539
  }
@@ -3730,6 +4083,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3730
4083
  throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER);
3731
4084
  }
3732
4085
  }
4086
+ if (isNodeActorsExcluded(node) &&
4087
+ (await this.isExcludedNodeActorTarget(manager, instance, node, toMemberId))) {
4088
+ throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR);
4089
+ }
3733
4090
  if (!task.isAdhoc && isOrgManagerChainNode(node)) {
3734
4091
  const requiredGrade = await this.readTransferTargetRequiredGrade(manager, {
3735
4092
  actorMemberId: fromMemberId,
@@ -3942,58 +4299,178 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3942
4299
  });
3943
4300
  }
3944
4301
  /**
3945
- * Resolves who gets the node's task. `null` means the node asked to be
3946
- * skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
3947
- * was the only approver — the sole caller creates no task and advances the
3948
- * token instead.
4302
+ * Resolves who gets the node's task. A {@link SkippedRuntimeTask} means the
4303
+ * node asked to be skipped (`excludeInitiator.whenEmpty` or
4304
+ * `excludeNodeActors.whenEmpty` = SKIP_NODE) because everyone left was kept
4305
+ * out: the sole caller creates no task and advances the token instead.
4306
+ *
4307
+ * With both exclusions set the initiator rule runs first. When it alone
4308
+ * leaves nobody, `excludeInitiator.whenEmpty` decides; when it leaves
4309
+ * somebody and the earlier-stage rule then removes them all,
4310
+ * `excludeNodeActors.whenEmpty` decides.
3949
4311
  */
3950
4312
  async resolveRuntimeTaskCandidates(manager, instance, node) {
3951
4313
  const label = `簽核節點「${node.data.label}」`;
3952
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
4314
+ const exclusions = await this.readNodeExclusions(manager, instance, node);
4315
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds);
3953
4316
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
3954
- const excludeInitiator = node.data.excludeInitiator;
4317
+ const hasExclusion = hasSeatExclusion(node);
3955
4318
  const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
3956
- const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
4319
+ const uniqueCandidates = selectNodeCandidates(delegatedCandidates, hasExclusion, exclusions.memberIds);
3957
4320
  if (uniqueCandidates.length > 0) {
3958
4321
  return {
3959
4322
  candidates: uniqueCandidates,
3960
- skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
4323
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, hasExclusion, exclusions.memberIds),
3961
4324
  };
3962
4325
  }
3963
- // Only the initiator was left — what the node does about it is configured.
3964
- // Nothing resolved to begin with is the ordinary failure below.
3965
- if (!excludeInitiator || delegatedCandidates.length === 0) {
4326
+ // Only excluded people were left — what the node does about it is
4327
+ // configured. Nothing resolved to begin with is the ordinary failure below.
4328
+ if (!hasExclusion || delegatedCandidates.length === 0) {
3966
4329
  throw new common_1.ConflictException(`${label} did not resolve to any member id`);
3967
4330
  }
3968
- if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
3969
- return null;
4331
+ const emptiedByInitiator = isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions);
4332
+ const whenEmpty = emptiedByInitiator
4333
+ ? node.data.excludeInitiator?.whenEmpty
4334
+ : node.data.excludeNodeActors?.whenEmpty;
4335
+ if (whenEmpty === 'SKIP_NODE') {
4336
+ return { skippedBy: emptiedByInitiator ? 'INITIATOR' : 'NODE_ACTORS' };
3970
4337
  }
3971
- if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
3972
- const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
4338
+ if (whenEmpty === 'INITIATOR_MANAGER') {
4339
+ const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node, exclusions.memberIds);
3973
4340
  if (managerCandidates.candidates.length > 0) {
3974
4341
  return managerCandidates;
3975
4342
  }
3976
- throw new common_1.ConflictException(hasSubmitter
3977
- ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
3978
- : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
4343
+ throw new common_1.ConflictException(!emptiedByInitiator
4344
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人,且找不到申請人的主管可改派。`
4345
+ : hasSubmitter
4346
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
4347
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
4348
+ }
4349
+ throw new common_1.ConflictException(!emptiedByInitiator
4350
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者(含委派後的代理人),此節點不允許與前面關卡的簽核者為同一人。`
4351
+ : hasSubmitter
4352
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
4353
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
4354
+ }
4355
+ /**
4356
+ * Everyone `node` keeps out of its approvers, per rule: the initiator and
4357
+ * the submitter for `excludeInitiator`, and everyone who actually approved
4358
+ * one of the listed earlier stages for `excludeNodeActors`. Read through
4359
+ * `manager` so an approval written earlier in this transaction counts.
4360
+ */
4361
+ async readNodeExclusions(manager, instance, node) {
4362
+ const initiatorMemberIds = isInitiatorExcluded(node)
4363
+ ? readInitiatorAndSubmitter(instance)
4364
+ : [];
4365
+ const nodeActorMemberIds = isNodeActorsExcluded(node)
4366
+ ? await this.readExcludedNodeActorMemberIds(manager, instance, readExcludedNodeActorNodeIds(node))
4367
+ : [];
4368
+ return {
4369
+ initiatorMemberIds,
4370
+ memberIds: uniqueTexts([...initiatorMemberIds, ...nodeActorMemberIds]),
4371
+ nodeActorMemberIds,
4372
+ };
4373
+ }
4374
+ /**
4375
+ * The people who approved any of `nodeIds`, each listed node read on its own
4376
+ * with the `NODE_ACTOR` rules ({@link readNodeActorMemberIds}) and the
4377
+ * results combined. A source reads only the most recent of its listed nodes;
4378
+ * an exclusion must cover every listed node, so it asks one node at a time.
4379
+ *
4380
+ * There is no round boundary here: a node's newest batch is what counts. The
4381
+ * publish lint makes that the right batch, since every listed node runs and
4382
+ * is signed by a person on every path to this node (a return to the start
4383
+ * runs it again; a return to a stage below it leaves its earlier approval
4384
+ * standing, and the exclusion with it).
4385
+ */
4386
+ async readExcludedNodeActorMemberIds(manager, instance, nodeIds) {
4387
+ const groups = await Promise.all(nodeIds.map(async (nodeId) => {
4388
+ const approvals = await this.readNodeActorApprovals(manager, instance, [
4389
+ nodeId,
4390
+ ]);
4391
+ const principalMemberIds = await this.readApprovalPrincipalMemberIds(manager, approvals);
4392
+ return [
4393
+ ...approvals.map((decision) => decision.decidedByMemberId),
4394
+ ...principalMemberIds,
4395
+ ];
4396
+ }));
4397
+ return uniqueTexts(groups.flat());
4398
+ }
4399
+ /**
4400
+ * Whether `targetMemberId` may not take a seat on `node` under
4401
+ * `excludeNodeActors`, checked the way task creation checks a candidate: the
4402
+ * target is one of the excluded members, or one end of a delegation between
4403
+ * the target and an excluded member is on the path. A delegation is read in
4404
+ * both directions, since the target may be the delegate of an excluded
4405
+ * member, or delegate an excluded member's seat onward.
4406
+ */
4407
+ async isExcludedNodeActorTarget(manager, instance, node, targetMemberId) {
4408
+ const excludedMemberIds = await this.readExcludedNodeActorMemberIds(manager, instance, readExcludedNodeActorNodeIds(node));
4409
+ if (excludedMemberIds.includes(targetMemberId)) {
4410
+ return true;
3979
4411
  }
3980
- throw new common_1.ConflictException(hasSubmitter
3981
- ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
3982
- : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
4412
+ if (excludedMemberIds.length === 0) {
4413
+ return false;
4414
+ }
4415
+ const context = this.buildDelegationContext(instance, node.id);
4416
+ const gradeGated = isOrgManagerChainNode(node);
4417
+ const ownDelegation = await this.resolveDelegation(manager, targetMemberId, context, gradeGated);
4418
+ if (isExcludedMemberOnCandidatePath({
4419
+ delegationChain: ownDelegation.delegationChain,
4420
+ memberId: ownDelegation.finalAssigneeMemberId,
4421
+ originalMemberId: targetMemberId,
4422
+ }, excludedMemberIds)) {
4423
+ return true;
4424
+ }
4425
+ const excludedSeats = await Promise.all(excludedMemberIds.map(async (memberId) => {
4426
+ const delegation = await this.resolveDelegation(manager, memberId, context, gradeGated);
4427
+ return isExcludedMemberOnCandidatePath({
4428
+ delegationChain: delegation.delegationChain,
4429
+ memberId: delegation.finalAssigneeMemberId,
4430
+ originalMemberId: memberId,
4431
+ }, [targetMemberId]);
4432
+ }));
4433
+ return excludedSeats.some((routesToTarget) => routesToTarget);
3983
4434
  }
3984
- async resolveInitiatorManagerReplacements(manager, instance, node) {
4435
+ /**
4436
+ * The principals the approvals stand for: a delegate who approved counts as
4437
+ * the member who delegated to them, read from the candidate row each approval
4438
+ * was signed on (see {@link readRuleDelegatorMemberIds}).
4439
+ */
4440
+ async readApprovalPrincipalMemberIds(manager, approvals) {
4441
+ if (approvals.length === 0) {
4442
+ return [];
4443
+ }
4444
+ const candidates = await manager.getRepository(task_candidate_entity_1.TaskCandidateEntity).find({
4445
+ where: {
4446
+ taskId: (0, typeorm_2.In)(uniqueTexts(approvals.map((decision) => decision.taskId))),
4447
+ },
4448
+ });
4449
+ return uniqueTexts(approvals.flatMap((decision) => candidates
4450
+ .filter((candidate) => candidate.taskId === decision.taskId &&
4451
+ candidate.memberId === decision.decidedByMemberId)
4452
+ .flatMap(readRuleDelegatorMemberIds)));
4453
+ }
4454
+ async resolveInitiatorManagerReplacements(manager, instance, node, excludedMemberIds) {
4455
+ // Whoever the node keeps out is also kept out of the replacements: the
4456
+ // initiator and the submitter always (even when only `excludeNodeActors`
4457
+ // asked for this replacement), plus the earlier-stage approvers.
4458
+ const replacementExcludedMemberIds = uniqueTexts([
4459
+ ...readInitiatorAndSubmitter(instance),
4460
+ ...excludedMemberIds,
4461
+ ]);
3985
4462
  // The initiator's own direct manager: the closest org unit only. The
3986
4463
  // initiator himself (a unit head) and the member who submitted for him are
3987
4464
  // skipped before the tier is chosen, so resolution falls through to the
3988
4465
  // next tier instead of ending on either of them.
3989
- const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, readInitiatorAndSubmitter(instance));
4466
+ const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, replacementExcludedMemberIds);
3990
4467
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
3991
4468
  memberId,
3992
4469
  sourceType: 'EXCLUDE_INITIATOR_MANAGER',
3993
4470
  })), isOrgManagerChainNode(node));
3994
4471
  return {
3995
- candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
3996
- skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
4472
+ candidates: selectNodeCandidates(delegatedCandidates, true, replacementExcludedMemberIds),
4473
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
3997
4474
  };
3998
4475
  }
3999
4476
  buildDelegationContext(instance, nodeId) {
@@ -4107,7 +4584,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4107
4584
  return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
4108
4585
  }
4109
4586
  if (resolver.type === 'ORG_MANAGER_CHAIN') {
4110
- return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds);
4587
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
4111
4588
  }
4112
4589
  if (resolver.type === 'ORG_MANAGER') {
4113
4590
  return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
@@ -4212,6 +4689,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4212
4689
  * tasks and approvals the SLA timeout made do not count.
4213
4690
  */
4214
4691
  async readNodeActorMemberIds(manager, instance, nodeIds) {
4692
+ const approvals = await this.readNodeActorApprovals(manager, instance, nodeIds);
4693
+ return uniqueTexts(approvals.map((decision) => decision.decidedByMemberId));
4694
+ }
4695
+ /**
4696
+ * The real approvals (earliest first) of the most recent batch among
4697
+ * `nodeIds`: the rows {@link readNodeActorMemberIds} reads the approvers from.
4698
+ * Kept as decisions so the exclusion can also reach each decision's task.
4699
+ */
4700
+ async readNodeActorApprovals(manager, instance, nodeIds) {
4215
4701
  const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
4216
4702
  where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
4217
4703
  });
@@ -4242,10 +4728,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4242
4728
  const automaticDecisionIds = new Set(decidedLogs
4243
4729
  .filter((log) => log.payload['automatic'] === SLA_AUTO_APPROVE_MARK)
4244
4730
  .map((log) => log.payload['decisionId']));
4245
- return uniqueTexts([...decisions]
4731
+ return [...decisions]
4246
4732
  .filter((decision) => !automaticDecisionIds.has(decision.id))
4247
- .sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime())
4248
- .map((decision) => decision.decidedByMemberId));
4733
+ .sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime());
4249
4734
  }
4250
4735
  async resolvePositionCandidates(manager, positionId, label, subject) {
4251
4736
  const memberIds = await this.readActivePositionMemberIds(manager, positionId);
@@ -4284,16 +4769,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4284
4769
  return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
4285
4770
  }
4286
4771
  /**
4287
- * Walks up the initiator's manager chain, nearest manager first, until a
4288
- * manager whose position level reaches `minPositionLevel` is added. The
4289
- * list is returned nearest manager first. Members in `skippedMemberIds` are
4290
- * walked past without joining the chain or counting as the stopping point.
4772
+ * Walks up a manager chain, nearest manager first, until a manager whose
4773
+ * position level reaches `minPositionLevel` is added. The chain starts at
4774
+ * the initiator unless `startFrom` names someone else, and the start person
4775
+ * is never on it: the walk begins at their direct manager. The list is
4776
+ * returned nearest manager first. Members in `skippedMemberIds` are walked
4777
+ * past without joining the chain or counting as the stopping point, and so
4778
+ * are managers below `startPositionLevel`, until the first one at or above
4779
+ * it starts the chain.
4291
4780
  */
4292
- async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
4781
+ async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds) {
4782
+ const { fallback, minPositionLevel } = resolver;
4783
+ const startPositionLevel = (0, workflow_1.readOrgManagerChainStartPositionLevel)(resolver);
4784
+ const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject);
4785
+ // Nobody to climb from: the same "no manager" path as a chain whose walk
4786
+ // finds nobody, so the fallback decides.
4787
+ if (start.memberId === null) {
4788
+ return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, start.missingReason);
4789
+ }
4293
4790
  const date = this.readOrganizationToday();
4294
- const visitedMemberIds = new Set([instance.initiatorMemberId]);
4791
+ const visitedMemberIds = new Set([start.memberId]);
4295
4792
  const chain = [];
4296
- let currentMemberId = instance.initiatorMemberId;
4793
+ let currentMemberId = start.memberId;
4297
4794
  let lastAddedLevel = null;
4298
4795
  for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
4299
4796
  // A chain is a single path, so it always uses closest-org-unit semantics
@@ -4326,6 +4823,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4326
4823
  (lastAddedLevel !== null && level <= lastAddedLevel)) {
4327
4824
  continue;
4328
4825
  }
4826
+ // Below the start level a manager is walked past like one without a
4827
+ // position: he neither joins the chain nor raises the bar the next
4828
+ // manager has to clear. Every manager added after the first is above
4829
+ // the last one added, so this only ever holds before the chain starts.
4830
+ if (startPositionLevel !== null && level < startPositionLevel) {
4831
+ continue;
4832
+ }
4329
4833
  chain.push(managerMemberId);
4330
4834
  lastAddedLevel = level;
4331
4835
  if (level >= minPositionLevel) {
@@ -4334,9 +4838,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4334
4838
  }
4335
4839
  // A position fallback can name several holders. They all join the last
4336
4840
  // tier, so they all have to sign, the same as every other chain member.
4337
- const fallbackMemberIds = await this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
4841
+ const fallbackMemberIds = await this.resolveFallbackAssignees(manager, instance, fallback, label, subject, chain.length === 0 && startPositionLevel !== null
4842
+ ? `${start.description}沿主管鏈找不到職等達 ${startPositionLevel} 的主管(起始職等 ${startPositionLevel}、簽到職等 ${minPositionLevel})`
4843
+ : `${start.description}沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
4338
4844
  return uniqueTexts([...chain, ...fallbackMemberIds]);
4339
4845
  }
4846
+ /**
4847
+ * Whose manager an `ORG_MANAGER_CHAIN` starts from. `memberId` is `null`
4848
+ * when the start names nobody (an empty form field, an earlier stage nobody
4849
+ * approved), with `missingReason` saying why; `description` is the lead-in
4850
+ * of the "no manager reaches the level" reason, empty for the initiator so
4851
+ * a chain without a start keeps today's wording.
4852
+ *
4853
+ * A start that picks more than one person is refused rather than guessed,
4854
+ * the same as a manager rule that names several managers at one priority.
4855
+ */
4856
+ async resolveOrgManagerChainStart(manager, instance, resolver, label, subject) {
4857
+ if ((0, workflow_1.isOrgManagerChainStartedFromInitiator)(resolver)) {
4858
+ return {
4859
+ description: '',
4860
+ memberId: instance.initiatorMemberId,
4861
+ missingReason: '',
4862
+ };
4863
+ }
4864
+ // `INITIATOR` (and an absent start) was answered above.
4865
+ const startFrom = resolver.startFrom;
4866
+ const unsupportedType = (0, workflow_1.readUnsupportedOrgManagerChainStartType)(startFrom);
4867
+ const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
4868
+ // A template saved by a newer engine can carry a start this engine does
4869
+ // not know. Refuse it by name instead of climbing from the initiator,
4870
+ // which would route the task to the wrong people.
4871
+ if (unsupportedType !== null) {
4872
+ throw new common_1.ConflictException(`${label} ${failure}:此引擎版本不支援主管鏈起點「${unsupportedType}」。請升級 BPMCore 後再發佈或執行此流程。`);
4873
+ }
4874
+ // A notify node has no earlier stage and no form pick the publish lint
4875
+ // would have checked; one that reaches here anyway is refused.
4876
+ if (subject === '通知對象') {
4877
+ throw new common_1.ConflictException(`${label} ${failure}:知會節點的主管鏈只能從發起人開始。`);
4878
+ }
4879
+ if (startFrom.type === 'FORM_MEMBER') {
4880
+ const source = `依表單欄位「${startFrom.formPath}」`;
4881
+ const pickedMemberIds = findMemberIdsInValue(readValueAtPath(instance.formData, startFrom.formPath));
4882
+ // Picking one silently would route the document by accident.
4883
+ if (pickedMemberIds.length > 1) {
4884
+ throw new common_1.ConflictException(`${label} ${source}只能選一位人員`);
4885
+ }
4886
+ return {
4887
+ description: `從表單欄位「${startFrom.formPath}」所選人員起算,`,
4888
+ memberId: pickedMemberIds[0] ?? null,
4889
+ missingReason: `表單欄位「${startFrom.formPath}」沒有選人員`,
4890
+ };
4891
+ }
4892
+ const nodeNames = readNodeActorNodeNames(instance, startFrom.nodeIds);
4893
+ const actorMemberIds = await this.readNodeActorMemberIds(manager, instance, startFrom.nodeIds);
4894
+ if (actorMemberIds.length > 1) {
4895
+ throw new common_1.ConflictException(`${label} 無法沿主管鏈簽核:${nodeNames}有多位實際簽核人(${actorMemberIds.join('、')}),無法決定從誰的主管開始。請改選只有一位簽核人的關卡。`);
4896
+ }
4897
+ return {
4898
+ description: `從${nodeNames}的簽核者起算,`,
4899
+ memberId: actorMemberIds[0] ?? null,
4900
+ missingReason: `${nodeNames}沒有實際同意的簽核紀錄`,
4901
+ };
4902
+ }
4340
4903
  /** Highest position level over the member's active memberships, or `null`. */
4341
4904
  async readMemberPositionLevel(manager, memberId, date) {
4342
4905
  return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
@@ -4647,8 +5210,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4647
5210
  }
4648
5211
  if (action.type === 'SET_FORM_FIELD') {
4649
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) });
4650
- assertServiceTaskTextValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
5213
+ assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
4651
5214
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
5215
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
4652
5216
  // A service task runs inside this transaction, so it cannot call a host
4653
5217
  // provider to re-resolve the label. Rather than leave the previous
4654
5218
  // option snapshot describing a value that no longer exists, drop it: the
@@ -5027,6 +5591,8 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5027
5591
  tslib_1.__param(19, (0, common_1.Optional)()),
5028
5592
  tslib_1.__param(19, (0, common_1.Inject)(bpm_root_options_1.BPM_ROOT_OPTIONS)),
5029
5593
  tslib_1.__param(20, (0, common_1.Optional)()),
5594
+ tslib_1.__param(21, (0, common_1.Optional)()),
5595
+ tslib_1.__param(21, (0, common_1.Inject)(initiator_custom_fields_resolver_token_1.BPM_INITIATOR_CUSTOM_FIELDS_RESOLVER)),
5030
5596
  tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
5031
5597
  typeorm_2.Repository,
5032
5598
  typeorm_2.Repository,
@@ -5043,7 +5609,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5043
5609
  delegation_service_1.DelegationService,
5044
5610
  notification_service_1.NotificationService,
5045
5611
  signature_service_1.SignatureService,
5046
- sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService])
5612
+ sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object])
5047
5613
  ], WorkflowEngineService);
5048
5614
  function parseJsonObject(value, label) {
5049
5615
  try {
@@ -5066,6 +5632,74 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5066
5632
  memberId,
5067
5633
  };
5068
5634
  }
5635
+ function cloneJsonObject(value) {
5636
+ return JSON.parse(JSON.stringify(value));
5637
+ }
5638
+ /**
5639
+ * What a host's initiator `customFields` resolver returned, as the object the
5640
+ * snapshot keeps: a plain object that survives JSON, which is also how it is
5641
+ * stored, so the policy evaluated at submit and the conditions evaluated later
5642
+ * read the same values. Anything else is a bug in the host's resolver; the
5643
+ * caller fails the submission with it rather than replacing it by `{}`, and
5644
+ * `problem` is for the log only.
5645
+ *
5646
+ * The check runs on the round-tripped value too, because an object's `toJSON`
5647
+ * can turn it into something that is not an object. A key whose value is
5648
+ * `undefined` does not survive the round trip either, so it is not in the
5649
+ * stored snapshot.
5650
+ */
5651
+ function readResolvedInitiatorCustomFields(value) {
5652
+ if (!isPlainObjectValue(value)) {
5653
+ return { ok: false, problem: 'not a plain object' };
5654
+ }
5655
+ const roundTripped = (() => {
5656
+ try {
5657
+ return JSON.parse(JSON.stringify(value));
5658
+ }
5659
+ catch {
5660
+ return undefined;
5661
+ }
5662
+ })();
5663
+ if (roundTripped === undefined) {
5664
+ return { ok: false, problem: 'not JSON-serializable' };
5665
+ }
5666
+ if (!isPlainObjectValue(roundTripped)) {
5667
+ return { ok: false, problem: 'not a plain object once serialized' };
5668
+ }
5669
+ return { ok: true, value: roundTripped };
5670
+ }
5671
+ /**
5672
+ * Waits for `promise` at most `timeoutMs`. The timer is cleared as soon as
5673
+ * either side wins. Once the time has run out, whatever `promise` settles with
5674
+ * later goes to `onLateSettle` and nowhere else, so a late rejection is
5675
+ * handled rather than left to surface as an unhandled rejection.
5676
+ */
5677
+ function settleWithin(promise, timeoutMs, onLateSettle) {
5678
+ return new Promise((resolve) => {
5679
+ const state = { timedOut: false };
5680
+ const timer = setTimeout(() => {
5681
+ state.timedOut = true;
5682
+ resolve({ status: 'timeout' });
5683
+ }, timeoutMs);
5684
+ const settle = (outcome) => {
5685
+ if (state.timedOut) {
5686
+ onLateSettle(outcome);
5687
+ return;
5688
+ }
5689
+ clearTimeout(timer);
5690
+ resolve(outcome);
5691
+ };
5692
+ promise.then((value) => settle({ status: 'fulfilled', value }), (reason) => settle({ status: 'rejected', reason }));
5693
+ });
5694
+ }
5695
+ function describeRejection(reason) {
5696
+ return reason instanceof Error
5697
+ ? `${reason.name}: ${reason.message}`
5698
+ : String(reason);
5699
+ }
5700
+ function isPlainObjectValue(value) {
5701
+ return Object.prototype.toString.call(value) === '[object Object]';
5702
+ }
5069
5703
  function createWorkflowAuthContext(memberId) {
5070
5704
  return {
5071
5705
  memberId,
@@ -5108,32 +5742,87 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
5108
5742
  return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
5109
5743
  }
5110
5744
  /**
5111
- * Holds a `SET_FORM_FIELD` write to the length and format rules the form
5112
- * applies to a filler, so a service task cannot store text the field would
5113
- * have refused. Breaking them throws, which rolls back the submit or decision
5745
+ * Refuses form data whose filterable fields hold a value of the wrong type
5746
+ * (ADR 25 §6). The list query reads these values with type guards, so a wrong
5747
+ * value would silently fall out of every filter; refusing the write is what
5748
+ * keeps filter results trustworthy.
5749
+ */
5750
+ function assertFilterableFormDataValues(schema, formData, serviceNodeLabel) {
5751
+ const problems = (0, bpm_core_shared_1.readFilterableValueProblems)((0, bpm_core_shared_1.readFilterableFieldPaths)(schema), formData);
5752
+ if (problems.length === 0) {
5753
+ return;
5754
+ }
5755
+ const issues = problems.map((problem) => (0, form_data_issue_1.createFormDataIssue)(readFilterableProblemLocation(schema, problem), 'TYPE', `必須是${problem.expected}`));
5756
+ const details = (0, form_data_issue_1.readFormDataIssueMessage)(issues);
5757
+ throw (0, form_data_issue_1.createFormDataBadRequest)(serviceNodeLabel
5758
+ ? `系統節點「${serviceNodeLabel}」要寫入的表單值不符合可篩選欄位的型別(${details}),這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`
5759
+ : `可篩選欄位的值型別不正確:${details}`, issues);
5760
+ }
5761
+ /** Where a filterable problem sits: a field, a table row, or one cell of a row. */
5762
+ function readFilterableProblemLocation(schema, problem) {
5763
+ const [tableKey, columnKey] = problem.path.split('.');
5764
+ const table = schema?.fields.find((field) => field.fieldKey === tableKey && (0, form_1.isTableFieldDefinition)(field));
5765
+ const column = table && (0, form_1.isTableFieldDefinition)(table) && columnKey !== undefined
5766
+ ? table.columns.find((candidate) => candidate.fieldKey === columnKey)
5767
+ : undefined;
5768
+ if (table && (0, form_1.isTableFieldDefinition)(table)) {
5769
+ if (problem.rowIndex === null) {
5770
+ return (0, form_data_issue_1.readFieldIssueLocation)(table);
5771
+ }
5772
+ const rowLocation = (0, form_data_issue_1.readRowIssueLocation)(table, problem.rowIndex);
5773
+ return column ? (0, form_data_issue_1.readCellIssueLocation)(rowLocation, column) : rowLocation;
5774
+ }
5775
+ const field = schema?.fields.find((candidate) => candidate.fieldKey === problem.path);
5776
+ if (field) {
5777
+ return (0, form_data_issue_1.readFieldIssueLocation)(field);
5778
+ }
5779
+ // Not in the schema (the check and this lookup read the same schema, so
5780
+ // this is defensive): still address it the way FormDataIssue.path promises,
5781
+ // `table[i].col` for a row value rather than the dotted filter path.
5782
+ const row = `${tableKey}[${problem.rowIndex}]`;
5783
+ const path = problem.rowIndex === null
5784
+ ? problem.path
5785
+ : columnKey === undefined
5786
+ ? row
5787
+ : `${row}.${columnKey}`;
5788
+ return { path, ref: `欄位「${path}」` };
5789
+ }
5790
+ /**
5791
+ * Holds a `SET_FORM_FIELD` write to the rules the form applies to a filler —
5792
+ * text length and format, and the shape of a member or organization-unit value
5793
+ * — so a service task cannot store what the field would have refused. A member
5794
+ * value matters past the write: a `DYNAMIC_FORM` resolver or a manager chain
5795
+ * starting from the field reads it later, and an array on a single-member
5796
+ * field would make that node refuse inside a later decision, which then
5797
+ * cannot commit. Breaking them throws, which rolls back the submit or decision
5114
5798
  * that reached the node: the value is never half-written, and the node's value
5115
5799
  * has to be corrected. Only a top-level field can carry those rules — the
5116
5800
  * writer ignores an indexed path, and a nested record path names no field —
5117
5801
  * and an unreadable snapshot is left alone, as in
5118
5802
  * `readDataSourceFieldKeyAtPath`.
5119
5803
  */
5120
- function assertServiceTaskTextValue(snapshot, fieldPath, value, nodeLabel) {
5804
+ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
5121
5805
  const schema = snapshot?.schema;
5122
5806
  const segments = normalizeFormFieldPath(fieldPath);
5123
5807
  if (!isFormDefinitionSchema(schema) || segments.length !== 1) {
5124
5808
  return;
5125
5809
  }
5126
5810
  const field = schema.fields.find((candidate) => candidate.fieldKey === segments[0]);
5811
+ const written = { [segments[0]]: value };
5127
5812
  const errors = field
5128
- ? readSubmittedTextValueErrors(field, value, {
5129
- testPattern: (0, text_field_pattern_test_1.createTimedTextFieldPatternTest)(),
5130
- })
5813
+ ? [
5814
+ ...readSubmittedTextValueErrors(field, value, {
5815
+ testPattern: (0, text_field_pattern_test_1.createTimedTextFieldPatternTest)(),
5816
+ }),
5817
+ ...(0, form_member_value_validator_1.readSubmittedMemberValueErrors)([field], written),
5818
+ ...(0, form_org_unit_value_validator_1.readSubmittedOrgUnitValueErrors)([field], written),
5819
+ ]
5131
5820
  : [];
5132
5821
  if (!field || errors.length === 0) {
5133
5822
  return;
5134
5823
  }
5135
- throw new common_1.BadRequestException(`系統節點「${nodeLabel}」要寫入欄位「${field.label || field.fieldKey}」的值不符合該欄位的規則` +
5136
- `(${errors.join('; ')}),這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`);
5824
+ throw (0, form_data_issue_1.createFormDataBadRequest)(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
5825
+ `這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`, errors);
5137
5826
  }
5138
5827
  function readSnapshotWithoutField(snapshots, fieldKey) {
5139
5828
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
@@ -5559,6 +6248,24 @@ function isOrderedSequentialTask(task, candidates) {
5559
6248
  function isInitiatorExcluded(node) {
5560
6249
  return Boolean(node.data.excludeInitiator);
5561
6250
  }
6251
+ // Same rule for the earlier-stage exclusion: an explicit `null` is absent.
6252
+ function isNodeActorsExcluded(node) {
6253
+ return Boolean(node.data.excludeNodeActors);
6254
+ }
6255
+ // Whether the node keeps anyone out of its approvers at all.
6256
+ function hasSeatExclusion(node) {
6257
+ return isInitiatorExcluded(node) || isNodeActorsExcluded(node);
6258
+ }
6259
+ function readExcludedNodeActorNodeIds(node) {
6260
+ return node.data.excludeNodeActors?.nodeIds ?? [];
6261
+ }
6262
+ // With both rules set the initiator rule runs first, so the earlier-stage rule
6263
+ // is the one that emptied the list only when the initiator rule alone left
6264
+ // somebody. Without `excludeInitiator` the earlier-stage rule is always it.
6265
+ function isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions) {
6266
+ return (isInitiatorExcluded(node) &&
6267
+ selectNodeCandidates(delegatedCandidates, true, exclusions.initiatorMemberIds).length === 0);
6268
+ }
5562
6269
  // Everyone a node that excludes the initiator keeps out of its approvers: the
5563
6270
  // applicant and, when someone filed the document for them, that person too
5564
6271
  // (authority rule: the creator must not approve what they created).
@@ -5600,6 +6307,27 @@ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, exclud
5600
6307
  return [skipped];
5601
6308
  });
5602
6309
  }
6310
+ // The members a rule delegation stands for on a candidate row a person
6311
+ // approved: the delegators of the trailing run of rule steps (`ruleId` set).
6312
+ // A transfer step carries no rule and is not a delegation, so when one sits on
6313
+ // the chain the original assignee is not a delegator and is left out. A chain
6314
+ // of rule steps only also names the original assignee.
6315
+ function readRuleDelegatorMemberIds(candidate) {
6316
+ const steps = readDelegationSteps(candidate.delegationChain);
6317
+ const trailingRuleCount = [...steps]
6318
+ .reverse()
6319
+ .findIndex((step) => step.ruleId === null);
6320
+ const ruleSteps = trailingRuleCount === -1
6321
+ ? steps
6322
+ : steps.slice(steps.length - trailingRuleCount);
6323
+ if (ruleSteps.length === 0) {
6324
+ return [];
6325
+ }
6326
+ return [
6327
+ ...(trailingRuleCount === -1 ? [candidate.originalMemberId] : []),
6328
+ ...ruleSteps.map((step) => step.from),
6329
+ ];
6330
+ }
5603
6331
  // A delegate acting for the initiator is still the initiator approving their
5604
6332
  // own document, so the initiator anywhere on the delegation path (the
5605
6333
  // pre-delegation member, any hop, or the final assignee) drops the candidate.
@@ -6175,8 +6903,43 @@ function validateSubmittedFormData(schema, formData, options = {}) {
6175
6903
  .filter((field) => field.hidden === true)
6176
6904
  .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, testPattern, true)),
6177
6905
  ],
6906
+ // Every `file_upload` field, shown or not: the submit binds the files of
6907
+ // each one to the case, so a cap only the visible ones obey could be
6908
+ // stepped around by sending files in a hidden field.
6909
+ readFileUploadErrors: () => readSubmittedFileUploadErrors(schema.fields, formData),
6178
6910
  });
6179
6911
  }
6912
+ /**
6913
+ * A `file_upload` field holding more files than its `maxFiles`. The renderer
6914
+ * stops at the cap, but nothing kept a caller that writes the form data itself
6915
+ * from going past it. On a resubmit the value lists the files kept from the
6916
+ * returned case and the ones uploaded since, so both count, as they do in the
6917
+ * renderer. Each distinct non-empty id in the value is one file: the binding
6918
+ * attaches an id once however often it repeats, so a repeat is not a second
6919
+ * file. A field without `maxFiles` takes any number, as it does in the
6920
+ * renderer.
6921
+ */
6922
+ function readSubmittedFileUploadErrors(fields, formData) {
6923
+ return fields.flatMap((field) => {
6924
+ if (field.type !== 'file_upload' || typeof field.maxFiles !== 'number') {
6925
+ return [];
6926
+ }
6927
+ const fileCount = countFileUploadValueIds(readRowCellValue(formData, field.fieldKey));
6928
+ return fileCount > field.maxFiles
6929
+ ? [
6930
+ (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readFieldIssueLocation)(field), 'MAX_FILES', `最多 ${field.maxFiles} 個檔案`, field.maxFiles),
6931
+ ]
6932
+ : [];
6933
+ });
6934
+ }
6935
+ /**
6936
+ * The distinct files a `file_upload` value names. The ids are read by the same
6937
+ * helper the attachment binding uses, and collapsed the way the binding
6938
+ * collapses them (one attachment per id).
6939
+ */
6940
+ function countFileUploadValueIds(value) {
6941
+ return new Set((0, file_upload_value_ids_1.readFileUploadValueIds)(value)).size;
6942
+ }
6180
6943
  /**
6181
6944
  * What an approval at a node has to leave behind: every field the node lets the
6182
6945
  * approver fill in is well-formed (a table is an array of well-formed rows with
@@ -6207,30 +6970,31 @@ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys
6207
6970
  * The shared tail of submit and approver-fill validation: a missing required
6208
6971
  * field wins over a text value breaking its length or format rule, which wins
6209
6972
  * over a malformed member value, which wins over a malformed org unit value,
6210
- * which wins over a table problem, and the later checks are only evaluated
6211
- * when the earlier ones pass.
6973
+ * which wins over a table problem, which wins over a `file_upload` field
6974
+ * holding more files than it allows, and the later checks are only evaluated
6975
+ * when the earlier ones pass. The refusal carries every issue of the winning
6976
+ * check. Its message is those issues' sentences joined, except for missing
6977
+ * required fields, which keep the one-line summary `缺少必填欄位:A, B` that
6978
+ * submitters read; each issue there still says `欄位「A」為必填`. A client
6979
+ * that places errors on fields reads the issues, not the message.
6212
6980
  */
6213
- function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
6981
+ function throwFirstFormDataFieldError({ missingFields, readFileUploadErrors = () => [], readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
6214
6982
  if (missingFields.length > 0) {
6215
- throw new common_1.BadRequestException(`Form data is missing required fields: ${missingFields
6983
+ throw (0, form_data_issue_1.createFormDataBadRequest)(`缺少必填欄位:${missingFields
6216
6984
  .map((field) => field.label || field.fieldKey)
6217
- .join(', ')}`);
6218
- }
6219
- const textErrors = readTextErrors();
6220
- if (textErrors.length > 0) {
6221
- throw new common_1.BadRequestException(textErrors.join('; '));
6222
- }
6223
- const memberErrors = readMemberErrors();
6224
- if (memberErrors.length > 0) {
6225
- throw new common_1.BadRequestException(memberErrors.join('; '));
6985
+ .join(', ')}`, missingFields.map((field) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readFieldIssueLocation)(field), 'REQUIRED', '為必填')));
6226
6986
  }
6227
- const orgUnitErrors = readOrgUnitErrors();
6228
- if (orgUnitErrors.length > 0) {
6229
- throw new common_1.BadRequestException(orgUnitErrors.join('; '));
6230
- }
6231
- const tableErrors = readTableErrors();
6232
- if (tableErrors.length > 0) {
6233
- throw new common_1.BadRequestException(tableErrors.join('; '));
6987
+ // Each check runs only when every earlier one passed, so a later check's
6988
+ // work is never done for a request that is already refused.
6989
+ const winning = [
6990
+ readTextErrors,
6991
+ readMemberErrors,
6992
+ readOrgUnitErrors,
6993
+ readTableErrors,
6994
+ readFileUploadErrors,
6995
+ ].reduce((found, check) => (found.length > 0 ? found : check()), []);
6996
+ if (winning.length > 0) {
6997
+ throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)(winning), winning);
6234
6998
  }
6235
6999
  }
6236
7000
  /**
@@ -6239,64 +7003,73 @@ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrg
6239
7003
  * the flat text fields share. `minimum` / `maximum` stay a frontend hint.
6240
7004
  */
6241
7005
  function validateSubmittedTableValue(field, value, required, testPattern, shapeOnly = false) {
6242
- const path = `formData.${field.fieldKey}`;
7006
+ const location = (0, form_data_issue_1.readFieldIssueLocation)(field);
6243
7007
  if (typeof value === 'undefined' || value === null) {
6244
- return shapeOnly
6245
- ? []
6246
- : required || readTableMinRows(field) > 0
6247
- ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
6248
- : [];
7008
+ if (shapeOnly) {
7009
+ return [];
7010
+ }
7011
+ if (required || readTableMinRows(field) > 0) {
7012
+ const rows = readTableRequiredRowCount(field);
7013
+ return [
7014
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${rows} 列`, rows),
7015
+ ];
7016
+ }
7017
+ return [];
6249
7018
  }
6250
7019
  if (!Array.isArray(value)) {
6251
- return [`${path} must be an array of rows`];
7020
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是表格列的清單')];
6252
7021
  }
6253
7022
  // Too many rows is refused before any row is checked: the rows a submitter
6254
7023
  // sends would otherwise decide how many cells the text patterns run on.
6255
7024
  const maxRows = readTableMaxRows(field);
6256
7025
  if (value.length > maxRows) {
6257
- return [`${path} must include at most ${maxRows} row(s)`];
7026
+ return [
7027
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MAX_ROWS', `最多 ${maxRows} 列`, maxRows),
7028
+ ];
6258
7029
  }
6259
- const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, testPattern, shapeOnly));
7030
+ const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, (0, form_data_issue_1.readRowIssueLocation)(field, index), testPattern, shapeOnly));
6260
7031
  // A malformed row cannot be counted against the minimum meaningfully; report
6261
7032
  // the shape problem first so the submitter fixes the real cause.
6262
7033
  if (rowErrors.length > 0) {
6263
7034
  return rowErrors;
6264
7035
  }
6265
- return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
7036
+ return validateSubmittedTableRowCount(field, value.length, required, location, shapeOnly);
6266
7037
  }
6267
7038
  /** The minimum only: the maximum is refused before any row is checked. */
6268
- function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
7039
+ function validateSubmittedTableRowCount(field, rowCount, required, location, shapeOnly = false) {
6269
7040
  const minRows = shapeOnly
6270
7041
  ? 0
6271
7042
  : required
6272
7043
  ? Math.max(readTableMinRows(field), 1)
6273
7044
  : readTableMinRows(field);
6274
7045
  return rowCount < minRows
6275
- ? [`${path} must include at least ${minRows} row(s)`]
7046
+ ? [
7047
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${minRows} 列`, minRows),
7048
+ ]
6276
7049
  : [];
6277
7050
  }
6278
- function validateSubmittedTableRow(field, row, path, testPattern, shapeOnly = false) {
7051
+ function validateSubmittedTableRow(field, row, location, testPattern, shapeOnly = false) {
6279
7052
  if (!isRecord(row)) {
6280
- return [`${path} must be an object`];
7053
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是物件')];
6281
7054
  }
6282
7055
  const columnsByKey = new Map(field.columns.map((column) => [column.fieldKey, column]));
6283
7056
  const unknownKeyErrors = Object.keys(row)
6284
7057
  .filter((columnKey) => !columnsByKey.has(columnKey))
6285
- .map((columnKey) => `${path}.${columnKey} is not a table column`);
7058
+ .map((columnKey) => (0, form_data_issue_1.createFormDataIssue)(location, 'UNKNOWN_COLUMN', `含有不屬於此表格的欄位「${columnKey}」`));
6286
7059
  const cellErrors = Object.entries(row)
6287
7060
  .filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
6288
- .map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
7061
+ .map(([columnKey]) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, columnsByKey.get(columnKey) ?? { fieldKey: columnKey }), 'TYPE', '不是有效的值'));
6289
7062
  const requiredErrors = shapeOnly
6290
7063
  ? []
6291
7064
  : field.columns
6292
7065
  .filter((column) => isSubmittedTableColumnRequired(column) &&
6293
7066
  !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
6294
- .map((column) => `${path}.${column.fieldKey} is required`);
7067
+ .map((column) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, column), 'REQUIRED', '為必填'));
6295
7068
  // A shape-only pass covers a table nobody fills in, so the filler-facing
6296
7069
  // rules do not apply to it, the same as the required check above; only a
6297
7070
  // cell of the wrong type is still refused.
6298
7071
  const textErrors = field.columns.flatMap((column) => readSubmittedTextValueErrors(column, readRowCellValue(row, column.fieldKey), {
6299
- path: `${path}.${column.fieldKey}`,
7072
+ location: (0, form_data_issue_1.readCellIssueLocation)(location, column),
6300
7073
  shapeOnly,
6301
7074
  testPattern,
6302
7075
  }));
@@ -6313,7 +7086,7 @@ function validateSubmittedTableRow(field, row, path, testPattern, shapeOnly = fa
6313
7086
  * budget is spent, a value is reported as not checked rather than as a
6314
7087
  * format error.
6315
7088
  */
6316
- function readSubmittedTextValueErrors(field, value, { path = `formData.${field.fieldKey}`, shapeOnly = false, testPattern, }) {
7089
+ function readSubmittedTextValueErrors(field, value, { location = (0, form_data_issue_1.readFieldIssueLocation)(field), shapeOnly = false, testPattern, }) {
6317
7090
  if (field.type !== 'text' && field.type !== 'textarea') {
6318
7091
  return [];
6319
7092
  }
@@ -6332,21 +7105,27 @@ function readSubmittedTextValueErrors(field, value, { path = `formData.${field.f
6332
7105
  };
6333
7106
  const issue = readIssue();
6334
7107
  if (issue === 'timeout') {
6335
- return [`${path} could not be checked against the required format in time`];
7108
+ return [
7109
+ (0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN_TIMEOUT', '無法在時限內完成格式檢查'),
7110
+ ];
6336
7111
  }
6337
7112
  if (!issue || (shapeOnly && issue.kind !== 'type')) {
6338
7113
  return [];
6339
7114
  }
6340
7115
  if (issue.kind === 'type') {
6341
- return [`${path} must be text`];
7116
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是文字')];
6342
7117
  }
6343
7118
  if (issue.kind === 'minLength') {
6344
- return [`${path} must be at least ${issue.limit} characters`];
7119
+ return [
7120
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_LENGTH', `至少需要 ${issue.limit} 個字`, issue.limit),
7121
+ ];
6345
7122
  }
6346
7123
  if (issue.kind === 'maxLength') {
6347
- return [`${path} must be at most ${issue.limit} characters`];
7124
+ return [
7125
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MAX_LENGTH', `最多 ${issue.limit} 個字`, issue.limit),
7126
+ ];
6348
7127
  }
6349
- return [`${path} does not match the required format`];
7128
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN', '格式不正確')];
6350
7129
  }
6351
7130
  /**
6352
7131
  * Own-property read. `constructor`, `toString` and friends all satisfy the
@@ -6593,7 +7372,7 @@ function readDryRunPlaceholderAssignee(resolver) {
6593
7372
  return `nodeActorManager:${resolver.nodeIds.join(',')}:${resolver.levelsUp}`;
6594
7373
  }
6595
7374
  return resolver.type === 'ORG_MANAGER_CHAIN'
6596
- ? `orgManagerChain:${resolver.minPositionLevel}`
7375
+ ? (0, workflow_1.formatOrgManagerChainPlaceholder)(resolver)
6597
7376
  : resolver.type;
6598
7377
  }
6599
7378
  function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {