@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
@@ -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
  }
@@ -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 chỉ đứng đây khi KHÔNG nửa phải (màn hẹp) trên md tấm
57
- ảnh đã mang logo + tên công ty rồi, lặp lạithừa. */}
56
+ {/* Logo + tên công ty chỉ đứng ĐÚNG một chỗ: góc trên trái. Nửa phải
57
+ hoa văn thuần, không mang chữ nhồi logo lên đó lặp. */}
58
58
  <Link
59
59
  href={ensureLocalizedPathname("/", locale)}
60
- className="flex md:hidden items-center gap-2 text-foreground font-black"
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
- * Hình khối cố ýmức kỹ thuật lưới như giấy kẻ ô mấy cung tròn quét từ
107
- * góc trên phải (tâm nằm NGOÀI khung nên thấy nét quét chứ không thành cái bia
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 chữ hay logo đâylogo đã đứng góc trên trái, đặt thêm một cái
166
+ * nữa lên hoa văn 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-grid"
126
- width="44"
127
- height="44"
179
+ id="goerp-auth-cubes"
180
+ width={CUBE_TILE_W}
181
+ height={CUBE_TILE_H}
128
182
  patternUnits="userSpaceOnUse"
129
183
  >
130
- <path
131
- d="M44 0H0V44"
132
- stroke="currentColor"
133
- strokeOpacity="0.08"
134
- strokeWidth="1"
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
- <radialGradient id="goerp-auth-glow" cx="0.82" cy="0.1" r="0.8">
138
- <stop offset="0" stopColor="currentColor" stopOpacity="0.16" />
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, 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-grid)" />
143
- <rect width="600" height="900" fill="url(#goerp-auth-glow)" />
144
- <g stroke="currentColor" fill="none">
145
- <circle cx="640" cy="-60" r="280" strokeOpacity="0.16" />
146
- <circle cx="640" cy="-60" r="430" strokeOpacity="0.12" />
147
- <circle cx="640" cy="-60" r="600" strokeOpacity="0.08" />
148
- <circle cx="640" cy="-60" r="790" strokeOpacity="0.05" />
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, cùng một khối logo + tên công ty ở dưới:
164
- * - CÓ ảnh: ảnh phủ kín, một lớp tối chuyển dần từ đáy lên để chữ đè lên còn
165
- * đọc được (nền ảnh không kiểm soát được ⇒ không có lớp này thì chữ trắng
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/80 via-black/35 to-black/10"
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
+ });
@@ -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
- db.user.count({ where: { userType: "customer" } }).catch(() => 0),
331
- db.user.count({ where: { userType: "supplier" } }).catch(() => 0),
361
+ // Hai ô thống 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
- // Get total users count (without filters)
335
- const totalUsersCount = await db.user.count();
336
- const totalActiveCount = await db.user.count({ where: { isActive: true } });
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(db: UserPrismaClient) {
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.count({ where: { userType: "customer" } }).catch(() => 0),
429
- db.user.count({ where: { userType: "supplier" } }).catch(() => 0),
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