@goplusvn/core 0.1.75 → 0.1.77

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 (39) 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/user/__tests__/user-service-scope.test.ts +148 -0
  17. package/src/user/components/unified-profile-dialog.tsx +160 -0
  18. package/src/user/pages/users-client-page.tsx +12 -0
  19. package/src/user/user-service.ts +64 -10
  20. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  21. package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
  22. package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
  23. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  24. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  25. package/src/workspace/components/scope-level-select.tsx +91 -0
  26. package/src/workspace/components/workspace-members-panel.tsx +454 -0
  27. package/src/workspace/components/workspace-org-block.tsx +293 -0
  28. package/src/workspace/components/workspace-switcher.tsx +139 -0
  29. package/src/workspace/components/workspace-tree-view.tsx +301 -0
  30. package/src/workspace/context.ts +78 -0
  31. package/src/workspace/delegation.ts +400 -0
  32. package/src/workspace/guard.ts +138 -0
  33. package/src/workspace/index.ts +173 -0
  34. package/src/workspace/pages/workspace-list-page.tsx +802 -0
  35. package/src/workspace/route-handlers.ts +550 -0
  36. package/src/workspace/scope.ts +396 -0
  37. package/src/workspace/service.ts +301 -0
  38. package/src/workspace/tree.ts +193 -0
  39. package/src/workspace/types.ts +182 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.76 — Multi-tenant + workspace: một cây tổ chức/chi nhánh/phòng ban, uỷ quyền cấp tài khoản trong phạm vi
4
+
5
+ Nguồn: `docs/WORKSPACE-MULTITENANT-PLAN.md` + `docs/WORKSPACE-RESEARCH-IAM.md`
6
+ (khảo sát Keycloak / AWS IAM / Azure AD / NetSuite / Odoo trước khi chọn hình
7
+ dạng). Bài toán: một dự án có nhóm VẬN HÀNH toàn quyền và các nhóm KHÁCH HÀNG
8
+ chỉ được tạo/phân quyền người dùng trong không gian của mình — trục không gian
9
+ tuỳ dự án là tổ chức, chi nhánh, hay phòng ban.
10
+
11
+ **Toàn bộ đợt này là ADDITIVE.** Không đổi, không xoá API cũ; app đang chạy
12
+ không phải sửa dòng nào.
13
+
14
+ **Core — feature `workspaces` (mới):**
15
+ - `features/workspaces/` — `schema.prisma` + 2 migration SQL idempotent
16
+ (`0001_init`, `0002_delegation-columns`). Bảng `workspaces` (materialized
17
+ path) + `user_workspaces` (`is_admin`, `is_default`).
18
+ - **MỘT cây, hai độ sâu** thay vì ba cơ chế theo ba trục: nhãn trục là `kind`
19
+ do app khai, độ sâu là `depth`. Spartronics dừng ở đơn vị, thingtodo đi
20
+ thêm một tầng phòng ban, vinhhoa nếu dùng thì là chi nhánh — cùng một engine.
21
+ - `path`/`depth` CHỈ service được ghi (`createWorkspace`/`moveWorkspace`).
22
+ Ghi tay bằng `prisma.workspace.create` là cây vẫn hiện đúng trên UI trong
23
+ khi phạm vi đã hỏng — im lặng, không exception.
24
+ - `WorkspaceScope` = `canViewAll` + `rootIds` + `allowedIds?` + `adminIds` +
25
+ `defaultId?`. Không có quyền nào → sentinel `NO_WORKSPACE_ACCESS`, KHÔNG
26
+ phải `in: []` (Prisma coi `in: []` là "không lọc" ở vài dạng lồng nhau).
27
+ - `delegation.ts` — 7 bất biến D1–D7 chống leo thang: không tự nâng mình, không
28
+ cấp quyền mình không có, không đẩy nhánh sang tổ chức khác, không sửa người ở
29
+ ngoài phạm vi… có test cho từng cái.
30
+ - `route-handlers.ts` — GET/POST/PATCH/DELETE + `move`, tự cắt `path`/`parentId`
31
+ khỏi payload. App bọc bằng cổng RBAC của mình: hai tầng gác chồng lên nhau
32
+ (cổng gác VAI, handler gác PHẠM VI) là CỐ Ý, bỏ tầng nào cũng hở.
33
+ - UI: `WorkspaceListPage`, `WorkspaceTreeView`, `WorkspaceSwitcher`,
34
+ `ScopeLevelSelect`, `buildTree`.
35
+
36
+ **Core — vá rò rỉ có thật ở danh sách người dùng:**
37
+ - `user-service.ts` nhận `memberScopeWhere(scope)`. Trước đây trang Người dùng
38
+ trả về TOÀN BỘ user của hệ thống cho mọi vai có `user:view` — với mô hình
39
+ nhiều khách hàng dùng chung một app thì đó là danh bạ của khách hàng khác.
40
+ - `memberScopeWhere` CỐ Ý **không có nhánh `null`**: tài khoản chưa gán không
41
+ gian nào là tài khoản hệ thống/cấp trên, lọt ra là lọt đúng thứ không được lọt.
42
+
43
+ **Core — branch-scope dùng chung một AsyncLocalStorage với workspace:**
44
+ - `branch-scope/context.ts` giữ NGUYÊN tên và hành vi 4 hàm cũ, nhưng thân dời
45
+ sang `workspace/context.ts`. Hai store riêng thì `runWithoutBranchScope()`
46
+ chỉ thoát được một store, store kia vẫn lọc, `MAX(số phiếu)` lại bị cắt theo
47
+ phạm vi ⇒ **trùng số phiếu** (sự cố RS-260727-0003).
48
+
49
+ **Core — guardrails:**
50
+ - Luật `one-door/workspace`: chỉ file cửa được `import "@goerp/core/workspace"`.
51
+ - Luật `auth/server-action-bare-session`: cấm `getSession()` trần trong file
52
+ `"use server"` — phạm vi phải đi qua helper dùng chung, không chép ba dòng ở
53
+ mỗi action.
54
+ - `primitives.ts`/`scanner.ts`/`types.ts` nhận luật theo thư mục cửa.
55
+
56
+ **Core — đóng gói:**
57
+ - `exports` thêm `./workspace/route-handlers`. Wildcard `./*` map sang
58
+ `./src/*/index.ts` nên subpath là FILE (không phải thư mục có index) buộc
59
+ phải khai tường minh — y như `./rbac/route-handlers` đã phải khai.
60
+
3
61
  ## 0.1.71 — Bịt gap từ pilot init spartronics: cron giờ thật, đăng nhập username, proxy publicPrefixes, template hết vấp
4
62
 
5
63
  Nguồn: docs/INIT-PILOT-SPARTRONICS-GAPS.md (dựng app thật từ goerp-init 0.1.70
@@ -37,16 +37,20 @@ if (command === "status") {
37
37
  for (const s of f.steps.filter((s) => !s.materialized)) {
38
38
  console.log(` ↳ thiếu bước ${s.num}_${s.slug}`);
39
39
  }
40
+ for (const file of f.ignored ?? []) {
41
+ console.log(` ⚠ BỎ QUA ${file} — tên sai quy ước NNN_slug.sql (slug chỉ a-z0-9-)`);
42
+ }
40
43
  }
41
44
  process.exit(0);
42
45
  }
43
46
 
44
47
  if (command === "sync") {
45
48
  const actions = syncFeatures({ featuresRoot, appRoot, only });
46
- if (actions.length === 0) {
49
+ if (actions.filter((a) => a.type !== "ignored").length === 0) {
47
50
  console.log("✓ Không có gì để sync — app đã đủ schema + migrations.");
48
51
  } else {
49
52
  for (const a of actions) {
53
+ if (a.type === "ignored") continue;
50
54
  if (a.type === "schema") console.log(`✓ schema ${a.feature} → ${a.target}`);
51
55
  else console.log(`✓ migrate ${a.feature} + ${a.step} → prisma/migrations/${a.dir}`);
52
56
  }
@@ -54,6 +58,12 @@ if (command === "sync") {
54
58
  "\nTiếp theo: pnpm prisma migrate deploy && pnpm prisma generate"
55
59
  );
56
60
  }
61
+ // In sau cùng để không bị cuộn mất giữa danh sách "✓".
62
+ for (const a of actions.filter((a) => a.type === "ignored")) {
63
+ console.error(
64
+ `⚠ ${a.feature}: BỎ QUA ${a.file} — tên sai quy ước NNN_slug.sql (slug chỉ a-z0-9-). Bước này SẼ KHÔNG chạy.`
65
+ );
66
+ }
57
67
  process.exit(0);
58
68
  }
59
69
 
@@ -0,0 +1,72 @@
1
+ # feature `workspaces`
2
+
3
+ Cây không gian làm việc + membership + cột uỷ quyền. **Opt-in** — app nào chưa
4
+ cần thì không sync, không có bảng, không ảnh hưởng gì.
5
+
6
+ ```bash
7
+ pnpm goerp-features sync --only workspaces
8
+ pnpm prisma migrate deploy && pnpm prisma generate
9
+ ```
10
+
11
+ ## Bước thủ công (bắt buộc, sync không làm hộ)
12
+
13
+ `sync` copy được `schema.prisma` fragment vì `Workspace`/`UserWorkspace` là model
14
+ MỚI. Ba bảng còn lại (`users`, `roles`, `user_roles`) do **app sở hữu** — Prisma
15
+ cấm khai trùng model, nên bước 0002 chỉ chạy SQL, còn model thì app tự thêm:
16
+
17
+ ```prisma
18
+ model User {
19
+ // … giữ nguyên
20
+ isProtected Boolean @default(false) @map("is_protected")
21
+ permissionCeilingRoleId String? @map("permission_ceiling_role_id")
22
+ userWorkspaces UserWorkspace[] // ← back-relation BẮT BUỘC
23
+ }
24
+
25
+ model Role {
26
+ // … giữ nguyên
27
+ workspaceId String? @map("workspace_id")
28
+ rank Int @default(100)
29
+ isSystem Boolean @default(false)
30
+ }
31
+
32
+ model UserRole {
33
+ // … giữ nguyên
34
+ workspaceId String? @map("workspace_id") // W7 — khai từ đầu, enforce sau
35
+ }
36
+ ```
37
+
38
+ Thiếu back-relation `userWorkspaces` thì `prisma generate` báo lỗi quan hệ.
39
+
40
+ ## Cấu hình runtime
41
+
42
+ Composition root của app (cùng chỗ gọi `configureBranchScope`):
43
+
44
+ ```ts
45
+ import { configureWorkspaces } from "@goerp/core/workspace"
46
+
47
+ configureWorkspaces<Session>({
48
+ kinds: [
49
+ { key: "unit", label: "Đơn vị", childKinds: ["department"] },
50
+ { key: "department", label: "Bộ phận", canHaveChildren: false },
51
+ ],
52
+ db, // đọc user_workspaces + workspaces
53
+ scopeField: "workspaceId", // cột mang phạm vi trên chứng từ
54
+ getUserId: (s) => s.user?.id,
55
+ canViewAll: (s) => checkPermission(s, "workspace", "view-all-workspaces"),
56
+ })
57
+ ```
58
+
59
+ App **chưa** có bảng này (mức 0) thì bỏ `db`, cắm `getMemberships` vào nguồn sẵn
60
+ có (`user_branches`): cây phẳng, hành vi giống hệt branch-scope hôm nay.
61
+
62
+ ## Seed
63
+
64
+ Feature không seed dữ liệu — cây tổ chức là dữ liệu nghiệp vụ của từng app. Tạo
65
+ nút bằng `createWorkspace()` của `@goerp/core/workspace` (nó tự ghi `path`/`depth`
66
+ và cưỡng chế trần độ sâu), **đừng INSERT tay** rồi tự đặt `path`.
67
+
68
+ ## Quyền
69
+
70
+ Feature không tự cấp quyền. App khai trong Permission Registry của mình rồi chạy
71
+ `rbac-sync` — tối thiểu `workspace:view|create|update|delete` và action
72
+ `view-all-workspaces`. **Chủ tự tick quyền**, sync không cấp.
@@ -0,0 +1,63 @@
1
+ -- goerp feature: workspaces — bước 0001 (idempotent).
2
+ --
3
+ -- Cây không gian làm việc + membership. YÊU CẦU: bảng "users" tồn tại (schema
4
+ -- auth/RBAC chung của goerp) và model User của app khai back-relation
5
+ -- `userWorkspaces UserWorkspace[]` — xem features/workspaces/README.md.
6
+ --
7
+ -- Không có FK nào trỏ tới bảng chứng từ: chứng từ giữ nguyên cột phạm vi sẵn có
8
+ -- (`branch_id` / `department_id` / `workspace_id`), engine đọc cột đó qua
9
+ -- `scopeField`. Nhờ vậy app lên feature này KHÔNG phải migrate một dòng dữ liệu
10
+ -- chứng từ nào.
11
+ CREATE TABLE IF NOT EXISTS "workspaces" (
12
+ "id" TEXT NOT NULL,
13
+ "code" TEXT NOT NULL,
14
+ "name" TEXT NOT NULL,
15
+ "kind" TEXT NOT NULL DEFAULT 'unit',
16
+ "parent_id" TEXT,
17
+ "path" TEXT NOT NULL,
18
+ "depth" INTEGER NOT NULL DEFAULT 1,
19
+ "is_active" BOOLEAN NOT NULL DEFAULT true,
20
+ "settings" JSONB,
21
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
+ "updated_at" TIMESTAMP(3) NOT NULL,
23
+
24
+ CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
25
+ );
26
+
27
+ CREATE UNIQUE INDEX IF NOT EXISTS "workspaces_code_key" ON "workspaces"("code");
28
+ -- Index chính của cả cơ chế: bung con cháu = `path LIKE '/x/%'`.
29
+ CREATE INDEX IF NOT EXISTS "workspaces_path_idx" ON "workspaces"("path");
30
+ CREATE INDEX IF NOT EXISTS "workspaces_parent_id_idx" ON "workspaces"("parent_id");
31
+ CREATE INDEX IF NOT EXISTS "workspaces_kind_idx" ON "workspaces"("kind");
32
+
33
+ CREATE TABLE IF NOT EXISTS "user_workspaces" (
34
+ "id" TEXT NOT NULL,
35
+ "user_id" TEXT NOT NULL,
36
+ "workspace_id" TEXT NOT NULL,
37
+ "is_default" BOOLEAN NOT NULL DEFAULT false,
38
+ "is_admin" BOOLEAN NOT NULL DEFAULT false,
39
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
40
+
41
+ CONSTRAINT "user_workspaces_pkey" PRIMARY KEY ("id")
42
+ );
43
+
44
+ CREATE UNIQUE INDEX IF NOT EXISTS "user_workspaces_user_id_workspace_id_key" ON "user_workspaces"("user_id", "workspace_id");
45
+ CREATE INDEX IF NOT EXISTS "user_workspaces_user_id_idx" ON "user_workspaces"("user_id");
46
+ CREATE INDEX IF NOT EXISTS "user_workspaces_workspace_id_idx" ON "user_workspaces"("workspace_id");
47
+
48
+ -- FK thêm rời để chạy lại vô hại (Postgres không có ADD CONSTRAINT IF NOT EXISTS).
49
+ DO $$
50
+ BEGIN
51
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_parent_id_fkey') THEN
52
+ ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_parent_id_fkey"
53
+ FOREIGN KEY ("parent_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
54
+ END IF;
55
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_workspaces_user_id_fkey') THEN
56
+ ALTER TABLE "user_workspaces" ADD CONSTRAINT "user_workspaces_user_id_fkey"
57
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
58
+ END IF;
59
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_workspaces_workspace_id_fkey') THEN
60
+ ALTER TABLE "user_workspaces" ADD CONSTRAINT "user_workspaces_workspace_id_fkey"
61
+ FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
62
+ END IF;
63
+ END $$;
@@ -0,0 +1,34 @@
1
+ -- goerp feature: workspaces — bước 0002 (idempotent). Cột cho phần UỶ QUYỀN
2
+ -- (bất biến D1–D7). Tất cả đều nullable hoặc có DEFAULT ⇒ app đang chạy không vỡ,
3
+ -- và khi chưa có dòng nào dùng thì hành vi y hệt hôm nay.
4
+ --
5
+ -- CHÚ Ý: ba bảng dưới đây do APP sở hữu (roles / user_roles / users), nên
6
+ -- `schema.prisma` fragment của feature KHÔNG khai lại chúng — Prisma cấm trùng
7
+ -- model. App phải tự thêm các trường tương ứng vào model của mình; xem
8
+ -- features/workspaces/README.md mục "Bước thủ công".
9
+
10
+ -- D2 — trần quyền kiểu AWS permissions boundary. Quyền hiệu lực = GIAO của
11
+ -- quyền vai trò và trần này, đánh giá MỖI request. Người tạo bị thu hồi quyền
12
+ -- sau đó thì user họ tạo cũng hẹp lại theo, chứ không giữ nguyên quyền cũ.
13
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "permission_ceiling_role_id" TEXT;
14
+
15
+ -- D6 — tài khoản được bảo vệ: admin nhánh KHÔNG đổi mật khẩu / vô hiệu hoá /
16
+ -- gỡ vai trò của tài khoản này, dù cùng workspace và dù D5 thoả.
17
+ ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_protected" BOOLEAN NOT NULL DEFAULT false;
18
+
19
+ -- Vai trò riêng của một workspace (null = vai trò hệ thống, dùng chung).
20
+ ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
21
+ -- Cấp bậc: càng NHỎ càng cao. SUPER_ADMIN = 0. Admin nhánh chỉ tạo/gán được
22
+ -- vai trò có rank LỚN HƠN rank của chính mình.
23
+ ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "rank" INTEGER NOT NULL DEFAULT 100;
24
+ -- Vai trò hệ thống: chỉ quản trị toàn cục sửa được.
25
+ ALTER TABLE "roles" ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false;
26
+
27
+ -- W7 — vai trò có hiệu lực ở đúng một workspace. KHAI CỘT TỪ ĐẦU, ENFORCE SAU:
28
+ -- thêm cột vào bảng gán quyền khi đã có dữ liệu thật là việc đau. null = hành vi
29
+ -- y hệt hôm nay (hiệu lực ở MỌI membership).
30
+ ALTER TABLE "user_roles" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
31
+
32
+ CREATE INDEX IF NOT EXISTS "roles_workspace_id_idx" ON "roles"("workspace_id");
33
+ CREATE INDEX IF NOT EXISTS "user_roles_workspace_id_idx" ON "user_roles"("workspace_id");
34
+ CREATE INDEX IF NOT EXISTS "users_permission_ceiling_role_id_idx" ON "users"("permission_ceiling_role_id");
@@ -0,0 +1,56 @@
1
+ /// Cây KHÔNG GIAN LÀM VIỆC — đơn vị / tổ chức / chi nhánh / phòng ban là MỘT
2
+ /// cây, khác nhau ở nhãn `kind` và độ sâu, không phải bốn cơ chế song song.
3
+ ///
4
+ /// `path` là materialized path `/{ancestor}/…/{self}/` — bung con cháu bằng một
5
+ /// câu `path LIKE '/x/%'` đi được index btree, không đệ quy runtime, không CTE.
6
+ /// **Service ghi cột này, KHÔNG cho sửa tay**: đổi cha mà quên ghi lại path cho
7
+ /// cả nhánh con thì cây trông đúng trên UI mà phạm vi thì sai.
8
+ model Workspace {
9
+ id String @id @default(cuid()) @map("id")
10
+ code String @unique @map("code")
11
+ name String @map("name")
12
+ /// "org" | "unit" | "branch" | "department" | app tự thêm. Chỉ là NHÃN hiển
13
+ /// thị + luật cấu hình, không đổi logic phạm vi.
14
+ kind String @default("unit") @map("kind")
15
+ parentId String? @map("parent_id")
16
+ path String @map("path")
17
+ depth Int @default(1) @map("depth")
18
+ isActive Boolean @default(true) @map("is_active")
19
+ /// Cấu hình riêng của đơn vị (logo, ca làm, chính sách…).
20
+ settings Json? @map("settings")
21
+ createdAt DateTime @default(now()) @map("created_at")
22
+ updatedAt DateTime @updatedAt @map("updated_at")
23
+
24
+ parent Workspace? @relation("WorkspaceTree", fields: [parentId], references: [id])
25
+ children Workspace[] @relation("WorkspaceTree")
26
+ members UserWorkspace[]
27
+
28
+ @@index([path])
29
+ @@index([parentId])
30
+ @@index([kind])
31
+ @@map("workspaces")
32
+ }
33
+
34
+ /// Membership `user ↔ workspace`.
35
+ ///
36
+ /// `isAdmin` trả lời "nhánh NÀO", quyền RBAC trả lời "được LÀM GÌ" — hai trục
37
+ /// vuông góc, cố ý không gộp. Có `isAdmin` mà không có quyền `user:manage` thì
38
+ /// vẫn không tạo được tài khoản, và ngược lại.
39
+ model UserWorkspace {
40
+ id String @id @default(cuid()) @map("id")
41
+ userId String @map("user_id")
42
+ workspaceId String @map("workspace_id")
43
+ /// Không gian mở sẵn khi đăng nhập / gán cho bản ghi mới tạo.
44
+ isDefault Boolean @default(false) @map("is_default")
45
+ /// Quản trị viên của nhánh này (uỷ quyền cấp tài khoản trong phạm vi).
46
+ isAdmin Boolean @default(false) @map("is_admin")
47
+ createdAt DateTime @default(now()) @map("created_at")
48
+
49
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
50
+ workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
51
+
52
+ @@unique([userId, workspaceId])
53
+ @@index([userId])
54
+ @@index([workspaceId])
55
+ @@map("user_workspaces")
56
+ }
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.75",
4
+ "version": "0.1.77",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -54,6 +54,7 @@
54
54
  "./import": "./src/import/index.ts",
55
55
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
56
56
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
57
+ "./workspace/route-handlers": "./src/workspace/route-handlers.ts",
57
58
  "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
58
59
  "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
59
60
  "./errors/app-error": "./src/errors/app-error.ts",
@@ -23,19 +23,38 @@
23
23
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
 
26
- /** features/<name>/migrations/NNN_slug.sql [{num, slug, file}] theo thứ tự. */
26
+ const STEP_NAME = /^(\d{3,})_([a-z0-9-]+)\.sql$/;
27
+
28
+ /**
29
+ * features/<name>/migrations/NNN_slug.sql → [{num, slug, file}] theo thứ tự.
30
+ *
31
+ * Slug chỉ nhận `a-z0-9-` (KHÔNG gạch dưới) vì tên bước đi thẳng vào tên thư mục
32
+ * migration của app. File `.sql` đặt sai tên bị BỎ QUA — và bỏ qua im lặng là
33
+ * bẫy thật sự: bước tạo bảng chạy, bước thêm cột thì không, app deploy xanh mà
34
+ * cột không tồn tại. Đã dính đúng ca này với `0002_delegation_columns.sql`.
35
+ * Vì vậy trả kèm `ignored` để CLI kêu to.
36
+ */
27
37
  export function listFeatureSteps(featureDir) {
28
38
  const dir = join(featureDir, "migrations");
29
39
  if (!existsSync(dir)) return [];
30
40
  return readdirSync(dir)
31
- .filter((f) => /^\d{3,}_[a-z0-9-]+\.sql$/.test(f))
41
+ .filter((f) => STEP_NAME.test(f))
32
42
  .sort()
33
43
  .map((f) => {
34
- const m = f.match(/^(\d{3,})_([a-z0-9-]+)\.sql$/);
44
+ const m = f.match(STEP_NAME);
35
45
  return { num: m[1], slug: m[2], file: join(dir, f) };
36
46
  });
37
47
  }
38
48
 
49
+ /** File `.sql` nằm trong migrations/ nhưng sai quy ước tên — không bao giờ chạy. */
50
+ export function listIgnoredStepFiles(featureDir) {
51
+ const dir = join(featureDir, "migrations");
52
+ if (!existsSync(dir)) return [];
53
+ return readdirSync(dir)
54
+ .filter((f) => f.endsWith(".sql") && !STEP_NAME.test(f))
55
+ .sort();
56
+ }
57
+
39
58
  /** Tên mọi feature trong thư mục features/ của core. */
40
59
  export function listFeatures(featuresRoot) {
41
60
  if (!existsSync(featuresRoot)) return [];
@@ -93,6 +112,7 @@ export function featureStatus({ featuresRoot, appRoot }) {
93
112
  ...step,
94
113
  materialized: isStepMaterialized(migrationsDir, feature, step),
95
114
  })),
115
+ ignored: listIgnoredStepFiles(featureDir),
96
116
  };
97
117
  });
98
118
  }
@@ -110,6 +130,14 @@ const GENERATED_HEADER = (feature) =>
110
130
  */
111
131
  export function syncFeatures({ featuresRoot, appRoot, now = new Date(), only = [] }) {
112
132
  const actions = [];
133
+ // Kêu trước khi làm gì: file sai tên sẽ không bao giờ chạy, mà app vẫn deploy
134
+ // xanh — người dùng chỉ phát hiện khi một cột không tồn tại lúc runtime.
135
+ for (const feature of listFeatures(featuresRoot)) {
136
+ if (only.length > 0 && !only.includes(feature)) continue;
137
+ for (const file of listIgnoredStepFiles(join(featuresRoot, feature))) {
138
+ actions.push({ type: "ignored", feature, file });
139
+ }
140
+ }
113
141
  const migrationsDir = join(appRoot, "prisma", "migrations");
114
142
  let offset = 0;
115
143
 
@@ -1,4 +1,10 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
1
+ import {
2
+ getScopeContext,
3
+ resolveAmbientScope,
4
+ runWithScope,
5
+ runWithoutScope,
6
+ } from "../workspace/context";
7
+ import type { ScopeContext } from "../workspace/context";
2
8
 
3
9
  import type { BranchScope } from "./types";
4
10
 
@@ -6,61 +12,38 @@ import type { BranchScope } from "./types";
6
12
  * Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
7
13
  * xác thực; extension branch-guard đọc ở đây.
8
14
  *
9
- * Phạm vi được resolve LƯỜI lần đầu một model bị guard thực sự được đọc.
10
- * Request không đụng model nào bị guard thì không tốn thêm truy vấn nào.
15
+ * Thân của module này đã dời sang `workspace/context.ts` để `branch-scope`
16
+ * `workspace` dùng **chung đúng một AsyncLocalStorage**. Đây không phải dọn dẹp
17
+ * cho đẹp: hai store riêng thì `runWithoutBranchScope()` chỉ thoát được một
18
+ * store, store kia vẫn lọc, và `MAX(số phiếu)` lại bị cắt theo phạm vi ⇒ **trùng
19
+ * số phiếu** (sự cố có thật RS-260727-0003).
11
20
  *
12
- * File này cố ý không import phần scope: composition root của app tiêm hàm
13
- * `resolve` vào lúc mở context, nhờ vậy context không kéo theo prisma và không
14
- * tạo vòng import.
21
+ * API dưới đây giữ nguyên tên hành vi app không phải sửa một dòng nào.
15
22
  */
16
23
 
17
- export interface BranchScopeContext {
18
- resolve: () => Promise<BranchScope>;
19
- /** Cache trong phạm vi 1 request — extension gán ở lần resolve đầu. */
20
- cached?: Promise<BranchScope>;
21
- }
22
-
23
- const store = new AsyncLocalStorage<BranchScopeContext>();
24
-
25
- /**
26
- * PrismaPromise là LƯỜI: truy vấn chỉ thật sự chạy ở lần `.then()` đầu tiên.
27
- * `run(ctx, () => db.receipt.count())` trả promise chưa chạy ra ngoài, người gọi
28
- * `await` bên ngoài → truy vấn chạy NGOÀI context → guard im lặng không lọc
29
- * (và với `exit` thì ngược lại: truy vấn cần toàn cục lại bị lọc → trùng số
30
- * phiếu). Gọi `.then` ngay tại đây, khi còn ở trong/ngoài context đúng như ý,
31
- * để việc chạy được ghim vào đúng phạm vi — bất kể người gọi await ở đâu.
32
- */
33
- function pinToCurrentContext<T>(result: T): T {
34
- const thenable = result as { then?: unknown };
35
- if (typeof thenable?.then !== "function") return result;
36
- return (result as unknown as Promise<unknown>).then((value) => value) as T;
37
- }
24
+ export type BranchScopeContext = ScopeContext<BranchScope>;
38
25
 
39
26
  export function runWithBranchScope<T>(
40
27
  context: BranchScopeContext,
41
28
  fn: () => T | Promise<T>,
42
29
  ): T | Promise<T> {
43
- return store.run(context, () => pinToCurrentContext(fn()));
30
+ return runWithScope<T, BranchScope>(context, fn);
44
31
  }
45
32
 
46
33
  export function getBranchScopeContext(): BranchScopeContext | undefined {
47
- return store.getStore();
34
+ return getScopeContext() as BranchScopeContext | undefined;
48
35
  }
49
36
 
50
37
  /** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
51
38
  export function resolveAmbientBranchScope(): Promise<BranchScope> | undefined {
52
- const ctx = store.getStore();
53
- if (!ctx) return undefined;
54
- ctx.cached ??= ctx.resolve();
55
- return ctx.cached;
39
+ return resolveAmbientScope<BranchScope>();
56
40
  }
57
41
 
58
42
  /**
59
43
  * Chạy `fn` NGOÀI lưới guard — cho truy vấn hạ tầng mà kết quả phải toàn cục
60
- * bất kể phạm vi user. Ca kinh điển: đánh số phiếu (`MAX(number)` bị lọc theo
61
- * chi nhánh sinh trùng số lỗi unique). Mọi `await` bên trong đều thoát
62
- * guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
44
+ * bất kể phạm vi user. Ca kinh điển: đánh số phiếu. Mọi `await` bên trong đều
45
+ * thoát guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
63
46
  */
64
47
  export function runWithoutBranchScope<T>(fn: () => T): T {
65
- return store.exit(() => pinToCurrentContext(fn()));
48
+ return runWithoutScope(fn);
66
49
  }
@@ -126,6 +126,47 @@ describe("featureStatus", () => {
126
126
  })
127
127
  })
128
128
 
129
+ describe("file .sql sai quy ước tên", () => {
130
+ // Ca có thật: `0002_delegation_columns.sql` (gạch DƯỚI) bị bỏ qua im lặng, nên
131
+ // bảng thì được tạo mà cột uỷ quyền thì không — app deploy xanh, lỗi chỉ lộ ra
132
+ // lúc runtime. Bỏ qua thì vẫn bỏ qua, nhưng phải KÊU.
133
+ it("không materialize, nhưng có mặt trong `ignored` và trong log sync", () => {
134
+ writeFeature(
135
+ "f-a",
136
+ { "0001_init.sql": "SELECT 1;", "0002_bad_name.sql": "SELECT 2;" },
137
+ "model A { id String @id }"
138
+ )
139
+
140
+ const status = featureStatus({ featuresRoot, appRoot })
141
+ const feature = status.find((f: { feature: string }) => f.feature === "f-a")!
142
+ expect(feature.steps.map((s: { slug: string }) => s.slug)).toEqual(["init"])
143
+ expect(feature.ignored).toEqual(["0002_bad_name.sql"])
144
+
145
+ const actions = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
146
+ expect(
147
+ actions.filter((a: { type: string }) => a.type === "migration")
148
+ ).toHaveLength(1)
149
+ expect(
150
+ actions.find((a: { type: string }) => a.type === "ignored")
151
+ ).toMatchObject({ feature: "f-a", file: "0002_bad_name.sql" })
152
+ })
153
+
154
+ it("tên đúng quy ước (gạch ngang) thì chạy bình thường", () => {
155
+ writeFeature(
156
+ "f-b",
157
+ { "0001_init.sql": "SELECT 1;", "0002_delegation-columns.sql": "SELECT 2;" },
158
+ "model B { id String @id }"
159
+ )
160
+ const status = featureStatus({ featuresRoot, appRoot })
161
+ const feature = status.find((f: { feature: string }) => f.feature === "f-b")!
162
+ expect(feature.steps.map((s: { slug: string }) => s.slug)).toEqual([
163
+ "init",
164
+ "delegation-columns",
165
+ ])
166
+ expect(feature.ignored).toEqual([])
167
+ })
168
+ })
169
+
129
170
  describe("migrationTimestamp", () => {
130
171
  it("dạng YYYYMMDDHHMMSS UTC + offset giây", () => {
131
172
  const now = new Date("2026-07-31T01:02:03Z")
@@ -121,6 +121,53 @@ describe("rule bắt được vi phạm thật", () => {
121
121
  );
122
122
  });
123
123
 
124
+ it("one-door/workspace — import thẳng bị bắt; cửa branch-scope cũng là cửa hợp lệ", () => {
125
+ const leaky = fixture({
126
+ "lib/branch-scope.ts":
127
+ 'import { configureWorkspaces } from "@goerp/core/workspace"\nconfigureWorkspaces({ getUserId: (s) => s.id, canViewAll: () => false })',
128
+ "app/(main)/don-hang/page.tsx":
129
+ 'import { getWorkspaceScope } from "@goerp/core/workspace"\nexport default function Page() { return null }',
130
+ });
131
+ expect(resultOf(run(leaky), "one-door/workspace").violations).toEqual([
132
+ "app/(main)/don-hang/page.tsx",
133
+ ]);
134
+
135
+ // Dùng lớp 1 (getWorkspaceScope) mà cửa quên cấu hình singleton → bắt.
136
+ const unconfigured = fixture({
137
+ "lib/branch-scope.ts":
138
+ 'export { getWorkspaceScope } from "@goerp/core/workspace"',
139
+ "app/api/x/route.ts": "const s = await getWorkspaceScope(session)",
140
+ });
141
+ expect(
142
+ resultOf(run(unconfigured), "one-door/workspace").violations.join(),
143
+ ).toContain("KHÔNG gọi configureWorkspaces()");
144
+ });
145
+
146
+ it("one-door/workspace — chỉ dùng lớp 2 / hàm cây thì không đòi configureWorkspaces", () => {
147
+ const root = fixture({
148
+ "lib/branch-scope.ts":
149
+ 'export { createScopeGuardExtension } from "@goerp/core/workspace"',
150
+ });
151
+ expect(resultOf(run(root), "one-door/workspace").violations).toEqual([]);
152
+ });
153
+
154
+ it("one-door/* — `import type` không tính là đi cửa sau, nhưng `import { type X }` thì có", () => {
155
+ const root = fixture({
156
+ "lib/branch-scope.ts":
157
+ 'import { configureWorkspaces } from "@goerp/core/workspace"\nconfigureWorkspaces({ getUserId: (s) => s.id, canViewAll: () => false })',
158
+ // Chỉ mượn `interface` để chú kiểu — biên dịch xong là biến mất, không
159
+ // module nào bị nạp nên không thể bỏ lỡ configureWorkspaces().
160
+ "configs/workspace.config.ts":
161
+ 'import type { WorkspaceKindConfig } from "@goerp/core/workspace"\nexport const KINDS: WorkspaceKindConfig[] = []',
162
+ // Vẫn nạp module thật, chỉ là gắn `type` bên trong ngoặc → vẫn bắt.
163
+ "app/(main)/x/page.tsx":
164
+ 'import { buildTree, type WorkspaceNode } from "@goerp/core/workspace"\nexport default function Page() { return buildTree([]) }',
165
+ });
166
+ expect(resultOf(run(root), "one-door/workspace").violations).toEqual([
167
+ "app/(main)/x/page.tsx",
168
+ ]);
169
+ });
170
+
124
171
  it("auth/api-route-gated — không gác thì phải được KHAI công khai ở proxy.ts", () => {
125
172
  const root = fixture({
126
173
  "proxy.ts":
@@ -141,8 +141,21 @@ interface SingleDoorSpec {
141
141
  * theo lời gọi cấu hình — code chạy tới lúc thật mới nổ, và với branch-scope
142
142
  * thì "nổ" nghĩa là người chi nhánh này nhìn thấy dữ liệu chi nhánh khác.
143
143
  */
144
+ /**
145
+ * Bỏ các câu `import type { … } from "…"` trước khi soi.
146
+ *
147
+ * Import chỉ-kiểu bị xoá sạch lúc biên dịch: nó không kéo được module nào vào
148
+ * bundle, nên cũng không thể bỏ lỡ `configureX()` — cái mà rule này canh. Đếm
149
+ * nó thành vi phạm chỉ ép người ta hoặc bỏ chú kiểu, hoặc dựng một cửa vòng
150
+ * vèo chỉ để mượn một `interface`. Import THƯỜNG có kèm `type` bên trong ngoặc
151
+ * (`import { type X, y }`) vẫn tính — nó vẫn nạp module thật.
152
+ */
153
+ const TYPE_ONLY_IMPORT = /^\s*import\s+type\s[^\n]*$/gm;
154
+
144
155
  export function singleDoorImport(spec: SingleDoorSpec): GuardrailRule {
145
156
  const test = stateless(spec.pattern);
157
+ const readValueImports = (ctx: GuardrailContext, file: string) =>
158
+ ctx.readCode(file).replace(TYPE_ONLY_IMPORT, "");
146
159
  return {
147
160
  id: spec.id,
148
161
  title: spec.title,
@@ -157,7 +170,7 @@ export function singleDoorImport(spec: SingleDoorSpec): GuardrailRule {
157
170
  for (const file of ctx.files) {
158
171
  const rel = ctx.rel(file);
159
172
  if (doors.includes(rel)) continue;
160
- if (test.test(ctx.readCode(file))) offenders.push(rel);
173
+ if (test.test(readValueImports(ctx, file))) offenders.push(rel);
161
174
  }
162
175
 
163
176
  if (spec.mustConfigure && (spec.mustConfigureWhen?.(ctx) ?? true)) {