@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.
- 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/ui/auth/auth-layout.tsx +106 -82
- 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
|
@@ -70,6 +70,29 @@ export const oneDoorRules: GuardrailRule[] = [
|
|
|
70
70
|
ctx.files.some((f) => /\b(getBranchScope|scopedBranchWhere)\s*\(/.test(ctx.readCode(f))),
|
|
71
71
|
}),
|
|
72
72
|
|
|
73
|
+
singleDoorImport({
|
|
74
|
+
id: "one-door/workspace",
|
|
75
|
+
title: "chỉ file hạ tầng được import @goerp/core/workspace",
|
|
76
|
+
why:
|
|
77
|
+
"Cùng một engine, cùng một AsyncLocalStorage với branch-scope — chỉ tổng " +
|
|
78
|
+
"quát hơn (chi nhánh / đơn vị / phòng ban là một cây). Import thẳng lấy " +
|
|
79
|
+
"được hàm nhưng bỏ lỡ `configureWorkspaces()`, và ở module này 'bỏ lỡ' " +
|
|
80
|
+
"nghĩa là rò dữ liệu chéo không gian — im lặng, không exception.",
|
|
81
|
+
fix: "Import qua cửa của app (`@/lib/workspace` hoặc `@/lib/branch-scope`); composition root phải gọi `configureWorkspaces`.",
|
|
82
|
+
pattern: /from\s+["']@goerp\/core\/workspace["']/,
|
|
83
|
+
doors: (ctx) => ctx.options.doors.workspace,
|
|
84
|
+
mustConfigure: /configureWorkspaces\s*[<(]/,
|
|
85
|
+
mustConfigureName: "configureWorkspaces",
|
|
86
|
+
// Lớp 2 (`createScopeGuardExtension`) và các hàm cây thuần không cần
|
|
87
|
+
// singleton; chỉ lớp 1 mới cần, và thiếu thì nó NÉM.
|
|
88
|
+
mustConfigureWhen: (ctx) =>
|
|
89
|
+
ctx.files.some((f) =>
|
|
90
|
+
/\b(getWorkspaceScope|scopedWhere|scopeLevelWhere|workspaceScopeField)\s*\(/.test(
|
|
91
|
+
ctx.readCode(f),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
|
|
73
96
|
forbidPattern({
|
|
74
97
|
id: "one-door/export-no-window-open",
|
|
75
98
|
title: "không window.open(...) tới endpoint /export",
|
|
@@ -79,6 +79,15 @@ export function resolveOptions(
|
|
|
79
79
|
"lib/rbac/branch-scope.ts",
|
|
80
80
|
"lib/prisma.ts",
|
|
81
81
|
],
|
|
82
|
+
// `lib/workspace.ts` đứng trước để app nào tách file riêng thì phép kiểm
|
|
83
|
+
// "cửa duy nhất phải gọi configureWorkspaces" nhìn đúng file đó.
|
|
84
|
+
workspace: options.doors?.workspace ??
|
|
85
|
+
options.doors?.branchScope ?? [
|
|
86
|
+
"lib/workspace.ts",
|
|
87
|
+
"lib/branch-scope.ts",
|
|
88
|
+
"lib/rbac/branch-scope.ts",
|
|
89
|
+
"lib/prisma.ts",
|
|
90
|
+
],
|
|
82
91
|
},
|
|
83
92
|
};
|
|
84
93
|
}
|
package/src/guardrails/types.ts
CHANGED
|
@@ -137,6 +137,12 @@ export interface GuardrailOptions {
|
|
|
137
137
|
storage?: string[];
|
|
138
138
|
/** File hạ tầng được import `@goerp/core/branch-scope`. */
|
|
139
139
|
branchScope?: string[];
|
|
140
|
+
/**
|
|
141
|
+
* File hạ tầng được import `@goerp/core/workspace`. Mặc định gồm cả các cửa
|
|
142
|
+
* của `branchScope` — hai module chạy chung MỘT AsyncLocalStorage, nên app
|
|
143
|
+
* cắm chúng ở cùng một composition root là chuyện thường.
|
|
144
|
+
*/
|
|
145
|
+
workspace?: string[];
|
|
140
146
|
};
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -149,6 +155,7 @@ export type ResolvedGuardrailOptions = GuardrailOptions &
|
|
|
149
155
|
prisma: string;
|
|
150
156
|
storage: string[];
|
|
151
157
|
branchScope: string[];
|
|
158
|
+
workspace: string[];
|
|
152
159
|
};
|
|
153
160
|
};
|
|
154
161
|
|
|
@@ -53,11 +53,11 @@ export function Auth({
|
|
|
53
53
|
>
|
|
54
54
|
<div className="flex flex-col relative bg-background">
|
|
55
55
|
<div className="absolute top-0 inset-x-0 flex justify-between items-center gap-2 px-4 py-2.5 z-50">
|
|
56
|
-
{/* Logo
|
|
57
|
-
|
|
56
|
+
{/* Logo + tên công ty chỉ đứng ĐÚNG một chỗ: góc trên trái. Nửa phải
|
|
57
|
+
là hoa văn thuần, không mang chữ — nhồi logo lên đó là lặp. */}
|
|
58
58
|
<Link
|
|
59
59
|
href={ensureLocalizedPathname("/", locale)}
|
|
60
|
-
className="flex
|
|
60
|
+
className="flex items-center gap-2 text-foreground font-black"
|
|
61
61
|
>
|
|
62
62
|
{branding?.logo ? (
|
|
63
63
|
<Image
|
|
@@ -96,6 +96,65 @@ export function Auth({
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/*
|
|
100
|
+
* Hoa văn khối lập phương đẳng cự (isometric) — hình học của tấm nền đăng nhập.
|
|
101
|
+
*
|
|
102
|
+
* Cạnh `a`; mỗi khối là một lục giác đều cộng ba nan từ tâm ra ba đỉnh, xếp kiểu
|
|
103
|
+
* tổ ong thì thành một mặt phẳng toàn khối hộp. Chọn hoa văn này vì nó CÓ NGHĨA
|
|
104
|
+
* với phần mềm quản trị: kiện hàng xếp kho, mô-đun ghép lại thành hệ thống —
|
|
105
|
+
* không phải mảng loang trang trí cho có.
|
|
106
|
+
*
|
|
107
|
+
* Toạ độ tính sẵn ở mức mô-đun (không phụ thuộc render) để khỏi tính lại mỗi lần
|
|
108
|
+
* vẽ và để sai số làm rời mạch hoa văn thì thấy ngay ở đây.
|
|
109
|
+
*/
|
|
110
|
+
const CUBE_A = 60;
|
|
111
|
+
const CUBE_DX = 51.96; // a·√3/2 — nửa bề ngang lục giác
|
|
112
|
+
const CUBE_DY = 30; // a/2
|
|
113
|
+
const CUBE_TILE_W = 103.92; // a·√3
|
|
114
|
+
const CUBE_TILE_H = 180; // 3a — hai hàng tổ ong
|
|
115
|
+
|
|
116
|
+
function cubeAt(cx: number, cy: number) {
|
|
117
|
+
const { round } = Math;
|
|
118
|
+
const r = (n: number) => round(n * 100) / 100;
|
|
119
|
+
const [t, b] = [r(cy - CUBE_A), r(cy + CUBE_A)];
|
|
120
|
+
const [l, rt] = [r(cx - CUBE_DX), r(cx + CUBE_DX)];
|
|
121
|
+
const [up, dn] = [r(cy - CUBE_DY), r(cy + CUBE_DY)];
|
|
122
|
+
const x = r(cx);
|
|
123
|
+
return (
|
|
124
|
+
// Viền lục giác + ba nan tâm → ảo giác khối hộp.
|
|
125
|
+
`M${x} ${t}L${rt} ${up}L${rt} ${dn}L${x} ${b}L${l} ${dn}L${l} ${up}Z` +
|
|
126
|
+
`M${x} ${t}L${x} ${r(cy)}M${x} ${r(cy)}L${l} ${dn}M${x} ${r(cy)}L${rt} ${dn}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cubeTopFaceAt(cx: number, cy: number) {
|
|
131
|
+
return `M${cx} ${cy - CUBE_A}L${cx + CUBE_DX} ${cy - CUBE_DY}L${cx} ${cy}L${cx - CUBE_DX} ${cy - CUBE_DY}Z`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Ô lặp chứa hai hàng tổ ong; vẽ thêm tâm tràn ra ngoài mép để phần bị cắt được
|
|
135
|
+
// ô kế bên nối lại — thiếu chúng là hoa văn đứt thành từng mảng vuông.
|
|
136
|
+
const CUBE_TILE_CENTERS: Array<[number, number]> = [
|
|
137
|
+
[0, 0],
|
|
138
|
+
[CUBE_TILE_W, 0],
|
|
139
|
+
[0, CUBE_TILE_H],
|
|
140
|
+
[CUBE_TILE_W, CUBE_TILE_H],
|
|
141
|
+
[CUBE_TILE_W / 2, CUBE_TILE_H / 2],
|
|
142
|
+
[CUBE_TILE_W / 2, -CUBE_TILE_H / 2],
|
|
143
|
+
[CUBE_TILE_W / 2, CUBE_TILE_H * 1.5],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Vài mặt trên được tô đậm hơn, xếp thành hai vệt chéo song song — chỗ sáng chỗ
|
|
147
|
+
// tối làm hoa văn có nhịp, thay vì trải đều một mức xám chết.
|
|
148
|
+
const CUBE_HIGHLIGHTS: Array<[number, number, number]> = [
|
|
149
|
+
[311.77, 180, 0.1],
|
|
150
|
+
[363.73, 270, 0.16],
|
|
151
|
+
[415.69, 360, 0.12],
|
|
152
|
+
[467.65, 450, 0.07],
|
|
153
|
+
[155.88, 630, 0.09],
|
|
154
|
+
[207.85, 720, 0.14],
|
|
155
|
+
[259.8, 810, 0.08],
|
|
156
|
+
];
|
|
157
|
+
|
|
99
158
|
/**
|
|
100
159
|
* Nền vector mặc định cho nửa phải khi app chưa có ảnh chụp thật.
|
|
101
160
|
*
|
|
@@ -103,13 +162,8 @@ export function Auth({
|
|
|
103
162
|
* thương hiệu là nền đổi theo, không app nào phải ship thêm ảnh, và không có
|
|
104
163
|
* bitmap nào để phóng to bị vỡ.
|
|
105
164
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* bắn) — thay vì mấy vệt gradient loang: loang là thứ nhìn phát biết đồ máy
|
|
109
|
-
* sinh, còn lưới + cung tròn là ngôn ngữ của bản vẽ kỹ thuật, hợp phần mềm quản
|
|
110
|
-
* trị và không lỗi mốt.
|
|
111
|
-
*
|
|
112
|
-
* Mọi độ mờ đều dưới 20%: đây là nền, phần đọc được phải là logo + tên công ty.
|
|
165
|
+
* KHÔNG có chữ hay logo ở đây — logo đã đứng ở góc trên trái, đặt thêm một cái
|
|
166
|
+
* nữa lên hoa văn là lặp. Tấm này thuần hình.
|
|
113
167
|
*/
|
|
114
168
|
export function AuthBrandPattern({ className }: { className?: string }) {
|
|
115
169
|
return (
|
|
@@ -122,31 +176,45 @@ export function AuthBrandPattern({ className }: { className?: string }) {
|
|
|
122
176
|
>
|
|
123
177
|
<defs>
|
|
124
178
|
<pattern
|
|
125
|
-
id="goerp-auth-
|
|
126
|
-
width=
|
|
127
|
-
height=
|
|
179
|
+
id="goerp-auth-cubes"
|
|
180
|
+
width={CUBE_TILE_W}
|
|
181
|
+
height={CUBE_TILE_H}
|
|
128
182
|
patternUnits="userSpaceOnUse"
|
|
129
183
|
>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
184
|
+
{CUBE_TILE_CENTERS.map(([cx, cy]) => (
|
|
185
|
+
<path
|
|
186
|
+
key={`${cx}-${cy}`}
|
|
187
|
+
d={cubeAt(cx, cy)}
|
|
188
|
+
stroke="currentColor"
|
|
189
|
+
strokeOpacity="0.14"
|
|
190
|
+
strokeWidth="1.25"
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
136
193
|
</pattern>
|
|
137
|
-
|
|
138
|
-
|
|
194
|
+
{/* Ánh sáng chếch từ trên phải: hoa văn phẳng đều thì nhìn như giấy dán
|
|
195
|
+
tường, có vệt sáng mới ra chiều sâu. */}
|
|
196
|
+
<radialGradient id="goerp-auth-glow" cx="0.78" cy="0.12" r="0.85">
|
|
197
|
+
<stop offset="0" stopColor="currentColor" stopOpacity="0.14" />
|
|
139
198
|
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
|
140
199
|
</radialGradient>
|
|
200
|
+
{/* Đáy tối dần để khối chân trang không bị hoa văn cắn vào. */}
|
|
201
|
+
<linearGradient id="goerp-auth-foot" x1="0" y1="0" x2="0" y2="1">
|
|
202
|
+
<stop offset="0.55" stopColor="#000" stopOpacity="0" />
|
|
203
|
+
<stop offset="1" stopColor="#000" stopOpacity="0.22" />
|
|
204
|
+
</linearGradient>
|
|
141
205
|
</defs>
|
|
142
|
-
<rect width="600" height="900" fill="url(#goerp-auth-
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
206
|
+
<rect width="600" height="900" fill="url(#goerp-auth-cubes)" />
|
|
207
|
+
<g fill="currentColor">
|
|
208
|
+
{CUBE_HIGHLIGHTS.map(([cx, cy, opacity]) => (
|
|
209
|
+
<path
|
|
210
|
+
key={`${cx}-${cy}`}
|
|
211
|
+
d={cubeTopFaceAt(cx, cy)}
|
|
212
|
+
fillOpacity={opacity}
|
|
213
|
+
/>
|
|
214
|
+
))}
|
|
149
215
|
</g>
|
|
216
|
+
<rect width="600" height="900" fill="url(#goerp-auth-glow)" />
|
|
217
|
+
<rect width="600" height="900" fill="url(#goerp-auth-foot)" />
|
|
150
218
|
</svg>
|
|
151
219
|
);
|
|
152
220
|
}
|
|
@@ -158,12 +226,13 @@ interface AuthImageProps extends ComponentProps<"div"> {
|
|
|
158
226
|
}
|
|
159
227
|
|
|
160
228
|
/**
|
|
161
|
-
* Nửa phải trang đăng nhập.
|
|
229
|
+
* Nửa phải trang đăng nhập. THUẦN HÌNH — không logo, không tên công ty, không
|
|
230
|
+
* khẩu hiệu: thương hiệu đã đứng ở góc trên trái, in lại lần nữa ở đây là lặp và
|
|
231
|
+
* làm màn đăng nhập trông như trang quảng cáo.
|
|
162
232
|
*
|
|
163
|
-
* Hai trạng thái
|
|
164
|
-
* - CÓ ảnh: ảnh phủ kín, một lớp tối
|
|
165
|
-
*
|
|
166
|
-
* rơi vào vùng sáng là mất hút).
|
|
233
|
+
* Hai trạng thái:
|
|
234
|
+
* - CÓ ảnh: ảnh chụp thật phủ kín, một lớp tối rất nhẹ để mép ảnh không chọi
|
|
235
|
+
* với cột nhập liệu bên trái.
|
|
167
236
|
* - KHÔNG ảnh: nền `--primary` của tenant + `AuthBrandPattern`. Đây là mặc
|
|
168
237
|
* định, KHÔNG phải trạng thái lỗi — thà một tấm màu thương hiệu sạch còn hơn
|
|
169
238
|
* ảnh stock/AI dùng chung cho mọi khách.
|
|
@@ -177,7 +246,6 @@ export function AuthImage({
|
|
|
177
246
|
imgSrc,
|
|
178
247
|
...props
|
|
179
248
|
}: AuthImageProps) {
|
|
180
|
-
const branding = useOptionalTenant()?.branding;
|
|
181
249
|
const [imageBroken, setImageBroken] = useState(false);
|
|
182
250
|
const hasPhoto = Boolean(imgSrc) && !imageBroken;
|
|
183
251
|
|
|
@@ -210,9 +278,11 @@ export function AuthImage({
|
|
|
210
278
|
onError={() => setImageBroken(true)}
|
|
211
279
|
className={cn("object-cover", imageClassName)}
|
|
212
280
|
/>
|
|
281
|
+
{/* Lớp tối nhẹ thôi: không còn chữ nào đè lên ảnh, nó chỉ để ảnh khỏi
|
|
282
|
+
chói hơn hẳn cột nhập liệu bên trái. */}
|
|
213
283
|
<div
|
|
214
284
|
aria-hidden
|
|
215
|
-
className="absolute inset-0 bg-gradient-to-t from-black/
|
|
285
|
+
className="absolute inset-0 bg-gradient-to-t from-black/45 via-black/10 to-transparent"
|
|
216
286
|
/>
|
|
217
287
|
</>
|
|
218
288
|
) : (
|
|
@@ -226,55 +296,9 @@ export function AuthImage({
|
|
|
226
296
|
aria-hidden
|
|
227
297
|
className="absolute inset-0 hidden bg-black/55 dark:block"
|
|
228
298
|
/>
|
|
229
|
-
<AuthBrandPattern className="text-primary-foreground dark:text-white" />
|
|
299
|
+
<AuthBrandPattern className="motion-safe:animate-status-in text-primary-foreground dark:text-white" />
|
|
230
300
|
</>
|
|
231
301
|
)}
|
|
232
|
-
|
|
233
|
-
<div
|
|
234
|
-
className={cn(
|
|
235
|
-
"absolute inset-0 flex flex-col p-10",
|
|
236
|
-
hasPhoto ? "justify-end" : "justify-center",
|
|
237
|
-
)}
|
|
238
|
-
>
|
|
239
|
-
<div
|
|
240
|
-
className={cn(
|
|
241
|
-
"motion-safe:animate-status-in flex flex-col items-start gap-4",
|
|
242
|
-
hasPhoto ? "text-white" : "text-primary-foreground dark:text-white",
|
|
243
|
-
)}
|
|
244
|
-
>
|
|
245
|
-
{branding?.logo ? (
|
|
246
|
-
// Logo khách phần lớn là nét sẫm trên nền trong suốt ⇒ đặt trên tấm
|
|
247
|
-
// trắng đục để nó không tan vào ảnh tối hay nền màu.
|
|
248
|
-
<span className="inline-flex rounded-xl bg-white p-3 shadow-sm">
|
|
249
|
-
<Image
|
|
250
|
-
src={branding.logo}
|
|
251
|
-
alt={branding.companyName || "Logo"}
|
|
252
|
-
height={40}
|
|
253
|
-
width={160}
|
|
254
|
-
className="h-10 w-auto object-contain"
|
|
255
|
-
unoptimized
|
|
256
|
-
/>
|
|
257
|
-
</span>
|
|
258
|
-
) : null}
|
|
259
|
-
<div className="space-y-1.5">
|
|
260
|
-
<p className="text-3xl font-semibold leading-tight tracking-tight text-balance">
|
|
261
|
-
{branding?.companyName || "GoERP"}
|
|
262
|
-
</p>
|
|
263
|
-
{branding?.tagline ? (
|
|
264
|
-
<p
|
|
265
|
-
className={cn(
|
|
266
|
-
"max-w-sm text-balance text-sm leading-relaxed",
|
|
267
|
-
hasPhoto
|
|
268
|
-
? "text-white/80"
|
|
269
|
-
: "text-primary-foreground/80 dark:text-white/80",
|
|
270
|
-
)}
|
|
271
|
-
>
|
|
272
|
-
{branding.tagline}
|
|
273
|
-
</p>
|
|
274
|
-
) : null}
|
|
275
|
-
</div>
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
302
|
</div>
|
|
279
303
|
);
|
|
280
304
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
configureWorkspaces,
|
|
5
|
+
createWorkspaceScope,
|
|
6
|
+
resetWorkspaceConfig,
|
|
7
|
+
} from "../../workspace/scope";
|
|
8
|
+
import {
|
|
9
|
+
getUsersData,
|
|
10
|
+
getUserStats,
|
|
11
|
+
getUsersWithDetails,
|
|
12
|
+
} from "../user-service";
|
|
13
|
+
import type { UserPrismaClient } from "../user-service";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lỗ hổng đang được vá ở đây: danh sách người dùng của core dựng `where` từ
|
|
17
|
+
* search/status/roleCode và KHÔNG có điều kiện phạm vi nào — admin nhánh của
|
|
18
|
+
* khách hàng A nhìn thấy toàn bộ người dùng của khách hàng B.
|
|
19
|
+
*
|
|
20
|
+
* Test soi thẳng `where` gửi xuống Prisma chứ không soi kết quả: kết quả là do
|
|
21
|
+
* DB giả trả, còn `where` mới là thứ chạy trên DB thật.
|
|
22
|
+
*/
|
|
23
|
+
function spyDb() {
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
const calls: { fn: string; where: any }[] = [];
|
|
26
|
+
const db = {
|
|
27
|
+
user: {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
findMany: async (args: any) => {
|
|
30
|
+
calls.push({ fn: "user.findMany", where: args.where });
|
|
31
|
+
return [];
|
|
32
|
+
},
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
count: async (args?: any) => {
|
|
35
|
+
calls.push({ fn: "user.count", where: args?.where });
|
|
36
|
+
return 0;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
role: { count: async () => 0, findMany: async () => [] },
|
|
40
|
+
department: { findMany: async () => [] },
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
$transaction: async (args: any) => args,
|
|
43
|
+
} as unknown as UserPrismaClient;
|
|
44
|
+
return { db, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SCOPE_WHERE = {
|
|
48
|
+
userBranches: { some: { branchId: { in: ["spa", "spa-qc"] } } },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function scopeOfSpa() {
|
|
52
|
+
return createWorkspaceScope({
|
|
53
|
+
canViewAll: false,
|
|
54
|
+
rootIds: ["spa"],
|
|
55
|
+
allowedIds: ["spa", "spa-qc"],
|
|
56
|
+
adminIds: ["spa"],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
resetWorkspaceConfig();
|
|
62
|
+
configureWorkspaces({
|
|
63
|
+
getUserId: () => undefined,
|
|
64
|
+
canViewAll: () => false,
|
|
65
|
+
membershipRelation: "userBranches",
|
|
66
|
+
membershipField: "branchId",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("getUsersData", () => {
|
|
71
|
+
it("không truyền scope ⇒ where y hệt hôm nay (L2: additive)", async () => {
|
|
72
|
+
const { db, calls } = spyDb();
|
|
73
|
+
await getUsersData(db, { search: "an" });
|
|
74
|
+
expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("truyền scope ⇒ điều kiện phạm vi vào CẢ findMany lẫn count", async () => {
|
|
78
|
+
const { db, calls } = spyDb();
|
|
79
|
+
await getUsersData(db, { scope: scopeOfSpa() });
|
|
80
|
+
const find = calls.find((c) => c.fn === "user.findMany")!;
|
|
81
|
+
const count = calls.find((c) => c.fn === "user.count")!;
|
|
82
|
+
expect(find.where.AND).toContainEqual(SCOPE_WHERE);
|
|
83
|
+
expect(count.where.AND).toContainEqual(SCOPE_WHERE);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("phạm vi cộng THÊM vào bộ lọc, không thay thế", async () => {
|
|
87
|
+
const { db, calls } = spyDb();
|
|
88
|
+
await getUsersData(db, {
|
|
89
|
+
search: "an",
|
|
90
|
+
status: "active",
|
|
91
|
+
scope: scopeOfSpa(),
|
|
92
|
+
});
|
|
93
|
+
expect(calls[0].where.AND).toHaveLength(3);
|
|
94
|
+
expect(calls[0].where.AND).toContainEqual(SCOPE_WHERE);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("xem được tất ⇒ không thêm điều kiện", async () => {
|
|
98
|
+
const { db, calls } = spyDb();
|
|
99
|
+
await getUsersData(db, {
|
|
100
|
+
scope: createWorkspaceScope({ canViewAll: true }),
|
|
101
|
+
});
|
|
102
|
+
expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("adminOnly thu hẹp về nhánh được uỷ quyền", async () => {
|
|
106
|
+
const { db, calls } = spyDb();
|
|
107
|
+
await getUsersData(db, { scope: scopeOfSpa(), adminOnly: true });
|
|
108
|
+
expect(calls[0].where.AND).toContainEqual({
|
|
109
|
+
userBranches: { some: { branchId: { in: ["spa"] } } },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getUsersWithDetails — kể cả các ô thống kê", () => {
|
|
115
|
+
it("MỌI truy vấn đếm đều mang phạm vi, không sót ô nào", async () => {
|
|
116
|
+
const { db, calls } = spyDb();
|
|
117
|
+
await getUsersWithDetails(db, { scope: scopeOfSpa() });
|
|
118
|
+
const userQueries = calls.filter((c) => c.fn.startsWith("user."));
|
|
119
|
+
expect(userQueries.length).toBeGreaterThan(3);
|
|
120
|
+
for (const call of userQueries) {
|
|
121
|
+
expect(JSON.stringify(call.where)).toContain("userBranches");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("không có scope thì không truy vấn nào bị thêm điều kiện", async () => {
|
|
126
|
+
const { db, calls } = spyDb();
|
|
127
|
+
await getUsersWithDetails(db, {});
|
|
128
|
+
for (const call of calls) {
|
|
129
|
+
expect(JSON.stringify(call.where ?? {})).not.toContain("userBranches");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("getUserStats", () => {
|
|
135
|
+
it("đếm trong phạm vi khi được truyền scope", async () => {
|
|
136
|
+
const { db, calls } = spyDb();
|
|
137
|
+
await getUserStats(db, scopeOfSpa());
|
|
138
|
+
for (const call of calls.filter((c) => c.fn === "user.count")) {
|
|
139
|
+
expect(JSON.stringify(call.where)).toContain("userBranches");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("không scope ⇒ đếm toàn hệ thống như cũ", async () => {
|
|
144
|
+
const { db, calls } = spyDb();
|
|
145
|
+
await getUserStats(db);
|
|
146
|
+
expect(calls.find((c) => c.fn === "user.count")!.where).toEqual({});
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/user/user-service.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { memberScopeWhere } from "../workspace/scope";
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceScope } from "../workspace/types";
|
|
1
4
|
import type { CrudResponse } from "../types";
|
|
2
5
|
|
|
3
6
|
// Define the shape of the Prisma Client required by this service
|
|
@@ -16,6 +19,24 @@ export interface GetUsersParams {
|
|
|
16
19
|
status?: string;
|
|
17
20
|
roleCode?: string;
|
|
18
21
|
departmentId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Phạm vi không gian của người ĐANG XEM. Bỏ trống = không lọc, giữ nguyên hành
|
|
24
|
+
* vi cũ cho app một-tổ-chức (luật L2: additive).
|
|
25
|
+
*
|
|
26
|
+
* App nhiều tổ chức PHẢI truyền: thiếu nó thì admin nhánh khách hàng A nhìn
|
|
27
|
+
* thấy toàn bộ người dùng của khách hàng B. Lọc ở tầng service chứ không ở
|
|
28
|
+
* tầng page — Entra từng thừa nhận đúng lỗi này (UI lọc, API thì không).
|
|
29
|
+
*/
|
|
30
|
+
scope?: WorkspaceScope;
|
|
31
|
+
/** Chỉ hiện người dùng trong nhánh mình QUẢN TRỊ, không phải mọi nhánh nhìn được. */
|
|
32
|
+
adminOnly?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Điều kiện phạm vi cho danh sách người dùng; `null` khi không phải lọc. */
|
|
36
|
+
function scopeCondition(params: GetUsersParams): Record<string, unknown> | null {
|
|
37
|
+
if (!params.scope) return null;
|
|
38
|
+
const where = memberScopeWhere(params.scope, { adminOnly: params.adminOnly });
|
|
39
|
+
return Object.keys(where).length > 0 ? where : null;
|
|
19
40
|
}
|
|
20
41
|
|
|
21
42
|
export interface UserWithDetails {
|
|
@@ -82,6 +103,9 @@ export async function getUsersData(
|
|
|
82
103
|
});
|
|
83
104
|
}
|
|
84
105
|
|
|
106
|
+
const scoped = scopeCondition(params);
|
|
107
|
+
if (scoped) whereConditions.push(scoped);
|
|
108
|
+
|
|
85
109
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
86
110
|
|
|
87
111
|
// ⚡ Bolt: Execute independent read queries concurrently to reduce latency
|
|
@@ -252,8 +276,15 @@ export async function getUsersWithDetails(
|
|
|
252
276
|
});
|
|
253
277
|
}
|
|
254
278
|
|
|
279
|
+
const scoped = scopeCondition(params);
|
|
280
|
+
if (scoped) whereConditions.push(scoped);
|
|
281
|
+
|
|
255
282
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
256
283
|
|
|
284
|
+
/** Đếm KHÔNG theo bộ lọc nhưng VẪN theo phạm vi. */
|
|
285
|
+
const scopedCount = (extra: Record<string, unknown>): any =>
|
|
286
|
+
scoped ? { AND: [extra, scoped] } : extra;
|
|
287
|
+
|
|
257
288
|
// ⚡ Bolt: Execute independent read queries concurrently to reduce latency
|
|
258
289
|
const [users, total, activeCount, rolesCount, customers, suppliers] =
|
|
259
290
|
await Promise.all([
|
|
@@ -327,13 +358,22 @@ export async function getUsersWithDetails(
|
|
|
327
358
|
db.user.count({ where }),
|
|
328
359
|
db.user.count({ where: { ...where, isActive: true } }),
|
|
329
360
|
db.role.count({ where: { status: "active" } }),
|
|
330
|
-
|
|
331
|
-
|
|
361
|
+
// Hai ô thống kê này cố ý BỎ QUA bộ lọc tìm kiếm, nên phải tự cộng lại
|
|
362
|
+
// điều kiện phạm vi — không thì con số vẫn đếm cả người dùng của tổ chức
|
|
363
|
+
// khác. Rò một con số vẫn là rò.
|
|
364
|
+
db.user
|
|
365
|
+
.count({ where: scopedCount({ userType: "customer" }) })
|
|
366
|
+
.catch(() => 0),
|
|
367
|
+
db.user
|
|
368
|
+
.count({ where: scopedCount({ userType: "supplier" }) })
|
|
369
|
+
.catch(() => 0),
|
|
332
370
|
]);
|
|
333
371
|
|
|
334
|
-
//
|
|
335
|
-
const totalUsersCount = await db.user.count();
|
|
336
|
-
const totalActiveCount = await db.user.count({
|
|
372
|
+
// Tổng số người dùng — bỏ bộ lọc nhưng KHÔNG bỏ phạm vi.
|
|
373
|
+
const totalUsersCount = await db.user.count({ where: scoped ?? {} });
|
|
374
|
+
const totalActiveCount = await db.user.count({
|
|
375
|
+
where: scopedCount({ isActive: true }),
|
|
376
|
+
});
|
|
337
377
|
const employees = Math.max(0, totalUsersCount - customers - suppliers);
|
|
338
378
|
|
|
339
379
|
// Transform data
|
|
@@ -420,13 +460,27 @@ export async function getActiveDepartments(db: UserPrismaClient) {
|
|
|
420
460
|
/**
|
|
421
461
|
* Get user statistics
|
|
422
462
|
*/
|
|
423
|
-
export async function getUserStats(
|
|
463
|
+
export async function getUserStats(
|
|
464
|
+
db: UserPrismaClient,
|
|
465
|
+
/** Bỏ trống = đếm toàn hệ thống (hành vi cũ). Truyền vào để đếm trong phạm vi. */
|
|
466
|
+
scope?: WorkspaceScope,
|
|
467
|
+
) {
|
|
468
|
+
const scoped = scope ? scopeCondition({ scope }) : null;
|
|
469
|
+
const withScope = (extra?: Record<string, unknown>): any => {
|
|
470
|
+
if (!scoped) return extra ?? {};
|
|
471
|
+
return extra ? { AND: [extra, scoped] } : scoped;
|
|
472
|
+
};
|
|
473
|
+
|
|
424
474
|
const [totalUsers, activeUsers, customers, suppliers, totalRoles] =
|
|
425
475
|
await Promise.all([
|
|
426
|
-
db.user.count(),
|
|
427
|
-
db.user.count({ where: { isActive: true } }),
|
|
428
|
-
db.user
|
|
429
|
-
|
|
476
|
+
db.user.count({ where: withScope() }),
|
|
477
|
+
db.user.count({ where: withScope({ isActive: true }) }),
|
|
478
|
+
db.user
|
|
479
|
+
.count({ where: withScope({ userType: "customer" }) })
|
|
480
|
+
.catch(() => 0),
|
|
481
|
+
db.user
|
|
482
|
+
.count({ where: withScope({ userType: "supplier" }) })
|
|
483
|
+
.catch(() => 0),
|
|
430
484
|
db.role.count({ where: { status: "active" } }),
|
|
431
485
|
]);
|
|
432
486
|
|