@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,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Cây không gian làm việc — dạng DÒNG DÀY, không phải lưới thẻ (L11).
|
|
4
|
+
//
|
|
5
|
+
// Mỗi dòng mang: tên, mã, nhãn cấp, số thành viên, số quản trị viên. Cây tổ chức
|
|
6
|
+
// là thứ người ta quét dọc để tìm một nhánh, nên dòng mảnh xếp sát nhau đọc
|
|
7
|
+
// nhanh hơn hẳn thẻ to có icon.
|
|
8
|
+
//
|
|
9
|
+
// Nút hành động chỉ hiện khi hover / focus và CHỈ ở nhánh người dùng thực sự
|
|
10
|
+
// quản trị được: hiện nút rồi để server trả 403 là cách chắc chắn làm người dùng
|
|
11
|
+
// tưởng hệ thống hỏng.
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
|
|
14
|
+
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Badge,
|
|
18
|
+
Button,
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuSeparator,
|
|
23
|
+
DropdownMenuTrigger,
|
|
24
|
+
} from "../../ui";
|
|
25
|
+
import { cn } from "../../utils";
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceTreeNode {
|
|
28
|
+
id: string;
|
|
29
|
+
code: string;
|
|
30
|
+
name: string;
|
|
31
|
+
kind?: string;
|
|
32
|
+
kindLabel?: string;
|
|
33
|
+
isActive?: boolean;
|
|
34
|
+
memberCount?: number;
|
|
35
|
+
adminCount?: number;
|
|
36
|
+
children: WorkspaceTreeNode[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkspaceTreeViewProps {
|
|
40
|
+
nodes: WorkspaceTreeNode[];
|
|
41
|
+
/** Nhánh người dùng được uỷ quyền quản trị (đã bung con cháu). */
|
|
42
|
+
adminIds?: string[];
|
|
43
|
+
/** Xem-tất: mọi nút đều thao tác được. */
|
|
44
|
+
canManageAll?: boolean;
|
|
45
|
+
selectedId?: string | null;
|
|
46
|
+
onSelect?: (node: WorkspaceTreeNode) => void;
|
|
47
|
+
onAddChild?: (parent: WorkspaceTreeNode) => void;
|
|
48
|
+
onEdit?: (node: WorkspaceTreeNode) => void;
|
|
49
|
+
onMove?: (node: WorkspaceTreeNode) => void;
|
|
50
|
+
onDeactivate?: (node: WorkspaceTreeNode) => void;
|
|
51
|
+
/** Lọc theo tên/mã — nhánh cha của kết quả khớp vẫn hiện để giữ ngữ cảnh. */
|
|
52
|
+
search?: string;
|
|
53
|
+
emptyMessage?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Giữ nhánh nào có ÍT NHẤT một hậu duệ khớp — cắt cha thì mất ngữ cảnh. */
|
|
57
|
+
function filterTree(
|
|
58
|
+
nodes: WorkspaceTreeNode[],
|
|
59
|
+
term: string,
|
|
60
|
+
): WorkspaceTreeNode[] {
|
|
61
|
+
const needle = term.trim().toLowerCase();
|
|
62
|
+
if (!needle) return nodes;
|
|
63
|
+
const walk = (node: WorkspaceTreeNode): WorkspaceTreeNode | null => {
|
|
64
|
+
const children = node.children
|
|
65
|
+
.map(walk)
|
|
66
|
+
.filter((n): n is WorkspaceTreeNode => n !== null);
|
|
67
|
+
const hit =
|
|
68
|
+
node.name.toLowerCase().includes(needle) ||
|
|
69
|
+
node.code.toLowerCase().includes(needle);
|
|
70
|
+
if (!hit && children.length === 0) return null;
|
|
71
|
+
return { ...node, children };
|
|
72
|
+
};
|
|
73
|
+
return nodes.map(walk).filter((n): n is WorkspaceTreeNode => n !== null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectIds(nodes: WorkspaceTreeNode[], out: Set<string>): Set<string> {
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
out.add(node.id);
|
|
79
|
+
collectIds(node.children, out);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function WorkspaceTreeView({
|
|
85
|
+
nodes,
|
|
86
|
+
adminIds,
|
|
87
|
+
canManageAll = false,
|
|
88
|
+
selectedId,
|
|
89
|
+
onSelect,
|
|
90
|
+
onAddChild,
|
|
91
|
+
onEdit,
|
|
92
|
+
onMove,
|
|
93
|
+
onDeactivate,
|
|
94
|
+
search = "",
|
|
95
|
+
emptyMessage = "Chưa có không gian nào.",
|
|
96
|
+
}: WorkspaceTreeViewProps) {
|
|
97
|
+
const visible = React.useMemo(
|
|
98
|
+
() => filterTree(nodes, search),
|
|
99
|
+
[nodes, search],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Đang tìm kiếm thì bung hết — gõ xong mà vẫn phải tự mở từng nhánh là vô nghĩa.
|
|
103
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
|
104
|
+
const searching = search.trim().length > 0;
|
|
105
|
+
|
|
106
|
+
const adminSet = React.useMemo(() => new Set(adminIds ?? []), [adminIds]);
|
|
107
|
+
const canManage = React.useCallback(
|
|
108
|
+
(id: string) => canManageAll || adminSet.has(id),
|
|
109
|
+
[canManageAll, adminSet],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const toggle = (id: string) =>
|
|
113
|
+
setCollapsed((prev) => {
|
|
114
|
+
const next = new Set(prev);
|
|
115
|
+
if (next.has(id)) next.delete(id);
|
|
116
|
+
else next.add(id);
|
|
117
|
+
return next;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (visible.length === 0) {
|
|
121
|
+
return (
|
|
122
|
+
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
|
|
123
|
+
{searching ? "Không có không gian nào khớp." : emptyMessage}
|
|
124
|
+
</p>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const render = (node: WorkspaceTreeNode, depth: number): React.ReactNode => {
|
|
129
|
+
const hasChildren = node.children.length > 0;
|
|
130
|
+
const isOpen = searching || !collapsed.has(node.id);
|
|
131
|
+
const manageable = canManage(node.id);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<React.Fragment key={node.id}>
|
|
135
|
+
<div
|
|
136
|
+
className={cn(
|
|
137
|
+
"group flex items-center gap-2 border-b border-border/60 px-2 py-1.5 text-sm transition-colors last:border-b-0",
|
|
138
|
+
selectedId === node.id ? "bg-muted" : "hover:bg-muted/50",
|
|
139
|
+
node.isActive === false && "opacity-55",
|
|
140
|
+
)}
|
|
141
|
+
style={{ paddingLeft: `${0.5 + depth * 1.25}rem` }}
|
|
142
|
+
>
|
|
143
|
+
{hasChildren ? (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={() => toggle(node.id)}
|
|
147
|
+
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
148
|
+
title={isOpen ? "Thu gọn" : "Mở rộng"}
|
|
149
|
+
aria-label={isOpen ? "Thu gọn" : "Mở rộng"}
|
|
150
|
+
>
|
|
151
|
+
{isOpen ? (
|
|
152
|
+
<ChevronDown className="h-3.5 w-3.5" />
|
|
153
|
+
) : (
|
|
154
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
157
|
+
) : (
|
|
158
|
+
<span className="h-3.5 w-3.5 shrink-0" />
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => onSelect?.(node)}
|
|
164
|
+
className="min-w-0 flex-1 truncate text-left font-medium"
|
|
165
|
+
>
|
|
166
|
+
{node.name}
|
|
167
|
+
{node.isActive === false ? (
|
|
168
|
+
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
169
|
+
(ngừng)
|
|
170
|
+
</span>
|
|
171
|
+
) : null}
|
|
172
|
+
</button>
|
|
173
|
+
|
|
174
|
+
<span className="hidden shrink-0 text-xs tabular-nums text-muted-foreground sm:inline">
|
|
175
|
+
{node.code}
|
|
176
|
+
</span>
|
|
177
|
+
|
|
178
|
+
{node.kindLabel ? (
|
|
179
|
+
<Badge variant="outline" className="hidden shrink-0 md:inline-flex">
|
|
180
|
+
{node.kindLabel}
|
|
181
|
+
</Badge>
|
|
182
|
+
) : null}
|
|
183
|
+
|
|
184
|
+
{node.memberCount !== undefined ? (
|
|
185
|
+
<span
|
|
186
|
+
className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground"
|
|
187
|
+
title={
|
|
188
|
+
node.adminCount !== undefined
|
|
189
|
+
? `${node.memberCount} thành viên, ${node.adminCount} quản trị`
|
|
190
|
+
: `${node.memberCount} thành viên`
|
|
191
|
+
}
|
|
192
|
+
>
|
|
193
|
+
{node.memberCount}
|
|
194
|
+
{node.adminCount ? ` · ${node.adminCount}` : ""}
|
|
195
|
+
</span>
|
|
196
|
+
) : null}
|
|
197
|
+
|
|
198
|
+
{manageable ? (
|
|
199
|
+
<div className="flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
|
200
|
+
{onAddChild ? (
|
|
201
|
+
<Button
|
|
202
|
+
variant="ghost"
|
|
203
|
+
size="icon"
|
|
204
|
+
className="h-7 w-7"
|
|
205
|
+
onClick={() => onAddChild(node)}
|
|
206
|
+
title="Thêm không gian con"
|
|
207
|
+
>
|
|
208
|
+
<Plus className="h-3.5 w-3.5" />
|
|
209
|
+
</Button>
|
|
210
|
+
) : null}
|
|
211
|
+
<DropdownMenu>
|
|
212
|
+
<DropdownMenuTrigger asChild>
|
|
213
|
+
<Button
|
|
214
|
+
variant="ghost"
|
|
215
|
+
size="icon"
|
|
216
|
+
className="h-7 w-7"
|
|
217
|
+
title="Thao tác khác"
|
|
218
|
+
>
|
|
219
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
220
|
+
</Button>
|
|
221
|
+
</DropdownMenuTrigger>
|
|
222
|
+
<DropdownMenuContent align="end">
|
|
223
|
+
{onEdit ? (
|
|
224
|
+
<DropdownMenuItem onClick={() => onEdit(node)}>
|
|
225
|
+
Sửa thông tin
|
|
226
|
+
</DropdownMenuItem>
|
|
227
|
+
) : null}
|
|
228
|
+
{onMove ? (
|
|
229
|
+
<DropdownMenuItem onClick={() => onMove(node)}>
|
|
230
|
+
Chuyển nhánh
|
|
231
|
+
</DropdownMenuItem>
|
|
232
|
+
) : null}
|
|
233
|
+
{onDeactivate && node.isActive !== false ? (
|
|
234
|
+
<>
|
|
235
|
+
<DropdownMenuSeparator />
|
|
236
|
+
<DropdownMenuItem
|
|
237
|
+
className="text-destructive focus:text-destructive"
|
|
238
|
+
onClick={() => onDeactivate(node)}
|
|
239
|
+
>
|
|
240
|
+
Ngừng hoạt động
|
|
241
|
+
</DropdownMenuItem>
|
|
242
|
+
</>
|
|
243
|
+
) : null}
|
|
244
|
+
</DropdownMenuContent>
|
|
245
|
+
</DropdownMenu>
|
|
246
|
+
</div>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
{isOpen ? node.children.map((child) => render(child, depth + 1)) : null}
|
|
250
|
+
</React.Fragment>
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return <div>{visible.map((node) => render(node, 0))}</div>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Số nút trong cây — dùng cho bộ đếm ở toolbar. */
|
|
258
|
+
export function countTreeNodes(nodes: WorkspaceTreeNode[]): number {
|
|
259
|
+
return collectIds(nodes, new Set()).size;
|
|
260
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
import type { BranchScope } from "../branch-scope/types";
|
|
4
|
+
|
|
5
|
+
import type { WorkspaceScope } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Context của lưới an toàn lớp 2 — **MỘT AsyncLocalStorage duy nhất cho cả
|
|
9
|
+
* `branch-scope` lẫn `workspace`**.
|
|
10
|
+
*
|
|
11
|
+
* Đây là điểm dễ hỏng nhất của việc tách module: hai store riêng thì
|
|
12
|
+
* `runWithoutScope()` chỉ thoát được store của nó, còn store kia vẫn lọc —
|
|
13
|
+
* nghĩa là `MAX(số phiếu)` lại bị cắt theo phạm vi và **sinh trùng số phiếu**
|
|
14
|
+
* (sự cố có thật RS-260727-0003). Vì vậy `branch-scope/context.ts` re-export
|
|
15
|
+
* thẳng file này chứ không tự tạo store.
|
|
16
|
+
*
|
|
17
|
+
* Phạm vi được resolve LƯỜI — lần đầu một model bị guard thực sự được đọc.
|
|
18
|
+
* Request không đụng model nào bị guard thì không tốn thêm truy vấn nào.
|
|
19
|
+
*
|
|
20
|
+
* File này cố ý không import phần scope: composition root của app tiêm hàm
|
|
21
|
+
* `resolve` vào lúc mở context, nhờ vậy context không kéo theo prisma và không
|
|
22
|
+
* tạo vòng import.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface ScopeContext<TScope = BranchScope | WorkspaceScope> {
|
|
26
|
+
resolve: () => Promise<TScope>;
|
|
27
|
+
/** Cache trong phạm vi 1 request — extension gán ở lần resolve đầu. */
|
|
28
|
+
cached?: Promise<TScope>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
const store = new AsyncLocalStorage<ScopeContext<any>>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PrismaPromise là LƯỜI: truy vấn chỉ thật sự chạy ở lần `.then()` đầu tiên.
|
|
36
|
+
* `run(ctx, () => db.x.count())` trả promise chưa chạy ra ngoài, người gọi
|
|
37
|
+
* `await` bên ngoài → truy vấn chạy NGOÀI context → guard im lặng không lọc
|
|
38
|
+
* (và với `exit` thì ngược lại: truy vấn cần toàn cục lại bị lọc → trùng số
|
|
39
|
+
* phiếu). Gọi `.then` ngay tại đây, khi còn ở trong/ngoài context đúng như ý,
|
|
40
|
+
* để việc chạy được ghim vào đúng phạm vi — bất kể người gọi await ở đâu.
|
|
41
|
+
*/
|
|
42
|
+
function pinToCurrentContext<T>(result: T): T {
|
|
43
|
+
const thenable = result as { then?: unknown };
|
|
44
|
+
if (typeof thenable?.then !== "function") return result;
|
|
45
|
+
return (result as unknown as Promise<unknown>).then((value) => value) as T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function runWithScope<T, TScope = BranchScope | WorkspaceScope>(
|
|
49
|
+
context: ScopeContext<TScope>,
|
|
50
|
+
fn: () => T | Promise<T>,
|
|
51
|
+
): T | Promise<T> {
|
|
52
|
+
return store.run(context, () => pinToCurrentContext(fn()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
export function getScopeContext(): ScopeContext<any> | undefined {
|
|
57
|
+
return store.getStore();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
|
|
61
|
+
export function resolveAmbientScope<TScope = BranchScope | WorkspaceScope>():
|
|
62
|
+
| Promise<TScope>
|
|
63
|
+
| undefined {
|
|
64
|
+
const ctx = store.getStore();
|
|
65
|
+
if (!ctx) return undefined;
|
|
66
|
+
ctx.cached ??= ctx.resolve();
|
|
67
|
+
return ctx.cached as Promise<TScope>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Chạy `fn` NGOÀI lưới guard — cho truy vấn hạ tầng mà kết quả phải toàn cục
|
|
72
|
+
* bất kể phạm vi user. Ca kinh điển: đánh số phiếu (`MAX(number)` bị lọc theo
|
|
73
|
+
* phạm vi là sinh trùng số → lỗi unique). Mọi `await` bên trong đều thoát
|
|
74
|
+
* guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
|
|
75
|
+
*/
|
|
76
|
+
export function runWithoutScope<T>(fn: () => T): T {
|
|
77
|
+
return store.exit(() => pinToCurrentContext(fn()));
|
|
78
|
+
}
|