@gadmin2n/schematics 0.0.148 → 0.0.150

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 (24) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -1
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +1 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +66 -49
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +4 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +31 -4
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +166 -17
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +82 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +7 -2
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +91 -36
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +57 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +11 -3
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +11 -3
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +41 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +46 -5
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +14 -10
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +1 -1
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +2 -1
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +1 -1
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +2 -2
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.test.tsx +140 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +123 -60
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +31 -14
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +4 -2
  24. package/package.json +1 -1
@@ -378,7 +378,7 @@ model WorkflowBusinessEventRegistration {
378
378
  registrationKey String @unique @map("registration_key") @db.Char(64)
379
379
  schemaName String @map("schema_name") @db.VarChar(63)
380
380
  tableName String @map("table_name") @db.VarChar(63)
381
- operation WorkflowEventOperation
381
+ operations WorkflowEventOperation[]
382
382
  watchedFields Json @map("watched_fields")
383
383
  primaryKeyFields Json @map("primary_key_fields")
384
384
  isEnabled Boolean @default(true) @map("is_enabled")
@@ -38,7 +38,7 @@
38
38
  "dependencies": {
39
39
  "@agendajs/postgres-backend": "^3.0.5",
40
40
  "@azure/identity": "^4.13.0",
41
- "@gadmin2n/nest-common": "^0.1.3",
41
+ "@gadmin2n/nest-common": "^0.1.4",
42
42
  "@nestjs/cache-manager": "^3.0.1",
43
43
  "@nestjs/common": "^10.4.15",
44
44
  "@nestjs/config": "^3.2.0",
@@ -202,65 +202,82 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
202
202
  minLength: 1,
203
203
  description: '前端登记的 PostgreSQL 表字段监听标识',
204
204
  },
205
- match: {
206
- type: 'object',
207
- properties: {
208
- combineOperation: {
209
- type: 'string',
210
- enum: ['AND', 'OR'],
211
- },
212
- conditions: {
213
- type: 'array',
214
- minItems: 1,
215
- items: {
205
+ operationRules: {
206
+ type: 'array',
207
+ minItems: 1,
208
+ maxItems: 3,
209
+ description: '按数据操作配置启动规则;未启用的操作不启动',
210
+ items: {
211
+ type: 'object',
212
+ properties: {
213
+ operation: {
214
+ type: 'string',
215
+ enum: ['INSERT', 'UPDATE', 'DELETE'],
216
+ },
217
+ match: {
216
218
  type: 'object',
217
219
  properties: {
218
- id: { type: 'string', minLength: 1 },
219
- left: {
220
- type: 'object',
221
- properties: {
222
- source: { const: 'WORKFLOW' },
223
- path: { type: 'string', minLength: 1 },
224
- },
225
- required: ['source', 'path'],
226
- additionalProperties: false,
227
- },
228
- operator: {
220
+ combineOperation: {
229
221
  type: 'string',
230
- enum: ['EQ', 'NEQ', 'EMPTY', 'NOT_EMPTY'],
231
- },
232
- right: {
233
- type: 'object',
234
- properties: {
235
- source: { const: 'FIXED' },
236
- valueType: {
237
- type: 'string',
238
- enum: ['STRING', 'NUMBER', 'BOOLEAN'],
239
- },
240
- value: { type: 'string' },
241
- },
242
- required: ['source', 'valueType', 'value'],
243
- additionalProperties: false,
222
+ enum: ['AND', 'OR'],
244
223
  },
245
- },
246
- required: ['id', 'left', 'operator'],
247
- allOf: [
248
- {
249
- if: {
224
+ conditions: {
225
+ type: 'array',
226
+ minItems: 1,
227
+ items: {
228
+ type: 'object',
250
229
  properties: {
251
- operator: { enum: ['EQ', 'NEQ'] },
230
+ id: { type: 'string', minLength: 1 },
231
+ left: {
232
+ type: 'object',
233
+ properties: {
234
+ source: { const: 'WORKFLOW' },
235
+ path: { type: 'string', minLength: 1 },
236
+ },
237
+ required: ['source', 'path'],
238
+ additionalProperties: false,
239
+ },
240
+ operator: {
241
+ type: 'string',
242
+ enum: ['EQ', 'NEQ', 'IN', 'EMPTY', 'NOT_EMPTY'],
243
+ },
244
+ right: {
245
+ type: 'object',
246
+ properties: {
247
+ source: { const: 'FIXED' },
248
+ valueType: {
249
+ type: 'string',
250
+ enum: ['STRING', 'NUMBER', 'BOOLEAN'],
251
+ },
252
+ value: { type: 'string' },
253
+ },
254
+ required: ['source', 'valueType', 'value'],
255
+ additionalProperties: false,
256
+ },
252
257
  },
253
- required: ['operator'],
258
+ required: ['id', 'left', 'operator'],
259
+ allOf: [
260
+ {
261
+ if: {
262
+ properties: {
263
+ operator: { enum: ['EQ', 'NEQ', 'IN'] },
264
+ },
265
+ required: ['operator'],
266
+ },
267
+ then: { required: ['right'] },
268
+ },
269
+ ],
270
+ additionalProperties: false,
254
271
  },
255
- then: { required: ['right'] },
256
272
  },
257
- ],
273
+ },
274
+ required: ['combineOperation', 'conditions'],
258
275
  additionalProperties: false,
259
276
  },
260
277
  },
278
+ required: ['operation'],
279
+ additionalProperties: false,
261
280
  },
262
- required: ['combineOperation', 'conditions'],
263
- additionalProperties: false,
264
281
  },
265
282
  processingMode: {
266
283
  type: 'string',
@@ -281,7 +298,7 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
281
298
  description: '顺序调度器最多同时处理的业务对象数量',
282
299
  },
283
300
  },
284
- required: ['registrationCode', 'processingMode'],
301
+ required: ['registrationCode', 'operationRules', 'processingMode'],
285
302
  allOf: [
286
303
  {
287
304
  if: {
@@ -1327,7 +1344,7 @@ const dedicatedEditorNodes: NodeTypeSeed[] = [
1327
1344
  ),
1328
1345
  schemaVersion:
1329
1346
  type === 'event_trigger'
1330
- ? 4
1347
+ ? 5
1331
1348
  : type === 'db_execute'
1332
1349
  ? 4
1333
1350
  : type === 'parallel'
@@ -22,6 +22,8 @@ class JobsQueryDto {
22
22
  toggle?: string;
23
23
  module?: string;
24
24
  q?: string;
25
+ dateFrom?: string;
26
+ dateTo?: string;
25
27
  skip?: number;
26
28
  limit?: number;
27
29
  }
@@ -55,6 +57,8 @@ export class AgendaController {
55
57
  toggle: query.toggle,
56
58
  module: query.module,
57
59
  q: query.q,
60
+ dateFrom: query.dateFrom,
61
+ dateTo: query.dateTo,
58
62
  skip: query.skip ? Number(query.skip) : 0,
59
63
  limit: query.limit ? Number(query.limit) : 20,
60
64
  });
@@ -16,6 +16,8 @@ interface GetJobsParams {
16
16
  toggle?: string; // 'active' | 'paused'
17
17
  module?: string;
18
18
  q?: string;
19
+ dateFrom?: string; // ISO:按 lastRunAt 过滤区间下界
20
+ dateTo?: string; // ISO:按 lastRunAt 过滤区间上界
19
21
  skip?: number;
20
22
  limit?: number;
21
23
  }
@@ -364,7 +366,17 @@ export class AgendaService
364
366
  totalPages: 0,
365
367
  };
366
368
  }
367
- const { job, state, toggle, module, q, skip = 0, limit = 20 } = params;
369
+ const {
370
+ job,
371
+ state,
372
+ toggle,
373
+ module,
374
+ q,
375
+ dateFrom,
376
+ dateTo,
377
+ skip = 0,
378
+ limit = 20,
379
+ } = params;
368
380
 
369
381
  const allQuery: JobsQueryOptions = {};
370
382
  if (job) allQuery.name = job;
@@ -381,6 +393,21 @@ export class AgendaService
381
393
  )
382
394
  : allResult.jobs;
383
395
 
396
+ // 应用层过滤 lastRunAt 时间区间(Agenda queryJobs 同样不支持日期过滤)。
397
+ // 未执行过(lastRunAt 为空)的任务在设置了时间区间时不纳入。
398
+ const fromTs = dateFrom ? new Date(dateFrom).getTime() : null;
399
+ const toTs = dateTo ? new Date(dateTo).getTime() : null;
400
+ const dateFiltered =
401
+ fromTs !== null || toTs !== null
402
+ ? moduleFiltered.filter((j) => {
403
+ if (!j.lastRunAt) return false;
404
+ const t = new Date(j.lastRunAt).getTime();
405
+ if (fromTs !== null && t < fromTs) return false;
406
+ if (toTs !== null && t > toTs) return false;
407
+ return true;
408
+ })
409
+ : moduleFiltered;
410
+
384
411
  const overview: Record<string, number> = {
385
412
  running: 0,
386
413
  scheduled: 0,
@@ -392,15 +419,15 @@ export class AgendaService
392
419
  active: 0,
393
420
  };
394
421
 
395
- for (const j of moduleFiltered) {
422
+ for (const j of dateFiltered) {
396
423
  const s = j.state;
397
424
  if (s in overview) overview[s]++;
398
425
  }
399
426
 
400
427
  // 第一步:按 job 原始状态过滤(running/queued 等)
401
428
  const stateFiltered = state
402
- ? moduleFiltered.filter((j) => j.state === state)
403
- : moduleFiltered;
429
+ ? dateFiltered.filter((j) => j.state === state)
430
+ : dateFiltered;
404
431
 
405
432
  // 从状态过滤后的数据计算 active/paused 数量
406
433
  for (const j of stateFiltered) {
@@ -12,7 +12,7 @@ const registration = {
12
12
  registrationKey: 'a'.repeat(64),
13
13
  schemaName: 'public',
14
14
  tableName: 't_hardware_asset',
15
- operation: 'UPDATE',
15
+ operations: ['UPDATE'],
16
16
  watchedFields: ['status'],
17
17
  primaryKeyFields: ['id'],
18
18
  isEnabled: true,
@@ -67,6 +67,7 @@ describe('PostgreSQL business event registration', () => {
67
67
  type: 'event_trigger',
68
68
  config: {
69
69
  registrationCode: registration.code,
70
+ operationRules: [{ operation: 'UPDATE' }],
70
71
  processingMode: 'SERIAL_BY_KEY',
71
72
  concurrencyKeyPath: 'primaryKey.id',
72
73
  },
@@ -98,6 +99,7 @@ describe('PostgreSQL business event registration', () => {
98
99
  type: 'event_trigger',
99
100
  config: {
100
101
  registrationCode: registration.code,
102
+ operationRules: [{ operation: 'UPDATE' }],
101
103
  processingMode: 'SERIAL_BY_KEY',
102
104
  concurrencyKeyPath: 'missing',
103
105
  },
@@ -116,17 +118,22 @@ describe('PostgreSQL business event registration', () => {
116
118
  config: {
117
119
  registrationCode: registration.code,
118
120
  processingMode: 'PARALLEL',
119
- match: {
120
- combineOperation: 'AND',
121
- conditions: [
122
- {
123
- id: 'condition-1',
124
- left: { source: 'WORKFLOW', path: leftPath },
125
- operator: 'EQ',
126
- right: { source: 'FIXED', valueType, value: 'APPROVED' },
121
+ operationRules: [
122
+ {
123
+ operation: 'UPDATE',
124
+ match: {
125
+ combineOperation: 'AND',
126
+ conditions: [
127
+ {
128
+ id: 'condition-1',
129
+ left: { source: 'WORKFLOW', path: leftPath },
130
+ operator: 'EQ',
131
+ right: { source: 'FIXED', valueType, value: 'APPROVED' },
132
+ },
133
+ ],
127
134
  },
128
- ],
129
- },
135
+ },
136
+ ],
130
137
  },
131
138
  });
132
139
 
@@ -179,7 +186,7 @@ describe('PostgreSQL business event registration', () => {
179
186
  {
180
187
  schemaName: 'public',
181
188
  tableName: 't_hardware_asset',
182
- operation: 'UPDATE',
189
+ operations: ['UPDATE'],
183
190
  watchedFields: ['status'],
184
191
  },
185
192
  'admin',
@@ -224,7 +231,7 @@ describe('PostgreSQL business event registration', () => {
224
231
  {
225
232
  schemaName: 'another_schema',
226
233
  tableName: 't_hardware_asset',
227
- operation: 'UPDATE',
234
+ operations: ['UPDATE'],
228
235
  watchedFields: ['status'],
229
236
  },
230
237
  'admin',
@@ -236,8 +243,8 @@ describe('PostgreSQL business event registration', () => {
236
243
  it('registers INSERT for the whole row without requiring selected fields', async () => {
237
244
  const insertRegistration = {
238
245
  ...registration,
239
- operation: 'INSERT',
240
- watchedFields: ['id', 'status'],
246
+ operations: ['INSERT'],
247
+ watchedFields: [],
241
248
  };
242
249
  const executeRawUnsafe = jest.fn().mockResolvedValue(0);
243
250
  const tx = {
@@ -266,14 +273,14 @@ describe('PostgreSQL business event registration', () => {
266
273
  {
267
274
  schemaName: 'public',
268
275
  tableName: 't_hardware_asset',
269
- operation: 'INSERT',
276
+ operations: ['INSERT'],
270
277
  watchedFields: [],
271
278
  },
272
279
  'admin',
273
280
  );
274
281
 
275
282
  expect(tx.workflowBusinessEventRegistration.create).toHaveBeenCalledWith({
276
- data: expect.objectContaining({ watchedFields: ['id', 'status'] }),
283
+ data: expect.objectContaining({ watchedFields: [] }),
277
284
  });
278
285
  expect(executeRawUnsafe).toHaveBeenCalledWith(
279
286
  expect.stringContaining('AFTER INSERT ON'),
@@ -321,3 +328,145 @@ describe('PostgreSQL business event registration', () => {
321
328
  );
322
329
  });
323
330
  });
331
+
332
+ describe('multi-operation event rules', () => {
333
+ const [event] = buildBusinessEventCatalog(
334
+ [{ ...registration, operations: ['INSERT', 'UPDATE', 'DELETE'] }],
335
+ columns,
336
+ );
337
+ const condition = (path: string, values: string[]) => ({
338
+ id: path,
339
+ left: { source: 'WORKFLOW', path },
340
+ operator: 'IN',
341
+ right: {
342
+ source: 'FIXED',
343
+ valueType: 'STRING',
344
+ value: JSON.stringify(values),
345
+ },
346
+ });
347
+ const validate = (operationRules: unknown) =>
348
+ validateBusinessEventTriggers(
349
+ {
350
+ nodes: [
351
+ {
352
+ type: 'event_trigger',
353
+ config: {
354
+ registrationCode: event.registrationCode,
355
+ operationRules,
356
+ },
357
+ },
358
+ ],
359
+ },
360
+ [event],
361
+ );
362
+
363
+ it('requires stable primary keys for serial processing when before/after can be null', () => {
364
+ expect(
365
+ event.fields.find((field) => field.path === 'primaryKey.id')?.nullable,
366
+ ).toBe(false);
367
+ expect(
368
+ event.fields.find((field) => field.path === 'after.id')?.nullable,
369
+ ).toBe(true);
370
+ expect(
371
+ event.fields.find((field) => field.path === 'before.id')?.nullable,
372
+ ).toBe(true);
373
+ expect(() =>
374
+ validateBusinessEventTriggers(
375
+ {
376
+ nodes: [
377
+ {
378
+ type: 'event_trigger',
379
+ config: {
380
+ registrationCode: event.registrationCode,
381
+ operationRules: [{ operation: 'INSERT' }],
382
+ processingMode: 'SERIAL_BY_KEY',
383
+ concurrencyKeyPath: 'after.id',
384
+ },
385
+ },
386
+ ],
387
+ },
388
+ [event],
389
+ ),
390
+ ).toThrow(BadRequestException);
391
+ });
392
+
393
+ it('accepts independent insert and status transition rules', () => {
394
+ expect(() =>
395
+ validate([
396
+ {
397
+ operation: 'INSERT',
398
+ match: {
399
+ combineOperation: 'AND',
400
+ conditions: [
401
+ condition('after.status', ['PENDING', 'PENDING_SUPERIOR']),
402
+ ],
403
+ },
404
+ },
405
+ {
406
+ operation: 'UPDATE',
407
+ match: {
408
+ combineOperation: 'AND',
409
+ conditions: [
410
+ condition('before.status', ['DRAFT', 'REJECTED']),
411
+ condition('after.status', ['PENDING', 'PENDING_SUPERIOR']),
412
+ ],
413
+ },
414
+ },
415
+ ]),
416
+ ).not.toThrow();
417
+ });
418
+
419
+ it.each([
420
+ [],
421
+ [{ operation: 'UPSERT' }],
422
+ [{ operation: 'INSERT' }, { operation: 'INSERT' }],
423
+ [
424
+ {
425
+ operation: 'INSERT',
426
+ match: {
427
+ combineOperation: 'AND',
428
+ conditions: [condition('before.status', ['DRAFT'])],
429
+ },
430
+ },
431
+ ],
432
+ [
433
+ {
434
+ operation: 'DELETE',
435
+ match: {
436
+ combineOperation: 'AND',
437
+ conditions: [condition('after.status', ['DRAFT'])],
438
+ },
439
+ },
440
+ ],
441
+ [
442
+ {
443
+ operation: 'UPDATE',
444
+ match: {
445
+ combineOperation: 'AND',
446
+ conditions: [condition('after.status', [])],
447
+ },
448
+ },
449
+ ],
450
+ ])('rejects invalid or unavailable rules: %j', (...rules) => {
451
+ expect(() => validate(rules)).toThrow(BadRequestException);
452
+ });
453
+
454
+ it('rejects operations outside the registered set', () => {
455
+ expect(() =>
456
+ validateBusinessEventTriggers(
457
+ {
458
+ nodes: [
459
+ {
460
+ type: 'event_trigger',
461
+ config: {
462
+ registrationCode: event.registrationCode,
463
+ operationRules: [{ operation: 'DELETE' }],
464
+ },
465
+ },
466
+ ],
467
+ },
468
+ buildBusinessEventCatalog([registration], columns),
469
+ ),
470
+ ).toThrow(BadRequestException);
471
+ });
472
+ });
@@ -0,0 +1,82 @@
1
+ import { WorkflowService } from './workflow.service';
2
+
3
+ // Opt-in, isolated database check. All fixtures and DDL are rolled back.
4
+ const pgTest = process.env.WORKFLOW_EVENT_PG_TEST === '1' ? it : it.skip;
5
+
6
+ pgTest(
7
+ 'captures insert/delete rows and only real watched UPDATE changes, atomically',
8
+ async () => {
9
+ const { Client } = require('pg');
10
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
11
+ const schemaName = `wf_event_test_${process.pid}`;
12
+ await client.connect();
13
+ try {
14
+ await client.query('BEGIN');
15
+ await client.query(`CREATE SCHEMA "${schemaName}"`);
16
+ await client.query(`SET LOCAL search_path TO "${schemaName}"`);
17
+ await client.query(
18
+ `CREATE TYPE "WorkflowEventOperation" AS ENUM ('INSERT', 'UPDATE', 'DELETE')`,
19
+ );
20
+ await client.query(
21
+ `CREATE TABLE request (id integer PRIMARY KEY, status text NOT NULL, title text)`,
22
+ );
23
+ await client.query(`CREATE TABLE t_workflow_event_outbox (
24
+ id serial PRIMARY KEY, registration_id bigint, operation "WorkflowEventOperation",
25
+ schema_name text, table_name text, primary_key jsonb, before jsonb, after jsonb,
26
+ changed_fields jsonb, status text, retry_count integer,
27
+ next_attempt_at timestamptz, created_at timestamptz, updated_at timestamptz
28
+ )`);
29
+ const tx = {
30
+ $queryRaw: async () => [{ schemaName }],
31
+ $executeRawUnsafe: (sql: string) => client.query(sql),
32
+ };
33
+ const service = new WorkflowService(
34
+ {} as any,
35
+ {} as any,
36
+ {} as any,
37
+ {} as any,
38
+ );
39
+ await (service as any).installBusinessEventTrigger(tx, {
40
+ id: BigInt(1),
41
+ schemaName,
42
+ tableName: 'request',
43
+ operations: ['INSERT', 'UPDATE', 'DELETE'],
44
+ watchedFields: ['status'],
45
+ primaryKeyFields: ['id'],
46
+ });
47
+ await client.query(`INSERT INTO request VALUES (1, 'DRAFT', 'original')`);
48
+ await client.query(`UPDATE request SET title = 'edited' WHERE id = 1`);
49
+ await client.query(`UPDATE request SET status = 'DRAFT' WHERE id = 1`);
50
+ await client.query(`UPDATE request SET status = 'PENDING' WHERE id = 1`);
51
+ await client.query('SAVEPOINT submission');
52
+ await client.query(`UPDATE request SET status = 'APPROVED' WHERE id = 1`);
53
+ await client.query('ROLLBACK TO SAVEPOINT submission');
54
+ await client.query(`DELETE FROM request WHERE id = 1`);
55
+ const { rows } = await client.query(
56
+ 'SELECT * FROM t_workflow_event_outbox ORDER BY id',
57
+ );
58
+ expect(rows.map((row: any) => row.operation)).toEqual([
59
+ 'INSERT',
60
+ 'UPDATE',
61
+ 'DELETE',
62
+ ]);
63
+ expect(rows[0]).toMatchObject({
64
+ before: null,
65
+ after: { status: 'DRAFT' },
66
+ primary_key: { id: 1 },
67
+ });
68
+ expect(rows[1]).toMatchObject({
69
+ before: { status: 'DRAFT' },
70
+ after: { status: 'PENDING' },
71
+ changed_fields: ['status'],
72
+ });
73
+ expect(rows[2]).toMatchObject({
74
+ before: { status: 'PENDING' },
75
+ after: null,
76
+ });
77
+ } finally {
78
+ await client.query('ROLLBACK');
79
+ await client.end();
80
+ }
81
+ },
82
+ );
@@ -1,5 +1,7 @@
1
1
  import { Prisma } from '@prisma/client';
2
2
  import {
3
+ ArrayMinSize,
4
+ ArrayUnique,
3
5
  IsArray,
4
6
  IsBoolean,
5
7
  IsEnum,
@@ -32,8 +34,11 @@ export class RegisterBusinessEventDto {
32
34
  @Matches(/^[A-Za-z_][A-Za-z0-9_$]*$/)
33
35
  tableName: string;
34
36
 
35
- @IsEnum(WorkflowEventOperationEnum)
36
- operation: WorkflowEventOperationEnum;
37
+ @IsArray()
38
+ @ArrayMinSize(1)
39
+ @ArrayUnique()
40
+ @IsEnum(WorkflowEventOperationEnum, { each: true })
41
+ operations: WorkflowEventOperationEnum[];
37
42
 
38
43
  @IsArray()
39
44
  @IsString({ each: true })