@gadmin2n/schematics 0.0.150 → 0.0.152

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 (82) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.md +125 -36
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +42 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +0 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -0
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/check-dev-env.js +218 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/page-manage.ts +78 -12
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +37 -67
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +0 -17
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +46 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +60 -1
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-callback.controller.ts +3 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/workflow-callback-url.controller.ts +9 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +55 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.module.ts +2 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/page-transfer.service.ts +491 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.dto.ts +213 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.module.ts +28 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/resource-transfer.service.ts +137 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/role-transfer.service.ts +248 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-token.service.ts +65 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/user-transfer.service.ts +146 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +55 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.module.ts +2 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +55 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.module.ts +2 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.spec.ts +46 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.ts +3 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +55 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.module.ts +2 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +1 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +96 -14
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +41 -41
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.spec.ts +36 -0
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.ts +90 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +4 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +1 -0
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +115 -63
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.spec.ts +166 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.ts +217 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +33 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +57 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +196 -0
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.ts +125 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-node-type.controller.ts +9 -3
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +148 -4
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +12 -1
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +14 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +75 -45
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +25 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferExportModal.tsx +115 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +257 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -2
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +40 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +15 -13
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +40 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +15 -13
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +2 -1
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agendaJob/list.tsx +89 -65
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +51 -127
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +53 -109
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +45 -41
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +51 -72
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +49 -40
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +16 -5
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +28 -10
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +60 -1
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +94 -12
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +13 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +3 -3
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +8 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +167 -94
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +95 -95
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.test.tsx +81 -0
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.tsx +126 -0
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +48 -52
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +2 -2
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -3
  79. package/package.json +1 -1
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.local.md +0 -8
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/ac.config.ts +0 -24
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/SqlModal.tsx +0 -495
@@ -0,0 +1,491 @@
1
+ import {
2
+ BadRequestException,
3
+ Inject,
4
+ Injectable,
5
+ NotFoundException,
6
+ } from '@nestjs/common';
7
+ import { Prisma } from '@prisma/client';
8
+ import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
9
+ import { PrismaService } from 'nestjs-prisma';
10
+ import * as path from 'path';
11
+ import { Logger } from 'winston';
12
+ import {
13
+ assertTransferPayload,
14
+ ImportPreviewResponse,
15
+ MissingRef,
16
+ PageExportPayload,
17
+ PageResourceTransferItem,
18
+ PageTransferItem,
19
+ TRANSFER_PAYLOAD_VERSION,
20
+ TransferPayload,
21
+ } from './perm-transfer.dto';
22
+ import { TransferTokenService } from './transfer-token.service';
23
+
24
+ type PageRow = Prisma.PageGetPayload<{ include: { pageResources: true } }>;
25
+
26
+ /** actions is a Json column that historically holds an array or a CSV string */
27
+ function parseActions(actions: unknown): string[] {
28
+ if (!actions) return [];
29
+ if (Array.isArray(actions)) return actions.map(String).filter(Boolean);
30
+ if (typeof actions === 'string') {
31
+ try {
32
+ const parsed = JSON.parse(actions);
33
+ if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
34
+ } catch {
35
+ // fall through to CSV handling
36
+ }
37
+ return actions
38
+ .split(',')
39
+ .map((a) => a.trim())
40
+ .filter(Boolean);
41
+ }
42
+ return [];
43
+ }
44
+
45
+ @Injectable()
46
+ export class PageTransferService {
47
+ constructor(
48
+ private prisma: PrismaService,
49
+ private readonly tokenService: TransferTokenService,
50
+ @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
51
+ ) {
52
+ this.logger = this.logger.child({ file: path.basename(__filename) });
53
+ }
54
+
55
+ async generateExportToken(
56
+ code: string,
57
+ baseUrl: string,
58
+ ): Promise<{ url: string }> {
59
+ const page = await this.prisma.page.findUnique({
60
+ where: { code },
61
+ select: { code: true },
62
+ });
63
+ if (!page) throw new NotFoundException('Page not found');
64
+
65
+ const token = this.tokenService.sign('page', code);
66
+ return { url: this.tokenService.buildUrl(baseUrl, 'page', code, token) };
67
+ }
68
+
69
+ /**
70
+ * BFS-collect the root page and all descendants (parents before children),
71
+ * including pageResource bindings with resource codes resolved.
72
+ */
73
+ private async collectSubtree(
74
+ rootCode: string,
75
+ client: Prisma.TransactionClient | PrismaService = this.prisma,
76
+ ): Promise<{
77
+ pages: PageTransferItem[];
78
+ pageResources: PageResourceTransferItem[];
79
+ } | null> {
80
+ const root = await client.page.findUnique({
81
+ where: { code: rootCode },
82
+ include: { pageResources: true },
83
+ });
84
+ if (!root) return null;
85
+
86
+ const rows: PageRow[] = [root];
87
+ // `visited` guards against parent_id cycles (the page tree is only
88
+ // enforced by application code, so a drag-and-drop mishap could create one)
89
+ const visited = new Set<number>([root.id]);
90
+ let frontier: number[] = [root.id];
91
+ while (frontier.length > 0) {
92
+ const children = await client.page.findMany({
93
+ where: { parentId: { in: frontier } },
94
+ include: { pageResources: true },
95
+ orderBy: { id: 'asc' },
96
+ });
97
+ const fresh = children.filter((c) => !visited.has(c.id));
98
+ fresh.forEach((c) => visited.add(c.id));
99
+ rows.push(...fresh);
100
+ frontier = fresh.map((c) => c.id);
101
+ }
102
+
103
+ const codeById = new Map<number, string>();
104
+ const parentCodeById = new Map<number, string | null>();
105
+ for (const row of rows) codeById.set(row.id, row.code);
106
+ // parents outside the subtree (i.e. the root's parent) need one extra lookup
107
+ const outsideParentIds = rows
108
+ .map((r) => r.parentId)
109
+ .filter((id): id is number => id !== null && !codeById.has(id));
110
+ if (outsideParentIds.length > 0) {
111
+ const parents = await client.page.findMany({
112
+ where: { id: { in: outsideParentIds } },
113
+ select: { id: true, code: true },
114
+ });
115
+ for (const p of parents) codeById.set(p.id, p.code);
116
+ }
117
+ for (const row of rows) {
118
+ parentCodeById.set(
119
+ row.id,
120
+ row.parentId === null ? null : codeById.get(row.parentId) ?? null,
121
+ );
122
+ }
123
+
124
+ const resourceIds = [
125
+ ...new Set(
126
+ rows.flatMap((r) => r.pageResources.map((pr) => pr.resourceId)),
127
+ ),
128
+ ];
129
+ const resources = resourceIds.length
130
+ ? await client.resource.findMany({
131
+ where: { id: { in: resourceIds } },
132
+ select: { id: true, code: true },
133
+ })
134
+ : [];
135
+ const resourceCodeById = new Map(resources.map((r) => [r.id, r.code]));
136
+
137
+ const pages: PageTransferItem[] = rows.map((row) => ({
138
+ code: row.code,
139
+ name: row.name,
140
+ path: row.path,
141
+ iframeUrl: row.iframeUrl,
142
+ externalRedirectUrl: row.externalRedirectUrl,
143
+ description: row.description,
144
+ zhName: row.zhName,
145
+ enName: row.enName,
146
+ i18nKey: row.i18nKey,
147
+ parentCode: parentCodeById.get(row.id) ?? null,
148
+ sortOrder: row.sortOrder,
149
+ isVirtual: row.isVirtual,
150
+ }));
151
+ const pageResources: PageResourceTransferItem[] = rows.flatMap((row) =>
152
+ row.pageResources.flatMap((pr) => {
153
+ const resourceCode = resourceCodeById.get(pr.resourceId);
154
+ // skip dangling bindings rather than emitting an empty code that the
155
+ // importer would reject with a confusing "resource does not exist: "
156
+ if (!resourceCode) return [];
157
+ return [{ pageCode: row.code, resourceCode, actions: pr.actions }];
158
+ }),
159
+ );
160
+ return { pages, pageResources };
161
+ }
162
+
163
+ async getExportPayload(
164
+ code: string,
165
+ token: string,
166
+ ): Promise<PageExportPayload> {
167
+ this.tokenService.verify(token, 'page', code);
168
+
169
+ const subtree = await this.collectSubtree(code);
170
+ if (!subtree) throw new NotFoundException('Page not found');
171
+
172
+ return {
173
+ version: TRANSFER_PAYLOAD_VERSION,
174
+ exportedAt: new Date().toISOString(),
175
+ entity: 'page',
176
+ pages: subtree.pages,
177
+ pageResources: subtree.pageResources,
178
+ };
179
+ }
180
+
181
+ /** referenced resource / parentPage codes that must exist in the target DB */
182
+ private findMissingRefs(
183
+ pages: PageTransferItem[],
184
+ pageResources: PageResourceTransferItem[],
185
+ existingResourceCodes: Set<string>,
186
+ existingPageCodes: Set<string>,
187
+ ): MissingRef[] {
188
+ const payloadCodes = new Set(pages.map((p) => p.code));
189
+ const missing: MissingRef[] = [];
190
+
191
+ for (const pr of pageResources) {
192
+ if (!existingResourceCodes.has(pr.resourceCode)) {
193
+ missing.push({ type: 'resource', code: pr.resourceCode });
194
+ }
195
+ }
196
+ for (const p of pages) {
197
+ if (
198
+ p.parentCode &&
199
+ !payloadCodes.has(p.parentCode) &&
200
+ !existingPageCodes.has(p.parentCode)
201
+ ) {
202
+ missing.push({ type: 'parentPage', code: p.parentCode });
203
+ }
204
+ }
205
+ // de-duplicate
206
+ const seen = new Set<string>();
207
+ return missing.filter((m) => {
208
+ const k = `${m.type}:${m.code}`;
209
+ if (seen.has(k)) return false;
210
+ seen.add(k);
211
+ return true;
212
+ });
213
+ }
214
+
215
+ private async resolveRefs(
216
+ pages: PageTransferItem[],
217
+ pageResources: PageResourceTransferItem[],
218
+ ) {
219
+ const resourceCodes = [
220
+ ...new Set(pageResources.map((pr) => pr.resourceCode)),
221
+ ];
222
+ const payloadCodes = new Set(pages.map((p) => p.code));
223
+ const parentCodes = [
224
+ ...new Set(
225
+ pages
226
+ .map((p) => p.parentCode)
227
+ .filter((c): c is string => !!c && !payloadCodes.has(c)),
228
+ ),
229
+ ];
230
+ const [resources, parents] = await Promise.all([
231
+ resourceCodes.length
232
+ ? this.prisma.resource.findMany({
233
+ where: { code: { in: resourceCodes } },
234
+ select: { code: true },
235
+ })
236
+ : [],
237
+ parentCodes.length
238
+ ? this.prisma.page.findMany({
239
+ where: { code: { in: parentCodes } },
240
+ select: { code: true },
241
+ })
242
+ : [],
243
+ ]);
244
+ return {
245
+ existingResourceCodes: new Set(resources.map((r) => r.code)),
246
+ existingPageCodes: new Set(parents.map((p) => p.code)),
247
+ };
248
+ }
249
+
250
+ async importPreview(
251
+ payload: TransferPayload,
252
+ ): Promise<ImportPreviewResponse> {
253
+ assertTransferPayload(payload, 'page');
254
+ const { pages, pageResources } = payload as PageExportPayload;
255
+ if (!pages?.length) {
256
+ throw new BadRequestException('Invalid page payload: pages is empty');
257
+ }
258
+
259
+ const refs = await this.resolveRefs(pages, pageResources);
260
+ const missingRefs = this.findMissingRefs(
261
+ pages,
262
+ pageResources,
263
+ refs.existingResourceCodes,
264
+ refs.existingPageCodes,
265
+ );
266
+
267
+ // Import only writes the pages carried by the payload; child pages that
268
+ // exist solely in the target are left untouched. Restrict `current` to the
269
+ // payload's codes so the diff reflects exactly what will change.
270
+ const existing = await this.collectSubtree(pages[0].code);
271
+ const payloadCodes = new Set(pages.map((p) => p.code));
272
+ const current = existing
273
+ ? {
274
+ pages: existing.pages.filter((p) => payloadCodes.has(p.code)),
275
+ pageResources: existing.pageResources.filter((pr) =>
276
+ payloadCodes.has(pr.pageCode),
277
+ ),
278
+ }
279
+ : null;
280
+
281
+ return {
282
+ current,
283
+ incoming: { pages, pageResources },
284
+ missingRefs,
285
+ conflicts: [],
286
+ };
287
+ }
288
+
289
+ async importConfirm(
290
+ payload: TransferPayload,
291
+ operator: string,
292
+ ): Promise<{ success: true; key: string }> {
293
+ assertTransferPayload(payload, 'page');
294
+ const { pages, pageResources } = payload as PageExportPayload;
295
+ if (!pages?.length) {
296
+ throw new BadRequestException('Invalid page payload: pages is empty');
297
+ }
298
+ const rootCode = pages[0].code;
299
+ this.logger.info(
300
+ `[permTransfer] page import_confirm - root: ${rootCode}, pages: ${pages.length}, operator: ${operator}`,
301
+ );
302
+
303
+ await this.prisma.$transaction(async (tx) => {
304
+ // Re-validate refs inside the transaction so a mismatch rolls back.
305
+ const resourceCodes = [
306
+ ...new Set(pageResources.map((pr) => pr.resourceCode)),
307
+ ];
308
+ const resources = resourceCodes.length
309
+ ? await tx.resource.findMany({
310
+ where: { code: { in: resourceCodes } },
311
+ select: { id: true, code: true },
312
+ })
313
+ : [];
314
+ const resourceIdByCode = new Map<string, number>(
315
+ resources.map((r) => [r.code, r.id] as [string, number]),
316
+ );
317
+ const missingResource = resourceCodes.find(
318
+ (c) => !resourceIdByCode.has(c),
319
+ );
320
+ if (missingResource) {
321
+ throw new BadRequestException(
322
+ `Referenced resource does not exist: ${missingResource}`,
323
+ );
324
+ }
325
+
326
+ const payloadCodes = new Set(pages.map((p) => p.code));
327
+ const idByCode = new Map<string, number>();
328
+
329
+ // pages arrive parents-first (BFS); upsert in order so parentId resolves
330
+ for (const p of pages) {
331
+ let parentId: number | null = null;
332
+ if (p.parentCode) {
333
+ if (idByCode.has(p.parentCode)) {
334
+ parentId = idByCode.get(p.parentCode)!;
335
+ } else if (!payloadCodes.has(p.parentCode)) {
336
+ const parent = await tx.page.findUnique({
337
+ where: { code: p.parentCode },
338
+ select: { id: true },
339
+ });
340
+ if (!parent) {
341
+ throw new BadRequestException(
342
+ `Parent page does not exist: ${p.parentCode}`,
343
+ );
344
+ }
345
+ parentId = parent.id;
346
+ } else {
347
+ throw new BadRequestException(
348
+ `Payload pages are not in parents-first order: ${p.code} before ${p.parentCode}`,
349
+ );
350
+ }
351
+ }
352
+
353
+ const data = {
354
+ name: p.name,
355
+ path: p.path,
356
+ iframeUrl: p.iframeUrl,
357
+ externalRedirectUrl: p.externalRedirectUrl,
358
+ description: p.description,
359
+ zhName: p.zhName,
360
+ enName: p.enName,
361
+ i18nKey: p.i18nKey,
362
+ parentId,
363
+ sortOrder: p.sortOrder,
364
+ isVirtual: p.isVirtual,
365
+ };
366
+ const saved = await tx.page.upsert({
367
+ where: { code: p.code },
368
+ create: { code: p.code, ...data, creator: operator },
369
+ update: data,
370
+ select: { id: true },
371
+ });
372
+ idByCode.set(p.code, saved.id);
373
+ }
374
+
375
+ // rebuild pageResource bindings for the payload pages only
376
+ const pageIds = [...idByCode.values()];
377
+ // snapshot the bindings we are about to drop so roleResource can be
378
+ // reconciled for resources that this import *removes* from a page
379
+ const previousBindings = await tx.pageResource.findMany({
380
+ where: { pageId: { in: pageIds } },
381
+ select: { resourceId: true },
382
+ });
383
+ await tx.pageResource.deleteMany({ where: { pageId: { in: pageIds } } });
384
+ if (pageResources.length > 0) {
385
+ await tx.pageResource.createMany({
386
+ data: pageResources.map((pr) => ({
387
+ pageId: idByCode.get(pr.pageCode)!,
388
+ resourceId: resourceIdByCode.get(pr.resourceCode)!,
389
+ actions: pr.actions as Prisma.InputJsonValue,
390
+ creator: operator,
391
+ })),
392
+ });
393
+ }
394
+
395
+ const touchedResourceIds = [
396
+ ...new Set([
397
+ ...previousBindings.map((b) => b.resourceId),
398
+ ...pageResources.map((pr) => resourceIdByCode.get(pr.resourceCode)!),
399
+ ]),
400
+ ];
401
+ await this.syncRoleResources(tx, pageIds, touchedResourceIds, operator);
402
+
403
+ await tx.audit.create({
404
+ data: {
405
+ creator: operator,
406
+ authorName: operator,
407
+ action: 'IMPORT_PAGE',
408
+ resource: 'page',
409
+ resourceId: rootCode,
410
+ data: JSON.stringify({
411
+ rootCode,
412
+ pageCount: pages.length,
413
+ pageCodes: pages.map((p) => p.code),
414
+ pageResourceCount: pageResources.length,
415
+ }),
416
+ },
417
+ });
418
+ });
419
+
420
+ return { success: true, key: rootCode };
421
+ }
422
+
423
+ /**
424
+ * Keep the documented invariant "roleResource = union of the resources of
425
+ * all pages granted to the role" (see page.service.updatePageWithResources).
426
+ * Changing a page's pageResource rows silently breaks API permissions for
427
+ * every role holding that page, so reconcile them here.
428
+ *
429
+ * Scope is limited to `touchedResourceIds` so manually-tuned grants on
430
+ * unrelated resources are left alone.
431
+ */
432
+ private async syncRoleResources(
433
+ tx: Prisma.TransactionClient,
434
+ pageIds: number[],
435
+ touchedResourceIds: number[],
436
+ operator: string,
437
+ ): Promise<void> {
438
+ if (touchedResourceIds.length === 0) return;
439
+
440
+ const affectedRoles = await tx.rolePages.findMany({
441
+ where: { pageId: { in: pageIds } },
442
+ select: { roleId: true },
443
+ distinct: ['roleId'],
444
+ });
445
+
446
+ for (const { roleId } of affectedRoles) {
447
+ const grantedPages = await tx.rolePages.findMany({
448
+ where: { roleId },
449
+ select: { pageId: true },
450
+ });
451
+ const bindings = await tx.pageResource.findMany({
452
+ where: {
453
+ pageId: { in: grantedPages.map((g) => g.pageId) },
454
+ resourceId: { in: touchedResourceIds },
455
+ },
456
+ select: { resourceId: true, actions: true },
457
+ });
458
+
459
+ const desired = new Map<number, Set<string>>();
460
+ for (const b of bindings) {
461
+ const set = desired.get(b.resourceId) ?? new Set<string>();
462
+ parseActions(b.actions).forEach((a) => set.add(a));
463
+ desired.set(b.resourceId, set);
464
+ }
465
+
466
+ for (const resourceId of touchedResourceIds) {
467
+ const actions = desired.get(resourceId);
468
+ if (!actions || actions.size === 0) {
469
+ // no page of this role provides the resource any more
470
+ await tx.roleResource.deleteMany({ where: { roleId, resourceId } });
471
+ continue;
472
+ }
473
+ const actionList = [...actions];
474
+ await tx.roleResource.upsert({
475
+ where: { roleId_resourceId: { roleId, resourceId } },
476
+ create: {
477
+ roleId,
478
+ resourceId,
479
+ actions: actionList,
480
+ creator: operator,
481
+ },
482
+ update: { actions: actionList },
483
+ });
484
+ }
485
+ }
486
+
487
+ this.logger.info(
488
+ `[permTransfer] page import roleResource synced - roles: ${affectedRoles.length}, resources: ${touchedResourceIds.length}`,
489
+ );
490
+ }
491
+ }
@@ -0,0 +1,213 @@
1
+ import { BadRequestException } from '@nestjs/common';
2
+ import { IsObject } from 'class-validator';
3
+
4
+ /**
5
+ * Cross-environment transfer payloads for permission data
6
+ * (user / role / page / resource). Independent from workflow's
7
+ * export payload version (v4) — bump when the shape changes.
8
+ */
9
+ export const TRANSFER_PAYLOAD_VERSION = 1;
10
+
11
+ export type TransferEntityKind = 'user' | 'role' | 'page' | 'resource';
12
+
13
+ interface TransferEnvelopeBase {
14
+ version: number;
15
+ exportedAt: string;
16
+ entity: TransferEntityKind;
17
+ }
18
+
19
+ // ─── user ────────────────────────────────────────────────────────────────────
20
+ // NOTE: apiKey / isApiKey / lastUsedAt are intentionally excluded —
21
+ // credentials must not travel across environments.
22
+ export interface UserTransferData {
23
+ userid: string;
24
+ username: string | null;
25
+ /** string[] when the stored JSON parses cleanly, otherwise the raw string */
26
+ roles: unknown;
27
+ description: string | null;
28
+ isActive: boolean;
29
+ }
30
+
31
+ export interface UserExportPayload extends TransferEnvelopeBase {
32
+ entity: 'user';
33
+ user: UserTransferData;
34
+ }
35
+
36
+ // ─── resource ────────────────────────────────────────────────────────────────
37
+ export interface ResourceTransferData {
38
+ code: string;
39
+ name: string;
40
+ description: string | null;
41
+ isCommonResource: boolean;
42
+ }
43
+
44
+ export interface ResourceExportPayload extends TransferEnvelopeBase {
45
+ entity: 'resource';
46
+ resource: ResourceTransferData;
47
+ }
48
+
49
+ // ─── role ────────────────────────────────────────────────────────────────────
50
+ export interface RoleTransferData {
51
+ name: string;
52
+ description: string | null;
53
+ }
54
+
55
+ export interface RoleResourceTransferItem {
56
+ resourceCode: string;
57
+ actions: unknown;
58
+ }
59
+
60
+ export interface RoleExportPayload extends TransferEnvelopeBase {
61
+ entity: 'role';
62
+ role: RoleTransferData;
63
+ /** page.code list */
64
+ rolePages: string[];
65
+ roleResources: RoleResourceTransferItem[];
66
+ }
67
+
68
+ // ─── page ────────────────────────────────────────────────────────────────────
69
+ export interface PageTransferItem {
70
+ code: string;
71
+ name: string;
72
+ path: string | null;
73
+ iframeUrl: string | null;
74
+ externalRedirectUrl: string | null;
75
+ description: string | null;
76
+ zhName: string | null;
77
+ enName: string | null;
78
+ i18nKey: string | null;
79
+ /** null for the exported root; otherwise the parent page's code */
80
+ parentCode: string | null;
81
+ sortOrder: number | null;
82
+ isVirtual: boolean;
83
+ }
84
+
85
+ export interface PageResourceTransferItem {
86
+ pageCode: string;
87
+ resourceCode: string;
88
+ actions: unknown;
89
+ }
90
+
91
+ export interface PageExportPayload extends TransferEnvelopeBase {
92
+ entity: 'page';
93
+ /** root page + all descendants, parents before children (BFS order) */
94
+ pages: PageTransferItem[];
95
+ pageResources: PageResourceTransferItem[];
96
+ }
97
+
98
+ export type TransferPayload =
99
+ | UserExportPayload
100
+ | ResourceExportPayload
101
+ | RoleExportPayload
102
+ | PageExportPayload;
103
+
104
+ // ─── import ──────────────────────────────────────────────────────────────────
105
+ export class ImportPayloadDto {
106
+ @IsObject()
107
+ payload: TransferPayload;
108
+ }
109
+
110
+ export interface MissingRef {
111
+ type: 'page' | 'resource' | 'parentPage';
112
+ code: string;
113
+ }
114
+
115
+ /** Comparable shape used by both preview sides (diff view). */
116
+ export type TransferComparable =
117
+ | Pick<UserExportPayload, 'user'>
118
+ | Pick<ResourceExportPayload, 'resource'>
119
+ | Pick<RoleExportPayload, 'role' | 'rolePages' | 'roleResources'>
120
+ | Pick<PageExportPayload, 'pages' | 'pageResources'>;
121
+
122
+ export interface ImportPreviewResponse {
123
+ current: TransferComparable | null;
124
+ incoming: TransferComparable;
125
+ missingRefs: MissingRef[];
126
+ /** human-readable conflict descriptions (e.g. unique-name clashes) */
127
+ conflicts: string[];
128
+ }
129
+
130
+ function requireString(value: unknown, field: string): void {
131
+ if (typeof value !== 'string' || value.length === 0) {
132
+ throw new BadRequestException(
133
+ `Invalid import payload: ${field} is required`,
134
+ );
135
+ }
136
+ }
137
+
138
+ function requireArray(value: unknown, field: string): void {
139
+ if (!Array.isArray(value)) {
140
+ throw new BadRequestException(
141
+ `Invalid import payload: ${field} must be an array`,
142
+ );
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Validate the transfer envelope *and* the entity-specific shape.
148
+ * The payload is fetched from a user-supplied URL, so this is a trust
149
+ * boundary — a malformed body must surface as 400, never as a 500.
150
+ */
151
+ export function assertTransferPayload(
152
+ payload: TransferPayload,
153
+ entity: TransferEntityKind,
154
+ ): void {
155
+ if (!payload || typeof payload !== 'object') {
156
+ throw new BadRequestException('Invalid import payload');
157
+ }
158
+ if (payload.version !== TRANSFER_PAYLOAD_VERSION) {
159
+ throw new BadRequestException(
160
+ `Unsupported payload version: ${payload.version} (expected ${TRANSFER_PAYLOAD_VERSION})`,
161
+ );
162
+ }
163
+ if (payload.entity !== entity) {
164
+ throw new BadRequestException(
165
+ `Payload entity mismatch: ${payload.entity} (expected ${entity})`,
166
+ );
167
+ }
168
+
169
+ switch (payload.entity) {
170
+ case 'user':
171
+ requireString(payload.user?.userid, 'user.userid');
172
+ break;
173
+ case 'resource':
174
+ requireString(payload.resource?.code, 'resource.code');
175
+ requireString(payload.resource?.name, 'resource.name');
176
+ break;
177
+ case 'role':
178
+ requireString(payload.role?.name, 'role.name');
179
+ requireArray(payload.rolePages, 'rolePages');
180
+ requireArray(payload.roleResources, 'roleResources');
181
+ payload.rolePages.forEach((code, i) =>
182
+ requireString(code, `rolePages[${i}]`),
183
+ );
184
+ payload.roleResources.forEach((rr, i) =>
185
+ requireString(rr?.resourceCode, `roleResources[${i}].resourceCode`),
186
+ );
187
+ break;
188
+ case 'page': {
189
+ requireArray(payload.pages, 'pages');
190
+ requireArray(payload.pageResources, 'pageResources');
191
+ if (payload.pages.length === 0) {
192
+ throw new BadRequestException('Invalid import payload: pages is empty');
193
+ }
194
+ payload.pages.forEach((p, i) => {
195
+ requireString(p?.code, `pages[${i}].code`);
196
+ requireString(p?.name, `pages[${i}].name`);
197
+ });
198
+ const pageCodes = new Set(payload.pages.map((p) => p.code));
199
+ payload.pageResources.forEach((pr, i) => {
200
+ requireString(pr?.pageCode, `pageResources[${i}].pageCode`);
201
+ requireString(pr?.resourceCode, `pageResources[${i}].resourceCode`);
202
+ // every binding must target a page carried by this payload, otherwise
203
+ // the importer would have no id to write the row against
204
+ if (!pageCodes.has(pr.pageCode)) {
205
+ throw new BadRequestException(
206
+ `Invalid import payload: pageResources[${i}].pageCode "${pr.pageCode}" is not in pages`,
207
+ );
208
+ }
209
+ });
210
+ break;
211
+ }
212
+ }
213
+ }