@goplusvn/core 0.1.75 → 0.1.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { subtreePrefix } from "./tree";
|
|
2
|
+
import { NO_WORKSPACE_ACCESS } from "./types";
|
|
3
|
+
import type {
|
|
4
|
+
ScopeLevel,
|
|
5
|
+
WorkspaceMembership,
|
|
6
|
+
WorkspaceScope,
|
|
7
|
+
WorkspaceScopeConfig,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LỚP 1 — lọc tường minh. Page/route/service gọi `getWorkspaceScope(session)`
|
|
12
|
+
* rồi nhét `scopedWhere(scope)` vào `where`. Lớp 2 (guard extension) là lưới an
|
|
13
|
+
* toàn cho chỗ quên, KHÔNG phải thay thế lớp này.
|
|
14
|
+
*
|
|
15
|
+
* Singleton phải cấu hình MỘT lần ở composition root của app, y như
|
|
16
|
+
* `configureBranchScope` / `configureStorage`. Import thẳng từ core lấy được
|
|
17
|
+
* hàm nhưng bỏ lỡ lời gọi cấu hình — và với module này, "bỏ lỡ" nghĩa là rò dữ
|
|
18
|
+
* liệu chéo không gian.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
let configured: WorkspaceScopeConfig<any> | null = null;
|
|
23
|
+
|
|
24
|
+
export function configureWorkspaces<TSession>(
|
|
25
|
+
config: WorkspaceScopeConfig<TSession>,
|
|
26
|
+
): void {
|
|
27
|
+
configured = config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Chỉ dùng trong test — app không được gọi. */
|
|
31
|
+
export function resetWorkspaceConfig(): void {
|
|
32
|
+
configured = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireConfigured() {
|
|
36
|
+
if (!configured) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"[workspace] chưa configureWorkspaces(...) — gọi một lần ở composition root (src/lib/branch-scope.ts) rồi import phạm vi qua đúng file đó.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return configured;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getWorkspaceConfig() {
|
|
45
|
+
return requireConfigured();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cột mang phạm vi trên chứng từ; mặc định `branchId` để tương thích ngược. */
|
|
49
|
+
export function workspaceScopeField(): string {
|
|
50
|
+
return configured?.scopeField ?? "branchId";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function canViewAllWorkspaces<TSession>(session: TSession): boolean {
|
|
54
|
+
return requireConfigured().canViewAll(session);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Gói một phạm vi. Đặt `allowedBranchIds` TRỎ CÙNG mảng với `allowedIds` — đó
|
|
59
|
+
* là cây cầu tương thích để mọi hàm `branch-scope` cũ nhận thẳng
|
|
60
|
+
* `WorkspaceScope` mà không app nào phải sửa import (luật L2: additive).
|
|
61
|
+
* Đừng dựng scope bằng object literal, dùng hàm này.
|
|
62
|
+
*/
|
|
63
|
+
export function createWorkspaceScope(input: {
|
|
64
|
+
canViewAll: boolean;
|
|
65
|
+
rootIds?: string[];
|
|
66
|
+
allowedIds?: string[];
|
|
67
|
+
adminIds?: string[];
|
|
68
|
+
defaultId?: string;
|
|
69
|
+
}): WorkspaceScope {
|
|
70
|
+
if (input.canViewAll) {
|
|
71
|
+
return {
|
|
72
|
+
canViewAll: true,
|
|
73
|
+
rootIds: input.rootIds ?? [],
|
|
74
|
+
adminIds: input.adminIds ?? [],
|
|
75
|
+
defaultId: input.defaultId,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const allowed =
|
|
79
|
+
input.allowedIds && input.allowedIds.length > 0
|
|
80
|
+
? input.allowedIds
|
|
81
|
+
: [NO_WORKSPACE_ACCESS];
|
|
82
|
+
return {
|
|
83
|
+
canViewAll: false,
|
|
84
|
+
rootIds: input.rootIds ?? [],
|
|
85
|
+
allowedIds: allowed,
|
|
86
|
+
allowedBranchIds: allowed,
|
|
87
|
+
adminIds: input.adminIds ?? [],
|
|
88
|
+
defaultId: input.defaultId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Phạm vi của user. Chưa được gán không gian nào → sentinel, tức thấy 0 dòng —
|
|
94
|
+
* KHÔNG phải "thấy tất": mặc định an toàn là không thấy gì.
|
|
95
|
+
*/
|
|
96
|
+
export async function getWorkspaceScope<TSession>(
|
|
97
|
+
session: TSession,
|
|
98
|
+
): Promise<WorkspaceScope> {
|
|
99
|
+
const config = requireConfigured();
|
|
100
|
+
if (config.canViewAll(session)) {
|
|
101
|
+
return createWorkspaceScope({ canViewAll: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const memberships = await readMemberships(config, session);
|
|
105
|
+
const rootIds = unique(memberships.map((m) => m.workspaceId));
|
|
106
|
+
const adminRootIds = unique(
|
|
107
|
+
memberships.filter((m) => m.isAdmin).map((m) => m.workspaceId),
|
|
108
|
+
);
|
|
109
|
+
const defaultId =
|
|
110
|
+
memberships.find((m) => m.isDefault)?.workspaceId ?? rootIds[0];
|
|
111
|
+
|
|
112
|
+
const allowedIds = await expand(config, rootIds);
|
|
113
|
+
// Quản trị viên của nhánh nào thì quản trị được cả con cháu nhánh đó — cùng
|
|
114
|
+
// luật cộng dồn với phạm vi đọc, nếu không thì admin nút cha lại không đụng
|
|
115
|
+
// được phòng ban con của chính mình.
|
|
116
|
+
const adminIds =
|
|
117
|
+
adminRootIds.length > 0 ? await expand(config, adminRootIds) : [];
|
|
118
|
+
|
|
119
|
+
return createWorkspaceScope({
|
|
120
|
+
canViewAll: false,
|
|
121
|
+
rootIds,
|
|
122
|
+
allowedIds,
|
|
123
|
+
adminIds,
|
|
124
|
+
defaultId,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readMemberships<TSession>(
|
|
129
|
+
config: WorkspaceScopeConfig<TSession>,
|
|
130
|
+
session: TSession,
|
|
131
|
+
): Promise<WorkspaceMembership[]> {
|
|
132
|
+
if (config.getMemberships) {
|
|
133
|
+
const raw = (await config.getMemberships(session)) ?? [];
|
|
134
|
+
return raw
|
|
135
|
+
.map((entry) =>
|
|
136
|
+
typeof entry === "string" ? { workspaceId: entry } : entry,
|
|
137
|
+
)
|
|
138
|
+
.filter((m): m is WorkspaceMembership => Boolean(m?.workspaceId));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const userId = config.getUserId(session);
|
|
142
|
+
if (!userId) return [];
|
|
143
|
+
|
|
144
|
+
if (!config.db) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"[workspace] configureWorkspaces cần `db` (hoặc `getMemberships`) để biết user thuộc không gian nào.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const rows = await config.db.userWorkspace.findMany({
|
|
150
|
+
where: { userId },
|
|
151
|
+
select: { workspaceId: true, isAdmin: true, isDefault: true },
|
|
152
|
+
});
|
|
153
|
+
return rows
|
|
154
|
+
.map((row) => row as Partial<WorkspaceMembership>)
|
|
155
|
+
.filter((row): row is WorkspaceMembership => Boolean(row.workspaceId));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Bung con cháu. Không khai `expandDescendants` và cũng không có `db` ⇒ **cây
|
|
160
|
+
* phẳng** (mức 0): allowedIds = rootIds. Hẹp hơn chứ không rộng hơn, nên app
|
|
161
|
+
* chưa có bảng `workspaces` vẫn an toàn.
|
|
162
|
+
*/
|
|
163
|
+
async function expand<TSession>(
|
|
164
|
+
config: WorkspaceScopeConfig<TSession>,
|
|
165
|
+
rootIds: string[],
|
|
166
|
+
): Promise<string[]> {
|
|
167
|
+
if (rootIds.length === 0) return [];
|
|
168
|
+
if (config.expandDescendants) {
|
|
169
|
+
const ids = await config.expandDescendants(rootIds);
|
|
170
|
+
return unique([...rootIds, ...(ids ?? [])]);
|
|
171
|
+
}
|
|
172
|
+
if (!config.db) return rootIds;
|
|
173
|
+
return unique([...rootIds, ...(await expandViaDb(config.db, rootIds))]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Hai truy vấn, cả hai đều đi được index: (1) lấy `path` của các nút gốc, (2)
|
|
178
|
+
* lấy mọi nút có path bắt đầu bằng các tiền tố đó. Cố ý KHÔNG dùng `contains`:
|
|
179
|
+
* `startsWith` mới tận dụng được btree trên `path`.
|
|
180
|
+
*/
|
|
181
|
+
async function expandViaDb(
|
|
182
|
+
db: NonNullable<WorkspaceScopeConfig["db"]>,
|
|
183
|
+
rootIds: string[],
|
|
184
|
+
): Promise<string[]> {
|
|
185
|
+
const roots = await db.workspace.findMany({
|
|
186
|
+
where: { id: { in: rootIds } },
|
|
187
|
+
select: { id: true, path: true },
|
|
188
|
+
});
|
|
189
|
+
const prefixes = roots
|
|
190
|
+
.map((row) => (row as { path?: string | null }).path)
|
|
191
|
+
.filter((path): path is string => Boolean(path))
|
|
192
|
+
.map(subtreePrefix);
|
|
193
|
+
if (prefixes.length === 0) return [];
|
|
194
|
+
|
|
195
|
+
const descendants = await db.workspace.findMany({
|
|
196
|
+
where: { OR: prefixes.map((prefix) => ({ path: { startsWith: prefix } })) },
|
|
197
|
+
select: { id: true, path: true },
|
|
198
|
+
});
|
|
199
|
+
return descendants
|
|
200
|
+
.map((row) => (row as { id?: string | null }).id)
|
|
201
|
+
.filter((id): id is string => Boolean(id));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Guard trang chi tiết: user có được xem bản ghi thuộc không gian này không?
|
|
206
|
+
* Bản ghi không gắn không gian (null — dữ liệu cũ hoặc dùng chung) thì ai vào
|
|
207
|
+
* được trang đều xem được.
|
|
208
|
+
*/
|
|
209
|
+
export function canAccessWorkspace(
|
|
210
|
+
scope: WorkspaceScope,
|
|
211
|
+
workspaceId: string | null | undefined,
|
|
212
|
+
): boolean {
|
|
213
|
+
if (scope.canViewAll) return true;
|
|
214
|
+
if (!workspaceId) return true;
|
|
215
|
+
return (scope.allowedIds ?? []).includes(workspaceId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Người này có QUẢN TRỊ được không gian đó không (D1). Khác hẳn
|
|
220
|
+
* `canAccessWorkspace`: xem được ≠ cấp tài khoản được.
|
|
221
|
+
*/
|
|
222
|
+
export function isWorkspaceAdmin(
|
|
223
|
+
scope: WorkspaceScope,
|
|
224
|
+
workspaceId: string | null | undefined,
|
|
225
|
+
): boolean {
|
|
226
|
+
if (scope.canViewAll) return true;
|
|
227
|
+
if (!workspaceId) return false;
|
|
228
|
+
return scope.adminIds.includes(workspaceId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Có được uỷ quyền quản trị ở đâu đó không — dùng để bật/tắt nút "Thêm người dùng". */
|
|
232
|
+
export function hasAdminScope(scope: WorkspaceScope): boolean {
|
|
233
|
+
return scope.canViewAll || scope.adminIds.length > 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Fragment `where` để lọc **NGƯỜI DÙNG** theo phạm vi — khác `scopedWhere` ở hai
|
|
238
|
+
* điểm cố ý:
|
|
239
|
+
*
|
|
240
|
+
* 1. Người dùng gắn không gian qua **bảng nối** (`user_workspaces` /
|
|
241
|
+
* `user_branches`), không phải một cột ⇒ phải dùng `some`.
|
|
242
|
+
* 2. **KHÔNG có nhánh `null`.** Với chứng từ, "chưa gắn không gian" là dữ liệu
|
|
243
|
+
* dùng chung nên cho xem. Với người dùng thì ngược lại: tài khoản chưa gắn
|
|
244
|
+
* không gian nào là tài khoản của cấp trên hoặc tài khoản hệ thống — lộ ra
|
|
245
|
+
* là lộ đúng thứ không được lộ.
|
|
246
|
+
*
|
|
247
|
+
* Đây là chỗ vá lỗ hổng đang có thật: trang Người dùng của core dựng `where` từ
|
|
248
|
+
* search/status/roleCode và KHÔNG có điều kiện phạm vi nào, nên admin nhánh nhìn
|
|
249
|
+
* thấy toàn bộ danh sách người dùng của mọi khách hàng khác.
|
|
250
|
+
*/
|
|
251
|
+
export function memberScopeWhere(
|
|
252
|
+
scope: WorkspaceScope,
|
|
253
|
+
options?: { relation?: string; field?: string; adminOnly?: boolean },
|
|
254
|
+
): Record<string, unknown> {
|
|
255
|
+
if (scope.canViewAll) return {};
|
|
256
|
+
const config = configured;
|
|
257
|
+
const relation =
|
|
258
|
+
options?.relation ?? config?.membershipRelation ?? "userWorkspaces";
|
|
259
|
+
const field = options?.field ?? config?.membershipField ?? "workspaceId";
|
|
260
|
+
const ids = options?.adminOnly
|
|
261
|
+
? scope.adminIds.length > 0
|
|
262
|
+
? scope.adminIds
|
|
263
|
+
: [NO_WORKSPACE_ACCESS]
|
|
264
|
+
: (scope.allowedIds ?? [NO_WORKSPACE_ACCESS]);
|
|
265
|
+
return { [relation]: { some: { [field]: { in: ids } } } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Fragment `where` cho model có cột phạm vi: thuộc không gian được phép HOẶC
|
|
270
|
+
* chưa gắn không gian. `{}` khi xem được tất.
|
|
271
|
+
*
|
|
272
|
+
* `relation` cho model scope qua quan hệ (kho → chi nhánh):
|
|
273
|
+
* `scopedWhere(scope, { relation: "warehouse" })`.
|
|
274
|
+
*/
|
|
275
|
+
export function scopedWhere(
|
|
276
|
+
scope: WorkspaceScope,
|
|
277
|
+
options?: { field?: string; relation?: string },
|
|
278
|
+
): Record<string, unknown> {
|
|
279
|
+
if (scope.canViewAll) return {};
|
|
280
|
+
const field = options?.field ?? workspaceScopeField();
|
|
281
|
+
const ids = scope.allowedIds ?? [NO_WORKSPACE_ACCESS];
|
|
282
|
+
const inClause = { [field]: { in: ids } };
|
|
283
|
+
const nullClause = { [field]: null };
|
|
284
|
+
return options?.relation
|
|
285
|
+
? {
|
|
286
|
+
OR: [
|
|
287
|
+
{ [options.relation]: inClause },
|
|
288
|
+
{ [options.relation]: nullClause },
|
|
289
|
+
],
|
|
290
|
+
}
|
|
291
|
+
: { OR: [inClause, nullClause] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `where` theo thang 5 nấc (Δ1). Nấc cao bao trọn nấc thấp, nên chỉ cần đúng
|
|
296
|
+
* một nhánh cho mỗi nấc:
|
|
297
|
+
*
|
|
298
|
+
* - `none` → sentinel, 0 dòng
|
|
299
|
+
* - `own` → chỉ bản ghi của chính user (cần `ownerField` + `userId`)
|
|
300
|
+
* - `workspace`→ đúng các nút được gán, KHÔNG bung con cháu
|
|
301
|
+
* - `subtree` → nút được gán + con cháu (mặc định của `scopedWhere`)
|
|
302
|
+
* - `all` → không thêm điều kiện
|
|
303
|
+
*/
|
|
304
|
+
export function scopeLevelWhere(
|
|
305
|
+
level: ScopeLevel,
|
|
306
|
+
scope: WorkspaceScope,
|
|
307
|
+
options?: {
|
|
308
|
+
field?: string;
|
|
309
|
+
relation?: string;
|
|
310
|
+
ownerField?: string;
|
|
311
|
+
userId?: string | null;
|
|
312
|
+
},
|
|
313
|
+
): Record<string, unknown> {
|
|
314
|
+
const field = options?.field ?? workspaceScopeField();
|
|
315
|
+
switch (level) {
|
|
316
|
+
case "all":
|
|
317
|
+
return {};
|
|
318
|
+
case "none":
|
|
319
|
+
return { [field]: { in: [NO_WORKSPACE_ACCESS] } };
|
|
320
|
+
case "own": {
|
|
321
|
+
const ownerField = options?.ownerField ?? "createdBy";
|
|
322
|
+
// Không biết user là ai thì đóng lại, không mở ra.
|
|
323
|
+
return { [ownerField]: options?.userId ?? NO_WORKSPACE_ACCESS };
|
|
324
|
+
}
|
|
325
|
+
case "workspace": {
|
|
326
|
+
if (scope.canViewAll) return {};
|
|
327
|
+
const ids =
|
|
328
|
+
scope.rootIds.length > 0 ? scope.rootIds : [NO_WORKSPACE_ACCESS];
|
|
329
|
+
return scopedWhere(
|
|
330
|
+
createWorkspaceScope({ canViewAll: false, allowedIds: ids }),
|
|
331
|
+
options,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
case "subtree":
|
|
335
|
+
default:
|
|
336
|
+
return scopedWhere(scope, options);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const SCOPE_LEVEL_RANK: Record<ScopeLevel, number> = {
|
|
341
|
+
none: 0,
|
|
342
|
+
own: 1,
|
|
343
|
+
workspace: 2,
|
|
344
|
+
subtree: 3,
|
|
345
|
+
all: 4,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** Nấc `a` có rộng bằng hoặc hơn nấc `b` không — dùng khi so trần quyền. */
|
|
349
|
+
export function scopeLevelAtLeast(a: ScopeLevel, b: ScopeLevel): boolean {
|
|
350
|
+
return SCOPE_LEVEL_RANK[a] >= SCOPE_LEVEL_RANK[b];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Kẹp bộ lọc client gửi lên vào trong phạm vi được phép. `undefined` = client
|
|
355
|
+
* không lọc gì. Chọn toàn không gian ngoài phạm vi → sentinel: thấy 0 dòng,
|
|
356
|
+
* chứ KHÔNG rơi về "không lọc".
|
|
357
|
+
*/
|
|
358
|
+
export function clampScopeFilter(
|
|
359
|
+
requested: string[] | string | null | undefined,
|
|
360
|
+
scope: WorkspaceScope,
|
|
361
|
+
): string[] | undefined {
|
|
362
|
+
const ids = normalizeIds(requested);
|
|
363
|
+
if (ids.length === 0) return undefined;
|
|
364
|
+
if (scope.canViewAll) return ids;
|
|
365
|
+
const allowed = scope.allowedIds ?? [];
|
|
366
|
+
const valid = ids.filter((id) => allowed.includes(id));
|
|
367
|
+
return valid.length > 0 ? valid : [NO_WORKSPACE_ACCESS];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* D1 — kẹp danh sách không gian mà người thao tác định GÁN cho một tài khoản
|
|
372
|
+
* vào đúng nhánh họ được uỷ quyền. Trả `null` = có id nằm ngoài `adminIds` ⇒
|
|
373
|
+
* caller phải TỪ CHỐI, không được lặng lẽ bỏ bớt: gán thiếu không gian cũng là
|
|
374
|
+
* sai, và người dùng sẽ tưởng đã cấp xong.
|
|
375
|
+
*/
|
|
376
|
+
export function clampToAdminScope(
|
|
377
|
+
requested: string[] | string | null | undefined,
|
|
378
|
+
scope: WorkspaceScope,
|
|
379
|
+
): string[] | null {
|
|
380
|
+
const ids = normalizeIds(requested);
|
|
381
|
+
if (scope.canViewAll) return ids;
|
|
382
|
+
const outside = ids.filter((id) => !scope.adminIds.includes(id));
|
|
383
|
+
return outside.length > 0 ? null : ids;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizeIds(
|
|
387
|
+
requested: string[] | string | null | undefined,
|
|
388
|
+
): string[] {
|
|
389
|
+
return (Array.isArray(requested) ? requested : [requested]).filter(
|
|
390
|
+
(id): id is string => Boolean(id && id.trim()),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function unique(ids: string[]): string[] {
|
|
395
|
+
return [...new Set(ids.filter(Boolean))];
|
|
396
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dịch vụ ghi cây không gian làm việc.
|
|
3
|
+
*
|
|
4
|
+
* Đây là **nơi duy nhất** được ghi cột `path` / `depth`. Lý do không cho INSERT
|
|
5
|
+
* tay: `path` là dữ liệu dẫn xuất từ cha, và đổi cha mà quên ghi lại path cho cả
|
|
6
|
+
* nhánh con thì cây trông vẫn đúng trên UI trong khi phạm vi đã sai — dạng lỗi
|
|
7
|
+
* im lặng, chỉ lộ ra lúc ai đó thấy dữ liệu đáng lẽ không được thấy.
|
|
8
|
+
*
|
|
9
|
+
* Ba luật cưỡng chế ở đây chứ không phải quy ước miệng: trần độ sâu (Δ9), chống
|
|
10
|
+
* chu trình, và repath cả nhánh khi đổi cha.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assertDepthLimit,
|
|
15
|
+
assertNoCycle,
|
|
16
|
+
buildPath,
|
|
17
|
+
pathDepth,
|
|
18
|
+
repathSubtree,
|
|
19
|
+
subtreePrefix,
|
|
20
|
+
WorkspaceTreeError,
|
|
21
|
+
} from "./tree";
|
|
22
|
+
import { getWorkspaceConfig } from "./scope";
|
|
23
|
+
import { DEFAULT_MAX_CHILDREN, DEFAULT_MAX_DEPTH } from "./types";
|
|
24
|
+
import type { WorkspaceKind, WorkspaceNode } from "./types";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Delegate Prisma tối thiểu mà service cần. Cố ý không kéo `PrismaClient` vào
|
|
28
|
+
* core — app truyền `db` thật vào, kiểu khớp về mặt cấu trúc.
|
|
29
|
+
*/
|
|
30
|
+
export interface WorkspaceServiceDb {
|
|
31
|
+
workspace: {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
findMany: (args?: any) => Promise<any[]>;
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
findUnique: (args: any) => Promise<any | null>;
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
create: (args: any) => Promise<any>;
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
update: (args: any) => Promise<any>;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
count: (args?: any) => Promise<number>;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CreateWorkspaceInput {
|
|
46
|
+
code: string;
|
|
47
|
+
name: string;
|
|
48
|
+
kind?: WorkspaceKind;
|
|
49
|
+
parentId?: string | null;
|
|
50
|
+
settings?: unknown;
|
|
51
|
+
/** Cho phép chỉ định id — dùng khi backfill để `workspace.id = branch.id`. */
|
|
52
|
+
id?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MoveWorkspaceInput {
|
|
56
|
+
id: string;
|
|
57
|
+
newParentId: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Đọc toàn bộ cây. Bảng này cỡ chục–trăm dòng, nạp hết rẻ hơn mọi cách khác. */
|
|
61
|
+
async function loadTree(db: WorkspaceServiceDb): Promise<WorkspaceNode[]> {
|
|
62
|
+
const rows = await db.workspace.findMany({
|
|
63
|
+
select: { id: true, parentId: true, path: true, kind: true, name: true },
|
|
64
|
+
});
|
|
65
|
+
return rows as WorkspaceNode[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Luật nhãn: `kinds` khai `childKinds` / `canHaveChildren` thì cưỡng chế ở đây.
|
|
70
|
+
* Không khai gì = cho hết, engine vẫn chạy.
|
|
71
|
+
*/
|
|
72
|
+
function assertKindAllowed(
|
|
73
|
+
parentKind: WorkspaceKind | undefined,
|
|
74
|
+
childKind: WorkspaceKind | undefined,
|
|
75
|
+
): void {
|
|
76
|
+
const kinds = getWorkspaceConfig().kinds;
|
|
77
|
+
if (!kinds || !parentKind) return;
|
|
78
|
+
const parent = kinds.find((k) => k.key === parentKind);
|
|
79
|
+
if (!parent) return;
|
|
80
|
+
if (parent.canHaveChildren === false) {
|
|
81
|
+
throw new WorkspaceTreeError(
|
|
82
|
+
`"${parent.label}" không được có workspace con.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
parent.childKinds &&
|
|
87
|
+
childKind &&
|
|
88
|
+
!parent.childKinds.includes(childKind)
|
|
89
|
+
) {
|
|
90
|
+
const allowed = parent.childKinds
|
|
91
|
+
.map((k) => kinds.find((x) => x.key === k)?.label ?? k)
|
|
92
|
+
.join(", ");
|
|
93
|
+
throw new WorkspaceTreeError(
|
|
94
|
+
`Dưới "${parent.label}" chỉ đặt được: ${allowed}.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Cảnh báo (KHÔNG chặn) khi một nút có quá nhiều con trực tiếp. GCP chốt cứng
|
|
101
|
+
* 300 folder/cha; ta cảnh báo ở 200 vì UI cây mới là chỗ gãy trước, và chặn
|
|
102
|
+
* cứng một thao tác tạo hợp lệ thì tệ hơn là báo cho người vận hành biết.
|
|
103
|
+
*/
|
|
104
|
+
async function warnIfTooManyChildren(
|
|
105
|
+
db: WorkspaceServiceDb,
|
|
106
|
+
parentId: string | null,
|
|
107
|
+
onWarn?: (message: string) => void,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
if (!parentId || !onWarn) return;
|
|
110
|
+
const max = getWorkspaceConfig().maxChildren ?? DEFAULT_MAX_CHILDREN;
|
|
111
|
+
const count = await db.workspace.count({ where: { parentId } });
|
|
112
|
+
if (count >= max) {
|
|
113
|
+
onWarn(
|
|
114
|
+
`Không gian này đã có ${count} nút con (ngưỡng khuyến nghị ${max}) — cân nhắc chia thêm một tầng.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WorkspaceServiceOptions {
|
|
120
|
+
onWarn?: (message: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Tạo nút mới. `path` do service tính từ cha, `depth` suy ra từ `path` — người
|
|
125
|
+
* gọi không truyền hai trường này.
|
|
126
|
+
*/
|
|
127
|
+
export async function createWorkspace(
|
|
128
|
+
db: WorkspaceServiceDb,
|
|
129
|
+
input: CreateWorkspaceInput,
|
|
130
|
+
options: WorkspaceServiceOptions = {},
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
): Promise<any> {
|
|
133
|
+
const config = getWorkspaceConfig();
|
|
134
|
+
const maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
135
|
+
|
|
136
|
+
let parentPath: string | null = null;
|
|
137
|
+
if (input.parentId) {
|
|
138
|
+
const parent = await db.workspace.findUnique({
|
|
139
|
+
where: { id: input.parentId },
|
|
140
|
+
select: { id: true, path: true, kind: true },
|
|
141
|
+
});
|
|
142
|
+
if (!parent) {
|
|
143
|
+
throw new WorkspaceTreeError("Không tìm thấy workspace cha.");
|
|
144
|
+
}
|
|
145
|
+
assertKindAllowed(parent.kind, input.kind);
|
|
146
|
+
parentPath = parent.path;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Id phải biết TRƯỚC khi tính path (path chứa chính id nó). Prisma sinh cuid ở
|
|
150
|
+
// tầng client nên không lấy được id trước — vì vậy service tự sinh.
|
|
151
|
+
const id = input.id ?? generateId();
|
|
152
|
+
const path = buildPath(parentPath, id);
|
|
153
|
+
assertDepthLimit(path, maxDepth);
|
|
154
|
+
await warnIfTooManyChildren(db, input.parentId ?? null, options.onWarn);
|
|
155
|
+
|
|
156
|
+
return db.workspace.create({
|
|
157
|
+
data: {
|
|
158
|
+
id,
|
|
159
|
+
code: input.code,
|
|
160
|
+
name: input.name,
|
|
161
|
+
kind: input.kind ?? "unit",
|
|
162
|
+
parentId: input.parentId ?? null,
|
|
163
|
+
path,
|
|
164
|
+
depth: pathDepth(path),
|
|
165
|
+
settings: input.settings ?? undefined,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Đổi cha. Ghi lại `path` + `depth` cho **cả nhánh con**, trong một transaction
|
|
172
|
+
* nếu app truyền vào client có `$transaction`; không có thì tuần tự.
|
|
173
|
+
*
|
|
174
|
+
* Trả về số nút đã cập nhật (gồm chính nút được chuyển).
|
|
175
|
+
*/
|
|
176
|
+
export async function moveWorkspace(
|
|
177
|
+
db: WorkspaceServiceDb,
|
|
178
|
+
input: MoveWorkspaceInput,
|
|
179
|
+
options: WorkspaceServiceOptions = {},
|
|
180
|
+
): Promise<number> {
|
|
181
|
+
const config = getWorkspaceConfig();
|
|
182
|
+
const maxDepth = config.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
183
|
+
const nodes = await loadTree(db);
|
|
184
|
+
|
|
185
|
+
const moved = nodes.find((n) => n.id === input.id);
|
|
186
|
+
if (!moved)
|
|
187
|
+
throw new WorkspaceTreeError("Không tìm thấy workspace cần chuyển.");
|
|
188
|
+
|
|
189
|
+
assertNoCycle(input.id, input.newParentId, nodes);
|
|
190
|
+
|
|
191
|
+
let newParentPath: string | null = null;
|
|
192
|
+
if (input.newParentId) {
|
|
193
|
+
const parent = nodes.find((n) => n.id === input.newParentId);
|
|
194
|
+
if (!parent) throw new WorkspaceTreeError("Không tìm thấy workspace cha.");
|
|
195
|
+
assertKindAllowed(parent.kind, moved.kind);
|
|
196
|
+
newParentPath = parent.path;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const updates = repathSubtree(input.id, newParentPath, nodes);
|
|
200
|
+
// Kiểm trần TRƯỚC khi ghi bất cứ dòng nào: nhánh sâu chuyển xuống dưới một nút
|
|
201
|
+
// đã sâu sẵn là cách dễ nhất vượt trần mà không ai để ý.
|
|
202
|
+
for (const update of updates) {
|
|
203
|
+
assertDepthLimit(update.path, maxDepth);
|
|
204
|
+
}
|
|
205
|
+
await warnIfTooManyChildren(db, input.newParentId, options.onWarn);
|
|
206
|
+
|
|
207
|
+
for (const update of updates) {
|
|
208
|
+
await db.workspace.update({
|
|
209
|
+
where: { id: update.id },
|
|
210
|
+
data: {
|
|
211
|
+
path: update.path,
|
|
212
|
+
depth: update.depth,
|
|
213
|
+
// Chỉ nút được chuyển mới đổi cha; con cháu giữ nguyên cha của chúng.
|
|
214
|
+
...(update.id === input.id ? { parentId: input.newParentId } : {}),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return updates.length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Nút + toàn bộ con cháu. Dùng cho trang chi tiết và cho phép xoá theo nhánh.
|
|
223
|
+
*/
|
|
224
|
+
export async function listSubtree(
|
|
225
|
+
db: WorkspaceServiceDb,
|
|
226
|
+
rootId: string,
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
228
|
+
): Promise<any[]> {
|
|
229
|
+
const root = await db.workspace.findUnique({
|
|
230
|
+
where: { id: rootId },
|
|
231
|
+
select: { id: true, path: true },
|
|
232
|
+
});
|
|
233
|
+
if (!root) return [];
|
|
234
|
+
return db.workspace.findMany({
|
|
235
|
+
where: { path: { startsWith: subtreePrefix(root.path) } },
|
|
236
|
+
orderBy: { path: "asc" },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Vô hiệu hoá cả nhánh. Cố ý KHÔNG xoá cứng: nút đã có chứng từ tham chiếu mà
|
|
242
|
+
* xoá đi thì phạm vi của những chứng từ đó thành mồ côi.
|
|
243
|
+
*/
|
|
244
|
+
export async function deactivateSubtree(
|
|
245
|
+
db: WorkspaceServiceDb,
|
|
246
|
+
rootId: string,
|
|
247
|
+
): Promise<number> {
|
|
248
|
+
const nodes = await listSubtree(db, rootId);
|
|
249
|
+
for (const node of nodes) {
|
|
250
|
+
await db.workspace.update({
|
|
251
|
+
where: { id: node.id },
|
|
252
|
+
data: { isActive: false },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return nodes.length;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Dựng cây lồng nhau cho UI từ danh sách phẳng. */
|
|
259
|
+
export interface WorkspaceTreeItem extends WorkspaceNode {
|
|
260
|
+
children: WorkspaceTreeItem[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `T` cộng thêm nhánh con — giữ nguyên mọi trường app tự thêm. */
|
|
264
|
+
export type WorkspaceTreeOf<T> = T & { children: WorkspaceTreeOf<T>[] };
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Dựng cây từ danh sách phẳng, GIỮ NGUYÊN kiểu của phần tử đầu vào.
|
|
268
|
+
*
|
|
269
|
+
* Generic chứ không nhận cứng `WorkspaceNode`: trang danh sách còn kèm `code`,
|
|
270
|
+
* `memberCount`, `kindLabel`… — những trường `WorkspaceNode` không khai. Nếu
|
|
271
|
+
* tham số cứng kiểu, mọi nơi gọi phải `as any` để lọt, và `as any` thì mất luôn
|
|
272
|
+
* việc kiểm kiểu ở đầu ra: cây trả về coi như `any`, gõ sai tên trường cũng
|
|
273
|
+
* không ai báo. Ràng buộc chỉ cần đúng hai thứ hàm này thật sự đọc.
|
|
274
|
+
*/
|
|
275
|
+
export function buildTree<T extends { id: string; parentId?: string | null }>(
|
|
276
|
+
nodes: readonly T[],
|
|
277
|
+
): WorkspaceTreeOf<T>[] {
|
|
278
|
+
const byId = new Map<string, WorkspaceTreeOf<T>>();
|
|
279
|
+
for (const node of nodes)
|
|
280
|
+
byId.set(node.id, { ...node, children: [] } as WorkspaceTreeOf<T>);
|
|
281
|
+
|
|
282
|
+
const roots: WorkspaceTreeOf<T>[] = [];
|
|
283
|
+
for (const node of byId.values()) {
|
|
284
|
+
const parent = node.parentId
|
|
285
|
+
? byId.get(node.parentId as string)
|
|
286
|
+
: undefined;
|
|
287
|
+
if (parent) parent.children.push(node);
|
|
288
|
+
else roots.push(node);
|
|
289
|
+
}
|
|
290
|
+
return roots;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Sinh id kiểu cuid rút gọn. Core không phụ thuộc `@paralleldrive/cuid2`, và id
|
|
295
|
+
* ở đây chỉ cần duy nhất + an toàn khi nhét vào `path` (chữ và số, không `/`).
|
|
296
|
+
*/
|
|
297
|
+
function generateId(): string {
|
|
298
|
+
const bytes = new Uint8Array(16);
|
|
299
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
300
|
+
return `w${Array.from(bytes, (b) => b.toString(36).padStart(2, "0")).join("")}`;
|
|
301
|
+
}
|