@goplusvn/core 0.1.78 → 0.1.81

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.
@@ -56,12 +56,13 @@ import { cn } from "../../utils";
56
56
  import { countTreeNodes } from "../components/workspace-tree-view";
57
57
  import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
58
58
  import {
59
- WorkspaceOrgBlock,
59
+ WorkspaceTreePanel,
60
60
  filterWorkspaceTree,
61
61
  rollupCounts,
62
- } from "../components/workspace-org-block";
63
- import type { WorkspaceOrgAnnotation } from "../components/workspace-org-block";
62
+ } from "../components/workspace-tree-panel";
63
+ import type { WorkspaceOrgAnnotation } from "../components/workspace-tree-panel";
64
64
  import { WorkspaceMembersPanel } from "../components/workspace-members-panel";
65
+ import type { CandidateFilter } from "../components/workspace-members-panel";
65
66
  import type { WorkspaceKindConfig } from "../types";
66
67
 
67
68
  export interface WorkspaceListPageProps {
@@ -83,6 +84,11 @@ export interface WorkspaceListPageProps {
83
84
  * bảng chúng là hai dòng y hệt nhau. Bỏ trống thì mọi khối vẽ trung tính.
84
85
  */
85
86
  rootAnnotations?: Record<string, WorkspaceOrgAnnotation>;
87
+ /**
88
+ * Trục lọc cho chế độ gán hàng loạt ở panel thành viên (chi nhánh, bộ phận…).
89
+ * Bỏ trống thì panel giữ nguyên hành vi thêm từng người một.
90
+ */
91
+ memberFilters?: CandidateFilter[];
86
92
  }
87
93
 
88
94
  interface FormState {
@@ -182,6 +188,7 @@ export function WorkspaceListPage({
182
188
  apiEndpoint = "/api/workspaces",
183
189
  title = "Workspace",
184
190
  rootAnnotations,
191
+ memberFilters,
185
192
  }: WorkspaceListPageProps) {
186
193
  const [tree, setTree] = React.useState(initialTree);
187
194
  const [search, setSearch] = React.useState("");
@@ -386,11 +393,44 @@ export function WorkspaceListPage({
386
393
  if (!deactivating) return;
387
394
  const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
388
395
  if (ok) {
389
- toast.success("Đã ngừng hoạt động cả nhánh.");
396
+ toast.success("Đã xoá cả nhánh khỏi danh sách đang dùng.");
390
397
  setDeactivating(null);
391
398
  }
392
399
  };
393
400
 
401
+ /** Bật lại một nhánh đã xoá — cùng đường PATCH, đi cả nhánh ở phía server. */
402
+ const restore = async (node: WorkspaceTreeNode) => {
403
+ const ok = await submit(`${apiEndpoint}/${node.id}`, "PATCH", {
404
+ isActive: true,
405
+ });
406
+ if (ok) toast.success("Đã khôi phục cả nhánh.");
407
+ };
408
+
409
+ /** Nút nào người đang đăng nhập ghi được — dùng cho menu trên từng dòng cây. */
410
+ const canManageNode = React.useCallback(
411
+ (node: WorkspaceTreeNode) =>
412
+ canManageAll || (adminIds ?? []).includes(node.id),
413
+ [canManageAll, adminIds],
414
+ );
415
+
416
+ const openEdit = React.useCallback(
417
+ (node: WorkspaceTreeNode) =>
418
+ setForm({
419
+ mode: "edit",
420
+ id: node.id,
421
+ parentId: null,
422
+ code: node.code,
423
+ name: node.name,
424
+ kind: node.kind ?? kinds[0]?.key ?? "unit",
425
+ }),
426
+ [kinds],
427
+ );
428
+
429
+ const openMove = React.useCallback((node: WorkspaceTreeNode) => {
430
+ setMoving(node);
431
+ setMoveTarget("__root__");
432
+ }, []);
433
+
394
434
  // Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
395
435
  const moveOptions = React.useMemo(() => {
396
436
  if (!moving) return [];
@@ -438,7 +478,7 @@ export function WorkspaceListPage({
438
478
  ) : null}
439
479
  </RbacPageBar>
440
480
 
441
- <div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
481
+ <div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,23rem)_minmax(0,1fr)] lg:items-start">
442
482
  {/* KHUNG TRÁI — điều hướng. Ô tìm kiếm ở đây chứ không ở đầu trang: nó
443
483
  lọc cây, không lọc thứ đang mở bên phải. */}
444
484
  <div
@@ -507,16 +547,22 @@ export function WorkspaceListPage({
507
547
  ) : null}
508
548
  </div>
509
549
  ) : (
510
- visibleRoots.map((root) => (
511
- <WorkspaceOrgBlock
512
- key={root.id}
513
- root={root}
514
- annotation={rootAnnotations?.[root.code]}
515
- activeId={selectedId}
516
- onSelect={selectNode}
517
- childKindLabel={addChildLabelOf}
518
- />
519
- ))
550
+ <WorkspaceTreePanel
551
+ nodes={visibleRoots}
552
+ annotations={rootAnnotations}
553
+ activeId={selectedId}
554
+ onSelect={selectNode}
555
+ // Đang lọc thì mở hết: nút khớp thường nằm ở cấp sâu, để nguyên
556
+ // trạng thái gấp thì người dùng gõ đúng tên mà màn hình không đổi.
557
+ expandAll={Boolean(search)}
558
+ childKindLabel={addChildLabelOf}
559
+ canManage={canManageNode}
560
+ onAddChild={openCreate}
561
+ onEdit={openEdit}
562
+ onMove={openMove}
563
+ onDelete={setDeactivating}
564
+ onRestore={(node) => void restore(node)}
565
+ />
520
566
  )}
521
567
  </div>
522
568
 
@@ -607,38 +653,32 @@ export function WorkspaceListPage({
607
653
  </DropdownMenuTrigger>
608
654
  <DropdownMenuContent align="end">
609
655
  <DropdownMenuItem
610
- onClick={() =>
611
- setForm({
612
- mode: "edit",
613
- id: selected.id,
614
- parentId: null,
615
- code: selected.code,
616
- name: selected.name,
617
- kind: selected.kind ?? kinds[0]?.key ?? "unit",
618
- })
619
- }
656
+ onClick={() => openEdit(selected)}
620
657
  >
621
- Sửa thông tin
658
+ Sửa tên / mã
622
659
  </DropdownMenuItem>
623
- <DropdownMenuItem
624
- onClick={() => {
625
- setMoving(selected);
626
- setMoveTarget("__root__");
627
- }}
628
- >
660
+ <DropdownMenuItem onClick={() => openMove(selected)}>
629
661
  Chuyển nhánh
630
662
  </DropdownMenuItem>
631
- {selected.isActive !== false ? (
632
- <>
633
- <DropdownMenuSeparator />
634
- <DropdownMenuItem
635
- className="text-destructive focus:text-destructive"
636
- onClick={() => setDeactivating(selected)}
637
- >
638
- Ngừng hoạt động
639
- </DropdownMenuItem>
640
- </>
641
- ) : null}
663
+ <DropdownMenuSeparator />
664
+ {selected.isActive === false ? (
665
+ <DropdownMenuItem
666
+ onClick={() => void restore(selected)}
667
+ >
668
+ Khôi phục
669
+ </DropdownMenuItem>
670
+ ) : (
671
+ // Chữ "Xoá" phải có mặt: người đi tìm chức năng xoá
672
+ // quét mắt tìm đúng chữ đó, "Ngừng hoạt động" họ đọc
673
+ // thành một trạng thái nào khác rồi kết luận là
674
+ // trang không có xoá.
675
+ <DropdownMenuItem
676
+ className="text-destructive focus:text-destructive"
677
+ onClick={() => setDeactivating(selected)}
678
+ >
679
+ Xoá workspace
680
+ </DropdownMenuItem>
681
+ )}
642
682
  </DropdownMenuContent>
643
683
  </DropdownMenu>
644
684
  </div>
@@ -652,6 +692,16 @@ export function WorkspaceListPage({
652
692
  </div>
653
693
 
654
694
  <StatStrip items={stats} />
695
+
696
+ {/* Câu giải thích phạm vi ở ĐÂY chứ không trên cây: cột trái
697
+ là danh sách để quét mắt, một đoạn ba dòng chen giữa các
698
+ dòng làm mất luôn nhịp đọc. Bên này đang nói về đúng một
699
+ nút nên có chỗ. */}
700
+ {rootAnnotations?.[selected.code]?.hint ? (
701
+ <p className="text-[13px] leading-relaxed text-muted-foreground">
702
+ {rootAnnotations[selected.code].hint}
703
+ </p>
704
+ ) : null}
655
705
  </div>
656
706
 
657
707
  <WorkspaceMembersPanel
@@ -662,6 +712,12 @@ export function WorkspaceListPage({
662
712
  canManage={canManageSelected}
663
713
  apiEndpoint={apiEndpoint}
664
714
  onCountChange={applyMemberCount}
715
+ bulkFilters={memberFilters}
716
+ // Cây làm phẳng: hộp thêm thành viên lọc được "đang ở bộ phận
717
+ // nào", trục mà người dùng thật sự dùng ("chuyển cả tổ QC sang
718
+ // Kho"). Cây đầy đủ chứ không phải cây đã lọc — ô tìm bên trái
719
+ // lọc cây điều hướng, không lọc bộ lọc của hộp thoại.
720
+ workspaceOptions={flat}
665
721
  />
666
722
  </>
667
723
  ) : (
@@ -791,9 +847,13 @@ export function WorkspaceListPage({
791
847
  <ConfirmDialog
792
848
  open={deactivating !== null}
793
849
  onOpenChange={(open) => !open && setDeactivating(null)}
794
- title="Ngừng hoạt động workspace?"
795
- description={`"${deactivating?.name}" toàn bộ cấp dưới sẽ ngừng hoạt động. Dữ liệu giữ nguyên, không bị xóa.`}
796
- confirmText="Ngừng hoạt động"
850
+ title="Xoá workspace?"
851
+ // Nói đây xoá MỀM: dữ liệu tham chiếu tới nút này (chứng từ,
852
+ // hồ sơ) vẫn còn nguyên phạm vi, và khôi phục lại được — nếu không nói
853
+ // ra thì người dùng hoặc sợ không dám bấm, hoặc bấm xong tưởng mất dữ
854
+ // liệu thật.
855
+ description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ bị gỡ khỏi danh sách đang dùng. Dữ liệu cũ giữ nguyên và khôi phục lại được.`}
856
+ confirmText="Xoá"
797
857
  variant="destructive"
798
858
  onConfirm={confirmDeactivate}
799
859
  />
@@ -25,6 +25,7 @@ import {
25
25
  buildTree,
26
26
  createWorkspace,
27
27
  deactivateSubtree,
28
+ reactivateSubtree,
28
29
  moveWorkspace,
29
30
  } from "./service";
30
31
  import { WorkspaceTreeError } from "./tree";
@@ -50,6 +51,54 @@ export interface WorkspaceHandlerDeps {
50
51
  * cho tới khi người dùng F5.
51
52
  */
52
53
  kinds?: { key: string; label: string }[];
54
+ /**
55
+ * Bộ lọc ứng viên do APP định nghĩa, cho màn hình gán hàng loạt.
56
+ *
57
+ * Core cố ý không biết "chi nhánh" hay "bộ phận" là gì — mỗi app có trục
58
+ * khác nhau. App nhận `URLSearchParams` và trả về một mảnh `where` của
59
+ * Prisma trên model `User`; core chỉ AND nó vào truy vấn ứng viên. Bỏ trống
60
+ * thì hộp tìm kiếm chạy y như cũ.
61
+ *
62
+ * Mảnh `where` này KHÔNG thay thế `memberScopeWhere(..., adminOnly)` — nó
63
+ * chồng thêm, nên app không thể dùng nó để với ra ngoài phạm vi quản trị.
64
+ */
65
+ candidateFilter?: (params: URLSearchParams) => unknown;
66
+ /**
67
+ * Các nhánh tìm kiếm THÊM cho ô "tìm người", ngoài tên và email của core.
68
+ *
69
+ * Người đi gán gõ MÃ NHÂN VIÊN, không gõ email — nhưng mã nhân viên nằm ở
70
+ * bảng hồ sơ của app (`Employee.employee_code` ở spartronics), core không
71
+ * biết bảng đó tồn tại. App trả về các mảnh `where` sẽ được OR vào cùng tên
72
+ * và email. Bỏ trống thì tìm kiếm giữ nguyên phạm vi tên + email.
73
+ */
74
+ candidateSearch?: (query: string) => unknown[];
75
+ /**
76
+ * Danh tính người dùng để hiện KÈM mỗi dòng: chi nhánh nào, vai trò gì.
77
+ *
78
+ * Cùng lý do với `candidateFilter`: "chi nhánh" và "vai trò" là quan hệ của
79
+ * app, không phải của core — nhét thẳng `userBranches` / `userRoles` vào
80
+ * truy vấn thì app nào không có đúng hai quan hệ đó sẽ vỡ ngay lúc chạy.
81
+ * Nên app đưa `select` của mình vào, và tự rút gọn dòng user thành nhãn.
82
+ *
83
+ * Bỏ trống thì danh sách chỉ còn tên + email + các không gian đang thuộc về
84
+ * (phần đó core tự biết) — đúng như trước khi có tuỳ chọn này.
85
+ */
86
+ memberIdentity?: {
87
+ /** Mảnh `select` ghép thêm vào truy vấn `User`. */
88
+ select: Record<string, unknown>;
89
+ /**
90
+ * Rút gọn một dòng user thành nhãn hiển thị.
91
+ *
92
+ * `code` là mã nhân viên (hoặc mã nhân sự tương đương) — hiện ngay cạnh
93
+ * tên, vì đó là thứ người đi gán đọc để chắc chắn không nhầm hai người
94
+ * trùng tên.
95
+ */
96
+ describe: (user: any) => {
97
+ branch?: string | null;
98
+ roles?: string[];
99
+ code?: string | null;
100
+ };
101
+ };
53
102
  onError?: (error: unknown, req: Request) => Response | Promise<Response>;
54
103
  }
55
104
 
@@ -235,6 +284,17 @@ export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
235
284
  if (body?.name !== undefined) data.name = String(body.name).trim();
236
285
  if (body?.kind !== undefined) data.kind = body.kind;
237
286
  if (body?.settings !== undefined) data.settings = body.settings;
287
+
288
+ // Bật lại một nút đã ngừng — đi cả nhánh, nên tách khỏi `update` thường.
289
+ // Chỉ nhận `isActive: true`: TẮT vẫn phải đi qua DELETE, nơi có câu xác
290
+ // nhận nói rõ là cả nhánh cùng ngừng.
291
+ if (body?.isActive === true) {
292
+ const count = await reactivateSubtree(deps.prisma, id);
293
+ if (Object.keys(data).length > 0) {
294
+ await deps.prisma.workspace.update({ where: { id }, data });
295
+ }
296
+ return json({ reactivated: count });
297
+ }
238
298
  // `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
239
299
  // làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
240
300
 
@@ -329,6 +389,33 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
329
389
  };
330
390
  }
331
391
 
392
+ /**
393
+ * Các không gian một người ĐANG thuộc về, do core tự đọc.
394
+ *
395
+ * Không phải trang trí: phạm vi đọc của một người là HỢP các không gian họ
396
+ * thuộc về, nên "còn ở đâu nữa" là thứ quyết định việc gán này có cô lập
397
+ * được họ hay không. Thiếu cột đó thì người gán bấm "Thêm" và tưởng đã xong.
398
+ */
399
+ const membershipSelect = {
400
+ userWorkspaces: {
401
+ select: { workspaceId: true, workspace: { select: { name: true } } },
402
+ },
403
+ };
404
+
405
+ /** Ghép nhãn danh tính vào một dòng user; `skipId` bỏ chính không gian đang mở. */
406
+ function describeUser(user: any, skipId?: string) {
407
+ const extra = deps.memberIdentity?.describe(user) ?? {};
408
+ return {
409
+ code: extra.code ?? null,
410
+ branch: extra.branch ?? null,
411
+ roles: extra.roles ?? [],
412
+ workspaces: (user.userWorkspaces ?? [])
413
+ .filter((m: any) => m.workspaceId !== skipId)
414
+ .map((m: any) => m.workspace?.name)
415
+ .filter(Boolean) as string[],
416
+ };
417
+ }
418
+
332
419
  async function GET(req: Request, ctx: Ctx) {
333
420
  try {
334
421
  const actor = await resolveActor(deps);
@@ -353,26 +440,64 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
353
440
  // Ứng viên chỉ lấy trong nhánh mình QUẢN TRỊ (adminOnly), không phải
354
441
  // nhánh mình xem được: xem được cả công ty mà thêm được ai cũng vào
355
442
  // nhánh mình thì D1 chỉ còn là trang trí.
443
+ // Gán hàng loạt cần nhìn thấy cả danh sách để tick, không phải 20 dòng
444
+ // đầu — nhưng vẫn có trần, danh sách vài nghìn dòng thì trình duyệt
445
+ // đơ chứ không giúp ai chọn nhanh hơn.
446
+ const take = Math.min(
447
+ Math.max(Number(url.searchParams.get("take")) || 20, 1),
448
+ 200,
449
+ );
450
+
451
+ /**
452
+ * "Lấy người đang ở bộ phận X" — trục lọc do CORE lo, không phải app.
453
+ *
454
+ * Bảng `userWorkspace` là của core, nên đây là trục duy nhất core tự
455
+ * dựng được. Nó cũng là cách người dùng thật mô tả việc mình đang làm
456
+ * ("chuyển cả tổ QC sang Kho"), trong khi `candidateFilter` của app lo
457
+ * những trục app mới biết (chi nhánh, vai trò).
458
+ */
459
+ const inWorkspaceId = url.searchParams.get("inWorkspaceId") ?? "";
460
+
356
461
  const rows = await deps.prisma.user.findMany({
357
462
  where: {
358
463
  AND: [
359
464
  memberScopeWhere(actor.scope, { adminOnly: true }),
360
465
  { userWorkspaces: { none: { workspaceId: id } } },
466
+ inWorkspaceId
467
+ ? { userWorkspaces: { some: { workspaceId: inWorkspaceId } } }
468
+ : {},
361
469
  query
362
470
  ? {
363
471
  OR: [
364
472
  { name: { contains: query, mode: "insensitive" } },
365
473
  { email: { contains: query, mode: "insensitive" } },
474
+ ...(deps.candidateSearch?.(query) ?? []),
366
475
  ],
367
476
  }
368
477
  : {},
478
+ (deps.candidateFilter?.(url.searchParams) ?? {}) as object,
369
479
  ],
370
480
  },
371
- select: { id: true, name: true, email: true, isActive: true },
481
+ select: {
482
+ id: true,
483
+ name: true,
484
+ email: true,
485
+ isActive: true,
486
+ ...membershipSelect,
487
+ ...(deps.memberIdentity?.select ?? {}),
488
+ },
372
489
  orderBy: { name: "asc" },
373
- take: 20,
490
+ take,
374
491
  });
375
- return json(rows);
492
+ return json(
493
+ rows.map((u: any) => ({
494
+ id: u.id,
495
+ name: u.name,
496
+ email: u.email,
497
+ isActive: u.isActive,
498
+ ...describeUser(u),
499
+ })),
500
+ );
376
501
  }
377
502
 
378
503
  const rows = await deps.prisma.userWorkspace.findMany({
@@ -381,7 +506,14 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
381
506
  isAdmin: true,
382
507
  isDefault: true,
383
508
  user: {
384
- select: { id: true, name: true, email: true, isActive: true },
509
+ select: {
510
+ id: true,
511
+ name: true,
512
+ email: true,
513
+ isActive: true,
514
+ ...membershipSelect,
515
+ ...(deps.memberIdentity?.select ?? {}),
516
+ },
385
517
  },
386
518
  },
387
519
  orderBy: [{ isAdmin: "desc" }, { user: { name: "asc" } }],
@@ -394,6 +526,9 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
394
526
  isActive: r.user.isActive,
395
527
  isAdmin: r.isAdmin,
396
528
  isDefault: r.isDefault,
529
+ // Bỏ chính nút đang mở ra khỏi danh sách "cũng ở": ai cũng thuộc về
530
+ // nó, nhắc lại trên mọi dòng thì cột này không còn nói được gì.
531
+ ...describeUser(r.user, id),
397
532
  })),
398
533
  );
399
534
  } catch (error) {
@@ -411,6 +546,85 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
411
546
  if (denied) return denied;
412
547
 
413
548
  const body = await req.json();
549
+
550
+ // Đường GÁN HÀNG LOẠT. Tách hẳn khỏi đường một-người bên dưới vì ngữ
551
+ // nghĩa khác nhau: ở đây `mode: "move"` GỠ mọi membership khác của người
552
+ // đó. Phải gỡ thật — phạm vi là HỢP của các membership, nên "thêm vào bộ
553
+ // phận Kho" mà vẫn giữ membership gốc thì người đó vẫn thấy toàn hệ
554
+ // thống, tức là bấm gán xong mà không cô lập được gì.
555
+ if (Array.isArray(body?.userIds)) {
556
+ const userIds = [
557
+ ...new Set(
558
+ body.userIds
559
+ .filter((v: unknown) => typeof v === "string")
560
+ .map((v: string) => v.trim())
561
+ .filter(Boolean),
562
+ ),
563
+ ] as string[];
564
+ if (userIds.length === 0) {
565
+ return json({ error: "Chưa chọn người dùng nào." }, 400);
566
+ }
567
+ const move = body?.mode !== "add";
568
+
569
+ const failed: { userId: string; error: string }[] = [];
570
+ let changed = 0;
571
+
572
+ for (const target0 of userIds) {
573
+ const target = await loadTarget(target0);
574
+ if (!target) {
575
+ failed.push({
576
+ userId: target0,
577
+ error: "Không tìm thấy người dùng.",
578
+ });
579
+ continue;
580
+ }
581
+ const next = move ? [id] : [...new Set([...target.workspaceIds, id])];
582
+ try {
583
+ assertCanUpdateUser(toDelegationActor(actor), target, {
584
+ workspaceIds: next,
585
+ });
586
+ } catch (error) {
587
+ failed.push({
588
+ userId: target0,
589
+ error:
590
+ error instanceof DelegationError
591
+ ? error.message
592
+ : "Không cập nhật được.",
593
+ });
594
+ continue;
595
+ }
596
+
597
+ await deps.prisma.$transaction(async (tx: any) => {
598
+ if (move) {
599
+ await tx.userWorkspace.deleteMany({
600
+ where: { userId: target0, NOT: { workspaceId: id } },
601
+ });
602
+ } else {
603
+ await tx.userWorkspace.updateMany({
604
+ where: {
605
+ userId: target0,
606
+ isDefault: true,
607
+ NOT: { workspaceId: id },
608
+ },
609
+ data: { isDefault: false },
610
+ });
611
+ }
612
+ await tx.userWorkspace.upsert({
613
+ where: {
614
+ userId_workspaceId: { userId: target0, workspaceId: id },
615
+ },
616
+ // Không đụng `isAdmin` của người đã ở sẵn trong nút: gán hàng loạt
617
+ // mà hạ cờ quản trị của họ là một tác dụng phụ không ai yêu cầu.
618
+ create: { userId: target0, workspaceId: id, isDefault: true },
619
+ update: { isDefault: true },
620
+ });
621
+ });
622
+ changed += 1;
623
+ }
624
+
625
+ return json({ changed, failed, mode: move ? "move" : "add" });
626
+ }
627
+
414
628
  const userId = String(body?.userId ?? "").trim();
415
629
  if (!userId) return json({ error: "Thiếu userId." }, 400);
416
630
 
@@ -66,6 +66,8 @@ export function createWorkspaceScope(input: {
66
66
  allowedIds?: string[];
67
67
  adminIds?: string[];
68
68
  defaultId?: string;
69
+ level?: ScopeLevel;
70
+ userId?: string;
69
71
  }): WorkspaceScope {
70
72
  if (input.canViewAll) {
71
73
  return {
@@ -73,6 +75,8 @@ export function createWorkspaceScope(input: {
73
75
  rootIds: input.rootIds ?? [],
74
76
  adminIds: input.adminIds ?? [],
75
77
  defaultId: input.defaultId,
78
+ level: input.level,
79
+ userId: input.userId,
76
80
  };
77
81
  }
78
82
  const allowed =
@@ -86,6 +90,8 @@ export function createWorkspaceScope(input: {
86
90
  allowedBranchIds: allowed,
87
91
  adminIds: input.adminIds ?? [],
88
92
  defaultId: input.defaultId,
93
+ level: input.level,
94
+ userId: input.userId,
89
95
  };
90
96
  }
91
97
 
@@ -97,8 +103,18 @@ export async function getWorkspaceScope<TSession>(
97
103
  session: TSession,
98
104
  ): Promise<WorkspaceScope> {
99
105
  const config = requireConfigured();
106
+ const userId = config.getUserId(session) ?? undefined;
107
+ // Nấc cô lập của vai trò. `all` mở toàn bộ, `none` đóng sạch — hai nấc này
108
+ // quyết định xong là không cần đọc membership nữa.
109
+ const level = (await config.getScopeLevel?.(session)) ?? undefined;
110
+ if (level === "all") {
111
+ return createWorkspaceScope({ canViewAll: true, level, userId });
112
+ }
113
+ if (level === "none") {
114
+ return createWorkspaceScope({ canViewAll: false, allowedIds: [], level, userId });
115
+ }
100
116
  if (config.canViewAll(session)) {
101
- return createWorkspaceScope({ canViewAll: true });
117
+ return createWorkspaceScope({ canViewAll: true, level, userId });
102
118
  }
103
119
 
104
120
  const memberships = await readMemberships(config, session);
@@ -109,7 +125,10 @@ export async function getWorkspaceScope<TSession>(
109
125
  const defaultId =
110
126
  memberships.find((m) => m.isDefault)?.workspaceId ?? rootIds[0];
111
127
 
112
- const allowedIds = await expand(config, rootIds);
128
+ // `workspace` = đúng nút được gán, KHÔNG bung con cháu. Gấp ngay vào
129
+ // `allowedIds` ở đây thay vì bắt từng trang nhớ gọi `scopeLevelWhere`.
130
+ const allowedIds =
131
+ level === "workspace" ? rootIds : await expand(config, rootIds);
113
132
  // Quản trị viên của nhánh nào thì quản trị được cả con cháu nhánh đó — cùng
114
133
  // luật cộng dồn với phạm vi đọc, nếu không thì admin nút cha lại không đụng
115
134
  // được phòng ban con của chính mình.
@@ -122,6 +141,8 @@ export async function getWorkspaceScope<TSession>(
122
141
  allowedIds,
123
142
  adminIds,
124
143
  defaultId,
144
+ level,
145
+ userId,
125
146
  });
126
147
  }
127
148
 
@@ -252,6 +273,8 @@ export function memberScopeWhere(
252
273
  scope: WorkspaceScope,
253
274
  options?: { relation?: string; field?: string; adminOnly?: boolean },
254
275
  ): Record<string, unknown> {
276
+ // Nấc `own` trên chính bảng NGƯỜI DÙNG = chỉ thấy hồ sơ của mình.
277
+ if (scope.level === "own") return { id: scope.userId ?? NO_WORKSPACE_ACCESS };
255
278
  if (scope.canViewAll) return {};
256
279
  const config = configured;
257
280
  const relation =
@@ -274,8 +297,23 @@ export function memberScopeWhere(
274
297
  */
275
298
  export function scopedWhere(
276
299
  scope: WorkspaceScope,
277
- options?: { field?: string; relation?: string },
300
+ options?: { field?: string; relation?: string; ownerField?: string },
278
301
  ): Record<string, unknown> {
302
+ // Nấc `own` là nấc DUY NHẤT không gấp được vào `allowedIds` — nó lọc theo
303
+ // người chứ không theo không gian. Không khai `ownerField` (ở đây hoặc trong
304
+ // `configureWorkspaces`) thì đóng lại: fail-closed, đừng lặng lẽ nới thành
305
+ // `subtree`.
306
+ if (scope.level === "own") {
307
+ const ownerField = options?.ownerField ?? configured?.ownerField;
308
+ if (options?.relation) {
309
+ return ownerField
310
+ ? { [options.relation]: { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS } }
311
+ : { [options.relation]: { id: NO_WORKSPACE_ACCESS } };
312
+ }
313
+ return ownerField
314
+ ? { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS }
315
+ : { id: NO_WORKSPACE_ACCESS };
316
+ }
279
317
  if (scope.canViewAll) return {};
280
318
  const field = options?.field ?? workspaceScopeField();
281
319
  const ids = scope.allowedIds ?? [NO_WORKSPACE_ACCESS];
@@ -255,6 +255,28 @@ export async function deactivateSubtree(
255
255
  return nodes.length;
256
256
  }
257
257
 
258
+ /**
259
+ * Bật lại cả nhánh — đối xứng với `deactivateSubtree`.
260
+ *
261
+ * Phải có, nếu không thì "xoá" là một chiều: nút đã ngừng nằm mãi trong danh
262
+ * sách với chữ "(ngừng)" và không đường nào đưa về. Bật lại CẢ NHÁNH chứ không
263
+ * riêng nút được bấm: nhánh tắt theo nhau thì cũng phải bật theo nhau, bật mỗi
264
+ * nút cha để lại một cây nửa sống nửa chết mà giao diện không nói ra được.
265
+ */
266
+ export async function reactivateSubtree(
267
+ db: WorkspaceServiceDb,
268
+ rootId: string,
269
+ ): Promise<number> {
270
+ const nodes = await listSubtree(db, rootId);
271
+ for (const node of nodes) {
272
+ await db.workspace.update({
273
+ where: { id: node.id },
274
+ data: { isActive: true },
275
+ });
276
+ }
277
+ return nodes.length;
278
+ }
279
+
258
280
  /** Dựng cây lồng nhau cho UI từ danh sách phẳng. */
259
281
  export interface WorkspaceTreeItem extends WorkspaceNode {
260
282
  children: WorkspaceTreeItem[];