@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
@@ -175,6 +175,17 @@ export class UserController {
175
175
  return res;
176
176
  }
177
177
 
178
+ @Delete('deletePerson/:id')
179
+ @UseRoles({
180
+ resource: 'user',
181
+ action: 'delete',
182
+ possession: 'any',
183
+ })
184
+ @ApiOkResponse({ type: EffectedCount })
185
+ async deletePerson(@Param('id', ParseIntPipe) id: number) {
186
+ return this.userService.deletePerson(id);
187
+ }
188
+
178
189
  @Post('upload')
179
190
  @UseRoles({
180
191
  resource: 'user',
@@ -1,6 +1,8 @@
1
1
  /// <reference types="jest" />
2
+ import { ConflictException } from '@nestjs/common';
2
3
  import { ConfigService } from '@nestjs/config';
3
4
  import { Test, TestingModule } from '@nestjs/testing';
5
+ import { Prisma } from '@prisma/client';
4
6
  import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
5
7
 
6
8
  // Mock nestjs-prisma 模块
@@ -26,6 +28,12 @@ const mockPrismaService = {
26
28
  groupBy: jest.fn(),
27
29
  aggregate: jest.fn(),
28
30
  },
31
+ workflow: {
32
+ findMany: jest.fn(),
33
+ },
34
+ approvalCandidate: {
35
+ count: jest.fn(),
36
+ },
29
37
  };
30
38
 
31
39
  // Mock Logger
@@ -103,6 +111,28 @@ describe('UserService', () => {
103
111
  'Database connection failed',
104
112
  );
105
113
  });
114
+
115
+ it('returns a useful conflict when userid already exists', async () => {
116
+ prisma.user.create.mockRejectedValue(
117
+ new Prisma.PrismaClientKnownRequestError('duplicate userid', {
118
+ code: 'P2002',
119
+ clientVersion: '6.19.3',
120
+ meta: { target: ['userid'] },
121
+ }),
122
+ );
123
+
124
+ await expect(
125
+ service.createOne({
126
+ data: { userid: 'existing-user', roles: '[]' },
127
+ } as any),
128
+ ).rejects.toMatchObject({
129
+ constructor: ConflictException,
130
+ response: {
131
+ code: 'USER_ALREADY_EXISTS',
132
+ message: '该人员已存在,无需重复添加',
133
+ },
134
+ });
135
+ });
106
136
  });
107
137
 
108
138
  describe('createMany', () => {
@@ -133,6 +163,73 @@ describe('UserService', () => {
133
163
  });
134
164
  });
135
165
 
166
+ describe('deletePerson', () => {
167
+ it('deletes an unreferenced person without roles', async () => {
168
+ prisma.user.findUnique.mockResolvedValue({
169
+ userid: 'person-a',
170
+ roles: '[]',
171
+ groupMemberships: [],
172
+ });
173
+ prisma.workflow.findMany.mockResolvedValue([]);
174
+ prisma.approvalCandidate.count.mockResolvedValue(0);
175
+ prisma.user.delete.mockResolvedValue({ id: 10 });
176
+
177
+ await expect(service.deletePerson(10)).resolves.toEqual({ count: 1 });
178
+ expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 10 } });
179
+ });
180
+
181
+ it('rejects deleting a person that still has a role', async () => {
182
+ prisma.user.findUnique.mockResolvedValue({
183
+ userid: 'admin-a',
184
+ roles: '["SYSTEM_ADMIN"]',
185
+ groupMemberships: [],
186
+ });
187
+ prisma.workflow.findMany.mockResolvedValue([]);
188
+ prisma.approvalCandidate.count.mockResolvedValue(0);
189
+
190
+ await expect(service.deletePerson(10)).rejects.toMatchObject({
191
+ response: {
192
+ code: 'PERSON_IN_USE',
193
+ roles: ['SYSTEM_ADMIN'],
194
+ },
195
+ });
196
+ expect(prisma.user.delete).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('rejects deleting a person referenced by a Workflow', async () => {
200
+ prisma.user.findUnique.mockResolvedValue({
201
+ userid: 'approver-a',
202
+ roles: '[]',
203
+ groupMemberships: [],
204
+ });
205
+ prisma.workflow.findMany.mockResolvedValue([
206
+ {
207
+ id: BigInt(7),
208
+ name: '资产审批',
209
+ dsl: {
210
+ nodes: [
211
+ {
212
+ config: {
213
+ subjects: [{ source: 'STATIC', refs: ['approver-a'] }],
214
+ },
215
+ },
216
+ ],
217
+ },
218
+ versions: [],
219
+ },
220
+ ]);
221
+ prisma.approvalCandidate.count.mockResolvedValue(0);
222
+
223
+ await expect(service.deletePerson(10)).rejects.toMatchObject({
224
+ response: {
225
+ code: 'PERSON_IN_USE',
226
+ workflows: [{ id: '7', name: '资产审批' }],
227
+ },
228
+ });
229
+ expect(prisma.user.delete).not.toHaveBeenCalled();
230
+ });
231
+ });
232
+
136
233
  describe('findMany', () => {
137
234
  it('should return entities with itemCount', async () => {
138
235
  const findArgs = {
@@ -1,4 +1,9 @@
1
- import { Inject, Injectable } from '@nestjs/common';
1
+ import {
2
+ ConflictException,
3
+ Inject,
4
+ Injectable,
5
+ NotFoundException,
6
+ } from '@nestjs/common';
2
7
  import { Prisma, User } from '@prisma/client';
3
8
  import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
4
9
  import { PrismaService } from 'nestjs-prisma';
@@ -20,7 +25,21 @@ export class UserService {
20
25
  }
21
26
 
22
27
  async createOne(userCreateArgs: Prisma.UserCreateArgs) {
23
- return await this.prisma.user.create(userCreateArgs);
28
+ try {
29
+ return await this.prisma.user.create(userCreateArgs);
30
+ } catch (error) {
31
+ if (
32
+ error instanceof Prisma.PrismaClientKnownRequestError &&
33
+ error.code === 'P2002' &&
34
+ prismaErrorTargets(error).some((target) => target.includes('userid'))
35
+ ) {
36
+ throw new ConflictException({
37
+ code: 'USER_ALREADY_EXISTS',
38
+ message: '该人员已存在,无需重复添加',
39
+ });
40
+ }
41
+ throw error;
42
+ }
24
43
  }
25
44
 
26
45
  async createMany(data: Prisma.UserCreateManyArgs['data']) {
@@ -69,6 +88,94 @@ export class UserService {
69
88
  return { count: 1 };
70
89
  }
71
90
 
91
+ async deletePerson(id: number) {
92
+ const person = await this.prisma.user.findUnique({
93
+ where: { id },
94
+ select: {
95
+ userid: true,
96
+ roles: true,
97
+ groupMemberships: {
98
+ select: { group: { select: { code: true, name: true } } },
99
+ },
100
+ },
101
+ });
102
+ if (!person) throw new NotFoundException('人员不存在');
103
+
104
+ const [workflows, activeApprovalCount] = await Promise.all([
105
+ this.prisma.workflow.findMany({
106
+ select: {
107
+ id: true,
108
+ name: true,
109
+ dsl: true,
110
+ versions: { select: { dsl: true } },
111
+ },
112
+ }),
113
+ this.prisma.approvalCandidate.count({
114
+ where: {
115
+ principalType: 'USER',
116
+ principalRef: person.userid,
117
+ invalidatedAt: null,
118
+ request: { status: { in: ['PENDING', 'BLOCKED'] } },
119
+ },
120
+ }),
121
+ ]);
122
+ const roles = assignedRoles(person.roles);
123
+ const referencedWorkflows = workflows
124
+ .filter(
125
+ (workflow) =>
126
+ workflowDslReferencesUser(workflow.dsl, person.userid) ||
127
+ workflow.versions.some((version) =>
128
+ workflowDslReferencesUser(version.dsl, person.userid),
129
+ ),
130
+ )
131
+ .map((workflow) => ({ id: String(workflow.id), name: workflow.name }));
132
+ const groups = person.groupMemberships.map(({ group }) => group);
133
+ const reasons = [
134
+ ...(roles.length ? [`已授予角色:${roles.join('、')}`] : []),
135
+ ...(groups.length
136
+ ? [`已加入人员组:${groups.map((group) => group.name).join('、')}`]
137
+ : []),
138
+ ...(referencedWorkflows.length
139
+ ? [
140
+ `被 Workflow 引用:${referencedWorkflows
141
+ .map((workflow) => workflow.name)
142
+ .join('、')}`,
143
+ ]
144
+ : []),
145
+ ...(activeApprovalCount
146
+ ? [`仍有 ${activeApprovalCount} 个待处理审批`]
147
+ : []),
148
+ ];
149
+
150
+ if (reasons.length) {
151
+ throw new ConflictException({
152
+ code: 'PERSON_IN_USE',
153
+ message: `无法删除该人员:${reasons.join(';')}`,
154
+ reasons,
155
+ roles,
156
+ groups,
157
+ workflows: referencedWorkflows,
158
+ activeApprovalCount,
159
+ });
160
+ }
161
+
162
+ try {
163
+ await this.prisma.user.delete({ where: { id } });
164
+ return { count: 1 };
165
+ } catch (error) {
166
+ if (
167
+ error instanceof Prisma.PrismaClientKnownRequestError &&
168
+ error.code === 'P2003'
169
+ ) {
170
+ throw new ConflictException({
171
+ code: 'PERSON_IN_USE',
172
+ message: '该人员刚被其他数据引用,请刷新后重试',
173
+ });
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+
72
179
  async count(userCountArgs?: Prisma.UserCountArgs) {
73
180
  return await this.prisma.user.count(userCountArgs);
74
181
  }
@@ -102,3 +209,44 @@ export class UserService {
102
209
  return key.substring(0, 8) + '****';
103
210
  }
104
211
  }
212
+
213
+ function prismaErrorTargets(
214
+ error: Prisma.PrismaClientKnownRequestError,
215
+ ): string[] {
216
+ const target = error.meta?.target;
217
+ if (Array.isArray(target)) return target.map(String);
218
+ return target == null ? [] : [String(target)];
219
+ }
220
+
221
+ function assignedRoles(value: string): string[] {
222
+ try {
223
+ const parsed = JSON.parse(value);
224
+ return Array.isArray(parsed)
225
+ ? parsed.map(String).filter((role) => role.trim())
226
+ : value.trim()
227
+ ? [value]
228
+ : [];
229
+ } catch {
230
+ return value.trim() ? [value] : [];
231
+ }
232
+ }
233
+
234
+ function workflowDslReferencesUser(
235
+ value: Prisma.JsonValue | null,
236
+ userid: string,
237
+ ): boolean {
238
+ if (Array.isArray(value))
239
+ return value.some((item) => workflowDslReferencesUser(item, userid));
240
+ if (!value || typeof value !== 'object') return false;
241
+
242
+ const record = value as Record<string, Prisma.JsonValue>;
243
+ const refs = Array.isArray(record.refs) ? record.refs.map(String) : [];
244
+ if (
245
+ refs.includes(userid) &&
246
+ (record.source === 'STATIC' || record.provider === 'USER')
247
+ )
248
+ return true;
249
+ return Object.values(record).some((item) =>
250
+ workflowDslReferencesUser(item, userid),
251
+ );
252
+ }
@@ -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
  Param,
7
8
  Patch,
@@ -19,6 +20,7 @@ import {
19
20
  } from '../subjectProvider/actor-context.util';
20
21
  import {
21
22
  CreateUserGroupDto,
23
+ DeleteUserGroupDto,
22
24
  ReplaceUserGroupMembersDto,
23
25
  SetUserGroupActiveDto,
24
26
  UpdateUserGroupDto,
@@ -35,7 +37,7 @@ export class UserGroupController {
35
37
  @Get()
36
38
  @UseRoles({ resource: 'userGroup:read', action: 'read', possession: 'any' })
37
39
  search(@Query() query: UserGroupSearchDto) {
38
- return this.service.search(query.q ?? '');
40
+ return this.service.search(query.q ?? '', query.includeInactive ?? false);
39
41
  }
40
42
 
41
43
  @Get(':code/preview')
@@ -116,4 +118,14 @@ export class UserGroupController {
116
118
  actorContextFromRequest(request),
117
119
  );
118
120
  }
121
+
122
+ @Delete(':code')
123
+ @UseRoles({
124
+ resource: 'userGroup:create',
125
+ action: 'delete',
126
+ possession: 'any',
127
+ })
128
+ remove(@Param('code') code: string, @Query() query: DeleteUserGroupDto) {
129
+ return this.service.deleteGroup(code, query.expectedRevision);
130
+ }
119
131
  }
@@ -1,4 +1,4 @@
1
- import { Type } from 'class-transformer';
1
+ import { Transform, Type } from 'class-transformer';
2
2
  import {
3
3
  ArrayUnique,
4
4
  IsArray,
@@ -84,8 +84,20 @@ export class SetUserGroupActiveDto {
84
84
  isActive!: boolean;
85
85
  }
86
86
 
87
+ export class DeleteUserGroupDto {
88
+ @Type(() => Number)
89
+ @IsInt()
90
+ @Min(1)
91
+ expectedRevision!: number;
92
+ }
93
+
87
94
  export class UserGroupSearchDto {
88
95
  @IsOptional()
89
96
  @IsString()
90
97
  q?: string;
98
+
99
+ @IsOptional()
100
+ @Transform(({ value }) => value === true || value === 'true')
101
+ @IsBoolean()
102
+ includeInactive?: boolean;
91
103
  }
@@ -4,6 +4,7 @@ export type UserGroupErrorCode =
4
4
  | 'USER_GROUP_CYCLE'
5
5
  | 'USER_GROUP_DEPTH_EXCEEDED'
6
6
  | 'USER_GROUP_MEMBER_INVALID'
7
+ | 'USER_GROUP_IN_USE'
7
8
  | 'USER_GROUP_REVISION_CONFLICT'
8
9
  | 'USER_GROUP_NOT_FOUND';
9
10
 
@@ -1,3 +1,4 @@
1
+ import { Prisma } from '@prisma/client';
1
2
  import { UserGroupService } from './user-group.service';
2
3
 
3
4
  const group = {
@@ -20,6 +21,7 @@ function createHarness() {
20
21
  findMany: jest.fn(),
21
22
  create: jest.fn(),
22
23
  updateMany: jest.fn().mockResolvedValue({ count: 1 }),
24
+ delete: jest.fn().mockResolvedValue(group),
23
25
  },
24
26
  user: { findMany: jest.fn() },
25
27
  userGroupUser: {
@@ -32,6 +34,8 @@ function createHarness() {
32
34
  deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
33
35
  createMany: jest.fn().mockResolvedValue({ count: 1 }),
34
36
  },
37
+ workflow: { findMany: jest.fn().mockResolvedValue([]) },
38
+ approvalRequestSubject: { count: jest.fn().mockResolvedValue(0) },
35
39
  };
36
40
  const prisma = {
37
41
  ...tx,
@@ -44,6 +48,19 @@ function createHarness() {
44
48
  }
45
49
 
46
50
  describe('UserGroupService', () => {
51
+ it('can include inactive groups for the management page', async () => {
52
+ const { service, tx } = createHarness();
53
+ tx.userGroup.findMany.mockResolvedValue([]);
54
+
55
+ await service.search('finance', true);
56
+
57
+ expect(tx.userGroup.findMany).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ where: expect.not.objectContaining({ isActive: true }),
60
+ }),
61
+ );
62
+ });
63
+
47
64
  it('returns direct users and child groups for editing', async () => {
48
65
  const { service, tx } = createHarness();
49
66
  tx.userGroup.findUnique
@@ -148,6 +165,31 @@ describe('UserGroupService', () => {
148
165
  });
149
166
  });
150
167
 
168
+ it('only accepts active non-API users as direct members', async () => {
169
+ const { service, tx } = createHarness();
170
+ tx.user.findMany.mockResolvedValue([{ userid: 'alice' }]);
171
+ tx.userGroup.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
172
+
173
+ await service.replaceMembers(
174
+ 'finance',
175
+ {
176
+ expectedRevision: 4,
177
+ userIds: ['alice'],
178
+ childGroupCodes: [],
179
+ },
180
+ actor,
181
+ );
182
+
183
+ expect(tx.user.findMany).toHaveBeenCalledWith({
184
+ where: {
185
+ userid: { in: ['alice'] },
186
+ isActive: true,
187
+ isApiKey: false,
188
+ },
189
+ select: { userid: true },
190
+ });
191
+ });
192
+
151
193
  it('returns 409 when expectedRevision is stale', async () => {
152
194
  const { service } = createHarness();
153
195
  await expect(
@@ -158,4 +200,87 @@ describe('UserGroupService', () => {
158
200
  ),
159
201
  ).rejects.toMatchObject({ status: 409 });
160
202
  });
203
+
204
+ it('deletes an unreferenced group and its owned memberships', async () => {
205
+ const { service, tx } = createHarness();
206
+ tx.userGroupChild.findMany.mockResolvedValue([]);
207
+
208
+ await expect(service.deleteGroup('finance', 4)).resolves.toEqual({
209
+ count: 1,
210
+ });
211
+ expect(tx.userGroupUser.deleteMany).toHaveBeenCalledWith({
212
+ where: { groupId: group.id },
213
+ });
214
+ expect(tx.userGroupChild.deleteMany).toHaveBeenCalledWith({
215
+ where: { parentGroupId: group.id },
216
+ });
217
+ expect(tx.userGroup.delete).toHaveBeenCalledWith({
218
+ where: { id: group.id },
219
+ });
220
+ });
221
+
222
+ it('rejects deleting a group referenced by another group', async () => {
223
+ const { service, tx } = createHarness();
224
+ tx.userGroupChild.findMany.mockResolvedValue([
225
+ { parentGroup: { code: 'all-finance', name: 'All Finance' } },
226
+ ]);
227
+
228
+ await expect(service.deleteGroup('finance', 4)).rejects.toMatchObject({
229
+ status: 409,
230
+ response: {
231
+ code: 'USER_GROUP_IN_USE',
232
+ message: expect.stringContaining('All Finance'),
233
+ },
234
+ });
235
+ expect(tx.userGroup.delete).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('rejects deleting a group referenced by a Workflow', async () => {
239
+ const { service, tx } = createHarness();
240
+ tx.userGroupChild.findMany.mockResolvedValue([]);
241
+ tx.workflow.findMany.mockResolvedValue([
242
+ {
243
+ id: BigInt(9),
244
+ name: 'Finance Approval',
245
+ dsl: {
246
+ nodes: [
247
+ {
248
+ config: {
249
+ subjects: [{ provider: 'USER_GROUP', refs: ['finance'] }],
250
+ },
251
+ },
252
+ ],
253
+ },
254
+ versions: [],
255
+ },
256
+ ]);
257
+
258
+ await expect(service.deleteGroup('finance', 4)).rejects.toMatchObject({
259
+ status: 409,
260
+ response: {
261
+ code: 'USER_GROUP_IN_USE',
262
+ message: expect.stringContaining('Finance Approval'),
263
+ },
264
+ });
265
+ expect(tx.userGroup.delete).not.toHaveBeenCalled();
266
+ });
267
+
268
+ it('returns 409 when a concurrent reference makes the database reject deletion', async () => {
269
+ const { service, tx } = createHarness();
270
+ tx.userGroupChild.findMany.mockResolvedValue([]);
271
+ tx.userGroup.delete.mockRejectedValue(
272
+ new Prisma.PrismaClientKnownRequestError('foreign key conflict', {
273
+ code: 'P2003',
274
+ clientVersion: '6.19.3',
275
+ }),
276
+ );
277
+
278
+ await expect(service.deleteGroup('finance', 4)).rejects.toMatchObject({
279
+ status: 409,
280
+ response: {
281
+ code: 'USER_GROUP_IN_USE',
282
+ message: expect.stringContaining('刷新后重试'),
283
+ },
284
+ });
285
+ });
161
286
  });