@rytass/bpm-core-nestjs-module 0.17.6 → 0.17.8
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.
- package/CHANGELOG.md +46 -0
- package/package.json +2 -2
- package/src/lib/form/form-table-reference.d.ts +1 -0
- package/src/lib/form/form-table-reference.js +4 -0
- package/src/lib/form/form-table-reference.js.map +1 -1
- package/src/lib/notification/notification.service.js +2 -0
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/template/template.service.js +160 -44
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +4 -17
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/approval-instance.entity.d.ts +7 -0
- package/src/lib/workflow-engine/approval-instance.entity.js +15 -0
- package/src/lib/workflow-engine/approval-instance.entity.js.map +1 -1
- package/src/lib/workflow-engine/dto/decide-task.input.d.ts +2 -0
- package/src/lib/workflow-engine/dto/decide-task.input.js +21 -0
- package/src/lib/workflow-engine/dto/decide-task.input.js.map +1 -1
- package/src/lib/workflow-engine/form-data-fingerprint.d.ts +11 -0
- package/src/lib/workflow-engine/form-data-fingerprint.js +36 -0
- package/src/lib/workflow-engine/form-data-fingerprint.js.map +1 -0
- package/src/lib/workflow-engine/task.entity.d.ts +8 -0
- package/src/lib/workflow-engine/task.entity.js +12 -0
- package/src/lib/workflow-engine/task.entity.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.js +15 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +15 -3
- package/src/lib/workflow-engine/workflow-engine.service.js +206 -53
- 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
395
|
+
const taskBeforeLock = await taskRepository.findOne({
|
|
375
396
|
where: { id: input.taskId },
|
|
376
397
|
});
|
|
377
|
-
if (!
|
|
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
|
-
|
|
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
|
|
842
|
+
const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
754
843
|
const preparation = this.formDataSourceValueResolver
|
|
755
|
-
? await this.prepareResubmitFormDataOptions(input,
|
|
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
|
-
|
|
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
|
|
2163
|
-
return tasks.map((task) =>
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
2205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|