@rytass/bpm-core-nestjs-module 0.20.1 → 0.21.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -4
  3. package/package.json +2 -2
  4. package/src/lib/form/form-data-issue.d.ts +34 -0
  5. package/src/lib/form/form-data-issue.js +59 -0
  6. package/src/lib/form/form-data-issue.js.map +1 -0
  7. package/src/lib/form/form-member-value.validator.d.ts +2 -2
  8. package/src/lib/form/form-member-value.validator.js +2 -3
  9. package/src/lib/form/form-member-value.validator.js.map +1 -1
  10. package/src/lib/form/form-org-unit-value.validator.d.ts +2 -2
  11. package/src/lib/form/form-org-unit-value.validator.js +4 -1
  12. package/src/lib/form/form-org-unit-value.validator.js.map +1 -1
  13. package/src/lib/form/form-schema.validator.js +40 -0
  14. package/src/lib/form/form-schema.validator.js.map +1 -1
  15. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.d.ts +29 -0
  16. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js +44 -0
  17. package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js.map +1 -0
  18. package/src/lib/migrations/index.d.ts +1 -0
  19. package/src/lib/migrations/index.js +3 -0
  20. package/src/lib/migrations/index.js.map +1 -1
  21. package/src/lib/template/filterable-field-guard.d.ts +49 -0
  22. package/src/lib/template/filterable-field-guard.js +141 -0
  23. package/src/lib/template/filterable-field-guard.js.map +1 -0
  24. package/src/lib/template/filterable-field-issue.object.d.ts +21 -0
  25. package/src/lib/template/filterable-field-issue.object.js +65 -0
  26. package/src/lib/template/filterable-field-issue.object.js.map +1 -0
  27. package/src/lib/template/filterable-field.queries.d.ts +20 -0
  28. package/src/lib/template/filterable-field.queries.js +80 -0
  29. package/src/lib/template/filterable-field.queries.js.map +1 -0
  30. package/src/lib/template/index.d.ts +1 -0
  31. package/src/lib/template/index.js +1 -0
  32. package/src/lib/template/index.js.map +1 -1
  33. package/src/lib/template/template.module.js +2 -0
  34. package/src/lib/template/template.module.js.map +1 -1
  35. package/src/lib/template/template.service.d.ts +39 -0
  36. package/src/lib/template/template.service.js +167 -44
  37. package/src/lib/template/template.service.js.map +1 -1
  38. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.d.ts +49 -0
  39. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js +501 -0
  40. package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js.map +1 -0
  41. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.d.ts +15 -0
  42. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js +20 -0
  43. package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js.map +1 -0
  44. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.d.ts +15 -0
  45. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js +55 -0
  46. package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js.map +1 -0
  47. package/src/lib/workflow-engine/dto/approval-instance-list-args.d.ts +9 -0
  48. package/src/lib/workflow-engine/dto/approval-instance-list-args.js +24 -0
  49. package/src/lib/workflow-engine/dto/approval-instance-list-args.js.map +1 -0
  50. package/src/lib/workflow-engine/dto/approval-instance-sort.input.d.ts +10 -0
  51. package/src/lib/workflow-engine/dto/approval-instance-sort.input.js +32 -0
  52. package/src/lib/workflow-engine/dto/approval-instance-sort.input.js.map +1 -0
  53. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +12 -0
  54. package/src/lib/workflow-engine/workflow-engine.enums.js +21 -1
  55. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  56. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +5 -3
  57. package/src/lib/workflow-engine/workflow-engine.queries.js +31 -9
  58. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  59. package/src/lib/workflow-engine/workflow-engine.service.d.ts +35 -4
  60. package/src/lib/workflow-engine/workflow-engine.service.js +266 -53
  61. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -18,6 +18,7 @@ const form_definition_version_entity_1 = require("../form/form-definition-versio
18
18
  const form_enums_1 = require("../form/form.enums");
19
19
  const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
20
20
  const form_member_value_validator_1 = require("../form/form-member-value.validator");
21
+ const form_data_issue_1 = require("../form/form-data-issue");
21
22
  const form_org_unit_value_validator_1 = require("../form/form-org-unit-value.validator");
22
23
  const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
23
24
  const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
@@ -39,6 +40,8 @@ const activity_log_entity_1 = require("./activity-log.entity");
39
40
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
40
41
  const adhoc_enums_1 = require("./adhoc.enums");
41
42
  const approval_instance_entity_1 = require("./approval-instance.entity");
43
+ const approval_instance_field_filter_compiler_1 = require("./approval-instance-field-filter.compiler");
44
+ const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
42
45
  const text_field_pattern_test_1 = require("./text-field-pattern-test");
43
46
  const form_data_fingerprint_1 = require("./form-data-fingerprint");
44
47
  const instance_submitter_1 = require("./instance-submitter");
@@ -195,6 +198,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
195
198
  this.formDataSourceValueResolver = formDataSourceValueResolver;
196
199
  this.webhookDeliveryService = webhookDeliveryService;
197
200
  this.logger = new common_1.Logger(WorkflowEngineService_1.name);
201
+ this.approvalInstanceFieldFilterCompiler = new approval_instance_field_filter_compiler_1.ApprovalInstanceFieldFilterCompiler();
202
+ this.approvalInstanceFilterableFieldPolicy = new approval_instance_filterable_field_policy_1.ApprovalInstanceFilterableFieldPolicy();
198
203
  /** Webhooks each open engine transaction will send once it has committed. */
199
204
  this.deferredWebhookDispatches = new WeakMap();
200
205
  // A binding under the token wins: that is either the host's own provider
@@ -308,6 +313,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
308
313
  validateSubmittedFormData(formDefinitionVersion.schema, formData, {
309
314
  deferredFieldKeys,
310
315
  });
316
+ // Checked against the bound version's schema before any data source is
317
+ // called, so a bad value is refused cheaply. The transaction below
318
+ // confirms under the template lock that this version is still current
319
+ // (ADR 25 §15), which is what makes this schema the one in force.
320
+ assertFilterableFormDataValues(formDefinitionVersion.schema, formData);
311
321
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
312
322
  authContext: authContext ?? createWorkflowAuthContext(input.initiatorMemberId),
313
323
  formData,
@@ -334,6 +344,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
334
344
  throw new common_1.BadRequestException('Published workflow does not include a startEvent');
335
345
  }
336
346
  return this.runEngineTransaction(async (manager) => {
347
+ // ADR 25 §6 and §15: take FOR SHARE on the template row, so a publish,
348
+ // deactivation or edit cannot commit between this point and the
349
+ // insert, then re-check under the lock what was read before it. The
350
+ // filterable check above used the version read then; it holds only
351
+ // while that version is still the current one.
352
+ const lockedTemplate = await manager
353
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
354
+ .findOne({
355
+ lock: { mode: 'pessimistic_read' },
356
+ where: { deletedAt: (0, typeorm_2.IsNull)(), id: template.id },
357
+ });
358
+ if (!lockedTemplate) {
359
+ throw new common_1.NotFoundException(`Approval template ${template.id} was not found`);
360
+ }
361
+ if (!lockedTemplate.isActive) {
362
+ throw new common_1.ConflictException('Approval template is deactivated');
363
+ }
364
+ if (lockedTemplate.currentVersionId !== templateVersion.id) {
365
+ throw new common_1.ConflictException('範本在送出期間發布了新版本,這次申請沒有送出;請重新整理頁面後再送出');
366
+ }
337
367
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
338
368
  const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
339
369
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
@@ -460,6 +490,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
460
490
  ? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
461
491
  : submittedFormData;
462
492
  validateSubmittedFormData(schema, formData, { deferredFieldKeys });
493
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
463
494
  const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
464
495
  authContext,
465
496
  formData,
@@ -673,6 +704,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
673
704
  }
674
705
  }
675
706
  if (filledFormData !== null && changedFieldKeys.length > 0) {
707
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
676
708
  // Merged into the shared `instance` before it is signed and before
677
709
  // the token moves on: the signature must cover the filled values, a
678
710
  // gateway after this node must route on them, and an end node saves
@@ -994,6 +1026,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
994
1026
  validateSubmittedFormData(resubmitSchema, formData, {
995
1027
  deferredFieldKeys,
996
1028
  });
1029
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
997
1030
  if (!preparation) {
998
1031
  assertNoUnresolvableDynamicValues(resubmitSchema, formData);
999
1032
  }
@@ -2085,22 +2118,76 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2085
2118
  }
2086
2119
  return instance;
2087
2120
  }
2121
+ /**
2122
+ * ADR 25 §6: a value written to a filterable field must fit its type, on
2123
+ * every path that writes form data. The fields checked are the ones the
2124
+ * template's *current* version marks filterable, not the instance's own
2125
+ * snapshot: an older instance can be re-filled, resubmitted or written by a
2126
+ * service task after a newer version enabled filtering on a field, and the
2127
+ * enable check (ADR 25 §5.4) guarantees the type is the same in every version
2128
+ * that holds instances, so the current definition judges the old value too.
2129
+ */
2130
+ async assertCurrentFilterableFormDataValues(templateId, formData, manager, serviceNodeLabel) {
2131
+ const schema = await this.readCurrentFormSchema(templateId, manager);
2132
+ assertFilterableFormDataValues(schema, formData, serviceNodeLabel);
2133
+ }
2134
+ /** The form schema of the template's current version, or `null` when there is none. */
2135
+ async readCurrentFormSchema(templateId, manager) {
2136
+ // Inside a write transaction the template row is taken FOR SHARE (ADR 25
2137
+ // §15): a publish, compose or rollback needs FOR UPDATE on the same row, so
2138
+ // the current version read here cannot change before the write commits.
2139
+ const template = manager
2140
+ ? await manager.getRepository(approval_template_entity_1.ApprovalTemplateEntity).findOne({
2141
+ lock: { mode: 'pessimistic_read' },
2142
+ where: { id: templateId },
2143
+ })
2144
+ : await this.approvalTemplateRepository.findOne({
2145
+ where: { id: templateId },
2146
+ });
2147
+ if (!template?.currentVersionId) {
2148
+ return null;
2149
+ }
2150
+ const versions = manager
2151
+ ? manager.getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
2152
+ : this.approvalTemplateVersionRepository;
2153
+ const version = await versions.findOne({
2154
+ where: { id: template.currentVersionId },
2155
+ });
2156
+ if (!version?.formDefinitionVersionId) {
2157
+ return null;
2158
+ }
2159
+ const formVersions = manager
2160
+ ? manager.getRepository(form_definition_version_entity_1.FormDefinitionVersionEntity)
2161
+ : this.formDefinitionVersionRepository;
2162
+ const formVersion = await formVersions.findOne({
2163
+ where: { id: version.formDefinitionVersionId },
2164
+ });
2165
+ return formVersion?.schema ?? null;
2166
+ }
2088
2167
  async listApprovalInstances(scope, options = {}) {
2089
- const queryBuilder = await this.createFilteredApprovalInstanceQueryBuilder(scope, options);
2090
- if (!queryBuilder) {
2168
+ const query = await this.createFilteredApprovalInstanceQuery(scope, options);
2169
+ if (!query) {
2091
2170
  return [];
2092
2171
  }
2093
- queryBuilder.orderBy('"approvalInstance"."created_at"', 'DESC');
2172
+ const { orderBy, queryBuilder } = query;
2173
+ orderBy.forEach((order, index) => {
2174
+ if (index === 0) {
2175
+ queryBuilder.orderBy(order.sql, order.direction);
2176
+ }
2177
+ else {
2178
+ queryBuilder.addOrderBy(order.sql, order.direction);
2179
+ }
2180
+ });
2094
2181
  if (shouldPaginateList(options)) {
2095
2182
  queryBuilder
2096
- .skip(readPageOffset(options.page, options.pageSize))
2097
- .take(normalizePageSize(options.pageSize));
2183
+ .offset(readPageOffset(options.page, options.pageSize))
2184
+ .limit(normalizePageSize(options.pageSize));
2098
2185
  }
2099
2186
  return queryBuilder.getMany();
2100
2187
  }
2101
2188
  async countApprovalInstances(scope, options = {}) {
2102
- const queryBuilder = await this.createFilteredApprovalInstanceQueryBuilder(scope, options);
2103
- return queryBuilder ? queryBuilder.getCount() : 0;
2189
+ const query = await this.createFilteredApprovalInstanceQuery(scope, options);
2190
+ return query ? query.queryBuilder.getCount() : 0;
2104
2191
  }
2105
2192
  async readApprovalInstancePageInfo(scope, options = {}) {
2106
2193
  const totalCount = await this.countApprovalInstances(scope, options);
@@ -2116,7 +2203,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2116
2203
  totalPages,
2117
2204
  };
2118
2205
  }
2119
- async createFilteredApprovalInstanceQueryBuilder(scope, options = {}) {
2206
+ async createFilteredApprovalInstanceQuery(scope, options = {}) {
2120
2207
  const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
2121
2208
  const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
2122
2209
  if (!hasReadableInstances) {
@@ -2134,7 +2221,72 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2134
2221
  });
2135
2222
  }
2136
2223
  applyApprovalInstanceSearchFilter(queryBuilder, options.searchText);
2137
- return queryBuilder;
2224
+ const compiled = await this.compileApprovalInstanceFieldQuery(scope, templateId, options);
2225
+ compiled.conditions.forEach((condition) => {
2226
+ queryBuilder.andWhere(condition.sql, condition.parameters);
2227
+ });
2228
+ queryBuilder.setParameters(compiled.orderBy.reduce((all, order) => ({ ...all, ...order.parameters }), {}));
2229
+ return { orderBy: compiled.orderBy, queryBuilder };
2230
+ }
2231
+ /**
2232
+ * The WHERE conditions and sort keys of the field filters and sort on the
2233
+ * approval-instance list (ADR 25 §7). The template's current schema is read,
2234
+ * and the static option lists of every version holding instances, only when
2235
+ * a condition or a non-time sort key needs them.
2236
+ */
2237
+ async compileApprovalInstanceFieldQuery(scope, templateId, options) {
2238
+ const fieldFilters = options.fieldFilters ?? [];
2239
+ const sort = options.sort ?? [];
2240
+ const needsSchema = fieldFilters.length > 0 ||
2241
+ sort.some((item) => !(0, bpm_core_shared_1.isApprovalInstanceSortTimeField)(item.field));
2242
+ if (needsSchema && !templateId) {
2243
+ throw new common_1.BadRequestException('篩選或排序表單欄位時需要指定簽核範本');
2244
+ }
2245
+ const schema = needsSchema && templateId
2246
+ ? await this.readCurrentFormSchema(templateId)
2247
+ : null;
2248
+ // Only a name search (CONTAINS) reads option names; other conditions and
2249
+ // sort keys need the current schema alone.
2250
+ const needsOptionNames = fieldFilters.some((filter) => filter.operator === 'CONTAINS');
2251
+ const staticOptionsByPath = needsOptionNames && templateId
2252
+ ? await this.readStaticOptionsByPathForTemplate(templateId, schema)
2253
+ : (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([]);
2254
+ return this.approvalInstanceFieldFilterCompiler.compile({
2255
+ alias: 'approvalInstance',
2256
+ fieldFilters,
2257
+ paths: this.approvalInstanceFilterableFieldPolicy.readFilterableFieldPaths(scope, schema),
2258
+ sort,
2259
+ staticOptionsByPath,
2260
+ });
2261
+ }
2262
+ /**
2263
+ * Static option lists by field path, across the current schema and the form
2264
+ * version of every template version that holds instances (ADR 25 §7.3): a
2265
+ * CONTAINS on a retired option still matches its name.
2266
+ */
2267
+ async readStaticOptionsByPathForTemplate(templateId, currentSchema) {
2268
+ const held = await this.approvalInstanceRepository
2269
+ .createQueryBuilder('approvalInstance')
2270
+ .select('DISTINCT "approvalInstance"."template_version_id"', 'id')
2271
+ .where('"approvalInstance"."template_id" = :templateId', { templateId })
2272
+ .getRawMany();
2273
+ const templateVersions = held.length === 0
2274
+ ? []
2275
+ : await this.approvalTemplateVersionRepository.find({
2276
+ where: { id: (0, typeorm_2.In)(held.map((row) => row.id)) },
2277
+ });
2278
+ const formVersionIds = templateVersions
2279
+ .map((version) => version.formDefinitionVersionId)
2280
+ .filter((id) => id !== null);
2281
+ const formVersions = formVersionIds.length === 0
2282
+ ? []
2283
+ : await this.formDefinitionVersionRepository.find({
2284
+ where: { id: (0, typeorm_2.In)(formVersionIds) },
2285
+ });
2286
+ return (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([
2287
+ currentSchema,
2288
+ ...formVersions.map((version) => version.schema),
2289
+ ]);
2138
2290
  }
2139
2291
  applyReadableInstanceFilter(queryBuilder, scope, options) {
2140
2292
  if (!scope) {
@@ -4649,6 +4801,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4649
4801
  const value = this.conditionService.evaluateValue(action.value, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
4650
4802
  assertServiceTaskTextValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
4651
4803
  const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
4804
+ await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
4652
4805
  // A service task runs inside this transaction, so it cannot call a host
4653
4806
  // provider to re-resolve the label. Rather than leave the previous
4654
4807
  // option snapshot describing a value that no longer exists, drop it: the
@@ -5107,6 +5260,52 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
5107
5260
  const field = schema.fields.find((candidate) => isRecord(candidate) && candidate.fieldKey === fieldKey);
5108
5261
  return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
5109
5262
  }
5263
+ /**
5264
+ * Refuses form data whose filterable fields hold a value of the wrong type
5265
+ * (ADR 25 §6). The list query reads these values with type guards, so a wrong
5266
+ * value would silently fall out of every filter; refusing the write is what
5267
+ * keeps filter results trustworthy.
5268
+ */
5269
+ function assertFilterableFormDataValues(schema, formData, serviceNodeLabel) {
5270
+ const problems = (0, bpm_core_shared_1.readFilterableValueProblems)((0, bpm_core_shared_1.readFilterableFieldPaths)(schema), formData);
5271
+ if (problems.length === 0) {
5272
+ return;
5273
+ }
5274
+ const issues = problems.map((problem) => (0, form_data_issue_1.createFormDataIssue)(readFilterableProblemLocation(schema, problem), 'TYPE', `必須是${problem.expected}`));
5275
+ const details = (0, form_data_issue_1.readFormDataIssueMessage)(issues);
5276
+ throw (0, form_data_issue_1.createFormDataBadRequest)(serviceNodeLabel
5277
+ ? `系統節點「${serviceNodeLabel}」要寫入的表單值不符合可篩選欄位的型別(${details}),這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`
5278
+ : `可篩選欄位的值型別不正確:${details}`, issues);
5279
+ }
5280
+ /** Where a filterable problem sits: a field, a table row, or one cell of a row. */
5281
+ function readFilterableProblemLocation(schema, problem) {
5282
+ const [tableKey, columnKey] = problem.path.split('.');
5283
+ const table = schema?.fields.find((field) => field.fieldKey === tableKey && (0, form_1.isTableFieldDefinition)(field));
5284
+ const column = table && (0, form_1.isTableFieldDefinition)(table) && columnKey !== undefined
5285
+ ? table.columns.find((candidate) => candidate.fieldKey === columnKey)
5286
+ : undefined;
5287
+ if (table && (0, form_1.isTableFieldDefinition)(table)) {
5288
+ if (problem.rowIndex === null) {
5289
+ return (0, form_data_issue_1.readFieldIssueLocation)(table);
5290
+ }
5291
+ const rowLocation = (0, form_data_issue_1.readRowIssueLocation)(table, problem.rowIndex);
5292
+ return column ? (0, form_data_issue_1.readCellIssueLocation)(rowLocation, column) : rowLocation;
5293
+ }
5294
+ const field = schema?.fields.find((candidate) => candidate.fieldKey === problem.path);
5295
+ if (field) {
5296
+ return (0, form_data_issue_1.readFieldIssueLocation)(field);
5297
+ }
5298
+ // Not in the schema (the check and this lookup read the same schema, so
5299
+ // this is defensive): still address it the way FormDataIssue.path promises,
5300
+ // `table[i].col` for a row value rather than the dotted filter path.
5301
+ const row = `${tableKey}[${problem.rowIndex}]`;
5302
+ const path = problem.rowIndex === null
5303
+ ? problem.path
5304
+ : columnKey === undefined
5305
+ ? row
5306
+ : `${row}.${columnKey}`;
5307
+ return { path, ref: `欄位「${path}」` };
5308
+ }
5110
5309
  /**
5111
5310
  * Holds a `SET_FORM_FIELD` write to the length and format rules the form
5112
5311
  * applies to a filler, so a service task cannot store text the field would
@@ -5132,8 +5331,8 @@ function assertServiceTaskTextValue(snapshot, fieldPath, value, nodeLabel) {
5132
5331
  if (!field || errors.length === 0) {
5133
5332
  return;
5134
5333
  }
5135
- throw new common_1.BadRequestException(`系統節點「${nodeLabel}」要寫入欄位「${field.label || field.fieldKey}」的值不符合該欄位的規則` +
5136
- `(${errors.join('; ')}),這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`);
5334
+ throw new common_1.BadRequestException(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
5335
+ `這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`);
5137
5336
  }
5138
5337
  function readSnapshotWithoutField(snapshots, fieldKey) {
5139
5338
  return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
@@ -6208,29 +6407,28 @@ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys
6208
6407
  * field wins over a text value breaking its length or format rule, which wins
6209
6408
  * over a malformed member value, which wins over a malformed org unit value,
6210
6409
  * which wins over a table problem, and the later checks are only evaluated
6211
- * when the earlier ones pass.
6410
+ * when the earlier ones pass. The refusal carries every issue of the winning
6411
+ * check. Its message is those issues' sentences joined, except for missing
6412
+ * required fields, which keep the one-line summary `缺少必填欄位:A, B` that
6413
+ * submitters read; each issue there still says `欄位「A」為必填`. A client
6414
+ * that places errors on fields reads the issues, not the message.
6212
6415
  */
6213
6416
  function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
6214
6417
  if (missingFields.length > 0) {
6215
- throw new common_1.BadRequestException(`Form data is missing required fields: ${missingFields
6418
+ throw (0, form_data_issue_1.createFormDataBadRequest)(`缺少必填欄位:${missingFields
6216
6419
  .map((field) => field.label || field.fieldKey)
6217
- .join(', ')}`);
6218
- }
6219
- const textErrors = readTextErrors();
6220
- if (textErrors.length > 0) {
6221
- throw new common_1.BadRequestException(textErrors.join('; '));
6222
- }
6223
- const memberErrors = readMemberErrors();
6224
- if (memberErrors.length > 0) {
6225
- throw new common_1.BadRequestException(memberErrors.join('; '));
6420
+ .join(', ')}`, missingFields.map((field) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readFieldIssueLocation)(field), 'REQUIRED', '為必填')));
6226
6421
  }
6227
- const orgUnitErrors = readOrgUnitErrors();
6228
- if (orgUnitErrors.length > 0) {
6229
- throw new common_1.BadRequestException(orgUnitErrors.join('; '));
6230
- }
6231
- const tableErrors = readTableErrors();
6232
- if (tableErrors.length > 0) {
6233
- throw new common_1.BadRequestException(tableErrors.join('; '));
6422
+ // Each check runs only when every earlier one passed, so a later check's
6423
+ // work is never done for a request that is already refused.
6424
+ const winning = [
6425
+ readTextErrors,
6426
+ readMemberErrors,
6427
+ readOrgUnitErrors,
6428
+ readTableErrors,
6429
+ ].reduce((found, check) => (found.length > 0 ? found : check()), []);
6430
+ if (winning.length > 0) {
6431
+ throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)(winning), winning);
6234
6432
  }
6235
6433
  }
6236
6434
  /**
@@ -6239,64 +6437,73 @@ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrg
6239
6437
  * the flat text fields share. `minimum` / `maximum` stay a frontend hint.
6240
6438
  */
6241
6439
  function validateSubmittedTableValue(field, value, required, testPattern, shapeOnly = false) {
6242
- const path = `formData.${field.fieldKey}`;
6440
+ const location = (0, form_data_issue_1.readFieldIssueLocation)(field);
6243
6441
  if (typeof value === 'undefined' || value === null) {
6244
- return shapeOnly
6245
- ? []
6246
- : required || readTableMinRows(field) > 0
6247
- ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
6248
- : [];
6442
+ if (shapeOnly) {
6443
+ return [];
6444
+ }
6445
+ if (required || readTableMinRows(field) > 0) {
6446
+ const rows = readTableRequiredRowCount(field);
6447
+ return [
6448
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${rows} 列`, rows),
6449
+ ];
6450
+ }
6451
+ return [];
6249
6452
  }
6250
6453
  if (!Array.isArray(value)) {
6251
- return [`${path} must be an array of rows`];
6454
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是表格列的清單')];
6252
6455
  }
6253
6456
  // Too many rows is refused before any row is checked: the rows a submitter
6254
6457
  // sends would otherwise decide how many cells the text patterns run on.
6255
6458
  const maxRows = readTableMaxRows(field);
6256
6459
  if (value.length > maxRows) {
6257
- return [`${path} must include at most ${maxRows} row(s)`];
6460
+ return [
6461
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MAX_ROWS', `最多 ${maxRows} 列`, maxRows),
6462
+ ];
6258
6463
  }
6259
- const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, testPattern, shapeOnly));
6464
+ const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, (0, form_data_issue_1.readRowIssueLocation)(field, index), testPattern, shapeOnly));
6260
6465
  // A malformed row cannot be counted against the minimum meaningfully; report
6261
6466
  // the shape problem first so the submitter fixes the real cause.
6262
6467
  if (rowErrors.length > 0) {
6263
6468
  return rowErrors;
6264
6469
  }
6265
- return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
6470
+ return validateSubmittedTableRowCount(field, value.length, required, location, shapeOnly);
6266
6471
  }
6267
6472
  /** The minimum only: the maximum is refused before any row is checked. */
6268
- function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
6473
+ function validateSubmittedTableRowCount(field, rowCount, required, location, shapeOnly = false) {
6269
6474
  const minRows = shapeOnly
6270
6475
  ? 0
6271
6476
  : required
6272
6477
  ? Math.max(readTableMinRows(field), 1)
6273
6478
  : readTableMinRows(field);
6274
6479
  return rowCount < minRows
6275
- ? [`${path} must include at least ${minRows} row(s)`]
6480
+ ? [
6481
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${minRows} 列`, minRows),
6482
+ ]
6276
6483
  : [];
6277
6484
  }
6278
- function validateSubmittedTableRow(field, row, path, testPattern, shapeOnly = false) {
6485
+ function validateSubmittedTableRow(field, row, location, testPattern, shapeOnly = false) {
6279
6486
  if (!isRecord(row)) {
6280
- return [`${path} must be an object`];
6487
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是物件')];
6281
6488
  }
6282
6489
  const columnsByKey = new Map(field.columns.map((column) => [column.fieldKey, column]));
6283
6490
  const unknownKeyErrors = Object.keys(row)
6284
6491
  .filter((columnKey) => !columnsByKey.has(columnKey))
6285
- .map((columnKey) => `${path}.${columnKey} is not a table column`);
6492
+ .map((columnKey) => (0, form_data_issue_1.createFormDataIssue)(location, 'UNKNOWN_COLUMN', `含有不屬於此表格的欄位「${columnKey}」`));
6286
6493
  const cellErrors = Object.entries(row)
6287
6494
  .filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
6288
- .map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
6495
+ .map(([columnKey]) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, columnsByKey.get(columnKey) ?? { fieldKey: columnKey }), 'TYPE', '不是有效的值'));
6289
6496
  const requiredErrors = shapeOnly
6290
6497
  ? []
6291
6498
  : field.columns
6292
6499
  .filter((column) => isSubmittedTableColumnRequired(column) &&
6293
6500
  !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
6294
- .map((column) => `${path}.${column.fieldKey} is required`);
6501
+ .map((column) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, column), 'REQUIRED', '為必填'));
6295
6502
  // A shape-only pass covers a table nobody fills in, so the filler-facing
6296
6503
  // rules do not apply to it, the same as the required check above; only a
6297
6504
  // cell of the wrong type is still refused.
6298
6505
  const textErrors = field.columns.flatMap((column) => readSubmittedTextValueErrors(column, readRowCellValue(row, column.fieldKey), {
6299
- path: `${path}.${column.fieldKey}`,
6506
+ location: (0, form_data_issue_1.readCellIssueLocation)(location, column),
6300
6507
  shapeOnly,
6301
6508
  testPattern,
6302
6509
  }));
@@ -6313,7 +6520,7 @@ function validateSubmittedTableRow(field, row, path, testPattern, shapeOnly = fa
6313
6520
  * budget is spent, a value is reported as not checked rather than as a
6314
6521
  * format error.
6315
6522
  */
6316
- function readSubmittedTextValueErrors(field, value, { path = `formData.${field.fieldKey}`, shapeOnly = false, testPattern, }) {
6523
+ function readSubmittedTextValueErrors(field, value, { location = (0, form_data_issue_1.readFieldIssueLocation)(field), shapeOnly = false, testPattern, }) {
6317
6524
  if (field.type !== 'text' && field.type !== 'textarea') {
6318
6525
  return [];
6319
6526
  }
@@ -6332,21 +6539,27 @@ function readSubmittedTextValueErrors(field, value, { path = `formData.${field.f
6332
6539
  };
6333
6540
  const issue = readIssue();
6334
6541
  if (issue === 'timeout') {
6335
- return [`${path} could not be checked against the required format in time`];
6542
+ return [
6543
+ (0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN_TIMEOUT', '無法在時限內完成格式檢查'),
6544
+ ];
6336
6545
  }
6337
6546
  if (!issue || (shapeOnly && issue.kind !== 'type')) {
6338
6547
  return [];
6339
6548
  }
6340
6549
  if (issue.kind === 'type') {
6341
- return [`${path} must be text`];
6550
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是文字')];
6342
6551
  }
6343
6552
  if (issue.kind === 'minLength') {
6344
- return [`${path} must be at least ${issue.limit} characters`];
6553
+ return [
6554
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MIN_LENGTH', `至少需要 ${issue.limit} 個字`, issue.limit),
6555
+ ];
6345
6556
  }
6346
6557
  if (issue.kind === 'maxLength') {
6347
- return [`${path} must be at most ${issue.limit} characters`];
6558
+ return [
6559
+ (0, form_data_issue_1.createFormDataIssue)(location, 'MAX_LENGTH', `最多 ${issue.limit} 個字`, issue.limit),
6560
+ ];
6348
6561
  }
6349
- return [`${path} does not match the required format`];
6562
+ return [(0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN', '格式不正確')];
6350
6563
  }
6351
6564
  /**
6352
6565
  * Own-property read. `constructor`, `toString` and friends all satisfy the