@goplusvn/core 0.1.79 → 0.1.82

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 CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.81 — Cây workspace kiểu explorer, xoá/khôi phục lộ ra, thêm thành viên bằng dialog
4
+
5
+ Vẫn **ADDITIVE**: prop mới đều tuỳ chọn, app không truyền thì màn hình vẫn chạy.
6
+
7
+ - `workspace/components/workspace-tree-panel.tsx` (MỚI, thay
8
+ `workspace-org-block.tsx` đã xoá) — cây thu gọn được kiểu explorer: một kiểu
9
+ dòng duy nhất cho mọi cấp, chevron riêng, vạch dẫn con, nút gọn thì cộng dồn
10
+ số người của cả nhánh. Chọn dòng bằng `bg-accent` + vạch `primary` bên trái
11
+ thay vì nền đậm. Mỗi dòng có menu `⋯`: thêm cấp con, sửa tên/mã, chuyển
12
+ nhánh, và **“Xoá workspace”** — trước đây thao tác xoá nấp sau chữ “Ngừng
13
+ hoạt động” nên nhìn vào không ai thấy màn hình có chức năng xoá.
14
+ Tên cũ (`WorkspaceOrgAnnotation`, `filterWorkspaceTree`, `rollupCounts`,
15
+ `WorkspaceRollup`) vẫn export được từ module này.
16
+ - `workspace/service.ts` — `reactivateSubtree()` đối xứng với
17
+ `deactivateSubtree()`; `PATCH { isActive: true }` bật lại CẢ NHÁNH. Xoá là
18
+ cửa hai chiều: không có đường về thì nút đã xoá nằm lại vĩnh viễn.
19
+ - `workspace/components/workspace-member-picker-dialog.tsx` (MỚI) — “Thêm thành
20
+ viên” chuyển từ khối chèn trong trang sang **dialog** theo đúng khuôn dialog
21
+ chọn của vinhhoa (`h-[85vh]`, header/toolbar dính, vùng cuộn `bg-muted/30`,
22
+ footer hành động). Tìm theo họ tên · mã nhân viên · email; lọc theo chi nhánh,
23
+ vai trò (app truyền) và **“đang ở bộ phận nào”** (core tự phục vụ bằng chính
24
+ bảng `userWorkspace`). Tick nhiều dòng, gán một lượt; dòng nào hỏng thì giữ
25
+ dialog và chọn lại đúng những người đó.
26
+ - `workspace/route-handlers.ts` — dep tuỳ chọn `candidateSearch(query)` (nhánh
27
+ tìm THÊM của app, ví dụ mã nhân viên) và tham số `inWorkspaceId`;
28
+ `memberIdentity.describe` trả thêm `code` để hiện mã nhân viên cạnh tên.
29
+
30
+ ## 0.1.80 — Gán người vào workspace: hàng loạt theo bộ lọc, và nhìn ra người đó là ai
31
+
32
+ Đợt 0.1.76–0.1.79 dựng xong hàng rào; đợt này là phần THAO TÁC — chỗ người ta
33
+ thực sự ngồi làm. Toàn bộ **ADDITIVE**: mọi prop và dep mới đều tuỳ chọn, app
34
+ không truyền thì màn hình chạy y như 0.1.79.
35
+
36
+ - `workspace/components/workspace-members-panel.tsx`
37
+ - **Gán hàng loạt** (bật bằng prop `bulkFilters`): lọc → tick nhiều dòng →
38
+ chuyển một lần. Hai nút, hai nghĩa: **“Chuyển”** (`mode: "move"`) GỠ mọi
39
+ không gian khác của người được chọn, **“Thêm”** giữ nguyên nơi cũ. Đây là
40
+ điểm dễ hiểu nhầm nhất của cả tính năng: phạm vi đọc là HỢP các không gian
41
+ một người thuộc về, nên chỉ THÊM thì người vừa “gán vào bộ phận Kho” vẫn
42
+ còn membership gốc và vẫn thấy toàn hệ thống — bấm xong tưởng đã cô lập mà
43
+ thực ra không đổi gì. Vì vậy “Chuyển” là mặc định, không phải “Thêm”.
44
+ - **Danh tính mỗi dòng**: chi nhánh · vai trò · các không gian đang thuộc về,
45
+ cho cả thành viên lẫn ứng viên. Tên với email không trả lời được câu hỏi
46
+ thật của người đang gán (“ông này là ai, đang nằm ở đâu, có phải quản trị
47
+ không”), mà gán nhầm là cắt mất tầm nhìn của một người thật.
48
+ - `workspace/route-handlers.ts`
49
+ - Dep tuỳ chọn `candidateFilter(params)` — app dịch tham số URL thành mảnh
50
+ `where` của Prisma. Mảnh này **chồng thêm** vào `memberScopeWhere(...,
51
+ adminOnly)`, không thay thế: không dùng nó để với ra ngoài phạm vi quản trị
52
+ được.
53
+ - Dep tuỳ chọn `memberIdentity { select, describe }` — “chi nhánh”/“vai trò”
54
+ là quan hệ của app, nhét thẳng `userBranches`/`userRoles` vào truy vấn thì
55
+ app nào không có đúng hai quan hệ đó sẽ vỡ lúc chạy. Các không gian đang
56
+ thuộc về thì core tự đọc (đã trừ nút đang mở).
57
+ - POST nhận `{ userIds[], mode }`; từng dòng hỏng trả lý do RIÊNG (ngoài phạm
58
+ vi, tài khoản được bảo vệ…) thay vì gộp thành một câu “có lỗi”.
59
+ - `workspace/components/workspace-org-block.tsx` — cây con **nằm trong** nút
60
+ gốc: thụt lề mọc ra từ tim ô icon của khối (1.75rem) kèm vạch dọc nối các nút
61
+ cùng cấp. Trước đó cấp đầu tiên chỉ lùi 0.75rem — ít hơn cả lề của tiêu đề
62
+ khối — nên đơn vị con nhìn ra thành ngang hàng với tổ chức, đúng thứ màn hình
63
+ này không được phép nói. Prop `annotations` (tra theo `code`) gắn được huy
64
+ hiệu cho cả nút CON, cần khi cây có một nút gốc phủ toàn hệ thống và “vận
65
+ hành”/“khách hàng” tụt xuống làm con.
66
+ - `rbac` — `Role.workspaceName` giải sẵn ở server (một câu SQL cho cả trang,
67
+ không `include` lồng: quan hệ `workspace` có thể chưa khai trên `Role`).
68
+ `PUT /api/roles/[id]` cho **chuyển chủ sở hữu** vai trò, nhưng chỉ ghi khi
69
+ client CÓ gửi `workspaceId` — ghi vô điều kiện thì vai trò dùng chung do vai
70
+ vận hành sửa sẽ lặng lẽ rơi vào một đơn vị.
71
+
3
72
  ## 0.1.79 — Vai trò cũng có phạm vi: quản trị viên đơn vị chỉ thấy & sửa vai trò của mình
4
73
 
5
74
  Lỗ hổng đối xứng của đợt 0.1.76: người dùng đã bị kẹp theo workspace, còn VAI
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.79",
4
+ "version": "0.1.82",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -296,6 +296,75 @@ describe("roles route handlers — uỷ quyền", () => {
296
296
  expect(updated[0].name).toBe("Tổ trưởng bếp");
297
297
  });
298
298
 
299
+ // ── Chủ sở hữu vai trò (`Role.workspaceId`) — ô "Đơn vị sở hữu" trên form ──
300
+ // Khác `dataScope`: cái này trả lời "vai trò của ai, AI SỬA ĐƯỢC", không phải
301
+ // "người mang vai trò thấy dữ liệu tới đâu".
302
+
303
+ it("POST nhận đơn vị do vai vận hành chọn thay vì tự đóng dấu", async () => {
304
+ const { deps, actor, created } = tenantDb();
305
+ actor.canManageAll = true;
306
+ const res = await createRolesCollectionHandlers(deps as any).POST(
307
+ post({
308
+ code: "qc_lead",
309
+ name: "Trưởng QC",
310
+ workspaceId: "spa-qc",
311
+ permissions: ["view:meal-order"],
312
+ })
313
+ );
314
+
315
+ expect(res.status).toBe(201);
316
+ expect(created[0].workspaceId).toBe("spa-qc");
317
+ });
318
+
319
+ it("POST vai vận hành chọn 'dùng chung' thì workspaceId là null", async () => {
320
+ const { deps, actor, created } = tenantDb();
321
+ actor.canManageAll = true;
322
+ const res = await createRolesCollectionHandlers(deps as any).POST(
323
+ post({ code: "chung", name: "Dùng chung", workspaceId: null, permissions: [] })
324
+ );
325
+
326
+ expect(res.status).toBe(201);
327
+ expect(created[0].workspaceId).toBeNull();
328
+ });
329
+
330
+ it("PUT KHÔNG gửi workspaceId thì không đóng dấu lại (vai trò dùng chung ở yên)", async () => {
331
+ // Đây là hồi quy thật: ghi `gate.workspaceId` vô điều kiện thì mỗi lần vai
332
+ // vận hành sửa một vai trò dùng chung là nó lặng lẽ rơi vào một đơn vị.
333
+ const { deps, actor, updated } = tenantDb({ workspaceId: null });
334
+ actor.canManageAll = true;
335
+ const res = await createRoleItemHandlers(deps as any).PUT(
336
+ put({ name: "Đổi tên", permissions: [] }),
337
+ ctx as any
338
+ );
339
+
340
+ expect(res.status).toBe(200);
341
+ expect(updated[0]).not.toHaveProperty("workspaceId");
342
+ });
343
+
344
+ it("PUT vai vận hành chuyển vai trò sang đơn vị khác", async () => {
345
+ const { deps, actor, updated } = tenantDb();
346
+ actor.canManageAll = true;
347
+ const res = await createRoleItemHandlers(deps as any).PUT(
348
+ put({ name: "Tổ trưởng", workspaceId: "tanloc", permissions: [] }),
349
+ ctx as any
350
+ );
351
+
352
+ expect(res.status).toBe(200);
353
+ expect(updated[0].workspaceId).toBe("tanloc");
354
+ });
355
+
356
+ it("PUT quản trị viên đơn vị KHÔNG chuyển được vai trò ra ngoài nhánh mình", async () => {
357
+ const { deps, updated } = tenantDb();
358
+ const res = await createRoleItemHandlers(deps as any).PUT(
359
+ put({ name: "Tổ trưởng", workspaceId: "tanloc", permissions: ["view:meal-order"] }),
360
+ ctx as any
361
+ );
362
+
363
+ expect(res.status).toBe(403);
364
+ expect((await res.json()).code).toBe("D1_OUT_OF_SCOPE");
365
+ expect(updated).toHaveLength(0);
366
+ });
367
+
299
368
  it("DELETE chặn vai trò hệ thống", async () => {
300
369
  const { deps, deleted } = tenantDb({ isSystem: true });
301
370
  const res = await createRoleItemHandlers(deps as any).DELETE(
@@ -58,6 +58,17 @@ export const RoleCard = memo(function RoleCard({
58
58
  ? "Dùng chung"
59
59
  : null;
60
60
 
61
+ // ĐƠN VỊ SỞ HỮU — chỉ ở app multi-tenant. Phân biệt `undefined` (app một tổ
62
+ // chức, không có khái niệm này) với `null` (có, và là vai trò dùng chung):
63
+ // gộp hai cái là app cũ tự nhiên mọc thêm huy hiệu "Dùng chung" vô nghĩa.
64
+ const multiTenant = "workspaceId" in role;
65
+ // Người không sửa được đã có nhãn riêng ở trên rồi, đừng dán hai huy hiệu
66
+ // cùng nghĩa cạnh nhau.
67
+ const ownerLabel =
68
+ multiTenant && !readOnlyLabel
69
+ ? (role.workspaceName ?? (role.workspaceId ? "Đơn vị khác" : "Dùng chung"))
70
+ : null;
71
+
61
72
  const handleEdit = useCallback(() => {
62
73
  router.push(`/${lang}/roles/${role.id}/edit`);
63
74
  }, [router, lang, role.id]);
@@ -95,6 +106,19 @@ export const RoleCard = memo(function RoleCard({
95
106
  {readOnlyLabel}
96
107
  </Badge>
97
108
  )}
109
+ {ownerLabel && (
110
+ <Badge
111
+ variant="outline"
112
+ className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold max-w-[9rem] truncate"
113
+ title={
114
+ role.workspaceId
115
+ ? "Đơn vị sở hữu vai trò — chỉ quản trị đơn vị này sửa được"
116
+ : "Vai trò dùng chung — mọi đơn vị đều gán được, chỉ cấp vận hành sửa"
117
+ }
118
+ >
119
+ {ownerLabel}
120
+ </Badge>
121
+ )}
98
122
  {role.status === "active" ? (
99
123
  <Badge
100
124
  variant="outline"
@@ -45,6 +45,7 @@ import {
45
45
  House,
46
46
  Info,
47
47
  LayoutTemplate,
48
+ Network,
48
49
  Save,
49
50
  ShieldCheck,
50
51
  X,
@@ -84,6 +85,9 @@ const LANDING_AUTO = "__auto__"
84
85
  /** Cùng lý do: "" = theo mặc định của app (`subtree`). */
85
86
  const DATA_SCOPE_DEFAULT = "__default__"
86
87
 
88
+ /** Cùng lý do: "" = vai trò DÙNG CHUNG (`workspaceId = null`). */
89
+ const WORKSPACE_SHARED = "__shared__"
90
+
87
91
  /**
88
92
  * Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
89
93
  * DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
@@ -185,6 +189,8 @@ export interface RoleFormPageProps {
185
189
  landingPath?: string | null
186
190
  /** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
187
191
  dataScope?: string | null
192
+ /** Đơn vị sở hữu vai trò. Null = vai trò dùng chung. */
193
+ workspaceId?: string | null
188
194
  }
189
195
  actionLabels?: Record<string, ActionLabelConfig>
190
196
  roleTemplates?: RoleTemplate[]
@@ -212,6 +218,22 @@ export interface RoleFormPageProps {
212
218
  * trường này nếu người gửi không phải vai vận hành — đây chỉ là lớp hiển thị.
213
219
  */
214
220
  showDataScope?: boolean
221
+ /**
222
+ * Các ĐƠN VỊ được phép đặt làm chủ sở hữu vai trò (`Role.workspaceId`). Bỏ
223
+ * trống ⇒ không hiện ô, payload không mang trường này, engine tự đóng dấu theo
224
+ * nhánh người tạo — đúng hành vi trước khi có ô.
225
+ *
226
+ * Hai trường dễ lẫn, đừng gộp:
227
+ * - `workspaceId` = vai trò này CỦA ĐƠN VỊ NÀO, tức AI ĐƯỢC SỬA nó;
228
+ * - `dataScope` = người MANG vai trò này nhìn thấy dữ liệu tới đâu.
229
+ * Một vai trò dùng chung (`null`) vẫn có thể cô lập chặt, và ngược lại.
230
+ *
231
+ * `allowShared` chỉ bật cho vai vận hành: quản trị viên đơn vị mà tạo được vai
232
+ * trò dùng chung là họ vừa đẻ ra thứ mọi đơn vị khác cũng gán được. API chặn
233
+ * sẵn (D1), đây là lớp hiển thị cho khớp.
234
+ */
235
+ workspaceOptions?: Array<{ id: string; name: string; depth?: number }>
236
+ allowSharedWorkspace?: boolean
215
237
  }
216
238
 
217
239
  export function RoleFormPage({
@@ -230,6 +252,8 @@ export function RoleFormPage({
230
252
  historyEndpoint,
231
253
  grantablePermissions,
232
254
  showDataScope,
255
+ workspaceOptions,
256
+ allowSharedWorkspace,
233
257
  }: RoleFormPageProps) {
234
258
  const router = useRouter()
235
259
 
@@ -273,6 +297,13 @@ export function RoleFormPage({
273
297
  initialData?.dataScope ?? ""
274
298
  )
275
299
 
300
+ // Cùng lý do tách khỏi `formData`: không hiện ô thì payload phải KHÔNG có
301
+ // trường `workspaceId`, để cổng uỷ quyền tự đóng dấu theo nhánh người tạo.
302
+ const showWorkspace = Boolean(workspaceOptions?.length)
303
+ const [workspaceId, setWorkspaceId] = React.useState<string>(
304
+ initialData?.workspaceId ?? ""
305
+ )
306
+
276
307
  const getLocalizedName = React.useCallback(
277
308
  (key: string | null) => {
278
309
  if (!key) return ""
@@ -580,9 +611,11 @@ export function RoleFormPage({
580
611
  const res = await fetch(url, {
581
612
  method,
582
613
  headers: { "Content-Type": "application/json" },
583
- body: JSON.stringify(
584
- showDataScope ? { ...formData, dataScope: dataScope || null } : formData
585
- ),
614
+ body: JSON.stringify({
615
+ ...formData,
616
+ ...(showDataScope ? { dataScope: dataScope || null } : {}),
617
+ ...(showWorkspace ? { workspaceId: workspaceId || null } : {}),
618
+ }),
586
619
  })
587
620
  if (!res.ok) {
588
621
  await throwFetchError(res, `Failed to ${mode} role`)
@@ -942,6 +975,56 @@ export function RoleFormPage({
942
975
  </Select>
943
976
  )}
944
977
 
978
+ {/* Đơn vị sở hữu — vai trò này CỦA AI, tức ai được sửa nó. Khác hẳn
979
+ ô "Cô lập dữ liệu" ngay bên cạnh (người MANG vai trò thấy gì). */}
980
+ {showWorkspace && (
981
+ <div className="flex items-center gap-1.5">
982
+ <Network className="h-3.5 w-3.5 text-muted-foreground" />
983
+ <span className="text-xs text-muted-foreground">
984
+ Đơn vị sở hữu:
985
+ </span>
986
+ <Select
987
+ value={workspaceId || WORKSPACE_SHARED}
988
+ onValueChange={(v) =>
989
+ setWorkspaceId(v === WORKSPACE_SHARED ? "" : v)
990
+ }
991
+ >
992
+ <SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
993
+ <SelectValue>
994
+ {workspaceId
995
+ ? (workspaceOptions?.find((w) => w.id === workspaceId)
996
+ ?.name ?? workspaceId)
997
+ : "Dùng chung (mọi đơn vị)"}
998
+ </SelectValue>
999
+ </SelectTrigger>
1000
+ <SelectContent className="rounded-lg">
1001
+ {allowSharedWorkspace !== false && (
1002
+ <SelectItem value={WORKSPACE_SHARED}>
1003
+ <span className="flex flex-col">
1004
+ <span>Dùng chung (mọi đơn vị)</span>
1005
+ <span className="text-[11px] text-muted-foreground">
1006
+ Đơn vị nào cũng gán được, chỉ cấp vận hành sửa
1007
+ </span>
1008
+ </span>
1009
+ </SelectItem>
1010
+ )}
1011
+ {workspaceOptions?.map((w) => (
1012
+ <SelectItem key={w.id} value={w.id}>
1013
+ <span
1014
+ className="flex flex-col"
1015
+ // Thụt theo cấp để nhìn ra bộ phận con của đơn vị nào —
1016
+ // danh sách phẳng thì "Kho" của hai khách hàng trông y hệt.
1017
+ style={{ paddingLeft: `${((w.depth ?? 1) - 1) * 12}px` }}
1018
+ >
1019
+ <span>{w.name}</span>
1020
+ </span>
1021
+ </SelectItem>
1022
+ ))}
1023
+ </SelectContent>
1024
+ </Select>
1025
+ </div>
1026
+ )}
1027
+
945
1028
  {branchScopeResources.length > 0 && (
946
1029
  <div className="flex items-center gap-1.5">
947
1030
  <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
@@ -206,6 +206,30 @@ export async function getRolesData(
206
206
  }),
207
207
  ]);
208
208
 
209
+ // Tên đơn vị sở hữu: MỘT câu cho cả trang, không phải `include` lồng — quan hệ
210
+ // `workspace` có thể không được khai trên `Role` ở app chỉ thêm cột thô, mà
211
+ // `include` sai tên quan hệ là Prisma ném lỗi cả trang danh sách.
212
+ const workspaceNames = new Map<string, string>();
213
+ if (hasWorkspaceField) {
214
+ const ids = [
215
+ ...new Set(items.map((r: any) => r.workspaceId).filter(Boolean)),
216
+ ] as string[];
217
+ // `db.workspace` chỉ tồn tại ở app đã chạy migration workspace. Có cột mà
218
+ // chưa có bảng là chuyện có thật giữa hai lần deploy — im lặng bỏ tên còn
219
+ // hơn cho nổ trang.
220
+ if (ids.length > 0 && (db as any).workspace?.findMany) {
221
+ try {
222
+ const rows = await (db as any).workspace.findMany({
223
+ where: { id: { in: ids } },
224
+ select: { id: true, name: true },
225
+ });
226
+ for (const w of rows) workspaceNames.set(w.id, w.name);
227
+ } catch {
228
+ /* không có bảng workspaces — bỏ qua tên, danh sách vẫn chạy */
229
+ }
230
+ }
231
+ }
232
+
209
233
  // Transform raw DB items to RoleData type
210
234
  const transformedItems = items.map((role: any) => ({
211
235
  id: role.id,
@@ -234,6 +258,9 @@ export async function getRolesData(
234
258
  ...(hasWorkspaceField
235
259
  ? {
236
260
  workspaceId: role.workspaceId ?? null,
261
+ workspaceName: role.workspaceId
262
+ ? (workspaceNames.get(role.workspaceId) ?? null)
263
+ : null,
237
264
  isSystem: Boolean(role.isSystem),
238
265
  rank: typeof role.rank === "number" ? role.rank : undefined,
239
266
  // Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
@@ -308,11 +308,23 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
308
308
  try {
309
309
  const gate = assertCanManageRole(actor, loaded.delegation, {
310
310
  code: existing.code,
311
- workspaceId: loaded.delegation.workspaceId,
311
+ // Client KHÔNG gửi trường ⇒ giữ nguyên chủ sở hữu. Gửi ⇒ cổng uỷ
312
+ // quyền quyết: vai vận hành đổi được tuỳ ý, quản trị đơn vị chỉ
313
+ // chuyển được trong nhánh mình (D1 ném nếu ra ngoài).
314
+ workspaceId:
315
+ body.workspaceId !== undefined
316
+ ? body.workspaceId
317
+ : loaded.delegation.workspaceId,
312
318
  rank: typeof body.rank === "number" ? body.rank : loaded.delegation.rank,
313
319
  permissions: toResourceAction(permissions),
314
320
  });
315
321
  if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
322
+ // Chỉ ghi khi client CÓ ý đổi: `gate.workspaceId` luôn có giá trị, ghi
323
+ // vô điều kiện là mọi lần sửa vai trò đều đóng dấu lại — vai trò dùng
324
+ // chung do vai vận hành sửa sẽ lặng lẽ rơi vào một đơn vị.
325
+ if (body.workspaceId !== undefined && hasRoleField(prisma, "workspaceId")) {
326
+ stamp.workspaceId = gate.workspaceId;
327
+ }
316
328
  } catch (e) {
317
329
  const denied = delegationDenied(e);
318
330
  if (denied) return denied;
package/src/rbac/types.ts CHANGED
@@ -22,6 +22,12 @@ export type Role = {
22
22
  updatedBy?: string | null | undefined;
23
23
  /** App multi-tenant: đơn vị sở hữu vai trò; `null` = vai trò dùng chung. */
24
24
  workspaceId?: string | null;
25
+ /**
26
+ * Tên đơn vị sở hữu — server giải sẵn để thẻ vai trò khỏi phải tra cây. `null`
27
+ * khi vai trò dùng chung; trường VẮNG MẶT khi app không bật multi-tenant (khác
28
+ * hẳn `null`, đừng gộp hai trường hợp).
29
+ */
30
+ workspaceName?: string | null;
25
31
  isSystem?: boolean;
26
32
  rank?: number;
27
33
  /**
@@ -118,12 +118,16 @@ function fakeDb(users: FakeUser[]) {
118
118
  const before = members.length;
119
119
  for (let i = members.length - 1; i >= 0; i -= 1) {
120
120
  const m = members[i];
121
- if (
122
- m.userId === args.where.userId &&
123
- m.workspaceId === args.where.workspaceId
124
- ) {
125
- members.splice(i, 1);
126
- }
121
+ if (m.userId !== args.where.userId) continue;
122
+ // Hai dạng `where` mà handler thực sự gửi xuống: gỡ ĐÚNG một nút
123
+ // (đường xoá một người) và gỡ MỌI nút khác (đường chuyển hàng loạt).
124
+ const matches =
125
+ args.where.workspaceId !== undefined
126
+ ? m.workspaceId === args.where.workspaceId
127
+ : args.where.NOT?.workspaceId !== undefined
128
+ ? m.workspaceId !== args.where.NOT.workspaceId
129
+ : true;
130
+ if (matches) members.splice(i, 1);
127
131
  }
128
132
  return { count: before - members.length };
129
133
  },
@@ -405,3 +409,66 @@ describe("tìm ứng viên để thêm", () => {
405
409
  expect(res.status).toBe(403);
406
410
  });
407
411
  });
412
+
413
+ describe("gán HÀNG LOẠT", () => {
414
+ let db: ReturnType<typeof fakeDb>;
415
+ beforeEach(() => {
416
+ db = fakeDb(baseUsers());
417
+ configure(db);
418
+ });
419
+
420
+ const wsOf = (userId: string) =>
421
+ db.members.filter((m) => m.userId === userId).map((m) => m.workspaceId);
422
+
423
+ it('mode "move" GỠ mọi không gian cũ — nếu không thì cô lập là giả', async () => {
424
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
425
+ const res = await POST(
426
+ postReq({ userIds: ["u-qc", "u-tanloc"], mode: "move" }),
427
+ ctx("spa-qc"),
428
+ );
429
+
430
+ expect(res.status).toBe(200);
431
+ await expect(res.json()).resolves.toMatchObject({ changed: 2, failed: [] });
432
+ // Còn giữ membership cũ là người đó vẫn thấy dữ liệu nhánh cũ (phạm vi là
433
+ // HỢP các không gian), tức bấm gán xong mà không đổi gì.
434
+ expect(wsOf("u-qc")).toEqual(["spa-qc"]);
435
+ expect(wsOf("u-tanloc")).toEqual(["spa-qc"]);
436
+ });
437
+
438
+ it('mode "add" giữ nguyên nơi cũ', async () => {
439
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
440
+ const res = await POST(
441
+ postReq({ userIds: ["u-qc"], mode: "add" }),
442
+ ctx("spa-qc"),
443
+ );
444
+
445
+ expect(res.status).toBe(200);
446
+ expect(wsOf("u-qc").sort()).toEqual(["spa", "spa-qc"]);
447
+ });
448
+
449
+ it("người ngoài phạm vi bị loại RIÊNG, không kéo đổ cả mẻ", async () => {
450
+ const { POST } = createWorkspaceMemberHandlers(
451
+ deps(db, customerAdmin) as any,
452
+ );
453
+ const res = await POST(
454
+ // `u-tanloc` thuộc nhánh Tấn Lộc — admin khách hàng không sở hữu (D7).
455
+ postReq({ userIds: ["u-qc", "u-tanloc"] }),
456
+ ctx("spa-qc"),
457
+ );
458
+
459
+ expect(res.status).toBe(200);
460
+ const payload = await res.json();
461
+ expect(payload.changed).toBe(1);
462
+ expect(payload.failed).toHaveLength(1);
463
+ expect(payload.failed[0].userId).toBe("u-tanloc");
464
+ // Nạn nhân KHÔNG bị đẩy khỏi nhánh cũ — đó chính là đường chiếm người mà D7 chặn.
465
+ expect(wsOf("u-tanloc")).toEqual(["tanloc"]);
466
+ expect(wsOf("u-qc")).toEqual(["spa-qc"]);
467
+ });
468
+
469
+ it("danh sách rỗng → 400 chứ không im lặng báo thành công", async () => {
470
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
471
+ const res = await POST(postReq({ userIds: [] }), ctx("spa-qc"));
472
+ expect(res.status).toBe(400);
473
+ });
474
+ });