@gadmin2n/schematics 0.0.147 → 0.0.148

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 (92) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -0
  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 +17 -4
  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/codeRuntime/code-runtime.controller.spec.ts +2 -6
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.spec.ts +28 -29
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.ts +16 -34
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.dto.ts +3 -3
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.spec.ts +61 -6
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.spec.ts +2 -2
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/index.ts +2 -0
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.controller.ts +11 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.spec.ts +126 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.ts +136 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.controller.ts +24 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.dto.ts +8 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.service.spec.ts +62 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.service.ts +94 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staff-directory.types.ts +6 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/staffDirectory/staffDirectory.module.ts +10 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user.provider.ts +6 -2
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.spec.ts +20 -12
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.ts +21 -19
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +11 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.spec.ts +97 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.ts +150 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.controller.ts +13 -1
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.dto.ts +13 -1
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.errors.ts +1 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.spec.ts +125 -0
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/userGroup/user-group.service.ts +118 -5
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +20 -3
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +6 -9
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-runtime.facade.ts +2 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +4 -1
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +5 -1
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +44 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +15 -1
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +2 -3
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.spec.ts +33 -22
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.ts +10 -23
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/StaffSelect.test.tsx +69 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/StaffSelect.tsx +145 -0
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/index.ts +2 -0
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/StaffSelect/staff-directory.api.ts +16 -0
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +10 -0
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +20 -2
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +6 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +49 -11
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +49 -11
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/PeopleGroupModal.tsx +181 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/index.tsx +203 -0
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/people-groups.api.ts +145 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/PersonModal.tsx +112 -0
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/index.tsx +214 -0
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/person-directory.api.ts +79 -0
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/components/form-modal.tsx +7 -11
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +7 -7
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.test.tsx +20 -0
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +97 -28
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +87 -119
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.test.tsx +22 -1
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.tsx +8 -2
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connections.css +8 -46
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connectionsApi.ts +7 -0
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.test.tsx +32 -3
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +163 -106
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +8 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +26 -0
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +34 -6
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-config.css +13 -16
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +185 -0
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +224 -105
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.test.tsx +1 -1
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +21 -12
  79. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +1 -2
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.test.tsx +160 -8
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.tsx +125 -55
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +47 -3
  83. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.test.tsx +33 -1
  84. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.tsx +28 -46
  85. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/human-approval-config.css +16 -0
  86. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/subject-provider.api.ts +0 -6
  87. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.test.tsx +24 -0
  88. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +14 -17
  89. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -0
  90. package/package.json +1 -1
  91. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CreateNodeInstanceDrawer.tsx +0 -162
  92. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupCreateModal.tsx +0 -72
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ import {
2
+ BadGatewayException,
3
+ Injectable,
4
+ ServiceUnavailableException,
5
+ } from '@nestjs/common';
6
+ import { ConfigService } from '@nestjs/config';
7
+ import axios from 'axios';
8
+ import { StaffDirectoryPerson } from './staff-directory.types';
9
+
10
+ interface UpstreamStaff {
11
+ staffAccountName?: unknown;
12
+ staffDisplayName?: unknown;
13
+ bgNameEn?: unknown;
14
+ deptNameEn?: unknown;
15
+ }
16
+
17
+ @Injectable()
18
+ export class StaffDirectoryService {
19
+ constructor(private readonly config: ConfigService) {}
20
+
21
+ async search(keyword: string): Promise<StaffDirectoryPerson[]> {
22
+ const query = keyword.trim();
23
+ if (!query) return [];
24
+
25
+ const baseUrl = this.config
26
+ .get<string>('staffDirectory.baseUrl')
27
+ ?.trim()
28
+ .replace(/\/$/, '');
29
+ const apiKey = this.config.get<string>('staffDirectory.apiKey')?.trim();
30
+ const searchPath = normalizeSearchPath(
31
+ this.config.get<string>('staffDirectory.searchPath'),
32
+ );
33
+ if (!baseUrl || !apiKey) {
34
+ throw new ServiceUnavailableException({
35
+ code: 'STAFF_DIRECTORY_NOT_CONFIGURED',
36
+ message:
37
+ 'Staff directory is not configured; set STAFF_DIRECTORY_BASE_URL and STAFF_DIRECTORY_API_KEY',
38
+ });
39
+ }
40
+
41
+ try {
42
+ const response = await axios.request<unknown>({
43
+ url: `${baseUrl}${searchPath}`,
44
+ method: 'GET',
45
+ params: { keyword: query },
46
+ headers: { 'X-API-KEY': apiKey },
47
+ timeout: 5000,
48
+ maxRedirects: 0,
49
+ validateStatus: (status) => status >= 200 && status < 300,
50
+ });
51
+ if (!Array.isArray(response.data)) throw new Error('invalid response');
52
+ return [
53
+ ...new Map(
54
+ response.data
55
+ .flatMap(normalizePerson)
56
+ .map((person) => [person.userId, person]),
57
+ ).values(),
58
+ ];
59
+ } catch {
60
+ throw new BadGatewayException({
61
+ code: 'STAFF_DIRECTORY_UNAVAILABLE',
62
+ message: 'Staff directory search is temporarily unavailable',
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ function normalizeSearchPath(value?: string): string {
69
+ const path = value?.trim() || '/api/staff/search';
70
+ return path.startsWith('/') ? path : `/${path}`;
71
+ }
72
+
73
+ function normalizePerson(value: unknown): StaffDirectoryPerson[] {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
75
+ const staff = value as UpstreamStaff;
76
+ const userId = stringValue(staff.staffAccountName);
77
+ if (!userId) return [];
78
+ return [
79
+ {
80
+ userId,
81
+ displayName: stringValue(staff.staffDisplayName) || userId,
82
+ ...(stringValue(staff.bgNameEn) && {
83
+ businessGroup: stringValue(staff.bgNameEn),
84
+ }),
85
+ ...(stringValue(staff.deptNameEn) && {
86
+ department: stringValue(staff.deptNameEn),
87
+ }),
88
+ },
89
+ ];
90
+ }
91
+
92
+ function stringValue(value: unknown): string {
93
+ return typeof value === 'string' ? value.trim() : '';
94
+ }
@@ -0,0 +1,6 @@
1
+ export interface StaffDirectoryPerson {
2
+ userId: string;
3
+ displayName: string;
4
+ businessGroup?: string;
5
+ department?: string;
6
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { StaffDirectoryController } from './staff-directory.controller';
3
+ import { StaffDirectoryService } from './staff-directory.service';
4
+
5
+ @Module({
6
+ controllers: [StaffDirectoryController],
7
+ providers: [StaffDirectoryService],
8
+ exports: [StaffDirectoryService],
9
+ })
10
+ export class StaffDirectoryModule {}
@@ -46,6 +46,7 @@ export class UserProvider implements SubjectProvider {
46
46
  const { entities } = await this.users.findMany({
47
47
  where: {
48
48
  isActive: true,
49
+ isApiKey: false,
49
50
  ...(q && {
50
51
  OR: [
51
52
  { userid: { contains: q, mode: 'insensitive' } },
@@ -132,8 +133,11 @@ export class UserProvider implements SubjectProvider {
132
133
  private async find(refs: string[]): Promise<User[]> {
133
134
  if (!refs.length) return [];
134
135
  try {
135
- return (await this.users.findMany({ where: { userid: { in: refs } } }))
136
- .entities;
136
+ return (
137
+ await this.users.findMany({
138
+ where: { userid: { in: refs }, isApiKey: false },
139
+ })
140
+ ).entities;
137
141
  } catch {
138
142
  throw new SubjectProviderUnavailableError(this.key);
139
143
  }
@@ -85,7 +85,6 @@ describe('TemporalTenantConfigController', () => {
85
85
  dbUrl: 'postgresql://user:pass@gadmin-db:5432/gadmin_demo',
86
86
  displayName: 'Gadmin Test',
87
87
  runtimeBaseUrl: 'http://gadmin-server:8000/api',
88
- runtimeToken: '0123456789abcdef0123456789abcdef',
89
88
  } as any);
90
89
 
91
90
  expect(global.fetch).toHaveBeenCalledWith(
@@ -120,34 +119,40 @@ describe('TemporalTenantConfigController', () => {
120
119
  dbUrl: 'postgresql://user:pass@gadmin-db:5432/gadmin_demo',
121
120
  displayName: 'Gadmin Test',
122
121
  runtimeBaseUrl: 'http://gadmin-server:8000/api',
123
- runtimeToken: '0123456789abcdef0123456789abcdef',
122
+ runtimeToken: 'caller-controlled-runtime-token-value',
124
123
  } as any,
125
124
  (value) => value,
126
125
  { user: { username: 'admin' } } as any,
127
126
  );
128
127
 
128
+ const registrationRequest = (global.fetch as jest.Mock).mock.calls[0][1];
129
+ const registrationBody = JSON.parse(registrationRequest.body);
130
+
129
131
  expect(global.fetch).toHaveBeenCalledWith(
130
132
  'http://temporal.example:8081/admin/tenants',
131
133
  expect.objectContaining({
132
134
  method: 'POST',
133
- body: JSON.stringify({
134
- namespace: 'gadmin-test',
135
- displayName: 'Gadmin Test',
136
- dbUrl: 'postgresql://user:pass@gadmin-db:5432/gadmin_demo',
137
- runtimeBaseUrl: 'http://gadmin-server:8000/api',
138
- runtimeToken: '0123456789abcdef0123456789abcdef',
139
- }),
140
135
  }),
141
136
  );
137
+ expect(registrationBody).toMatchObject({
138
+ namespace: 'gadmin-test',
139
+ displayName: 'Gadmin Test',
140
+ dbUrl: 'postgresql://user:pass@gadmin-db:5432/gadmin_demo',
141
+ runtimeBaseUrl: 'http://gadmin-server:8000/api',
142
+ });
143
+ expect(registrationBody.runtimeToken).toMatch(/^[0-9a-f]{64}$/);
144
+ expect(registrationBody.runtimeToken).not.toBe(
145
+ 'caller-controlled-runtime-token-value',
146
+ );
142
147
  expect(mockTemporalTenantConfigService.createOne).toHaveBeenCalledWith({
143
148
  data: expect.objectContaining({
144
149
  runtimeBaseUrl: 'http://gadmin-server:8000/api',
145
- runtimeToken: '0123456789abcdef0123456789abcdef',
150
+ runtimeToken: registrationBody.runtimeToken,
146
151
  }),
147
152
  });
148
153
  });
149
154
 
150
- it('updates the tenant runtime connection in shared Temporal', async () => {
155
+ it('updates the tenant runtime connection without accepting a replacement token', async () => {
151
156
  global.fetch = jest.fn().mockResolvedValue({
152
157
  ok: true,
153
158
  });
@@ -175,9 +180,12 @@ describe('TemporalTenantConfigController', () => {
175
180
  method: 'PATCH',
176
181
  body: JSON.stringify({
177
182
  runtimeBaseUrl: 'http://gadmin-server:8000/api',
178
- runtimeToken: '0123456789abcdef0123456789abcdef',
179
183
  }),
180
184
  }),
181
185
  );
186
+ expect(mockTemporalTenantConfigService.updateUnique).toHaveBeenCalledWith(
187
+ 1,
188
+ expect.not.objectContaining({ runtimeToken: expect.anything() }),
189
+ );
182
190
  });
183
191
  });
@@ -50,6 +50,7 @@ import { TemporalTenantConfigService } from './temporalTenantConfig.service';
50
50
 
51
51
  import { ConfigService } from '@nestjs/config';
52
52
  import { FileInterceptor } from '@nestjs/platform-express';
53
+ import { randomBytes } from 'crypto';
53
54
 
54
55
  @UseGuards(ACGuard)
55
56
  @Controller('temporalTenantConfig')
@@ -69,7 +70,6 @@ export class TemporalTenantConfigController {
69
70
  possession: 'any',
70
71
  })
71
72
  async testConnection(@Body() body: CreateTemporalTenantConfigDto) {
72
- assertRuntimeToken(body.runtimeToken);
73
73
  let response: Response;
74
74
  const adminUrl = body.adminUrl.replace(/\/+$/, '');
75
75
 
@@ -117,8 +117,12 @@ export class TemporalTenantConfigController {
117
117
  @ACAttrFilter() acAttrFilter,
118
118
  @Req() request: Request & { user: UserType },
119
119
  ) {
120
- assertRuntimeToken(body.runtimeToken);
121
- body.creator = request.user.username;
120
+ const runtimeToken = randomBytes(32).toString('hex');
121
+ const registration = {
122
+ ...body,
123
+ creator: request.user.username,
124
+ runtimeToken,
125
+ };
122
126
 
123
127
  // 先调 shared temporal admin API(原子:失败则不写 DB)
124
128
  const res = await fetch(`${body.adminUrl}/admin/tenants`, {
@@ -132,7 +136,7 @@ export class TemporalTenantConfigController {
132
136
  displayName: body.displayName,
133
137
  dbUrl: body.dbUrl,
134
138
  runtimeBaseUrl: body.runtimeBaseUrl,
135
- runtimeToken: body.runtimeToken,
139
+ runtimeToken,
136
140
  }),
137
141
  });
138
142
  if (!res.ok) {
@@ -144,7 +148,7 @@ export class TemporalTenantConfigController {
144
148
 
145
149
  return new TemporalTenantConfigEntity(
146
150
  await this.temporalTenantConfigService.createOne({
147
- data: acAttrFilter(body),
151
+ data: acAttrFilter(registration),
148
152
  }),
149
153
  );
150
154
  }
@@ -210,7 +214,8 @@ export class TemporalTenantConfigController {
210
214
  @Body() body: UpdateTemporalTenantConfigDto,
211
215
  @ACAttrFilter() acAttrFilter,
212
216
  ) {
213
- if (body.runtimeToken !== undefined) assertRuntimeToken(body.runtimeToken);
217
+ const editableBody = { ...body };
218
+ delete editableBody.runtimeToken;
214
219
  const existing = await this.temporalTenantConfigService.findUnique(id, {
215
220
  adminUrl: true,
216
221
  adminToken: true,
@@ -218,15 +223,17 @@ export class TemporalTenantConfigController {
218
223
  });
219
224
  if (!existing) throw new NotFoundException();
220
225
 
221
- const adminUrl = (body.adminUrl ?? existing.adminUrl) as string;
222
- const adminToken = (body.adminToken ?? existing.adminToken) as string;
223
- const namespace = (body.namespace ?? existing.namespace) as string;
226
+ const adminUrl = (editableBody.adminUrl ?? existing.adminUrl) as string;
227
+ const adminToken = (editableBody.adminToken ??
228
+ existing.adminToken) as string;
229
+ const namespace = (editableBody.namespace ?? existing.namespace) as string;
224
230
 
225
231
  const patchBody: Record<string, string> = {};
226
- if (body.dbUrl) patchBody.dbUrl = body.dbUrl;
227
- if (body.displayName) patchBody.displayName = body.displayName;
228
- if (body.runtimeBaseUrl) patchBody.runtimeBaseUrl = body.runtimeBaseUrl;
229
- if (body.runtimeToken) patchBody.runtimeToken = body.runtimeToken;
232
+ if (editableBody.dbUrl) patchBody.dbUrl = editableBody.dbUrl;
233
+ if (editableBody.displayName)
234
+ patchBody.displayName = editableBody.displayName;
235
+ if (editableBody.runtimeBaseUrl)
236
+ patchBody.runtimeBaseUrl = editableBody.runtimeBaseUrl;
230
237
 
231
238
  if (Object.keys(patchBody).length > 0) {
232
239
  const res = await fetch(
@@ -250,7 +257,7 @@ export class TemporalTenantConfigController {
250
257
 
251
258
  const item = await this.temporalTenantConfigService.updateUnique(
252
259
  id,
253
- acAttrFilter(body),
260
+ acAttrFilter(editableBody),
254
261
  );
255
262
  return new TemporalTenantConfigEntity(acAttrFilter(item));
256
263
  }
@@ -295,8 +302,3 @@ export class TemporalTenantConfigController {
295
302
  };
296
303
  }
297
304
  }
298
-
299
- function assertRuntimeToken(value: string | undefined): void {
300
- if (!value || value.length < 32)
301
- throw new BadRequestException('Runtime Token 至少需要 32 个字符');
302
- }