@goplusvn/core 0.1.75 → 0.1.76
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/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -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-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +260 -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 +157 -0
- package/src/workspace/pages/workspace-list-page.tsx +430 -0
- package/src/workspace/route-handlers.ts +274 -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,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cây không gian làm việc — HÀM THUẦN, không DB, không singleton.
|
|
3
|
+
*
|
|
4
|
+
* Bung con cháu bằng **materialized path** (`/{ancestor}/…/{self}/`) chứ không
|
|
5
|
+
* đệ quy runtime hay CTE: gán một người vào "Spartronics" phải tự động cho họ
|
|
6
|
+
* thấy "Spartronics / Kho", "Spartronics / QC"… và việc đó phải là MỘT câu
|
|
7
|
+
* `path LIKE '/spartronics-id/%'`, chạy được với index btree.
|
|
8
|
+
*
|
|
9
|
+
* Luật hợp nhất (Δ7): quyền chảy XUỐNG và HỢP lại. Nút con KHÔNG siết được nút
|
|
10
|
+
* cha — cấp rộng ở cha rồi cấp hẹp ở con thì user vẫn giữ phần rộng. Đây là
|
|
11
|
+
* hành vi của cả GCP lẫn AWS; muốn cấm phải là cơ chế deny riêng, và kỳ đầu ta
|
|
12
|
+
* cố ý KHÔNG làm deny.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_MAX_DEPTH } from "./types";
|
|
16
|
+
import type { WorkspaceNode } from "./types";
|
|
17
|
+
|
|
18
|
+
export const PATH_SEPARATOR = "/";
|
|
19
|
+
|
|
20
|
+
/** Path của nút gốc — cha rỗng. */
|
|
21
|
+
export const ROOT_PATH = PATH_SEPARATOR;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `buildPath("/a/", "b")` → `"/a/b/"`. Luôn có `/` hai đầu để `startsWith` và
|
|
25
|
+
* `LIKE` không bắt nhầm id có tiền tố trùng nhau.
|
|
26
|
+
*/
|
|
27
|
+
export function buildPath(
|
|
28
|
+
parentPath: string | null | undefined,
|
|
29
|
+
id: string,
|
|
30
|
+
): string {
|
|
31
|
+
const base = normalizePath(parentPath ?? ROOT_PATH);
|
|
32
|
+
return `${base}${id}${PATH_SEPARATOR}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Chuẩn hoá: luôn mở và đóng bằng `/`, không có `//`. */
|
|
36
|
+
export function normalizePath(path: string): string {
|
|
37
|
+
const trimmed = path.split(PATH_SEPARATOR).filter(Boolean);
|
|
38
|
+
return `${PATH_SEPARATOR}${trimmed.join(PATH_SEPARATOR)}${trimmed.length ? PATH_SEPARATOR : ""}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Các id từ gốc xuống tới chính nó. */
|
|
42
|
+
export function pathSegments(path: string): string[] {
|
|
43
|
+
return path.split(PATH_SEPARATOR).filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Độ sâu — nút gốc = 1. */
|
|
47
|
+
export function pathDepth(path: string): number {
|
|
48
|
+
return pathSegments(path).length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Id của chính nút (đoạn cuối). */
|
|
52
|
+
export function selfIdFromPath(path: string): string | undefined {
|
|
53
|
+
const segments = pathSegments(path);
|
|
54
|
+
return segments[segments.length - 1];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Id cha (đoạn áp cuối); `undefined` với nút gốc. */
|
|
58
|
+
export function parentIdFromPath(path: string): string | undefined {
|
|
59
|
+
const segments = pathSegments(path);
|
|
60
|
+
return segments.length >= 2 ? segments[segments.length - 2] : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `path` nằm trong nhánh của `ancestorPath`? Mặc định TÍNH CẢ CHÍNH NÓ — vì
|
|
65
|
+
* "được gán nhánh X" luôn có nghĩa là thấy cả X.
|
|
66
|
+
*/
|
|
67
|
+
export function isInSubtree(
|
|
68
|
+
path: string,
|
|
69
|
+
ancestorPath: string,
|
|
70
|
+
options?: { includeSelf?: boolean },
|
|
71
|
+
): boolean {
|
|
72
|
+
const self = normalizePath(path);
|
|
73
|
+
const ancestor = normalizePath(ancestorPath);
|
|
74
|
+
if (self === ancestor) return options?.includeSelf !== false;
|
|
75
|
+
return self.startsWith(ancestor);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tiền tố dùng cho `path LIKE '<prefix>%'` / `startsWith` khi truy vấn con cháu
|
|
80
|
+
* của một nút. Truyền path của nút, KHÔNG phải id.
|
|
81
|
+
*/
|
|
82
|
+
export function subtreePrefix(path: string): string {
|
|
83
|
+
return normalizePath(path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bung `rootIds` thành `rootIds` + toàn bộ con cháu, dựa trên danh sách nút có
|
|
88
|
+
* sẵn trong bộ nhớ. Bản dùng cho test và cho app đã nạp sẵn cây; bản đi DB nằm
|
|
89
|
+
* ở `scope.ts`.
|
|
90
|
+
*
|
|
91
|
+
* Id không có trong `nodes` vẫn được GIỮ LẠI: app mức 0 (chưa có bảng
|
|
92
|
+
* `workspaces`) truyền cây rỗng, và mất id ở đây nghĩa là user đang có phạm vi
|
|
93
|
+
* bỗng thành không thấy gì.
|
|
94
|
+
*/
|
|
95
|
+
export function selfAndDescendants(
|
|
96
|
+
rootIds: string[],
|
|
97
|
+
nodes: readonly WorkspaceNode[],
|
|
98
|
+
): string[] {
|
|
99
|
+
if (rootIds.length === 0) return [];
|
|
100
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
101
|
+
const prefixes = rootIds
|
|
102
|
+
.map((id) => byId.get(id)?.path)
|
|
103
|
+
.filter((path): path is string => Boolean(path))
|
|
104
|
+
.map(subtreePrefix);
|
|
105
|
+
|
|
106
|
+
const result = new Set(rootIds);
|
|
107
|
+
if (prefixes.length > 0) {
|
|
108
|
+
for (const node of nodes) {
|
|
109
|
+
if (prefixes.some((prefix) => isInSubtree(node.path, prefix))) {
|
|
110
|
+
result.add(node.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...result];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Đổi cha một nút ⇒ phải ghi lại `path` cho CẢ nhánh con. Quên bước này thì cây
|
|
119
|
+
* trông đúng trên UI mà phạm vi thì sai — kiểu bug im lặng tệ nhất.
|
|
120
|
+
*
|
|
121
|
+
* Trả về danh sách `{ id, path }` cần UPDATE, gồm cả chính nút được chuyển.
|
|
122
|
+
*/
|
|
123
|
+
export function repathSubtree(
|
|
124
|
+
movedId: string,
|
|
125
|
+
newParentPath: string | null,
|
|
126
|
+
nodes: readonly WorkspaceNode[],
|
|
127
|
+
): Array<{ id: string; path: string; depth: number }> {
|
|
128
|
+
const moved = nodes.find((node) => node.id === movedId);
|
|
129
|
+
if (!moved) return [];
|
|
130
|
+
|
|
131
|
+
const oldPrefix = subtreePrefix(moved.path);
|
|
132
|
+
const newPath = buildPath(newParentPath, movedId);
|
|
133
|
+
const updates: Array<{ id: string; path: string; depth: number }> = [
|
|
134
|
+
{ id: movedId, path: newPath, depth: pathDepth(newPath) },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if (node.id === movedId) continue;
|
|
139
|
+
if (!isInSubtree(node.path, oldPrefix, { includeSelf: false })) continue;
|
|
140
|
+
const suffix = normalizePath(node.path).slice(oldPrefix.length);
|
|
141
|
+
const nextPath = `${newPath}${suffix}`;
|
|
142
|
+
updates.push({ id: node.id, path: nextPath, depth: pathDepth(nextPath) });
|
|
143
|
+
}
|
|
144
|
+
return updates;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class WorkspaceTreeError extends Error {
|
|
148
|
+
constructor(message: string) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.name = "WorkspaceTreeError";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Chống chu trình: không cho một nút nhận chính nó (hoặc con cháu của nó) làm
|
|
156
|
+
* cha. Chu trình trong cây phạm vi không chỉ làm treo truy vấn — nó làm
|
|
157
|
+
* `selfAndDescendants` trả về tập sai, tức là rò dữ liệu.
|
|
158
|
+
*/
|
|
159
|
+
export function assertNoCycle(
|
|
160
|
+
movedId: string,
|
|
161
|
+
newParentId: string | null | undefined,
|
|
162
|
+
nodes: readonly WorkspaceNode[],
|
|
163
|
+
): void {
|
|
164
|
+
if (!newParentId) return;
|
|
165
|
+
if (newParentId === movedId) {
|
|
166
|
+
throw new WorkspaceTreeError("Không gian không thể là cha của chính nó.");
|
|
167
|
+
}
|
|
168
|
+
const parent = nodes.find((node) => node.id === newParentId);
|
|
169
|
+
const moved = nodes.find((node) => node.id === movedId);
|
|
170
|
+
if (!parent || !moved) return;
|
|
171
|
+
if (isInSubtree(parent.path, subtreePrefix(moved.path))) {
|
|
172
|
+
throw new WorkspaceTreeError(
|
|
173
|
+
"Không thể chuyển một không gian vào bên trong nhánh con của chính nó.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Trần độ sâu (Δ9). GCP cho folder lồng 10 tầng; ta chốt 4 vì `path LIKE` và
|
|
180
|
+
* UI cây đều xuống cấp theo độ sâu, mà 4 tầng đã phủ hết
|
|
181
|
+
* `org → khách hàng → chi nhánh → phòng ban`.
|
|
182
|
+
*/
|
|
183
|
+
export function assertDepthLimit(
|
|
184
|
+
path: string,
|
|
185
|
+
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
186
|
+
): void {
|
|
187
|
+
const depth = pathDepth(path);
|
|
188
|
+
if (depth > maxDepth) {
|
|
189
|
+
throw new WorkspaceTreeError(
|
|
190
|
+
`Cây không gian tối đa ${maxDepth} tầng — đường dẫn này sâu ${depth} tầng.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phạm vi dữ liệu theo KHÔNG GIAN LÀM VIỆC — hợp đồng chung.
|
|
3
|
+
*
|
|
4
|
+
* Tổng quát hoá `branch-scope`: chi nhánh / đơn vị / phòng ban / tổ chức không
|
|
5
|
+
* phải bốn cơ chế, mà là MỘT cây có nhãn `kind`. Coi chúng là bốn thứ khác nhau
|
|
6
|
+
* thì mỗi app phải tự đấu dây lại phần phân quyền — đó là lý do module này tồn
|
|
7
|
+
* tại.
|
|
8
|
+
*
|
|
9
|
+
* Xem `docs/WORKSPACE-MULTITENANT-PLAN.md` (thiết kế) và
|
|
10
|
+
* `docs/WORKSPACE-RESEARCH-IAM.md` (đối chiếu 10 hệ IAM lớn).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BranchScope } from "../branch-scope/types";
|
|
14
|
+
import { NO_BRANCH_ACCESS } from "../branch-scope/types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sentinel "không truy cập được gì". Cố ý DÙNG LẠI đúng hằng của branch-scope
|
|
18
|
+
* chứ không khai chuỗi mới: hai module chạy chung một lưới, hai sentinel khác
|
|
19
|
+
* nhau là kiểu bug chỉ lộ ra khi dữ liệu đã rò.
|
|
20
|
+
*/
|
|
21
|
+
export const NO_WORKSPACE_ACCESS = NO_BRANCH_ACCESS;
|
|
22
|
+
|
|
23
|
+
/** Trần độ sâu cây (Δ9 — GCP cho folder 10 tầng; ta không cần quá 4). */
|
|
24
|
+
export const DEFAULT_MAX_DEPTH = 4;
|
|
25
|
+
|
|
26
|
+
/** Ngưỡng cảnh báo số con trực tiếp của một nút (GCP: 300 folder/cha). */
|
|
27
|
+
export const DEFAULT_MAX_CHILDREN = 200;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Nhãn của một nút. KHÔNG đổi logic — chỉ đổi chữ hiện trên UI và luật cấu
|
|
31
|
+
* hình. Vinhhoa hiện "Chi nhánh", spartronics hiện "Đơn vị", thingtodo hiện
|
|
32
|
+
* "Khách hàng → Phòng ban", cùng một engine.
|
|
33
|
+
*/
|
|
34
|
+
export type WorkspaceKind =
|
|
35
|
+
| "org"
|
|
36
|
+
| "unit"
|
|
37
|
+
| "branch"
|
|
38
|
+
| "department"
|
|
39
|
+
| (string & {});
|
|
40
|
+
|
|
41
|
+
export interface WorkspaceKindConfig {
|
|
42
|
+
key: WorkspaceKind;
|
|
43
|
+
/** Chữ hiện trên UI, số ít. VD "Chi nhánh". */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Nhãn con được phép nằm dưới nhãn này; bỏ trống = cho hết. */
|
|
46
|
+
childKinds?: WorkspaceKind[];
|
|
47
|
+
canHaveChildren?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Nút trong cây — hình dạng TỐI THIỂU mà engine cần đọc. */
|
|
51
|
+
export interface WorkspaceNode {
|
|
52
|
+
id: string;
|
|
53
|
+
parentId?: string | null;
|
|
54
|
+
/** Materialized path `/{ancestor}/…/{self}/`. Service ghi, KHÔNG cho sửa tay. */
|
|
55
|
+
path: string;
|
|
56
|
+
kind?: WorkspaceKind;
|
|
57
|
+
name?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Một dòng `user_workspaces`. */
|
|
61
|
+
export interface WorkspaceMembership {
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
/** Quản trị viên của nhánh này — trả lời "nhánh nào", KHÔNG phải "được làm gì". */
|
|
64
|
+
isAdmin?: boolean;
|
|
65
|
+
isDefault?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Phạm vi của một phiên.
|
|
70
|
+
*
|
|
71
|
+
* KẾ THỪA `BranchScope` là có chủ đích: `allowedBranchIds` giữ đúng mảng với
|
|
72
|
+
* `allowedIds`, nên một `WorkspaceScope` cắm thẳng vào mọi hàm branch-scope cũ
|
|
73
|
+
* (`scopedBranchWhere`, `canAccessBranch`, guard lớp 2) mà không app nào phải
|
|
74
|
+
* sửa một dòng import. Đừng gán tay hai trường này lệch nhau — dùng
|
|
75
|
+
* `createWorkspaceScope()`.
|
|
76
|
+
*/
|
|
77
|
+
export interface WorkspaceScope extends BranchScope {
|
|
78
|
+
canViewAll: boolean;
|
|
79
|
+
/** Nút được gán TRỰC TIẾP, chưa bung con cháu. */
|
|
80
|
+
rootIds: string[];
|
|
81
|
+
/** rootIds + toàn bộ con cháu. undefined khi canViewAll; [sentinel] khi chưa gán gì. */
|
|
82
|
+
allowedIds?: string[];
|
|
83
|
+
/** Nhánh user là quản trị viên, ĐÃ bung con cháu. Rỗng = không được uỷ quyền gì. */
|
|
84
|
+
adminIds: string[];
|
|
85
|
+
/** Không gian mở sẵn khi đăng nhập / gán cho bản ghi mới tạo. */
|
|
86
|
+
defaultId?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Thang phạm vi 5 nấc (Δ1 — theo Dynamics 365: None → Basic → Local → Deep →
|
|
91
|
+
* Global). Nấc cao BAO TRỌN nấc thấp. Bản thiết kế đầu chỉ có 3 nấc và thiếu
|
|
92
|
+
* hẳn "chỉ bản ghi của tôi" — nấc mà vai trò nhân viên bán hàng / kỹ thuật viên
|
|
93
|
+
* cần tới.
|
|
94
|
+
*/
|
|
95
|
+
export type ScopeLevel = "none" | "own" | "workspace" | "subtree" | "all";
|
|
96
|
+
|
|
97
|
+
/** Thứ tự từ hẹp tới rộng — index dùng để so "nấc nào cao hơn". */
|
|
98
|
+
export const SCOPE_LEVELS: readonly ScopeLevel[] = [
|
|
99
|
+
"none",
|
|
100
|
+
"own",
|
|
101
|
+
"workspace",
|
|
102
|
+
"subtree",
|
|
103
|
+
"all",
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
export const SCOPE_LEVEL_LABELS: Record<ScopeLevel, string> = {
|
|
107
|
+
none: "Không",
|
|
108
|
+
own: "Của tôi",
|
|
109
|
+
workspace: "Không gian của tôi",
|
|
110
|
+
subtree: "Cả nhánh con",
|
|
111
|
+
all: "Tất cả",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Chỉ cần đúng phần delegate mà scope dùng tới — không kéo PrismaClient vào core. */
|
|
115
|
+
export interface WorkspaceScopeDb {
|
|
116
|
+
userWorkspace: {
|
|
117
|
+
findMany: (args: {
|
|
118
|
+
where: { userId: string };
|
|
119
|
+
select: { workspaceId: true; isAdmin: true; isDefault: true };
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
}) => Promise<any[]>;
|
|
122
|
+
};
|
|
123
|
+
workspace: {
|
|
124
|
+
findMany: (args: {
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
126
|
+
where: any;
|
|
127
|
+
select: { id: true; path: true };
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
}) => Promise<any[]>;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface WorkspaceScopeConfig<TSession = unknown> {
|
|
134
|
+
/** Nhãn hiển thị theo app. Bỏ trống = engine vẫn chạy, UI tự đặt chữ. */
|
|
135
|
+
kinds?: WorkspaceKindConfig[];
|
|
136
|
+
/**
|
|
137
|
+
* Cột mang phạm vi trên chứng từ. Mỗi app một tên: `branchId` (vinhhoa),
|
|
138
|
+
* `departmentId` (thingtodo), `workspaceId` (app mới). Khai MỘT lần ở đây,
|
|
139
|
+
* không rải ra call-site.
|
|
140
|
+
*/
|
|
141
|
+
scopeField?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Quan hệ nối NGƯỜI DÙNG với không gian, dùng khi lọc chính danh sách người
|
|
144
|
+
* dùng (`memberScopeWhere`). Mức 0 khai `userBranches`; app đã lên feature
|
|
145
|
+
* `workspaces` để mặc định `userWorkspaces`.
|
|
146
|
+
*/
|
|
147
|
+
membershipRelation?: string;
|
|
148
|
+
/** Cột khoá không gian trong quan hệ trên. Mặc định `workspaceId`. */
|
|
149
|
+
membershipField?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Nguồn membership mặc định: 2 bảng `user_workspaces` + `workspaces`. Bỏ
|
|
152
|
+
* trống được NẾU đã khai `getMemberships`.
|
|
153
|
+
*/
|
|
154
|
+
db?: WorkspaceScopeDb;
|
|
155
|
+
/**
|
|
156
|
+
* MỨC 0 — app chưa có bảng `workspaces` cắm thẳng nguồn sẵn có vào đây
|
|
157
|
+
* (vinhhoa: `user_branches`). Không có cây ⇒ không bung con cháu, hành vi
|
|
158
|
+
* giống hệt branch-scope hôm nay.
|
|
159
|
+
*/
|
|
160
|
+
getMemberships?: (
|
|
161
|
+
session: TSession,
|
|
162
|
+
) =>
|
|
163
|
+
| WorkspaceMembership[]
|
|
164
|
+
| string[]
|
|
165
|
+
| null
|
|
166
|
+
| undefined
|
|
167
|
+
| Promise<WorkspaceMembership[] | string[] | null | undefined>;
|
|
168
|
+
/**
|
|
169
|
+
* Bung con cháu. Bỏ trống + có `db` ⇒ dùng bản mặc định (2 truy vấn theo
|
|
170
|
+
* materialized path). Bỏ trống + không `db` ⇒ **cây phẳng**, allowedIds =
|
|
171
|
+
* rootIds. Đó là mức 0, an toàn: hẹp hơn chứ không rộng hơn.
|
|
172
|
+
*/
|
|
173
|
+
expandDescendants?: (rootIds: string[]) => Promise<string[]> | string[];
|
|
174
|
+
/** Lấy id user từ session của app. */
|
|
175
|
+
getUserId: (session: TSession) => string | null | undefined;
|
|
176
|
+
/** "Xem mọi không gian" — app tự quyết (vai trò quản trị / quyền `view-all-workspaces`). */
|
|
177
|
+
canViewAll: (session: TSession) => boolean;
|
|
178
|
+
/** Trần độ sâu cây. Mặc định 4 (Δ9). */
|
|
179
|
+
maxDepth?: number;
|
|
180
|
+
/** Ngưỡng cảnh báo số con trực tiếp. Mặc định 200. */
|
|
181
|
+
maxChildren?: number;
|
|
182
|
+
}
|