@rytass/bpm-core-nestjs-module 0.17.8 → 0.17.9

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.
@@ -113,6 +113,15 @@ class TaskReassignmentRejectedError extends Error {
113
113
  this.errorCode = errorCode;
114
114
  }
115
115
  }
116
+ /** The administrator screens are in Traditional Chinese: names sort by stroke count. */
117
+ const INACTIVE_MEMBER_NAME_LOCALE = 'zh-Hant-TW';
118
+ /**
119
+ * How many deactivated members have their open tasks counted at once. Each
120
+ * count is a few queries, so an unbounded fan-out would put one query in
121
+ * flight per member, and claim that many pool connections at the same time, on
122
+ * every load of the member screen.
123
+ */
124
+ const INACTIVE_MEMBER_COUNT_CONCURRENCY = 5;
116
125
  const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
117
126
  'bpm:*',
118
127
  'bpm.workflow.read_all',
@@ -908,6 +917,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
908
917
  instanceId: instance.id,
909
918
  nodeId: null,
910
919
  payload: {
920
+ comment: input.comment?.trim() || null,
911
921
  resubmitStrategy: returnedContext?.resubmitStrategy ?? 'RESTART',
912
922
  },
913
923
  taskId: null,
@@ -3340,6 +3350,97 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3340
3350
  };
3341
3351
  });
3342
3352
  }
3353
+ /**
3354
+ * Deactivated members who still hold open tasks, so an administrator can find
3355
+ * them: a deactivated member no longer shows up in the member search.
3356
+ *
3357
+ * BPM cannot tell a deactivated member from an id it never knew, so the host
3358
+ * names them: `resolveInactiveMembers` gets every member id that holds an open
3359
+ * seat and returns the ones it knows but has deactivated. The count per member
3360
+ * is the {@link listMemberOpenTasks} count, and members left with none are
3361
+ * dropped.
3362
+ */
3363
+ async listInactiveMembersWithOpenTasks(resolveInactiveMembers) {
3364
+ const holderIds = await this.readOpenSeatHolderMemberIds();
3365
+ if (!holderIds.length) {
3366
+ return [];
3367
+ }
3368
+ const holderIdSet = new Set(holderIds);
3369
+ // Only the ids it was asked about are counted, each once: a member the
3370
+ // host adds would only cost task queries before the count dropped it.
3371
+ const inactiveMembers = [
3372
+ ...new Map((await resolveInactiveMembers(holderIds))
3373
+ .filter((member) => holderIdSet.has(member.memberId))
3374
+ .map((member) => [member.memberId, member])).values(),
3375
+ ];
3376
+ const counted = await chunkArray(inactiveMembers, INACTIVE_MEMBER_COUNT_CONCURRENCY).reduce(async (previous, batch) => [
3377
+ ...(await previous),
3378
+ ...(await Promise.all(batch.map(async (member) => ({
3379
+ email: member.email,
3380
+ memberId: member.memberId,
3381
+ name: member.name,
3382
+ openTaskCount: (await this.readRunningMemberTasks(member.memberId))
3383
+ .length,
3384
+ })))),
3385
+ ], Promise.resolve([]));
3386
+ return counted
3387
+ .filter((record) => record.openTaskCount > 0)
3388
+ .sort((left, right) => left.name.localeCompare(right.name, INACTIVE_MEMBER_NAME_LOCALE) ||
3389
+ // Namesakes are common; the id keeps their order stable.
3390
+ (left.memberId < right.memberId
3391
+ ? -1
3392
+ : left.memberId > right.memberId
3393
+ ? 1
3394
+ : 0));
3395
+ }
3396
+ /**
3397
+ * Every member id that may hold an open seat on a RUNNING instance: the
3398
+ * assignee of an open task, or an open candidate row on one. Still a
3399
+ * superset (a countersign voter who already decided stays the assignee);
3400
+ * callers count with {@link readRunningMemberTasks}, which drops those.
3401
+ *
3402
+ * One query that returns only the distinct holder ids, never a row per
3403
+ * task or seat: the member screen loads it every time.
3404
+ */
3405
+ async readOpenSeatHolderMemberIds() {
3406
+ const taskTable = this.readQualifiedTableName(this.taskRepository);
3407
+ const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
3408
+ const instanceTable = this.readQualifiedTableName(this.approvalInstanceRepository);
3409
+ const rows = await this.approvalInstanceRepository.query(`SELECT holder.member_id AS "memberId"
3410
+ FROM (
3411
+ SELECT task.assignee_member_id AS member_id
3412
+ FROM ${taskTable} AS task
3413
+ JOIN ${instanceTable} AS instance
3414
+ ON instance.id = task.instance_id
3415
+ WHERE task.status = ANY($1::text[])
3416
+ AND task.assignee_member_id IS NOT NULL
3417
+ AND instance.state = $2
3418
+ UNION
3419
+ SELECT candidate.member_id
3420
+ FROM ${candidateTable} AS candidate
3421
+ JOIN ${taskTable} AS task ON task.id = candidate.task_id
3422
+ JOIN ${instanceTable} AS instance
3423
+ ON instance.id = task.instance_id
3424
+ WHERE candidate.status = ANY($3::text[])
3425
+ AND task.status = ANY($1::text[])
3426
+ AND instance.state = $2
3427
+ ) AS holder`, [
3428
+ [workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS],
3429
+ workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
3430
+ [...OPEN_SEAT_STATUSES],
3431
+ ]);
3432
+ // Sorted here, not in SQL: the default JS order (UTF-16 code units),
3433
+ // whatever the DB collation.
3434
+ return rows.map((row) => row.memberId).sort();
3435
+ }
3436
+ /** `schema.table` of a repository's entity, escaped for raw SQL. */
3437
+ readQualifiedTableName(repository) {
3438
+ const { driver } = this.approvalInstanceRepository.manager.connection;
3439
+ return [repository.metadata.schema, repository.metadata.tableName]
3440
+ .filter((part) => Boolean(part))
3441
+ .map((part) => driver.escape(part))
3442
+ .join('.');
3443
+ }
3343
3444
  /**
3344
3445
  * The open tasks `memberId` holds on RUNNING instances, with the instance
3345
3446
  * each belongs to. The instance filter is applied here, not only in the query.
@@ -5081,6 +5182,10 @@ function capDecisionPolicyToCandidateCount(policy, candidateCount) {
5081
5182
  ? { ...policy, threshold: Math.max(candidateCount, 1) }
5082
5183
  : policy;
5083
5184
  }
5185
+ /** Splits `items` into consecutive runs of at most `size`. */
5186
+ function chunkArray(items, size) {
5187
+ return Array.from({ length: Math.ceil(items.length / size) }, (_, index) => items.slice(index * size, (index + 1) * size));
5188
+ }
5084
5189
  function uniqueTasksById(tasks) {
5085
5190
  const seen = new Set();
5086
5191
  return tasks.filter((task) => {