@goplusvn/core 0.1.74 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/ui/auth/auth-layout.tsx +106 -82
  17. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  18. package/src/user/user-service.ts +64 -10
  19. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  20. package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
  21. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  22. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  23. package/src/workspace/components/scope-level-select.tsx +91 -0
  24. package/src/workspace/components/workspace-switcher.tsx +139 -0
  25. package/src/workspace/components/workspace-tree-view.tsx +260 -0
  26. package/src/workspace/context.ts +78 -0
  27. package/src/workspace/delegation.ts +400 -0
  28. package/src/workspace/guard.ts +138 -0
  29. package/src/workspace/index.ts +157 -0
  30. package/src/workspace/pages/workspace-list-page.tsx +430 -0
  31. package/src/workspace/route-handlers.ts +274 -0
  32. package/src/workspace/scope.ts +396 -0
  33. package/src/workspace/service.ts +301 -0
  34. package/src/workspace/tree.ts +193 -0
  35. 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
+ }