@rytass/bpm-core-nestjs-module 0.21.0 → 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 (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +105 -1
  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-data-source/form-data-source.module.d.ts +8 -0
  15. package/src/lib/form-data-source/form-data-source.module.js +1 -1
  16. package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
  17. package/src/lib/form-data-source/form-data-source.service.js +1 -1
  18. package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
  19. package/src/lib/notification/notification.service.js +6 -0
  20. package/src/lib/notification/notification.service.js.map +1 -1
  21. package/src/lib/organization/organization-time-zone.d.ts +4 -2
  22. package/src/lib/organization/organization-time-zone.js +3 -6
  23. package/src/lib/organization/organization-time-zone.js.map +1 -1
  24. package/src/lib/template/template.service.js +51 -5
  25. package/src/lib/template/template.service.js.map +1 -1
  26. package/src/lib/template/workflow-definition.validator.js +84 -2
  27. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  28. package/src/lib/workflow-engine/index.d.ts +1 -0
  29. package/src/lib/workflow-engine/index.js +1 -0
  30. package/src/lib/workflow-engine/index.js.map +1 -1
  31. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.d.ts +108 -0
  32. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js +9 -0
  33. package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js.map +1 -0
  34. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +1 -0
  35. package/src/lib/workflow-engine/workflow-engine.enums.js +1 -0
  36. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  37. package/src/lib/workflow-engine/workflow-engine.module.d.ts +2 -0
  38. package/src/lib/workflow-engine/workflow-engine.module.js +9 -3
  39. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  40. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +1 -1
  41. package/src/lib/workflow-engine/workflow-engine.queries.js +4 -3
  42. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  43. package/src/lib/workflow-engine/workflow-engine.service.d.ts +131 -14
  44. package/src/lib/workflow-engine/workflow-engine.service.js +654 -88
  45. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -13,6 +13,7 @@ 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");
@@ -56,6 +57,7 @@ const time_budget_1 = require("./time-budget");
56
57
  const expression_budget_1 = require("../condition/expression-budget");
57
58
  const expression_errors_1 = require("../condition/expression-errors");
58
59
  const expression_dialect_1 = require("../condition/expression-dialect");
60
+ const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
59
61
  const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
60
62
  const workflow_token_entity_1 = require("./workflow-token.entity");
61
63
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
@@ -70,6 +72,15 @@ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_ke
70
72
  const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
71
73
  const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
72
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 = '無法取得發起人的自訂欄位(回傳格式不正確),這次申請沒有送出;請聯絡系統管理者';
73
84
  // The dry run checks each expression against the roots its use point may read,
74
85
  // as publish does. Keep these in step with `WORKFLOW_EXPRESSION_ROOT_IDENTIFIERS`
75
86
  // in template.service.ts; P2 moves both to one place.
@@ -122,6 +133,7 @@ const TASK_REASSIGNMENT_MESSAGES = {
122
133
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED]: '接手人已對此任務簽過核',
123
134
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK]: '接手人已在此任務的待簽名單上',
124
135
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW]: '接手人職等不足,不能取代此簽核位置',
136
+ [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR]: '此節點不允許與前面關卡的簽核者為同一人,接手人不能是前面關卡的簽核者',
125
137
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR]: '此節點不允許申請人自簽,接手人不能是申請人',
126
138
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER]: '此節點不允許代送人簽核,接手人不能是代為送出此單的人',
127
139
  [workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE]: '接手人與原簽核人相同',
@@ -177,7 +189,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
177
189
  'workflow.read_all',
178
190
  ]);
179
191
  let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
180
- 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) {
181
193
  this.approvalInstanceRepository = approvalInstanceRepository;
182
194
  this.workflowTokenRepository = workflowTokenRepository;
183
195
  this.taskRepository = taskRepository;
@@ -210,12 +222,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
210
222
  // keeps a host that binds the token from its own global module working —
211
223
  // a module-local default would shadow that binding, silently sending
212
224
  // WEBHOOK service tasks through the built-in `fetch` dispatcher instead of
213
- // the host's signing or queueing one. It also reaches every copy of this
214
- // service, which a provider registered on one module does not.
225
+ // the host's signing or queueing one.
215
226
  this.serviceTaskDispatcher =
216
227
  injectedServiceTaskDispatcher ??
217
228
  rootOptions?.workflowServiceTaskDispatcher ??
218
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;
219
237
  this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
220
238
  }
221
239
  /**
@@ -281,6 +299,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
281
299
  }
282
300
  }
283
301
  async submitApprovalInstance(input, authContext) {
302
+ const submitAuthContext = authContext ?? createWorkflowAuthContext(input.initiatorMemberId);
284
303
  if (input.submittedByMemberId !== undefined &&
285
304
  input.submittedByMemberId !== null &&
286
305
  (typeof input.submittedByMemberId !== 'string' ||
@@ -319,14 +338,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
319
338
  // (ADR 25 §15), which is what makes this schema the one in force.
320
339
  assertFilterableFormDataValues(formDefinitionVersion.schema, formData);
321
340
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
322
- authContext: authContext ?? createWorkflowAuthContext(input.initiatorMemberId),
341
+ authContext: submitAuthContext,
323
342
  formData,
324
343
  revalidateAll: true,
325
344
  schema: formDefinitionVersion.schema,
326
345
  });
327
346
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
328
347
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
329
- : 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
+ });
330
357
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
331
358
  // The policy is written in its version's dialect (ADR 22 §3.3).
332
359
  readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
@@ -642,6 +669,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
642
669
  transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
643
670
  throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
644
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
+ }
645
681
  // Same carve-out: a human picking the target on a manager-chain node
646
682
  // must pick someone at least as senior as the member being replaced.
647
683
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
@@ -665,7 +701,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
665
701
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED &&
666
702
  !decisionComment &&
667
703
  isReturnCommentRequired(instance.workflowSnapshot, task.nodeId)) {
668
- 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('此節點要求同意時填寫意見,請輸入意見後再同意。');
669
716
  }
670
717
  if (filledFormData !== null && task.isAdhoc) {
671
718
  throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
@@ -1089,7 +1136,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1089
1136
  // reached, against that node's own setting. This checks the target
1090
1137
  // against the node being decided now, as an early rejection for the
1091
1138
  // common case; it is not a guarantee about the node it attaches to.
1092
- if (isInitiatorExcluded(node)) {
1139
+ if (hasSeatExclusion(node)) {
1093
1140
  await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
1094
1141
  }
1095
1142
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
@@ -1354,19 +1401,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1354
1401
  return task;
1355
1402
  }
1356
1403
  async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
1357
- 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);
1358
1406
  // Ad-hoc approvers are picked by a person, not walked up a manager chain,
1359
1407
  // so the chain's grade rule does not apply to them.
1360
1408
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
1361
- const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
1409
+ const candidates = selectNodeCandidates(delegatedCandidates, hasSeatExclusion(node), exclusions.memberIds);
1362
1410
  if (candidates.length > 0) {
1363
1411
  return candidates;
1364
1412
  }
1365
- 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)
1366
1419
  ? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
1367
1420
  ? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
1368
1421
  : `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
1369
- : `${label}「${node.data.label}」 did not resolve to any member id`);
1422
+ : `${label}「${node.data.label}」 無法建立任務:指定對象只有前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人。`);
1370
1423
  }
1371
1424
  async spawnCountersignTasksForNode(manager, instance, token, node, now) {
1372
1425
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
@@ -1814,6 +1867,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1814
1867
  }
1815
1868
  const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
1816
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.
1817
1874
  const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
1818
1875
  ? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
1819
1876
  : readDefaultInitiatorMetadataSnapshot(input.initiatorMemberId);
@@ -2012,6 +2069,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2012
2069
  outcome: 'STOPPED',
2013
2070
  };
2014
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
+ }
2015
2084
  const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
2016
2085
  if (memberIds === null) {
2017
2086
  return {
@@ -2394,7 +2463,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2394
2463
  },
2395
2464
  });
2396
2465
  }
2397
- 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) {
2398
2472
  const templates = await this.approvalTemplateRepository.find({
2399
2473
  order: { updatedAt: 'DESC' },
2400
2474
  where: { isActive: true },
@@ -2415,7 +2489,17 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2415
2489
  version.id,
2416
2490
  version,
2417
2491
  ]));
2418
- 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
+ });
2419
2503
  return templates.filter((template) => {
2420
2504
  const currentVersion = template.currentVersionId
2421
2505
  ? (versionById.get(template.currentVersionId) ?? null)
@@ -2433,8 +2517,19 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2433
2517
  return false;
2434
2518
  }
2435
2519
  // Each template is its own operation, so one slow template cannot use
2436
- // up the allowance of the rest of the list.
2437
- 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
+ }
2438
2533
  });
2439
2534
  }
2440
2535
  async listWorkflowTokens(instanceId, scope) {
@@ -2675,6 +2770,112 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2675
2770
  primaryOrgUnitId: primaryMembership?.orgUnitId ?? null,
2676
2771
  };
2677
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
+ }
2678
2879
  async listActivityLogs(instanceId, scope) {
2679
2880
  await this.getApprovalInstance(instanceId, scope);
2680
2881
  return this.activityLogRepository.find({
@@ -2895,10 +3096,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2895
3096
  skippedByEntryCondition: true,
2896
3097
  });
2897
3098
  }
2898
- async skipNodeByInitiatorExclusion(manager, instance, token, node) {
2899
- await this.skipNode(manager, instance, token, node, {
2900
- skippedByInitiatorExclusion: true,
2901
- });
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 });
2902
3103
  }
2903
3104
  async skipNode(manager, instance, token, node, reason) {
2904
3105
  // A NOTIFY node is an asynchronous branch that may not have an outgoing
@@ -3127,11 +3328,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3127
3328
  return;
3128
3329
  }
3129
3330
  const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
3130
- // The initiator was the only approver and the node asks to be skipped in
3131
- // that case: no task, the token moves on. Pending countersign directives
3132
- // stay pending for the next user task that actually gets created.
3133
- if (resolvedCandidates === null) {
3134
- 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);
3135
3336
  return;
3136
3337
  }
3137
3338
  const { candidates, skippedDelegations } = resolvedCandidates;
@@ -3154,7 +3355,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3154
3355
  : workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
3155
3356
  completedAt: null,
3156
3357
  createdAt: now,
3157
- decisionPolicySnapshot: isInitiatorExcluded(node)
3358
+ decisionPolicySnapshot: hasSeatExclusion(node)
3158
3359
  ? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
3159
3360
  : decisionPolicy,
3160
3361
  delegationChain: assignedCandidate?.delegationChain ?? [],
@@ -3252,7 +3453,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3252
3453
  const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
3253
3454
  candidate: nextCandidate,
3254
3455
  candidates,
3255
- excludesInitiator: isInitiatorExcluded(node),
3456
+ excludedMemberIds: (await this.readNodeExclusions(manager, instance, node)).memberIds,
3256
3457
  gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
3257
3458
  instance,
3258
3459
  manager,
@@ -3313,12 +3514,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3313
3514
  * Re-resolves the delegation of a candidate whose turn has come. When the
3314
3515
  * delegate already holds another candidate row on the task, the stored values
3315
3516
  * are kept: (task_id, member_id) is unique, and that member signs from the
3316
- * row they already have. On a node that excludes the initiator the stored
3317
- * values are kept as well when the new delegation path would run through the
3318
- * initiator or the submitter: creation already filtered such paths out, and a
3319
- * 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.
3320
3522
  */
3321
- async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
3523
+ async resolveActivationDelegation({ candidate, candidates, excludedMemberIds, gradeGated, instance, manager, nodeId, }) {
3322
3524
  const stored = {
3323
3525
  delegationChain: candidate.delegationChain,
3324
3526
  memberId: candidate.memberId,
@@ -3327,12 +3529,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3327
3529
  const collides = candidates.some((other) => other.id !== candidate.id &&
3328
3530
  other.memberId === resolution.finalAssigneeMemberId);
3329
3531
  if (collides ||
3330
- (excludesInitiator &&
3331
- isExcludedMemberOnCandidatePath({
3332
- delegationChain: resolution.delegationChain,
3333
- memberId: resolution.finalAssigneeMemberId,
3334
- originalMemberId: candidate.originalMemberId,
3335
- }, readInitiatorAndSubmitter(instance)))) {
3532
+ isExcludedMemberOnCandidatePath({
3533
+ delegationChain: resolution.delegationChain,
3534
+ memberId: resolution.finalAssigneeMemberId,
3535
+ originalMemberId: candidate.originalMemberId,
3536
+ }, excludedMemberIds)) {
3336
3537
  // The refusal happened even though the stored values are kept.
3337
3538
  return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
3338
3539
  }
@@ -3882,6 +4083,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3882
4083
  throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER);
3883
4084
  }
3884
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
+ }
3885
4090
  if (!task.isAdhoc && isOrgManagerChainNode(node)) {
3886
4091
  const requiredGrade = await this.readTransferTargetRequiredGrade(manager, {
3887
4092
  actorMemberId: fromMemberId,
@@ -4094,58 +4299,178 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4094
4299
  });
4095
4300
  }
4096
4301
  /**
4097
- * Resolves who gets the node's task. `null` means the node asked to be
4098
- * skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
4099
- * was the only approver — the sole caller creates no task and advances the
4100
- * 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.
4101
4311
  */
4102
4312
  async resolveRuntimeTaskCandidates(manager, instance, node) {
4103
4313
  const label = `簽核節點「${node.data.label}」`;
4104
- 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);
4105
4316
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
4106
- const excludeInitiator = node.data.excludeInitiator;
4317
+ const hasExclusion = hasSeatExclusion(node);
4107
4318
  const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
4108
- const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
4319
+ const uniqueCandidates = selectNodeCandidates(delegatedCandidates, hasExclusion, exclusions.memberIds);
4109
4320
  if (uniqueCandidates.length > 0) {
4110
4321
  return {
4111
4322
  candidates: uniqueCandidates,
4112
- skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
4323
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, hasExclusion, exclusions.memberIds),
4113
4324
  };
4114
4325
  }
4115
- // Only the initiator was left — what the node does about it is configured.
4116
- // Nothing resolved to begin with is the ordinary failure below.
4117
- 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) {
4118
4329
  throw new common_1.ConflictException(`${label} did not resolve to any member id`);
4119
4330
  }
4120
- if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
4121
- 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' };
4122
4337
  }
4123
- if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
4124
- const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
4338
+ if (whenEmpty === 'INITIATOR_MANAGER') {
4339
+ const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node, exclusions.memberIds);
4125
4340
  if (managerCandidates.candidates.length > 0) {
4126
4341
  return managerCandidates;
4127
4342
  }
4128
- throw new common_1.ConflictException(hasSubmitter
4129
- ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
4130
- : `${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;
4411
+ }
4412
+ if (excludedMemberIds.length === 0) {
4413
+ return false;
4131
4414
  }
4132
- throw new common_1.ConflictException(hasSubmitter
4133
- ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
4134
- : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
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);
4135
4434
  }
4136
- 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
+ ]);
4137
4462
  // The initiator's own direct manager: the closest org unit only. The
4138
4463
  // initiator himself (a unit head) and the member who submitted for him are
4139
4464
  // skipped before the tier is chosen, so resolution falls through to the
4140
4465
  // next tier instead of ending on either of them.
4141
- 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);
4142
4467
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
4143
4468
  memberId,
4144
4469
  sourceType: 'EXCLUDE_INITIATOR_MANAGER',
4145
4470
  })), isOrgManagerChainNode(node));
4146
4471
  return {
4147
- candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
4148
- skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
4472
+ candidates: selectNodeCandidates(delegatedCandidates, true, replacementExcludedMemberIds),
4473
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
4149
4474
  };
4150
4475
  }
4151
4476
  buildDelegationContext(instance, nodeId) {
@@ -4259,7 +4584,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4259
4584
  return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
4260
4585
  }
4261
4586
  if (resolver.type === 'ORG_MANAGER_CHAIN') {
4262
- return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds);
4587
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
4263
4588
  }
4264
4589
  if (resolver.type === 'ORG_MANAGER') {
4265
4590
  return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
@@ -4364,6 +4689,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4364
4689
  * tasks and approvals the SLA timeout made do not count.
4365
4690
  */
4366
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) {
4367
4701
  const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
4368
4702
  where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
4369
4703
  });
@@ -4394,10 +4728,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4394
4728
  const automaticDecisionIds = new Set(decidedLogs
4395
4729
  .filter((log) => log.payload['automatic'] === SLA_AUTO_APPROVE_MARK)
4396
4730
  .map((log) => log.payload['decisionId']));
4397
- return uniqueTexts([...decisions]
4731
+ return [...decisions]
4398
4732
  .filter((decision) => !automaticDecisionIds.has(decision.id))
4399
- .sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime())
4400
- .map((decision) => decision.decidedByMemberId));
4733
+ .sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime());
4401
4734
  }
4402
4735
  async resolvePositionCandidates(manager, positionId, label, subject) {
4403
4736
  const memberIds = await this.readActivePositionMemberIds(manager, positionId);
@@ -4436,16 +4769,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4436
4769
  return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
4437
4770
  }
4438
4771
  /**
4439
- * Walks up the initiator's manager chain, nearest manager first, until a
4440
- * manager whose position level reaches `minPositionLevel` is added. The
4441
- * list is returned nearest manager first. Members in `skippedMemberIds` are
4442
- * 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.
4443
4780
  */
4444
- 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
+ }
4445
4790
  const date = this.readOrganizationToday();
4446
- const visitedMemberIds = new Set([instance.initiatorMemberId]);
4791
+ const visitedMemberIds = new Set([start.memberId]);
4447
4792
  const chain = [];
4448
- let currentMemberId = instance.initiatorMemberId;
4793
+ let currentMemberId = start.memberId;
4449
4794
  let lastAddedLevel = null;
4450
4795
  for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
4451
4796
  // A chain is a single path, so it always uses closest-org-unit semantics
@@ -4478,6 +4823,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4478
4823
  (lastAddedLevel !== null && level <= lastAddedLevel)) {
4479
4824
  continue;
4480
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
+ }
4481
4833
  chain.push(managerMemberId);
4482
4834
  lastAddedLevel = level;
4483
4835
  if (level >= minPositionLevel) {
@@ -4486,9 +4838,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4486
4838
  }
4487
4839
  // A position fallback can name several holders. They all join the last
4488
4840
  // tier, so they all have to sign, the same as every other chain member.
4489
- 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} 的主管`);
4490
4844
  return uniqueTexts([...chain, ...fallbackMemberIds]);
4491
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
+ }
4492
4903
  /** Highest position level over the member's active memberships, or `null`. */
4493
4904
  async readMemberPositionLevel(manager, memberId, date) {
4494
4905
  return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
@@ -4799,7 +5210,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4799
5210
  }
4800
5211
  if (action.type === 'SET_FORM_FIELD') {
4801
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) });
4802
- assertServiceTaskTextValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
5213
+ assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
4803
5214
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
4804
5215
  await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
4805
5216
  // A service task runs inside this transaction, so it cannot call a host
@@ -5180,6 +5591,8 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5180
5591
  tslib_1.__param(19, (0, common_1.Optional)()),
5181
5592
  tslib_1.__param(19, (0, common_1.Inject)(bpm_root_options_1.BPM_ROOT_OPTIONS)),
5182
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)),
5183
5596
  tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
5184
5597
  typeorm_2.Repository,
5185
5598
  typeorm_2.Repository,
@@ -5196,7 +5609,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
5196
5609
  delegation_service_1.DelegationService,
5197
5610
  notification_service_1.NotificationService,
5198
5611
  signature_service_1.SignatureService,
5199
- 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])
5200
5613
  ], WorkflowEngineService);
5201
5614
  function parseJsonObject(value, label) {
5202
5615
  try {
@@ -5219,6 +5632,74 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5219
5632
  memberId,
5220
5633
  };
5221
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
+ }
5222
5703
  function createWorkflowAuthContext(memberId) {
5223
5704
  return {
5224
5705
  memberId,
@@ -5307,32 +5788,41 @@ function readFilterableProblemLocation(schema, problem) {
5307
5788
  return { path, ref: `欄位「${path}」` };
5308
5789
  }
5309
5790
  /**
5310
- * Holds a `SET_FORM_FIELD` write to the length and format rules the form
5311
- * applies to a filler, so a service task cannot store text the field would
5312
- * have refused. Breaking them throws, which rolls back the submit or decision
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
5313
5798
  * that reached the node: the value is never half-written, and the node's value
5314
5799
  * has to be corrected. Only a top-level field can carry those rules — the
5315
5800
  * writer ignores an indexed path, and a nested record path names no field —
5316
5801
  * and an unreadable snapshot is left alone, as in
5317
5802
  * `readDataSourceFieldKeyAtPath`.
5318
5803
  */
5319
- function assertServiceTaskTextValue(snapshot, fieldPath, value, nodeLabel) {
5804
+ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
5320
5805
  const schema = snapshot?.schema;
5321
5806
  const segments = normalizeFormFieldPath(fieldPath);
5322
5807
  if (!isFormDefinitionSchema(schema) || segments.length !== 1) {
5323
5808
  return;
5324
5809
  }
5325
5810
  const field = schema.fields.find((candidate) => candidate.fieldKey === segments[0]);
5811
+ const written = { [segments[0]]: value };
5326
5812
  const errors = field
5327
- ? readSubmittedTextValueErrors(field, value, {
5328
- testPattern: (0, text_field_pattern_test_1.createTimedTextFieldPatternTest)(),
5329
- })
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
+ ]
5330
5820
  : [];
5331
5821
  if (!field || errors.length === 0) {
5332
5822
  return;
5333
5823
  }
5334
- throw new common_1.BadRequestException(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
5335
- `這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`);
5824
+ throw (0, form_data_issue_1.createFormDataBadRequest)(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
5825
+ `這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`, errors);
5336
5826
  }
5337
5827
  function readSnapshotWithoutField(snapshots, fieldKey) {
5338
5828
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
@@ -5758,6 +6248,24 @@ function isOrderedSequentialTask(task, candidates) {
5758
6248
  function isInitiatorExcluded(node) {
5759
6249
  return Boolean(node.data.excludeInitiator);
5760
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
+ }
5761
6269
  // Everyone a node that excludes the initiator keeps out of its approvers: the
5762
6270
  // applicant and, when someone filed the document for them, that person too
5763
6271
  // (authority rule: the creator must not approve what they created).
@@ -5799,6 +6307,27 @@ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, exclud
5799
6307
  return [skipped];
5800
6308
  });
5801
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
+ }
5802
6331
  // A delegate acting for the initiator is still the initiator approving their
5803
6332
  // own document, so the initiator anywhere on the delegation path (the
5804
6333
  // pre-delegation member, any hop, or the final assignee) drops the candidate.
@@ -6374,8 +6903,43 @@ function validateSubmittedFormData(schema, formData, options = {}) {
6374
6903
  .filter((field) => field.hidden === true)
6375
6904
  .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, testPattern, true)),
6376
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),
6377
6910
  });
6378
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
+ }
6379
6943
  /**
6380
6944
  * What an approval at a node has to leave behind: every field the node lets the
6381
6945
  * approver fill in is well-formed (a table is an array of well-formed rows with
@@ -6406,14 +6970,15 @@ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys
6406
6970
  * The shared tail of submit and approver-fill validation: a missing required
6407
6971
  * field wins over a text value breaking its length or format rule, which wins
6408
6972
  * over a malformed member value, which wins over a malformed org unit value,
6409
- * which wins over a table problem, and the later checks are only evaluated
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
6410
6975
  * when the earlier ones pass. The refusal carries every issue of the winning
6411
6976
  * check. Its message is those issues' sentences joined, except for missing
6412
6977
  * required fields, which keep the one-line summary `缺少必填欄位:A, B` that
6413
6978
  * submitters read; each issue there still says `欄位「A」為必填`. A client
6414
6979
  * that places errors on fields reads the issues, not the message.
6415
6980
  */
6416
- function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
6981
+ function throwFirstFormDataFieldError({ missingFields, readFileUploadErrors = () => [], readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
6417
6982
  if (missingFields.length > 0) {
6418
6983
  throw (0, form_data_issue_1.createFormDataBadRequest)(`缺少必填欄位:${missingFields
6419
6984
  .map((field) => field.label || field.fieldKey)
@@ -6426,6 +6991,7 @@ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrg
6426
6991
  readMemberErrors,
6427
6992
  readOrgUnitErrors,
6428
6993
  readTableErrors,
6994
+ readFileUploadErrors,
6429
6995
  ].reduce((found, check) => (found.length > 0 ? found : check()), []);
6430
6996
  if (winning.length > 0) {
6431
6997
  throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)(winning), winning);
@@ -6806,7 +7372,7 @@ function readDryRunPlaceholderAssignee(resolver) {
6806
7372
  return `nodeActorManager:${resolver.nodeIds.join(',')}:${resolver.levelsUp}`;
6807
7373
  }
6808
7374
  return resolver.type === 'ORG_MANAGER_CHAIN'
6809
- ? `orgManagerChain:${resolver.minPositionLevel}`
7375
+ ? (0, workflow_1.formatOrgManagerChainPlaceholder)(resolver)
6810
7376
  : resolver.type;
6811
7377
  }
6812
7378
  function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {