@rytass/bpm-core-nestjs-module 0.17.6 → 0.17.7

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 (28) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +2 -2
  3. package/src/lib/form/form-table-reference.d.ts +1 -0
  4. package/src/lib/form/form-table-reference.js +4 -0
  5. package/src/lib/form/form-table-reference.js.map +1 -1
  6. package/src/lib/notification/notification.service.js +2 -0
  7. package/src/lib/notification/notification.service.js.map +1 -1
  8. package/src/lib/template/template.service.js +160 -44
  9. package/src/lib/template/template.service.js.map +1 -1
  10. package/src/lib/template/workflow-definition.validator.js +4 -17
  11. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  12. package/src/lib/workflow-engine/approval-instance.entity.d.ts +7 -0
  13. package/src/lib/workflow-engine/approval-instance.entity.js +15 -0
  14. package/src/lib/workflow-engine/approval-instance.entity.js.map +1 -1
  15. package/src/lib/workflow-engine/dto/decide-task.input.d.ts +2 -0
  16. package/src/lib/workflow-engine/dto/decide-task.input.js +21 -0
  17. package/src/lib/workflow-engine/dto/decide-task.input.js.map +1 -1
  18. package/src/lib/workflow-engine/form-data-fingerprint.d.ts +11 -0
  19. package/src/lib/workflow-engine/form-data-fingerprint.js +36 -0
  20. package/src/lib/workflow-engine/form-data-fingerprint.js.map +1 -0
  21. package/src/lib/workflow-engine/task.entity.d.ts +8 -0
  22. package/src/lib/workflow-engine/task.entity.js +12 -0
  23. package/src/lib/workflow-engine/task.entity.js.map +1 -1
  24. package/src/lib/workflow-engine/workflow-engine.mutations.js +15 -0
  25. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  26. package/src/lib/workflow-engine/workflow-engine.service.d.ts +15 -3
  27. package/src/lib/workflow-engine/workflow-engine.service.js +206 -53
  28. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -50,9 +50,18 @@ type SubmitApprovalInstanceCommand = SubmitApprovalInstanceInput & {
50
50
  * the initiator submitted the document themselves.
51
51
  */
52
52
  readonly submittedByMemberId?: string | null;
53
+ /**
54
+ * Set only by the GraphQL mutation, never by the DTO, so an end user cannot
55
+ * bring it along. The service then keeps the fields an approver fills in
56
+ * out of the submission; a host calling the service directly leaves it
57
+ * unset and may prefill them.
58
+ */
59
+ readonly endUserSubmission?: boolean;
53
60
  };
54
61
  type ResubmitApprovalInstanceCommand = ResubmitApprovalInstanceInput & {
55
62
  readonly initiatorMemberId: string;
63
+ /** See `SubmitApprovalInstanceCommand.endUserSubmission`. */
64
+ readonly endUserSubmission?: boolean;
56
65
  };
57
66
  export declare const MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = 200;
58
67
  /**
@@ -300,16 +309,19 @@ export declare class WorkflowEngineService {
300
309
  listTaskCandidates(taskId: string, scope?: WorkflowReadScope): Promise<readonly TaskCandidateEntity[]>;
301
310
  private attachTaskCandidateSummaries;
302
311
  /**
303
- * The display names of the nodes a page of tasks sits on, keyed by
312
+ * The display names of the nodes a page of tasks sits on, and whether each
313
+ * is a user task that lets its approver fill in fields, keyed by
304
314
  * {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
305
315
  * the labels out of the snapshots: a workflow snapshot is a whole workflow
306
316
  * definition, and every inbox, history and instance task list runs this, so
307
317
  * shipping the snapshots to the app just to read one string each would cost
308
318
  * page size × snapshot size on every refresh. A missing instance or node, a
309
319
  * snapshot without a `nodes` array, and a blank or non-string label all
310
- * leave the task without a label, as `readWorkflowNodeLabel` does.
320
+ * leave the task without a label, as `readWorkflowNodeLabel` does. Only the
321
+ * node's `fieldAccess` leaves the database for the fill flag, which
322
+ * `readNodeFillableFieldKeys` then reads as tolerantly as the engine does.
311
323
  */
312
- private readTaskNodeLabels;
324
+ private readTaskNodeSummaries;
313
325
  private readDefaultInitiatorMetadataSnapshot;
314
326
  listActivityLogs(instanceId: string, scope?: WorkflowReadScope): Promise<readonly ActivityLogEntity[]>;
315
327
  private assertTaskReadable;
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.WorkflowEngineService = exports.ADMIN_REASSIGN_DELEGATION_REASON = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
5
5
  const tslib_1 = require("tslib");
6
6
  const class_validator_1 = require("class-validator");
7
+ const bpm_core_shared_1 = require("@rytass/bpm-core-shared");
7
8
  const form_1 = require("@rytass/bpm-core-shared/form");
8
9
  const workflow_1 = require("@rytass/bpm-core-shared/workflow");
9
10
  const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
@@ -37,6 +38,7 @@ const activity_log_entity_1 = require("./activity-log.entity");
37
38
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
38
39
  const adhoc_enums_1 = require("./adhoc.enums");
39
40
  const approval_instance_entity_1 = require("./approval-instance.entity");
41
+ const form_data_fingerprint_1 = require("./form-data-fingerprint");
40
42
  const instance_submitter_1 = require("./instance-submitter");
41
43
  const task_decision_entity_1 = require("./task-decision.entity");
42
44
  const task_candidate_entity_1 = require("./task-candidate.entity");
@@ -171,7 +173,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
171
173
  input.submittedByMemberId.trim() === '')) {
172
174
  throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
173
175
  }
174
- const formData = parseJsonObject(input.formDataJson, 'formDataJson');
176
+ const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
175
177
  const businessKey = normalizeBusinessKey(input.businessKey);
176
178
  const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
177
179
  const template = await this.getTemplateOrThrow(input.templateId);
@@ -183,7 +185,20 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
183
185
  }
184
186
  const templateVersion = await this.getPublishedTemplateVersionOrThrow(template.currentVersionId);
185
187
  const formDefinitionVersion = await this.getPublishedFormVersionOrThrow(templateVersion.formDefinitionVersionId);
186
- validateSubmittedFormData(formDefinitionVersion.schema, formData);
188
+ // The fields an approver fills in belong to the approval, not the
189
+ // application: an end user's value for one is dropped (it would otherwise
190
+ // be an unreviewed value the approver has to overwrite), while a host
191
+ // calling the service directly may prefill them. They are only shape
192
+ // checked here; required and row minimums wait for the node that fills
193
+ // them. Read off the version the instance is bound to, not the template's
194
+ // latest, so a publish in between cannot change what is stripped.
195
+ const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(templateVersion.workflowDefinition);
196
+ const formData = input.endUserSubmission === true
197
+ ? (0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferredFieldKeys)
198
+ : submittedFormData;
199
+ validateSubmittedFormData(formDefinitionVersion.schema, formData, {
200
+ deferredFieldKeys,
201
+ });
187
202
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
188
203
  authContext: authContext ?? createWorkflowAuthContext(input.initiatorMemberId),
189
204
  formData,
@@ -308,7 +323,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
308
323
  schema,
309
324
  });
310
325
  }
311
- async prepareResubmitFormDataOptions(input, formData, authContext) {
326
+ async prepareResubmitFormDataOptions(input, submittedFormData, authContext) {
312
327
  const instance = await this.approvalInstanceRepository.findOne({
313
328
  where: { id: input.instanceId },
314
329
  });
@@ -326,7 +341,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
326
341
  throw new common_1.ConflictException('Approval template is deactivated');
327
342
  }
328
343
  const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
329
- validateSubmittedFormData(schema, formData);
344
+ const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
345
+ // Same overlay as inside the lock, so the options resolved here are for
346
+ // the form data that is actually saved.
347
+ const formData = input.endUserSubmission === true
348
+ ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
349
+ : submittedFormData;
350
+ validateSubmittedFormData(schema, formData, { deferredFieldKeys });
330
351
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
331
352
  authContext,
332
353
  formData,
@@ -371,15 +392,24 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
371
392
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
372
393
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
373
394
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
374
- const task = await taskRepository.findOne({
395
+ const taskBeforeLock = await taskRepository.findOne({
375
396
  where: { id: input.taskId },
376
397
  });
377
- if (!task) {
398
+ if (!taskBeforeLock) {
378
399
  throw new common_1.NotFoundException(`Task ${input.taskId} was not found`);
379
400
  }
380
401
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
381
- task.instanceId,
402
+ taskBeforeLock.instanceId,
382
403
  ]);
404
+ // Read again now that the lock is ours: a decision that held it first
405
+ // may have completed this task since the read above, and everything
406
+ // below must judge the task as it is now, not as it was.
407
+ const task = await taskRepository.findOne({
408
+ where: { id: input.taskId },
409
+ });
410
+ if (!task) {
411
+ throw new common_1.NotFoundException(`Task ${input.taskId} was not found`);
412
+ }
383
413
  if (task.status !== workflow_engine_enums_1.TaskStatusEnum.PENDING &&
384
414
  task.status !== workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS) {
385
415
  throw new common_1.ConflictException(`Task ${task.id} is not pending`);
@@ -405,6 +435,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
405
435
  transferToMemberId === input.decidedByMemberId) {
406
436
  throw new common_1.ConflictException('Transfer target must be a different member');
407
437
  }
438
+ // "Sent" means not null and not undefined, so '{}' counts.
439
+ const sentFormDataJson = input.formDataJson ?? null;
440
+ if (sentFormDataJson !== null &&
441
+ input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
442
+ throw new common_1.BadRequestException('formDataJson can only be sent with an APPROVED decision');
443
+ }
444
+ const filledFormData = sentFormDataJson === null
445
+ ? null
446
+ : parseJsonObject(sentFormDataJson, 'formDataJson');
408
447
  const instance = await instanceRepository.findOne({
409
448
  where: { id: task.instanceId },
410
449
  });
@@ -485,6 +524,53 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
485
524
  isReturnCommentRequired(instance.workflowSnapshot, task.nodeId)) {
486
525
  throw new common_1.BadRequestException(`Return decision comment is required by workflow node ${task.nodeId}`);
487
526
  }
527
+ if (filledFormData !== null && task.isAdhoc) {
528
+ throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
529
+ }
530
+ if (filledFormData !== null) {
531
+ const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
532
+ const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey));
533
+ if (refusedFieldKeys.length > 0) {
534
+ throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not let approvers fill in: ${refusedFieldKeys.join(', ')}`);
535
+ }
536
+ }
537
+ const expectedFormDataFingerprint = input.expectedFormDataFingerprint ?? null;
538
+ if (expectedFormDataFingerprint !== null &&
539
+ expectedFormDataFingerprint !==
540
+ (0, form_data_fingerprint_1.computeFormDataFingerprint)(instance.formData)) {
541
+ throw new common_1.ConflictException(`Approval instance ${instance.id} form data changed; refresh and retry`);
542
+ }
543
+ // Only keys whose value really changes are written and reported. A key
544
+ // the form data does not have yet counts as changed even when the new
545
+ // value is null.
546
+ const changedFieldKeys = filledFormData === null
547
+ ? []
548
+ : Object.keys(filledFormData)
549
+ .filter((fieldKey) => !Object.prototype.hasOwnProperty.call(instance.formData, fieldKey) ||
550
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(filledFormData[fieldKey]) !==
551
+ (0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formData[fieldKey]))
552
+ .sort();
553
+ const mergedFormData = { ...instance.formData, ...filledFormData };
554
+ // Every approval at a node that lets its approver fill in fields is
555
+ // held to those fields, whether or not this request sent any: the host
556
+ // may have pre-filled a table the approver then leaves as is.
557
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc) {
558
+ const nodeFillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
559
+ if (nodeFillableFieldKeys.size > 0) {
560
+ validateFilledFormFields(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
561
+ }
562
+ }
563
+ if (filledFormData !== null && changedFieldKeys.length > 0) {
564
+ // Merged into the shared `instance` before it is signed and before
565
+ // the token moves on: the signature must cover the filled values, a
566
+ // gateway after this node must route on them, and an end node saves
567
+ // this same object, which would write the old values back.
568
+ const savedInstance = await instanceRepository.save({
569
+ ...instance,
570
+ formData: mergedFormData,
571
+ });
572
+ Object.assign(instance, savedInstance);
573
+ }
488
574
  const decidedAt = new Date();
489
575
  const claimedTask = await taskRepository.save({
490
576
  ...task,
@@ -586,6 +672,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
586
672
  decisionId: decision.id,
587
673
  signatureId: signature.id,
588
674
  signedPayloadHash: signature.signedPayloadHash,
675
+ // Keys only: this payload is forwarded to hosts, and the values
676
+ // can be tax IDs or amounts.
677
+ ...(changedFieldKeys.length > 0 ? { changedFieldKeys } : {}),
589
678
  },
590
679
  taskId: completedTask.id,
591
680
  }));
@@ -750,9 +839,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
750
839
  return closedInstance;
751
840
  }
752
841
  async resubmitApprovalInstance(input, authContext) {
753
- const formData = parseJsonObject(input.formDataJson, 'formDataJson');
842
+ const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
754
843
  const preparation = this.formDataSourceValueResolver
755
- ? await this.prepareResubmitFormDataOptions(input, formData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
844
+ ? await this.prepareResubmitFormDataOptions(input, submittedFormData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
756
845
  : null;
757
846
  return this.approvalInstanceRepository.manager.transaction(async (manager) => {
758
847
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
@@ -780,7 +869,16 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
780
869
  throw new common_1.ConflictException('Approval instance changed while options were resolving; refresh and retry');
781
870
  }
782
871
  const resubmitSchema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
783
- validateSubmittedFormData(resubmitSchema, formData);
872
+ const deferredFieldKeys = (0, bpm_core_shared_1.readApproverFilledFieldKeys)(instance.workflowSnapshot);
873
+ // An end user gets back the value an approver already filled in, and
874
+ // loses a key the stored data never had: the renderer seeds `false`,
875
+ // `[]` and empty rows for every field, none of which an approver wrote.
876
+ const formData = input.endUserSubmission === true
877
+ ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
878
+ : submittedFormData;
879
+ validateSubmittedFormData(resubmitSchema, formData, {
880
+ deferredFieldKeys,
881
+ });
784
882
  if (!preparation) {
785
883
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
786
884
  }
@@ -2159,25 +2257,31 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2159
2257
  ]);
2160
2258
  return nextMap;
2161
2259
  }, new Map());
2162
- const nodeLabels = await this.readTaskNodeLabels(tasks);
2163
- return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
2164
- candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
2165
- nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
2166
- null,
2167
- openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
2168
- }));
2260
+ const nodeSummaries = await this.readTaskNodeSummaries(tasks);
2261
+ return tasks.map((task) => {
2262
+ const nodeSummary = nodeSummaries.get(readNodeLabelKey(task.instanceId, task.nodeId));
2263
+ return Object.assign(new task_entity_1.TaskEntity(), task, {
2264
+ candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
2265
+ hasFillableFields: !task.isAdhoc && (nodeSummary?.hasFillableFields ?? false),
2266
+ nodeLabel: nodeSummary?.label ?? null,
2267
+ openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
2268
+ });
2269
+ });
2169
2270
  }
2170
2271
  /**
2171
- * The display names of the nodes a page of tasks sits on, keyed by
2272
+ * The display names of the nodes a page of tasks sits on, and whether each
2273
+ * is a user task that lets its approver fill in fields, keyed by
2172
2274
  * {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
2173
2275
  * the labels out of the snapshots: a workflow snapshot is a whole workflow
2174
2276
  * definition, and every inbox, history and instance task list runs this, so
2175
2277
  * shipping the snapshots to the app just to read one string each would cost
2176
2278
  * page size × snapshot size on every refresh. A missing instance or node, a
2177
2279
  * snapshot without a `nodes` array, and a blank or non-string label all
2178
- * leave the task without a label, as `readWorkflowNodeLabel` does.
2280
+ * leave the task without a label, as `readWorkflowNodeLabel` does. Only the
2281
+ * node's `fieldAccess` leaves the database for the fill flag, which
2282
+ * `readNodeFillableFieldKeys` then reads as tolerantly as the engine does.
2179
2283
  */
2180
- async readTaskNodeLabels(tasks) {
2284
+ async readTaskNodeSummaries(tasks) {
2181
2285
  // The table as the entity reads it, so a configured `schema` is honoured
2182
2286
  // here exactly as it is by every repository read of the instance.
2183
2287
  const { metadata } = this.approvalInstanceRepository;
@@ -2188,6 +2292,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2188
2292
  .join('.');
2189
2293
  const rows = await this.approvalInstanceRepository.query(`SELECT instance.id AS "instanceId",
2190
2294
  node->>'id' AS "nodeId",
2295
+ node->>'type' AS "nodeType",
2296
+ node->'data'->'fieldAccess' AS "fieldAccess",
2191
2297
  CASE WHEN jsonb_typeof(node->'data'->'label') = 'string'
2192
2298
  THEN node->'data'->>'label' END AS "label"
2193
2299
  FROM ${instanceTable} AS instance
@@ -2195,15 +2301,27 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2195
2301
  CASE WHEN jsonb_typeof(instance.workflow_snapshot->'nodes') = 'array'
2196
2302
  THEN instance.workflow_snapshot->'nodes'
2197
2303
  ELSE '[]'::jsonb END
2198
- ) AS node
2304
+ ) WITH ORDINALITY AS entry(node, position)
2199
2305
  WHERE instance.id = ANY($1::uuid[])
2200
- AND node->>'id' = ANY($2::text[])`, [
2306
+ AND node->>'id' = ANY($2::text[])
2307
+ ORDER BY entry.position DESC`, [
2201
2308
  [...new Set(tasks.map((task) => task.instanceId))],
2202
2309
  [...new Set(tasks.map((task) => task.nodeId))],
2203
2310
  ]);
2204
- return new Map(rows.flatMap((row) => typeof row.label === 'string' && row.label.trim()
2205
- ? [[readNodeLabelKey(row.instanceId, row.nodeId), row.label]]
2206
- : []));
2311
+ // Rows come last node first, so the first node with a given id wins, as
2312
+ // `find` does everywhere else that reads a snapshot node by id.
2313
+ return new Map(rows.map((row) => [
2314
+ readNodeLabelKey(row.instanceId, row.nodeId),
2315
+ {
2316
+ hasFillableFields: row.nodeType === 'userTask' &&
2317
+ (0, bpm_core_shared_1.readNodeFillableFieldKeys)({
2318
+ data: { fieldAccess: row.fieldAccess },
2319
+ }).size > 0,
2320
+ label: typeof row.label === 'string' && row.label.trim()
2321
+ ? row.label
2322
+ : null,
2323
+ },
2324
+ ]));
2207
2325
  }
2208
2326
  async readDefaultInitiatorMetadataSnapshot(memberId) {
2209
2327
  const today = this.readOrganizationToday();
@@ -2734,6 +2852,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2734
2852
  const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
2735
2853
  const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
2736
2854
  task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
2855
+ task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(node).size > 0;
2737
2856
  await tokenRepository.save({
2738
2857
  ...token,
2739
2858
  status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
@@ -5418,39 +5537,82 @@ function readCandidateStatusForClosedTask(taskStatus) {
5418
5537
  }
5419
5538
  return workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED;
5420
5539
  }
5421
- function validateSubmittedFormData(schema, formData) {
5540
+ /**
5541
+ * `deferredFieldKeys` are fields an approver fills in later: they are checked
5542
+ * for shape (a table is an array of well-formed rows, a member value is a
5543
+ * member id) but never for being present, so an empty one cannot block a
5544
+ * submission that has nobody to fill it yet.
5545
+ */
5546
+ function validateSubmittedFormData(schema, formData, options = {}) {
5547
+ const deferredFieldKeys = options.deferredFieldKeys ?? new Set();
5422
5548
  const visibleFields = schema.fields.filter((field) => isSubmittedFormFieldVisible(field, schema.fields, formData));
5423
5549
  // Tables answer "is this filled?" by row count and report per-row problems
5424
5550
  // with an instance path, so they get their own pass instead of the flat
5425
5551
  // "missing required fields" list (ADR 16 §3.7).
5426
5552
  const missingFields = visibleFields.filter((field) => !(0, form_1.isTableFieldDefinition)(field) &&
5553
+ !deferredFieldKeys.has(field.fieldKey) &&
5427
5554
  isSubmittedFormFieldRequired(field, schema.fields, formData) &&
5428
5555
  !isSubmittedFieldValuePresent(formData[field.fieldKey]));
5556
+ throwFirstFormDataFieldError({
5557
+ missingFields,
5558
+ // A `DYNAMIC_FORM` resolver reads a member value whether or not its field
5559
+ // is visible, so a hidden field still holding a value must be well-formed
5560
+ // too. Whether a hidden field is *required* stays a visibility question
5561
+ // above.
5562
+ readMemberErrors: () => (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
5563
+ isSubmittedFieldValuePresent(formData[field.fieldKey])), formData),
5564
+ readTableErrors: () => [
5565
+ ...visibleFields
5566
+ .filter(form_1.isTableFieldDefinition)
5567
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData), deferredFieldKeys.has(field.fieldKey))),
5568
+ // A hidden table is not "visible", but its stored value still has to be
5569
+ // a well-formed table under the row cap: only the required / minimum
5570
+ // checks are skipped, since nobody can fill the table in.
5571
+ ...schema.fields
5572
+ .filter(form_1.isTableFieldDefinition)
5573
+ .filter((field) => field.hidden === true)
5574
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
5575
+ ],
5576
+ });
5577
+ }
5578
+ /**
5579
+ * What an approval at a node has to leave behind: every field the node lets the
5580
+ * approver fill in is well-formed (a table is an array of well-formed rows with
5581
+ * its required columns and minimum rows, a member value is a member id) and
5582
+ * every one the node marks `required` is present. The messages are the submit
5583
+ * ones: a host tells the two entry points apart by which call it made.
5584
+ */
5585
+ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys) {
5586
+ const fields = schema.fields.filter((field) => fieldKeys.has(field.fieldKey));
5587
+ // `readRowCellValue` is used below as an own-property reader for top-level
5588
+ // form keys: a key like `constructor` must not read off the prototype chain.
5589
+ throwFirstFormDataFieldError({
5590
+ // Tables answer by row count, so like submit they stay out of this list.
5591
+ missingFields: fields.filter((field) => !(0, form_1.isTableFieldDefinition)(field) &&
5592
+ requiredFieldKeys.has(field.fieldKey) &&
5593
+ (0, bpm_core_shared_1.isFormDataValueEmpty)(readRowCellValue(formData, field.fieldKey))),
5594
+ readMemberErrors: () => (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(fields, formData),
5595
+ readTableErrors: () => fields
5596
+ .filter(form_1.isTableFieldDefinition)
5597
+ .flatMap((field) => validateSubmittedTableValue(field, readRowCellValue(formData, field.fieldKey), requiredFieldKeys.has(field.fieldKey))),
5598
+ });
5599
+ }
5600
+ /**
5601
+ * The shared tail of submit and approver-fill validation: a missing required
5602
+ * field wins over a malformed member value, which wins over a table problem,
5603
+ * and the later checks are only evaluated when the earlier ones pass.
5604
+ */
5605
+ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readTableErrors, }) {
5429
5606
  if (missingFields.length > 0) {
5430
5607
  throw new common_1.BadRequestException(`Form data is missing required fields: ${missingFields
5431
5608
  .map((field) => field.label || field.fieldKey)
5432
5609
  .join(', ')}`);
5433
5610
  }
5434
- // A `DYNAMIC_FORM` resolver reads a member value whether or not its field is
5435
- // visible, so a hidden field still holding a value must be well-formed too.
5436
- // Whether a hidden field is *required* stays a visibility question above.
5437
- const memberErrors = (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
5438
- isSubmittedFieldValuePresent(formData[field.fieldKey])), formData);
5611
+ const memberErrors = readMemberErrors();
5439
5612
  if (memberErrors.length > 0) {
5440
5613
  throw new common_1.BadRequestException(memberErrors.join('; '));
5441
5614
  }
5442
- const tableErrors = [
5443
- ...visibleFields
5444
- .filter(form_1.isTableFieldDefinition)
5445
- .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData))),
5446
- // A hidden table is not "visible", but its stored value still has to be a
5447
- // well-formed table under the row cap: only the required / minimum checks
5448
- // are skipped, since nobody can fill the table in.
5449
- ...schema.fields
5450
- .filter(form_1.isTableFieldDefinition)
5451
- .filter((field) => field.hidden === true)
5452
- .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
5453
- ];
5615
+ const tableErrors = readTableErrors();
5454
5616
  if (tableErrors.length > 0) {
5455
5617
  throw new common_1.BadRequestException(tableErrors.join('; '));
5456
5618
  }
@@ -5676,16 +5838,7 @@ function parseFormConditionLiteral(value) {
5676
5838
  return trimmedValue;
5677
5839
  }
5678
5840
  function isSubmittedFieldValuePresent(value) {
5679
- if (typeof value === 'undefined' || value === null) {
5680
- return false;
5681
- }
5682
- if (typeof value === 'string') {
5683
- return value.trim().length > 0;
5684
- }
5685
- if (Array.isArray(value)) {
5686
- return value.length > 0;
5687
- }
5688
- return true;
5841
+ return !(0, bpm_core_shared_1.isFormDataValueEmpty)(value);
5689
5842
  }
5690
5843
  function isNumberFormField(field) {
5691
5844
  return field.type === 'number' || field.type === 'money';