@gadmin2n/schematics 0.0.147 → 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.
Files changed (106) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +2 -1
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permission.seed.ts +67 -15
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +83 -53
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +5 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/lib/structured-all-exceptions.filter.spec.ts +24 -0
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/lib/structured-all-exceptions.filter.ts +15 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +4 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +31 -4
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.controller.spec.ts +2 -6
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.spec.ts +28 -29
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.ts +16 -34
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.dto.ts +3 -3
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.spec.ts +61 -6
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.spec.ts +2 -2
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/index.ts +2 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.controller.ts +11 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.spec.ts +126 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.ts +136 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.controller.ts +24 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.dto.ts +8 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.service.spec.ts +62 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.service.ts +94 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.types.ts +6 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staffDirectory.module.ts +10 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user.provider.ts +6 -2
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.spec.ts +20 -12
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.ts +21 -19
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +11 -0
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.spec.ts +97 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.ts +150 -2
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.controller.ts +13 -1
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.dto.ts +13 -1
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.errors.ts +1 -0
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.spec.ts +125 -0
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.ts +118 -5
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +20 -3
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +6 -9
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +166 -17
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +82 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-runtime.facade.ts +2 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +4 -1
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +5 -1
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +7 -2
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +44 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +106 -37
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +59 -3
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.spec.ts +33 -22
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.ts +10 -23
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/StaffSelect.test.tsx +69 -0
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/StaffSelect.tsx +145 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/index.ts +2 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/staff-directory.api.ts +16 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +10 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +20 -2
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +6 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +60 -14
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +60 -14
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +41 -1
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/PeopleGroupModal.tsx +181 -0
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/index.tsx +203 -0
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/people-groups.api.ts +145 -0
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/PersonModal.tsx +112 -0
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/index.tsx +214 -0
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/person-directory.api.ts +79 -0
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/components/form-modal.tsx +7 -11
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +7 -7
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +46 -5
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +14 -10
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +1 -1
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +2 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +1 -1
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +2 -2
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.test.tsx +20 -0
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +97 -28
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +87 -119
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.test.tsx +22 -1
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.tsx +8 -2
  79. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connections.css +8 -46
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connectionsApi.ts +7 -0
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.test.tsx +32 -3
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +163 -106
  83. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +8 -1
  84. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +26 -0
  85. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +34 -6
  86. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-config.css +13 -16
  87. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +185 -0
  88. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +224 -105
  89. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.test.tsx +1 -1
  90. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +21 -12
  91. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.test.tsx +140 -0
  92. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +124 -62
  93. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.test.tsx +160 -8
  94. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.tsx +125 -55
  95. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +78 -17
  96. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.test.tsx +33 -1
  97. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.tsx +28 -46
  98. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +4 -2
  99. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/human-approval-config.css +16 -0
  100. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/subject-provider.api.ts +0 -6
  101. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.test.tsx +24 -0
  102. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +14 -17
  103. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -0
  104. package/package.json +1 -1
  105. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CreateNodeInstanceDrawer.tsx +0 -162
  106. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupCreateModal.tsx +0 -72
@@ -3,12 +3,11 @@ export type ApprovalSubjectProvider = 'USER' | 'USER_GROUP';
3
3
  export type ApprovalSubjectSource =
4
4
  | {
5
5
  source?: 'STATIC';
6
- provider: ApprovalSubjectProvider;
6
+ provider?: ApprovalSubjectProvider;
7
7
  refs: string[];
8
8
  }
9
9
  | {
10
10
  source: 'DYNAMIC';
11
- provider: ApprovalSubjectProvider;
12
11
  inputKey: string;
13
12
  };
14
13
 
@@ -21,50 +20,33 @@ export function materializeApprovalSubjects(
21
20
  sources: ApprovalSubjectSource[],
22
21
  dynamicInputs: Record<string, unknown> = {},
23
22
  ): MaterializedApprovalSubject[] {
24
- const refsByProvider = new Map<ApprovalSubjectProvider, string[]>();
23
+ const refsByProvider = new Map<ApprovalSubjectProvider, Set<string>>();
25
24
 
26
25
  for (const source of sources) {
27
- const refs =
26
+ const provider =
27
+ source.source === 'DYNAMIC' ? 'USER' : source.provider ?? 'USER';
28
+ const refs = normalizeRefs(
28
29
  source.source === 'DYNAMIC'
29
- ? normalizeDynamicRefs(
30
- dynamicInputs[source.inputKey],
31
- source.inputKey,
32
- source.provider,
33
- )
34
- : normalizeRefs(source.refs);
35
- const current = refsByProvider.get(source.provider) ?? [];
36
- refsByProvider.set(source.provider, [...new Set([...current, ...refs])]);
30
+ ? normalizeDynamicRefs(dynamicInputs[source.inputKey], source.inputKey)
31
+ : source.refs,
32
+ );
33
+ const combined = refsByProvider.get(provider) ?? new Set<string>();
34
+ refs.forEach((ref) => combined.add(ref));
35
+ refsByProvider.set(provider, combined);
37
36
  }
38
37
 
39
- return [...refsByProvider].map(([provider, refs]) => ({ provider, refs }));
38
+ return [...refsByProvider].flatMap(([provider, refs]) =>
39
+ refs.size ? [{ provider, refs: [...refs] }] : [],
40
+ );
40
41
  }
41
42
 
42
- function normalizeDynamicRefs(
43
- value: unknown,
44
- inputKey: string,
45
- provider: ApprovalSubjectProvider,
46
- ): string[] {
47
- const result = asObject(value);
48
- const subjects = Array.isArray(result.subjects) ? result.subjects : [];
49
- const refs = normalizeRefs(
50
- subjects.flatMap((item) => {
51
- const subject = asObject(item);
52
- return subject.provider === provider && Array.isArray(subject.refs)
53
- ? subject.refs
54
- : [];
55
- }),
56
- );
43
+ function normalizeDynamicRefs(value: unknown, inputKey: string): string[] {
44
+ const refs = Array.isArray(value) ? normalizeRefs(value) : [];
57
45
  if (refs.length === 0)
58
46
  throw new Error(`Dynamic approver input "${inputKey}" is missing or empty`);
59
47
  return refs;
60
48
  }
61
49
 
62
- function asObject(value: unknown): Record<string, unknown> {
63
- return value && typeof value === 'object' && !Array.isArray(value)
64
- ? (value as Record<string, unknown>)
65
- : {};
66
- }
67
-
68
50
  function normalizeRefs(values: unknown[]): string[] {
69
51
  return [
70
52
  ...new Set(
@@ -20,9 +20,9 @@ export class ApprovalSubjectConfigDto {
20
20
  @IsIn(['STATIC', 'DYNAMIC'])
21
21
  source: 'STATIC' | 'DYNAMIC';
22
22
 
23
- @IsString()
24
- @IsNotEmpty()
25
- provider: string;
23
+ @IsOptional()
24
+ @IsIn(['USER', 'USER_GROUP'])
25
+ provider?: 'USER' | 'USER_GROUP';
26
26
 
27
27
  @ValidateIf((input) => input.source === 'STATIC')
28
28
  @IsArray()
@@ -2,6 +2,65 @@ import { hashNodeInstanceConfig } from '../workflowNodeInstance/node-instance-co
2
2
  import { HumanApprovalService } from './human-approval.service';
3
3
 
4
4
  describe('HumanApprovalService', () => {
5
+ it('deduplicates people resolved from fixed users and fixed user groups', async () => {
6
+ const userProvider = {
7
+ preview: jest.fn().mockResolvedValue({
8
+ items: [{ ref: 'alice', label: 'Alice' }],
9
+ }),
10
+ resolve: jest.fn().mockResolvedValue([
11
+ {
12
+ principalType: 'USER',
13
+ principalRef: 'alice',
14
+ displayName: 'Alice',
15
+ sourceRefs: ['alice'],
16
+ },
17
+ ]),
18
+ };
19
+ const groupProvider = {
20
+ preview: jest.fn().mockResolvedValue({
21
+ items: [{ ref: 'finance', label: 'Finance' }],
22
+ }),
23
+ resolve: jest.fn().mockResolvedValue([
24
+ {
25
+ principalType: 'USER',
26
+ principalRef: 'alice',
27
+ displayName: 'Alice',
28
+ sourceRefs: ['finance'],
29
+ },
30
+ {
31
+ principalType: 'USER',
32
+ principalRef: 'bob',
33
+ displayName: 'Bob',
34
+ sourceRefs: ['finance'],
35
+ },
36
+ ]),
37
+ };
38
+ const service = new HumanApprovalService(
39
+ {} as any,
40
+ {
41
+ get: jest.fn((key) =>
42
+ key === 'USER_GROUP' ? groupProvider : userProvider,
43
+ ),
44
+ } as any,
45
+ );
46
+
47
+ const result = await (service as any).resolveSubjects(
48
+ [
49
+ { provider: 'USER', refs: ['alice'] },
50
+ { provider: 'USER_GROUP', refs: ['finance'] },
51
+ ],
52
+ { userId: 'runtime' },
53
+ );
54
+
55
+ expect(result.candidates).toEqual([
56
+ expect.objectContaining({
57
+ principalRef: 'alice',
58
+ sourceRefs: ['alice', 'finance'],
59
+ }),
60
+ expect.objectContaining({ principalRef: 'bob' }),
61
+ ]);
62
+ });
63
+
5
64
  it('stores the resolved task in the built-in approval request', async () => {
6
65
  const create = jest.fn().mockResolvedValue({
7
66
  id: BigInt(40),
@@ -56,9 +115,7 @@ describe('HumanApprovalService', () => {
56
115
  },
57
116
  details: [],
58
117
  },
59
- subjects: [
60
- { source: 'STATIC' as const, provider: 'USER', refs: ['alice'] },
61
- ],
118
+ subjects: [{ source: 'STATIC' as const, refs: ['alice'] }],
62
119
  approvalRule: { mode: 'ANY' as const },
63
120
  rejectionRule: { mode: 'ANY' as const },
64
121
  timeout: { durationSeconds: 0, action: 'REJECTED' as const },
@@ -227,9 +284,7 @@ describe('HumanApprovalService', () => {
227
284
  callbackResult: { script: 'return callback.body;' },
228
285
  },
229
286
  task: {},
230
- subjects: [
231
- { source: 'STATIC' as const, provider: 'USER', refs: ['alice'] },
232
- ],
287
+ subjects: [{ source: 'STATIC' as const, refs: ['alice'] }],
233
288
  approvalRule: { mode: 'ANY' as const },
234
289
  rejectionRule: { mode: 'ANY' as const },
235
290
  timeout: { durationSeconds: 60, action: 'REJECTED' as const },
@@ -67,7 +67,7 @@ describe('HumanApprovalRuntimeController', () => {
67
67
  },
68
68
  details: [],
69
69
  },
70
- subjects: [{ source: 'STATIC', provider: 'USER', refs: ['reviewer'] }],
70
+ subjects: [{ source: 'STATIC', refs: ['reviewer'] }],
71
71
  approvalRule: { mode: 'ANY' },
72
72
  rejectionRule: { mode: 'ANY' },
73
73
  timeout: { durationSeconds: 0, action: 'REJECTED' },
@@ -113,7 +113,7 @@ describe('HumanApprovalRuntimeController', () => {
113
113
  it('reuses a completed approval from the original retry operation', async () => {
114
114
  const config = {
115
115
  task: { title: { source: 'FIXED', value: '采购审批' }, details: [] },
116
- subjects: [{ source: 'STATIC', provider: 'USER', refs: ['reviewer'] }],
116
+ subjects: [{ source: 'STATIC', refs: ['reviewer'] }],
117
117
  approvalRule: { mode: 'ANY' },
118
118
  rejectionRule: { mode: 'ANY' },
119
119
  timeout: { durationSeconds: 0, action: 'REJECTED' },
@@ -5,6 +5,7 @@ import { ExternalInteractionModule } from './externalInteraction/externalInterac
5
5
  import { HttpRequestModule } from './httpRequest/httpRequest.module';
6
6
  import { HumanApprovalModule } from './humanApproval/humanApproval.module';
7
7
  import { IntegrationConnectionModule } from './integrationConnection/integrationConnection.module';
8
+ import { StaffDirectoryModule } from './staffDirectory/staffDirectory.module';
8
9
  import { SubjectProviderModule } from './subjectProvider/subjectProvider.module';
9
10
  import { UserGroupModule } from './userGroup/userGroup.module';
10
11
  import { WorkflowModule } from './workflow/workflow.module';
@@ -17,6 +18,7 @@ export default [
17
18
  HumanApprovalModule,
18
19
  HttpRequestModule,
19
20
  IntegrationConnectionModule,
21
+ StaffDirectoryModule,
20
22
  WorkflowModule,
21
23
  SubjectProviderModule,
22
24
  UserGroupModule,
@@ -2,6 +2,7 @@ import { ACGuard } from '@gadmin2n/nest-common';
2
2
  import {
3
3
  Body,
4
4
  Controller,
5
+ Delete,
5
6
  Get,
6
7
  Header,
7
8
  Param,
@@ -87,4 +88,14 @@ export class IntegrationConnectionController {
87
88
  ) {
88
89
  return this.service.update(code, input, actorContextFromRequest(request));
89
90
  }
91
+
92
+ @Delete(':code')
93
+ @UseRoles({
94
+ resource: 'integrationConnection',
95
+ action: 'delete',
96
+ possession: 'any',
97
+ })
98
+ remove(@Param('code') code: string) {
99
+ return this.service.remove(code);
100
+ }
90
101
  }
@@ -41,4 +41,130 @@ describe('IntegrationConnectionService', () => {
41
41
  );
42
42
  await expect(service.getSecrets(entity.code)).resolves.toEqual(secrets);
43
43
  });
44
+
45
+ it('rejects deletion when a published Workflow references the connection', async () => {
46
+ const deleteConnection = jest.fn();
47
+ const prisma = {
48
+ integrationConnection: {
49
+ findUnique: jest.fn().mockResolvedValue({ code: 'ticket-api' }),
50
+ delete: deleteConnection,
51
+ },
52
+ workflow: {
53
+ findMany: jest.fn().mockResolvedValue([
54
+ {
55
+ id: BigInt(7),
56
+ name: '工单审批',
57
+ dsl: null,
58
+ versions: [
59
+ {
60
+ version: 2,
61
+ dsl: {
62
+ nodes: [
63
+ {
64
+ id: 'send-ticket',
65
+ config: { connectionRef: 'ticket-api' },
66
+ },
67
+ ],
68
+ },
69
+ },
70
+ ],
71
+ },
72
+ ]),
73
+ },
74
+ workflowNodeInstance: { findMany: jest.fn() },
75
+ };
76
+ const service = new IntegrationConnectionService(prisma as any, {} as any);
77
+
78
+ await expect(service.remove('ticket-api')).rejects.toMatchObject({
79
+ response: {
80
+ code: 'INTEGRATION_CONNECTION_IN_USE',
81
+ callers: [
82
+ {
83
+ workflowId: '7',
84
+ workflowName: '工单审批',
85
+ source: 'PUBLISHED',
86
+ version: 2,
87
+ },
88
+ ],
89
+ },
90
+ });
91
+ expect(deleteConnection).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('rejects deletion when a draft uses a referenced Node Instance connection', async () => {
95
+ const deleteConnection = jest.fn();
96
+ const prisma = {
97
+ integrationConnection: {
98
+ findUnique: jest.fn().mockResolvedValue({ code: 'ticket-api' }),
99
+ delete: deleteConnection,
100
+ },
101
+ workflow: {
102
+ findMany: jest.fn().mockResolvedValue([
103
+ {
104
+ id: BigInt(8),
105
+ name: '资产审批',
106
+ dsl: {
107
+ nodes: [
108
+ {
109
+ id: 'notify',
110
+ instanceRef: {
111
+ instanceId: '12',
112
+ instanceCode: 'NOTIFY',
113
+ },
114
+ },
115
+ ],
116
+ },
117
+ versions: [],
118
+ },
119
+ ]),
120
+ },
121
+ workflowNodeInstance: {
122
+ findMany: jest.fn().mockResolvedValue([
123
+ {
124
+ id: BigInt(12),
125
+ config: {
126
+ external: {
127
+ request: { connectionRef: 'ticket-api' },
128
+ },
129
+ },
130
+ },
131
+ ]),
132
+ },
133
+ };
134
+ const service = new IntegrationConnectionService(prisma as any, {} as any);
135
+
136
+ await expect(service.remove('ticket-api')).rejects.toMatchObject({
137
+ response: {
138
+ code: 'INTEGRATION_CONNECTION_IN_USE',
139
+ callers: [
140
+ {
141
+ workflowId: '8',
142
+ workflowName: '资产审批',
143
+ source: 'DRAFT',
144
+ },
145
+ ],
146
+ },
147
+ });
148
+ expect(deleteConnection).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('deletes an unreferenced connection', async () => {
152
+ const deleteConnection = jest.fn().mockResolvedValue({});
153
+ const prisma = {
154
+ integrationConnection: {
155
+ findUnique: jest.fn().mockResolvedValue({ code: 'ticket-api' }),
156
+ delete: deleteConnection,
157
+ },
158
+ workflow: { findMany: jest.fn().mockResolvedValue([]) },
159
+ workflowNodeInstance: { findMany: jest.fn() },
160
+ };
161
+ const service = new IntegrationConnectionService(prisma as any, {} as any);
162
+
163
+ await expect(service.remove('ticket-api')).resolves.toEqual({
164
+ code: 'ticket-api',
165
+ });
166
+ expect(deleteConnection).toHaveBeenCalledWith({
167
+ where: { code: 'ticket-api' },
168
+ });
169
+ });
44
170
  });
@@ -135,6 +135,84 @@ export class IntegrationConnectionService {
135
135
  return this.get(code);
136
136
  }
137
137
 
138
+ async remove(code: string) {
139
+ await this.findOrThrow(code);
140
+ const workflows = await this.prisma.workflow.findMany({
141
+ select: {
142
+ id: true,
143
+ name: true,
144
+ dsl: true,
145
+ versions: { select: { version: true, dsl: true } },
146
+ },
147
+ });
148
+ const dsls = workflows.flatMap((workflow) => [
149
+ workflow.dsl,
150
+ ...workflow.versions.map((version) => version.dsl),
151
+ ]);
152
+ const referencedInstanceIds = [
153
+ ...new Set(dsls.flatMap((dsl) => referencedNodeInstanceIds(dsl))),
154
+ ];
155
+ const referencedInstances = referencedInstanceIds.length
156
+ ? await this.prisma.workflowNodeInstance.findMany({
157
+ where: {
158
+ id: { in: referencedInstanceIds.map((id) => BigInt(id)) },
159
+ },
160
+ select: { id: true, config: true },
161
+ })
162
+ : [];
163
+ const instanceConfigs = new Map(
164
+ referencedInstances.map((instance) => [
165
+ String(instance.id),
166
+ instance.config,
167
+ ]),
168
+ );
169
+ const callers: Array<{
170
+ workflowId: string;
171
+ workflowName: string;
172
+ source: 'DRAFT' | 'PUBLISHED';
173
+ version?: number;
174
+ }> = [];
175
+
176
+ for (const workflow of workflows) {
177
+ if (dslReferencesConnection(workflow.dsl, code, instanceConfigs))
178
+ callers.push({
179
+ workflowId: String(workflow.id),
180
+ workflowName: workflow.name,
181
+ source: 'DRAFT',
182
+ });
183
+ for (const version of workflow.versions) {
184
+ if (!dslReferencesConnection(version.dsl, code, instanceConfigs))
185
+ continue;
186
+ callers.push({
187
+ workflowId: String(workflow.id),
188
+ workflowName: workflow.name,
189
+ source: 'PUBLISHED',
190
+ version: version.version,
191
+ });
192
+ }
193
+ }
194
+
195
+ if (callers.length > 0) {
196
+ const workflowNames = [
197
+ ...new Set(callers.map((item) => item.workflowName)),
198
+ ];
199
+ const visibleNames = workflowNames.slice(0, 3).join('、');
200
+ const remaining = workflowNames.length - 3;
201
+ throw new ConflictException({
202
+ code: 'INTEGRATION_CONNECTION_IN_USE',
203
+ message:
204
+ '该连接仍被 Workflow ' +
205
+ visibleNames +
206
+ (remaining > 0 ? ' 等 ' + workflowNames.length + ' 个' : '') +
207
+ ' 使用,解除引用后才能删除',
208
+ callers,
209
+ });
210
+ }
211
+
212
+ await this.prisma.integrationConnection.delete({ where: { code } });
213
+ return { code };
214
+ }
215
+
138
216
  async resolveRuntime(code: string) {
139
217
  const entity = await this.findOrThrow(code);
140
218
  if (!entity.isEnabled)
@@ -281,3 +359,61 @@ function asObject(value: Prisma.JsonValue): Record<string, unknown> {
281
359
  ? (value as Record<string, unknown>)
282
360
  : {};
283
361
  }
362
+
363
+ function referencedNodeInstanceIds(dsl: unknown): string[] {
364
+ return workflowNodes(dsl).flatMap((node) => {
365
+ if (isRecord(node.source)) return [];
366
+ const instanceId = String(asPlainObject(node.instanceRef).instanceId ?? '');
367
+ return /^[1-9]\d*$/.test(instanceId) ? [instanceId] : [];
368
+ });
369
+ }
370
+
371
+ function dslReferencesConnection(
372
+ dsl: unknown,
373
+ connectionCode: string,
374
+ instanceConfigs: Map<string, unknown>,
375
+ ): boolean {
376
+ return workflowNodes(dsl).some((node) => {
377
+ let effectiveConfig: unknown = node.config;
378
+ if (!isRecord(node.source)) {
379
+ if (isRecord(node.configOverride)) {
380
+ effectiveConfig = node.configOverride;
381
+ } else {
382
+ const instanceId = String(
383
+ asPlainObject(node.instanceRef).instanceId ?? '',
384
+ );
385
+ effectiveConfig = instanceConfigs.get(instanceId) ?? node.config;
386
+ }
387
+ }
388
+ return containsConnectionReference(effectiveConfig, connectionCode);
389
+ });
390
+ }
391
+
392
+ function workflowNodes(dsl: unknown): Array<Record<string, unknown>> {
393
+ const nodes = asPlainObject(dsl).nodes;
394
+ return Array.isArray(nodes) ? nodes.filter(isRecord) : [];
395
+ }
396
+
397
+ function containsConnectionReference(
398
+ value: unknown,
399
+ connectionCode: string,
400
+ ): boolean {
401
+ if (Array.isArray(value))
402
+ return value.some((item) =>
403
+ containsConnectionReference(item, connectionCode),
404
+ );
405
+ if (!isRecord(value)) return false;
406
+ return Object.entries(value).some(
407
+ ([key, item]) =>
408
+ (key === 'connectionRef' && item === connectionCode) ||
409
+ containsConnectionReference(item, connectionCode),
410
+ );
411
+ }
412
+
413
+ function asPlainObject(value: unknown): Record<string, unknown> {
414
+ return isRecord(value) ? value : {};
415
+ }
416
+
417
+ function isRecord(value: unknown): value is Record<string, unknown> {
418
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
419
+ }
@@ -0,0 +1,24 @@
1
+ import { ACGuard } from '@gadmin2n/nest-common';
2
+ import { Controller, Get, Header, Query, UseGuards } from '@nestjs/common';
3
+ import { ApiTags } from '@nestjs/swagger';
4
+ import { UseRoles } from 'nest-access-control';
5
+ import { SearchStaffDirectoryDto } from './staff-directory.dto';
6
+ import { StaffDirectoryService } from './staff-directory.service';
7
+
8
+ @ApiTags('staff-directory')
9
+ @UseGuards(ACGuard)
10
+ @Controller('staff-directory')
11
+ export class StaffDirectoryController {
12
+ constructor(private readonly service: StaffDirectoryService) {}
13
+
14
+ @Get('search')
15
+ @Header('Cache-Control', 'no-store')
16
+ @UseRoles({
17
+ resource: 'staffDirectory',
18
+ action: 'read',
19
+ possession: 'any',
20
+ })
21
+ search(@Query() query: SearchStaffDirectoryDto) {
22
+ return this.service.search(query.keyword ?? '');
23
+ }
24
+ }
@@ -0,0 +1,8 @@
1
+ import { IsOptional, IsString, MaxLength } from 'class-validator';
2
+
3
+ export class SearchStaffDirectoryDto {
4
+ @IsOptional()
5
+ @IsString()
6
+ @MaxLength(128)
7
+ keyword?: string;
8
+ }
@@ -0,0 +1,62 @@
1
+ import axios from 'axios';
2
+ import { StaffDirectoryService } from './staff-directory.service';
3
+
4
+ describe('StaffDirectoryService', () => {
5
+ afterEach(() => jest.restoreAllMocks());
6
+
7
+ it('calls ERP with the server-side API key and normalizes people', async () => {
8
+ const request = jest.spyOn(axios, 'request').mockResolvedValue({
9
+ data: [
10
+ {
11
+ staffAccountName: 'junrujrwang',
12
+ staffDisplayName: 'Junru Wang',
13
+ bgNameEn: 'OIT',
14
+ deptNameEn: 'Operations',
15
+ },
16
+ ],
17
+ });
18
+ const service = new StaffDirectoryService({
19
+ get: jest.fn(
20
+ (key: string) =>
21
+ ({
22
+ 'staffDirectory.baseUrl': 'https://erp.example.com/',
23
+ 'staffDirectory.apiKey': 'secret',
24
+ 'staffDirectory.searchPath': '/api/staff/search',
25
+ }[key]),
26
+ ),
27
+ } as never);
28
+
29
+ await expect(service.search(' junru ')).resolves.toEqual([
30
+ {
31
+ userId: 'junrujrwang',
32
+ displayName: 'Junru Wang',
33
+ businessGroup: 'OIT',
34
+ department: 'Operations',
35
+ },
36
+ ]);
37
+ expect(request).toHaveBeenCalledWith(
38
+ expect.objectContaining({
39
+ url: 'https://erp.example.com/api/staff/search',
40
+ params: { keyword: 'junru' },
41
+ headers: { 'X-API-KEY': 'secret' },
42
+ }),
43
+ );
44
+ });
45
+
46
+ it('returns no results without calling ERP for an empty keyword', async () => {
47
+ const request = jest.spyOn(axios, 'request');
48
+ const service = new StaffDirectoryService({ get: jest.fn() } as never);
49
+
50
+ await expect(service.search(' ')).resolves.toEqual([]);
51
+ expect(request).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('reports missing server configuration without exposing secrets', async () => {
55
+ const service = new StaffDirectoryService({ get: jest.fn() } as never);
56
+
57
+ await expect(service.search('alice')).rejects.toMatchObject({
58
+ response: { code: 'STAFF_DIRECTORY_NOT_CONFIGURED' },
59
+ status: 503,
60
+ });
61
+ });
62
+ });