@rytass/bpm-core-nestjs-module 0.21.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -210,6 +210,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
210
210
  this.formDataSourceValueResolver = formDataSourceValueResolver;
211
211
  this.webhookDeliveryService = webhookDeliveryService;
212
212
  this.logger = new common_1.Logger(WorkflowEngineService_1.name);
213
+ /** In-flight inbox reads by member; see `readSharedOpenInboxTasks`. */
214
+ this.openInboxReadsInFlight = new Map();
213
215
  this.approvalInstanceFieldFilterCompiler = new approval_instance_field_filter_compiler_1.ApprovalInstanceFieldFilterCompiler();
214
216
  this.approvalInstanceFilterableFieldPolicy = new approval_instance_filterable_field_policy_1.ApprovalInstanceFilterableFieldPolicy();
215
217
  /** Webhooks each open engine transaction will send once it has committed. */
@@ -2500,37 +2502,86 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2500
2502
  memberId,
2501
2503
  submission: null,
2502
2504
  });
2503
- return templates.filter((template) => {
2504
- const currentVersion = template.currentVersionId
2505
- ? (versionById.get(template.currentVersionId) ?? null)
2506
- : null;
2507
- if (!currentVersion?.formDefinitionVersionId) {
2508
- return false;
2509
- }
2510
- // A policy in a dialect this version does not know cannot be evaluated,
2511
- // so the template is not launchable (fail closed), but only that one:
2512
- // publishing does not check the field yet (Phase 23 P2), and one such
2513
- // version must not break the list for every member.
2514
- if (hasExpressionText(currentVersion.initiatorPolicyCel) &&
2515
- !isReadableExpressionDialect(currentVersion.workflowDefinition)) {
2516
- this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of launchable templates: unknown expression dialect`);
2517
- return false;
2518
- }
2519
- // Each template is its own operation, so one slow template cannot use
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
- }
2505
+ return templates.filter((template) => this.isInitiatorPolicyMet(template, template.currentVersionId
2506
+ ? (versionById.get(template.currentVersionId) ?? null)
2507
+ : null, initiatorMetadataSnapshot, 'launchable templates'));
2508
+ }
2509
+ /**
2510
+ * The published version whose form `memberId` may read data-source options
2511
+ * for when starting `templateId`, or `null` when they may not.
2512
+ *
2513
+ * It applies the same checks as {@link listLaunchableApprovalTemplates} to
2514
+ * that one template, but asks the host's initiator `customFields` resolver
2515
+ * with `purpose: 'FORM_OPTIONS'` and the template and version ids. A host can
2516
+ * therefore open a template's options to members who may not submit it
2517
+ * themselves — a template only the host's backend submits — without the
2518
+ * template appearing in their launchable list. The snapshot is used for this
2519
+ * judgement only and is never stored. A resolver failure judges against
2520
+ * `customFields: {}`, as the list does, so a policy that reads
2521
+ * `initiator.customFields` then fails closed.
2522
+ */
2523
+ async readFormOptionsTemplateVersion(templateId, memberId, authContext) {
2524
+ const template = await this.approvalTemplateRepository.findOne({
2525
+ where: { id: templateId, isActive: true },
2526
+ });
2527
+ if (!template?.currentVersionId) {
2528
+ return null;
2529
+ }
2530
+ const currentVersion = await this.approvalTemplateVersionRepository.findOne({
2531
+ where: {
2532
+ id: template.currentVersionId,
2533
+ status: template_enums_1.ApprovalTemplateVersionStatusEnum.PUBLISHED,
2534
+ },
2535
+ });
2536
+ if (!currentVersion?.formDefinitionVersionId) {
2537
+ return null;
2538
+ }
2539
+ const initiatorMetadataSnapshot = await this.buildInitiatorMetadataSnapshot({
2540
+ authContext: authContext ?? createWorkflowAuthContext(memberId),
2541
+ formOptions: {
2542
+ templateId: template.id,
2543
+ templateVersionId: currentVersion.id,
2544
+ },
2545
+ memberId,
2546
+ submission: null,
2533
2547
  });
2548
+ return this.isInitiatorPolicyMet(template, currentVersion, initiatorMetadataSnapshot, 'form options')
2549
+ ? currentVersion
2550
+ : null;
2551
+ }
2552
+ /**
2553
+ * Whether `template`'s current published version lets the member described
2554
+ * by `initiatorMetadataSnapshot` start it: the launchable list and the form
2555
+ * options check share this, so the two cannot drift apart. `scope` only
2556
+ * names the caller in the log.
2557
+ */
2558
+ isInitiatorPolicyMet(template, currentVersion, initiatorMetadataSnapshot, scope) {
2559
+ if (!currentVersion?.formDefinitionVersionId) {
2560
+ return false;
2561
+ }
2562
+ // A policy in a dialect this version does not know cannot be evaluated,
2563
+ // so the template is not launchable (fail closed), but only that one:
2564
+ // publishing does not check the field yet (Phase 23 P2), and one such
2565
+ // version must not break the list for every member.
2566
+ if (hasExpressionText(currentVersion.initiatorPolicyCel) &&
2567
+ !isReadableExpressionDialect(currentVersion.workflowDefinition)) {
2568
+ this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of ${scope}: unknown expression dialect`);
2569
+ return false;
2570
+ }
2571
+ // Each template is its own operation, so one slow template cannot use
2572
+ // up the allowance of the rest of the list. A policy that throws (for
2573
+ // example a missing `initiator.customFields.*` key, because the resolver
2574
+ // did not return it, or failed and the list fell back to `{}`) is also
2575
+ // only that template's problem: it is left out, and the error code is
2576
+ // logged, never the values.
2577
+ try {
2578
+ return (0, expression_budget_1.runExpressionOperation)(() => this.conditionService.evaluateBoolean(currentVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel', readExpressionDialectOptions(currentVersion.initiatorPolicyCel, currentVersion.workflowDefinition)));
2579
+ }
2580
+ catch (error) {
2581
+ const reason = error instanceof expression_errors_1.ExpressionRuntimeError ? error.code : 'UNEXPECTED';
2582
+ this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of ${scope}: initiatorPolicyCel failed (${reason})`);
2583
+ return false;
2584
+ }
2534
2585
  }
2535
2586
  async listWorkflowTokens(instanceId, scope) {
2536
2587
  await this.getApprovalInstance(instanceId, scope);
@@ -2546,8 +2597,43 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2546
2597
  where: { instanceId },
2547
2598
  }));
2548
2599
  }
2549
- async listInboxTasks(assigneeMemberId) {
2550
- return this.attachTaskCandidateSummaries(await this.readOpenInboxTasks(assigneeMemberId));
2600
+ /**
2601
+ * The member's open tasks, newest first. Without `page` / `pageSize` every
2602
+ * one is returned, as before paging existed. The page is cut from the same
2603
+ * {@link readOpenInboxTasks} result the dashboard counters read, so the list,
2604
+ * {@link countInboxTasks} and the counters cannot disagree; only the page is
2605
+ * given its candidate summaries.
2606
+ */
2607
+ async listInboxTasks(assigneeMemberId, options = {}) {
2608
+ const openTasks = await this.readSharedOpenInboxTasks(assigneeMemberId);
2609
+ if (!isTaskListPaged(options)) {
2610
+ return this.attachTaskCandidateSummaries(openTasks);
2611
+ }
2612
+ const offset = readPageOffset(options.page, options.pageSize);
2613
+ return this.attachTaskCandidateSummaries(openTasks.slice(offset, offset + normalizePageSize(options.pageSize)));
2614
+ }
2615
+ /** How many tasks {@link listInboxTasks} pages through. */
2616
+ async countInboxTasks(assigneeMemberId) {
2617
+ return (await this.readSharedOpenInboxTasks(assigneeMemberId)).length;
2618
+ }
2619
+ /**
2620
+ * {@link readOpenInboxTasks} for the inbox list and its count, joining a
2621
+ * read for the same member that is still in flight. The client asks for
2622
+ * `inboxTasks` and `inboxTaskCount` in one request and GraphQL resolves the
2623
+ * two side by side, so this turns two identical reads into one. An entry
2624
+ * lives only until its read settles: nothing is cached across requests, and
2625
+ * a read that started earlier is never served once it has finished.
2626
+ */
2627
+ readSharedOpenInboxTasks(assigneeMemberId) {
2628
+ const inFlight = this.openInboxReadsInFlight.get(assigneeMemberId);
2629
+ if (inFlight) {
2630
+ return inFlight;
2631
+ }
2632
+ const read = this.readOpenInboxTasks(assigneeMemberId).finally(() => {
2633
+ this.openInboxReadsInFlight.delete(assigneeMemberId);
2634
+ });
2635
+ this.openInboxReadsInFlight.set(assigneeMemberId, read);
2636
+ return read;
2551
2637
  }
2552
2638
  /**
2553
2639
  * The open tasks that are this member's own to act on — the single
@@ -2609,33 +2695,86 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2609
2695
  const settledTaskIds = new Set(settledCandidateRows.map((candidate) => candidate.taskId));
2610
2696
  return openTasks.filter((task) => !settledTaskIds.has(task.id));
2611
2697
  }
2612
- async listApprovalHistoryTasks(assigneeMemberId) {
2613
- const directTasks = await this.taskRepository.find({
2614
- order: { completedAt: 'DESC', createdAt: 'DESC' },
2615
- take: DEFAULT_APPROVAL_HISTORY_TASK_LIMIT,
2616
- where: { assigneeMemberId, status: workflow_engine_enums_1.TaskStatusEnum.COMPLETED },
2617
- });
2618
- const candidateRows = await this.taskCandidateRepository.find({
2619
- where: {
2620
- memberId: assigneeMemberId,
2621
- status: workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED,
2622
- },
2698
+ /**
2699
+ * The tasks this member has acted on, most recently acted on first: tasks
2700
+ * assigned to them that are COMPLETED, and tasks on which their own
2701
+ * candidate row is COMPLETED. The latter needs no status on the task: a
2702
+ * countersign still waiting on somebody else already carries their vote, and
2703
+ * the inbox has stopped showing it to them.
2704
+ *
2705
+ * "Acted on" is their candidate row's `decided_at`, else the task's
2706
+ * `completed_at`, else its `created_at`; ties fall back to the task id so no
2707
+ * row moves between pages. Paged in SQL. Without `page` / `pageSize` the
2708
+ * latest {@link DEFAULT_APPROVAL_HISTORY_TASK_LIMIT} are returned, as before
2709
+ * paging existed. `instanceState` keeps only tasks of instances in that
2710
+ * state (RUNNING for "acted on, not finished").
2711
+ */
2712
+ async listApprovalHistoryTasks(assigneeMemberId, options = {}) {
2713
+ const paged = isTaskListPaged(options);
2714
+ const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState)
2715
+ .orderBy(this.readApprovalHistoryActedAtExpression(), 'DESC')
2716
+ .addOrderBy('"task"."id"', 'DESC')
2717
+ .offset(paged ? readPageOffset(options.page, options.pageSize) : 0)
2718
+ .limit(paged
2719
+ ? normalizePageSize(options.pageSize)
2720
+ : DEFAULT_APPROVAL_HISTORY_TASK_LIMIT)
2721
+ .getMany();
2722
+ return this.attachTaskCandidateSummaries(tasks);
2723
+ }
2724
+ /** How many tasks {@link listApprovalHistoryTasks} pages through. */
2725
+ async countApprovalHistoryTasks(assigneeMemberId, options = {}) {
2726
+ return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState).getCount();
2727
+ }
2728
+ /** The rows of {@link listApprovalHistoryTasks}, unordered and unpaged. */
2729
+ createApprovalHistoryTaskQuery(assigneeMemberId, instanceState) {
2730
+ const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
2731
+ const queryBuilder = this.taskRepository
2732
+ .createQueryBuilder('task')
2733
+ .andWhere(`
2734
+ (
2735
+ (
2736
+ "task"."assignee_member_id" = :historyMemberId
2737
+ AND "task"."status" = :historyTaskStatus
2738
+ )
2739
+ OR EXISTS (
2740
+ SELECT 1
2741
+ FROM ${candidateTable} "historyCandidate"
2742
+ WHERE "historyCandidate"."task_id" = "task"."id"
2743
+ AND "historyCandidate"."member_id" = :historyMemberId
2744
+ AND "historyCandidate"."status" = :historyCandidateStatus
2745
+ )
2746
+ )
2747
+ `, {
2748
+ historyCandidateStatus: workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED,
2749
+ historyMemberId: assigneeMemberId,
2750
+ historyTaskStatus: workflow_engine_enums_1.TaskStatusEnum.COMPLETED,
2623
2751
  });
2624
- // No status filter on this branch: the candidate row is already COMPLETED,
2625
- // which means this member has cast their decision. Requiring the task
2626
- // itself to be COMPLETED hid their own vote for as long as a multi-person
2627
- // countersign waited on somebody else — and the inbox has, correctly,
2628
- // stopped showing it to them by then, so it appeared nowhere at all.
2629
- const candidateTasks = candidateRows.length
2630
- ? await this.taskRepository.find({
2631
- order: { completedAt: 'DESC', createdAt: 'DESC' },
2632
- take: DEFAULT_APPROVAL_HISTORY_TASK_LIMIT,
2633
- where: {
2634
- id: (0, typeorm_2.In)(candidateRows.map((candidate) => candidate.taskId)),
2635
- },
2636
- })
2637
- : [];
2638
- return this.attachTaskCandidateSummaries(uniqueTasksById([...directTasks, ...candidateTasks]));
2752
+ if (instanceState) {
2753
+ queryBuilder.andWhere(`
2754
+ EXISTS (
2755
+ SELECT 1
2756
+ FROM ${this.readQualifiedTableName(this.approvalInstanceRepository)} "historyInstance"
2757
+ WHERE "historyInstance"."id" = "task"."instance_id"
2758
+ AND "historyInstance"."state" = :historyInstanceState
2759
+ )
2760
+ `, { historyInstanceState: instanceState });
2761
+ }
2762
+ return queryBuilder;
2763
+ }
2764
+ /** When the member acted on a history task; see {@link listApprovalHistoryTasks}. */
2765
+ readApprovalHistoryActedAtExpression() {
2766
+ const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
2767
+ return `COALESCE(
2768
+ (
2769
+ SELECT MAX("actedCandidate"."decided_at")
2770
+ FROM ${candidateTable} "actedCandidate"
2771
+ WHERE "actedCandidate"."task_id" = "task"."id"
2772
+ AND "actedCandidate"."member_id" = :historyMemberId
2773
+ AND "actedCandidate"."status" = :historyCandidateStatus
2774
+ ),
2775
+ "task"."completed_at",
2776
+ "task"."created_at"
2777
+ )`;
2639
2778
  }
2640
2779
  async listTaskDecisions(taskId, scope) {
2641
2780
  await this.assertTaskReadable(taskId, scope);
@@ -2793,26 +2932,30 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2793
2932
  * case on data the host never gave. It runs before the engine
2794
2933
  * transaction, so a failure leaves nothing behind and a slow lookup holds
2795
2934
  * 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
2935
+ * - The list, and the form options check, carry on with `customFields: {}`
2936
+ * and log a warning: one failing lookup must not take the whole list
2937
+ * away. That fails closed: a
2798
2938
  * policy that reads `initiator.customFields` then throws on the missing key
2799
2939
  * and the list's per-template catch leaves that template out, while a
2800
2940
  * template whose policy does not read it is listed as usual. Submitting is
2801
2941
  * still judged against the resolver's real answer.
2802
2942
  */
2803
- async buildInitiatorMetadataSnapshot({ authContext, memberId, submission, }) {
2943
+ async buildInitiatorMetadataSnapshot({ authContext, formOptions = null, memberId, submission, }) {
2804
2944
  const defaultSnapshot = await this.readDefaultInitiatorMetadataSnapshot(memberId);
2805
2945
  if (!this.initiatorCustomFieldsResolver) {
2806
2946
  return defaultSnapshot;
2807
2947
  }
2948
+ // The template the resolver is asked about: the one being submitted, or
2949
+ // the one whose options are read. The list asks about none.
2950
+ const template = submission ?? formOptions;
2808
2951
  const input = {
2809
2952
  authContext,
2810
2953
  defaultSnapshot: cloneJsonObject(defaultSnapshot),
2811
2954
  formData: submission ? cloneJsonObject(submission.formData) : null,
2812
2955
  memberId,
2813
- purpose: submission ? 'SUBMISSION' : 'LAUNCHABLE_TEMPLATES',
2814
- templateId: submission?.templateId ?? null,
2815
- templateVersionId: submission?.templateVersionId ?? null,
2956
+ purpose: readInitiatorCustomFieldsPurpose({ formOptions, submission }),
2957
+ templateId: template?.templateId ?? null,
2958
+ templateVersionId: template?.templateVersionId ?? null,
2816
2959
  };
2817
2960
  if (submission) {
2818
2961
  return {
@@ -2827,7 +2970,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2827
2970
  };
2828
2971
  }
2829
2972
  catch (error) {
2830
- this.logger.warn(`Launchable templates judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
2973
+ this.logger.warn(`${formOptions
2974
+ ? `Form options for template=${formOptions.templateId}`
2975
+ : 'Launchable templates'} judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
2831
2976
  return defaultSnapshot;
2832
2977
  }
2833
2978
  }
@@ -2848,9 +2993,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2848
2993
  * unhandled.
2849
2994
  */
2850
2995
  async resolveInitiatorCustomFields(resolver, input) {
2851
- const subject = input.purpose === 'SUBMISSION'
2852
- ? `template=${input.templateId} version=${input.templateVersionId}`
2853
- : 'launchable templates';
2996
+ const subject = input.purpose === 'LAUNCHABLE_TEMPLATES'
2997
+ ? 'launchable templates'
2998
+ : `${input.purpose === 'FORM_OPTIONS' ? 'form options ' : ''}template=${input.templateId} version=${input.templateVersionId}`;
2854
2999
  const outcome = await settleWithin(new Promise((resolve) => {
2855
3000
  resolve(resolver.resolveCustomFields(input));
2856
3001
  }), INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS, (late) => {
@@ -5635,6 +5780,13 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
5635
5780
  function cloneJsonObject(value) {
5636
5781
  return JSON.parse(JSON.stringify(value));
5637
5782
  }
5783
+ /** Which caller is building an initiator snapshot, as the resolver is told. */
5784
+ function readInitiatorCustomFieldsPurpose({ formOptions, submission, }) {
5785
+ if (submission) {
5786
+ return 'SUBMISSION';
5787
+ }
5788
+ return formOptions ? 'FORM_OPTIONS' : 'LAUNCHABLE_TEMPLATES';
5789
+ }
5638
5790
  /**
5639
5791
  * What a host's initiator `customFields` resolver returned, as the object the
5640
5792
  * snapshot keeps: a plain object that survives JSON, which is also how it is
@@ -6376,6 +6528,9 @@ function readPageOffset(page, pageSize) {
6376
6528
  const normalizedPageSize = normalizePageSize(pageSize);
6377
6529
  return (normalizePage(page) - 1) * normalizedPageSize;
6378
6530
  }
6531
+ function isTaskListPaged(options) {
6532
+ return options.page !== undefined || options.pageSize !== undefined;
6533
+ }
6379
6534
  function shouldPaginateList(options) {
6380
6535
  return (typeof options.page !== 'undefined' ||
6381
6536
  typeof options.pageSize !== 'undefined');