@gadmin2n/schematics 0.0.148 → 0.0.149
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/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +66 -49
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +4 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +31 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +166 -17
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +82 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +7 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +91 -36
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +57 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +11 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +11 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +41 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +46 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +14 -10
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.test.tsx +140 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +123 -60
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +31 -14
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +4 -2
- 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
|
-
|
|
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")
|
package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts
CHANGED
|
@@ -202,65 +202,82 @@ const NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
|
|
|
202
202
|
minLength: 1,
|
|
203
203
|
description: '前端登记的 PostgreSQL 表字段监听标识',
|
|
204
204
|
},
|
|
205
|
-
|
|
206
|
-
type: '
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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: ['
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
224
|
+
conditions: {
|
|
225
|
+
type: 'array',
|
|
226
|
+
minItems: 1,
|
|
227
|
+
items: {
|
|
228
|
+
type: 'object',
|
|
250
229
|
properties: {
|
|
251
|
-
|
|
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
|
-
?
|
|
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 {
|
|
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
|
|
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
|
-
?
|
|
403
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
watchedFields: [
|
|
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
|
-
|
|
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: [
|
|
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
|
-
@
|
|
36
|
-
|
|
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 })
|