@goplusvn/core 0.1.58 → 0.1.60

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 (145) hide show
  1. package/PLATFORM.md +83 -0
  2. package/bin/goerp-init.mjs +141 -0
  3. package/package.json +4 -4
  4. package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
  5. package/src/branch-scope/context.ts +66 -0
  6. package/src/branch-scope/guard.ts +100 -0
  7. package/src/branch-scope/index.ts +42 -0
  8. package/src/branch-scope/scope.ts +149 -0
  9. package/src/branch-scope/types.ts +57 -0
  10. package/src/cron/db-cron-manager.ts +3 -3
  11. package/src/cron/index.ts +2 -2
  12. package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
  13. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  14. package/src/infrastructure/index.ts +4 -7
  15. package/src/ui/management/index.ts +3 -2
  16. package/templates/starter-app/.dockerignore +41 -0
  17. package/templates/starter-app/.env.example +25 -0
  18. package/templates/starter-app/AGENTS.md +52 -0
  19. package/templates/starter-app/Dockerfile +74 -0
  20. package/templates/starter-app/README.md +141 -0
  21. package/templates/starter-app/gitignore +9 -0
  22. package/templates/starter-app/next.config.mjs +50 -0
  23. package/templates/starter-app/package.json +55 -0
  24. package/templates/starter-app/postcss.config.mjs +5 -0
  25. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  26. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  27. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  28. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  29. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  30. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  31. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  32. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  33. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  34. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  35. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  36. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  37. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  38. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  39. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  40. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  41. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  42. package/templates/starter-app/prisma/seed.ts +127 -0
  43. package/templates/starter-app/prisma.config.ts +20 -0
  44. package/templates/starter-app/public/.gitkeep +2 -0
  45. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  46. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  54. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  55. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  56. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  57. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  58. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  59. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  60. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  61. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  62. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  63. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  64. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  66. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  67. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  68. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  69. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  70. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  71. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  72. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  73. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  74. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  75. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  76. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  77. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  78. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  79. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  80. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  81. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  82. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  83. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  84. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  85. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  86. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  87. package/templates/starter-app/src/app/globals.css +15 -0
  88. package/templates/starter-app/src/app/layout.tsx +16 -0
  89. package/templates/starter-app/src/app/page.tsx +8 -0
  90. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  91. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  92. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  93. package/templates/starter-app/src/configs/i18n.ts +12 -0
  94. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  95. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  96. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  97. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  98. package/templates/starter-app/src/configs/tenant.ts +18 -0
  99. package/templates/starter-app/src/data/dictionary.ts +8 -0
  100. package/templates/starter-app/src/data/navigations.ts +61 -0
  101. package/templates/starter-app/src/instrumentation.ts +105 -0
  102. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  103. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  104. package/templates/starter-app/src/lib/auth.ts +62 -0
  105. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  106. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  107. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  108. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  109. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  110. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  111. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  112. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  113. package/templates/starter-app/src/lib/logger.ts +30 -0
  114. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  115. package/templates/starter-app/src/lib/prisma.ts +80 -0
  116. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  117. package/templates/starter-app/src/lib/storage.ts +28 -0
  118. package/templates/starter-app/src/providers/index.tsx +54 -0
  119. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  120. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  121. package/templates/starter-app/src/proxy.ts +45 -0
  122. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  123. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  124. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  125. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  126. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  127. package/templates/starter-app/src/types/session.ts +29 -0
  128. package/templates/starter-app/tsconfig.json +47 -0
  129. package/templates/starter-app/vitest.config.ts +17 -0
  130. package/src/infrastructure/cron/index.ts +0 -6
  131. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  132. package/src/infrastructure/event-bus/index.ts +0 -2
  133. package/src/infrastructure/event-bus/types.ts +0 -22
  134. package/src/infrastructure/lock/decorators.ts +0 -67
  135. package/src/infrastructure/lock/index.ts +0 -2
  136. package/src/infrastructure/lock/lock-manager.ts +0 -33
  137. package/src/plugin/apps-registry.ts +0 -97
  138. package/src/plugin/index.ts +0 -5
  139. package/src/plugin/types.ts +0 -41
  140. package/src/ui/management/audit-log-page.tsx +0 -14
  141. package/src/ui/management/job-management.tsx +0 -308
  142. package/src/workflow/activity-timeline.tsx +0 -412
  143. package/src/workflow/approval-workflow.tsx +0 -31
  144. package/src/workflow/index.ts +0 -2
  145. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
package/PLATFORM.md CHANGED
@@ -10,6 +10,24 @@ implementation of every pattern here.
10
10
  > mechanism, or a CAS update into an app, stop — it belongs in core. Promote it
11
11
  > and re-export a thin shim from the app so call sites don't change.
12
12
 
13
+ ## Starting a new app
14
+
15
+ This package ships the starter app it documents, plus the scaffolder:
16
+
17
+ ```bash
18
+ npx -p @goplusvn/core goerp-init my-app --port 3010
19
+ cd my-app && pnpm install
20
+ # edit DATABASE_URL in the generated .env
21
+ pnpm prisma:deploy && pnpm seed && pnpm rbac-sync && pnpm dev
22
+ ```
23
+
24
+ `goerp-init` fills in the app name, port, branding (`src/configs/tenant.ts`) and
25
+ a random `BETTER_AUTH_SECRET`; it never touches a database. `seed` must run
26
+ **before** `rbac-sync` — see the generated `README.md` for why. What you get is
27
+ a working login, RBAC, CRUD, audit/error logs, notifications, background tasks
28
+ and cron — all from core, not copied code. Do not copy the template directory by
29
+ hand: the rename steps are the whole point of the command.
30
+
13
31
  ## Design system (styles)
14
32
 
15
33
  One import gives the whole token system; override only your brand.
@@ -255,6 +273,71 @@ until `clearCache()`. The public endpoint is used for presigning only —
255
273
  a presigned signature is bound to the host that signed it, so signing with the
256
274
  internal endpoint yields URLs the browser cannot use.
257
275
 
276
+ ## Branch scope
277
+
278
+ Multi-branch data visibility (`@goerp/core/branch-scope`). Two layers, on
279
+ purpose — the second exists because the first is something a new route can
280
+ simply forget.
281
+
282
+ **Layer 1 — explicit filtering.** Every page/route/service that lists records
283
+ does `const scope = await getBranchScope(session)` then spreads
284
+ `scopedBranchWhere(scope)` into its `where`. `scopedBranchWhere` returns `{}`
285
+ for view-all users, so there is no branching at the call site. Related helpers:
286
+ `canAccessBranch(scope, row.branchId)` for detail pages, `clampBranchFilter`
287
+ for a branch filter the client sent (out-of-scope selections collapse to the
288
+ sentinel — 0 rows, never "no filter"), `clampIdFilter` for branch-owned things
289
+ like warehouses. Records with `branchId: null` are treated as shared and stay
290
+ visible to everyone.
291
+
292
+ **Layer 2 — the safety net.** `createBranchGuardExtension({ models })` is a
293
+ Prisma extension that ANDs the branch condition onto read operations
294
+ (`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
295
+ for the declared models. It only acts inside a request context opened by the
296
+ app's api-handler, so RSC pages, cron, webhooks and scripts see unfiltered data
297
+ and must use layer 1. `findUnique*` is deliberately not guarded (its `where`
298
+ only takes unique fields) — guard detail pages with `canAccessBranch`.
299
+
300
+ Wiring, all in the app's composition root:
301
+
302
+ ```ts
303
+ // src/lib/branch-scope.ts — the ONE door; nothing else imports the core module
304
+ configureBranchScope<Session>({
305
+ getUserId: (session) => session.user?.id,
306
+ canViewAll: (session) => session.user.roles.includes("admin"),
307
+ getAllowedBranchIds: (session) => session.user.branches, // or pass `db` instead
308
+ })
309
+
310
+ // src/lib/prisma.ts
311
+ db = rawClient.$extends(createBranchGuardExtension({ models: ["Invoice"] }))
312
+
313
+ // src/lib/api-handler.ts (middleware) — resolve is lazy, so a request that
314
+ // never touches a guarded model costs nothing
315
+ runWithBranchScope({ resolve: () => getBranchScope(session) }, () => next())
316
+ ```
317
+
318
+ Either give `configureBranchScope` a `db` (it reads `user_branches`) or
319
+ `getAllowedBranchIds` when the session already carries the list.
320
+
321
+ Three traps, each of which has cost a real incident:
322
+
323
+ - **A guarded model MUST have a `branchId` column**, or you must route it
324
+ through a relation: `buildWhere: (model, scope) => model === "GoodsReceipt" ?
325
+ scopedBranchWhere(scope, "warehouse") : null`. Declare a model without the
326
+ column and Prisma throws a validation error on *every* read a scoped user
327
+ makes — a blank app, not a few missing rows.
328
+ - **Document numbering must escape the guard.** `MAX(number)` filtered by
329
+ branch produces duplicate numbers and a unique violation. Wrap those queries
330
+ in `runWithoutBranchScope(...)`.
331
+ - **"No branches assigned" means zero rows, not everything.** That is what
332
+ `NO_BRANCH_ACCESS` encodes; an empty `in: []` is ambiguous in Prisma and one
333
+ mistake there exposes the whole table.
334
+
335
+ `runWithBranchScope` / `runWithoutBranchScope` pin a returned thenable to the
336
+ context before handing it back, so `runWithoutBranchScope(() => db.doc.aggregate(…))`
337
+ works even though a PrismaPromise does not run until it is awaited — otherwise
338
+ the query would execute wherever the caller happened to await it, i.e. in the
339
+ wrong scope.
340
+
258
341
  ## Utils
259
342
 
260
343
  `@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // Khởi tạo một app mới từ template đi kèm gói này.
3
+ //
4
+ // npx -p @goplusvn/core goerp-init <ten-app> [--dir <đường-dẫn>] [--port 3010]
5
+ // pnpm init-app <ten-app> ... (từ trong repo goerp-core)
6
+ //
7
+ // Template nằm trong chính gói npm (packages/core/templates/starter-app) nên
8
+ // lệnh này chạy được ở máy trắng, không cần clone goerp-core.
9
+ //
10
+ // App mới là repo ĐỘC LẬP, không phải thành viên workspace của core: nó cài
11
+ // @goerp/core từ npm như mọi app thật, nên lỗi kiểu "chạy được nhờ workspace"
12
+ // không lọt qua được.
13
+ //
14
+ // Script chỉ chép + thay tên/cổng + sinh secret. Không chạy pnpm install,
15
+ // không đụng DB — những việc đó in ra cho người dùng tự chạy.
16
+
17
+ import { randomBytes } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ const templateDir = path.join(packageRoot, "templates", "starter-app");
24
+
25
+ // Thứ sinh ra lúc build/cài: chép sang chỉ tổ làm app mới hỏng theo cách khó hiểu.
26
+ // `.env` cũng bỏ — nó là bí mật của máy đang chép, app mới sinh secret riêng.
27
+ const SKIP = new Set([
28
+ "node_modules",
29
+ ".next",
30
+ ".turbo",
31
+ ".git",
32
+ ".env",
33
+ "tsconfig.tsbuildinfo",
34
+ "next-env.d.ts",
35
+ ]);
36
+
37
+ function fail(message) {
38
+ console.error(`✗ ${message}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ // ── Tham số ──────────────────────────────────────────────────────────────────
43
+ const argv = process.argv.slice(2);
44
+ const name = argv.find((a) => !a.startsWith("-"));
45
+ if (!name) fail("Thiếu tên app. Dùng: goerp-init <ten-app> [--dir <path>] [--port 3010]");
46
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
47
+ fail(`Tên "${name}" không hợp lệ — dùng kebab-case (chữ thường, số, gạch ngang).`);
48
+ }
49
+
50
+ const flag = (key, fallback) => {
51
+ const i = argv.indexOf(`--${key}`);
52
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
53
+ };
54
+
55
+ const port = Number(flag("port", "3010"));
56
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) fail(`Cổng không hợp lệ: ${port}`);
57
+
58
+ const targetDir = path.resolve(flag("dir", path.join(process.cwd(), name)));
59
+
60
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
61
+ fail(`${targetDir} đã tồn tại và không rỗng — chọn thư mục khác cho chắc.`);
62
+ }
63
+ if (!fs.existsSync(templateDir)) fail(`Không thấy template ở ${templateDir}`);
64
+
65
+ // ── Chép ─────────────────────────────────────────────────────────────────────
66
+ function copyDir(from, to) {
67
+ fs.mkdirSync(to, { recursive: true });
68
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
69
+ if (SKIP.has(entry.name)) continue;
70
+ const src = path.join(from, entry.name);
71
+ const dst = path.join(to, entry.name);
72
+ if (entry.isDirectory()) copyDir(src, dst);
73
+ else fs.copyFileSync(src, dst);
74
+ }
75
+ }
76
+ copyDir(templateDir, targetDir);
77
+
78
+ const read = (p) => fs.readFileSync(path.join(targetDir, p), "utf8");
79
+ const write = (p, content) => fs.writeFileSync(path.join(targetDir, p), content);
80
+
81
+ // npm/pnpm KHÔNG đóng gói file tên `.gitignore` (nuốt hoặc đổi thành
82
+ // `.npmignore` khi cài). Template ship nó dưới tên `gitignore`, trả lại dấu
83
+ // chấm ở đây — thiếu bước này thì `git add .` đầu tiên của app mới commit luôn
84
+ // `.env` vừa sinh (kèm BETTER_AUTH_SECRET) và cả node_modules.
85
+ fs.renameSync(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
86
+
87
+ // "quan-ly-kho" → "Quan Ly Kho": nhãn hiển thị mặc định, chủ app sửa lại sau.
88
+ const title = name
89
+ .split("-")
90
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
91
+ .join(" ");
92
+
93
+ // ── package.json: tên + cổng ─────────────────────────────────────────────────
94
+ const pkg = JSON.parse(read("package.json"));
95
+ pkg.name = name;
96
+ pkg.version = "0.1.0";
97
+ for (const key of ["dev", "start"]) {
98
+ if (pkg.scripts?.[key]) pkg.scripts[key] = pkg.scripts[key].replace(/--port \d+/, `--port ${port}`);
99
+ }
100
+ write("package.json", `${JSON.stringify(pkg, null, 2)}\n`);
101
+
102
+ // ── .env: điền sẵn thứ điền được, để trống thứ chỉ người dùng biết ───────────
103
+ // BETTER_AUTH_SECRET sinh ngay tại đây: bỏ trống thì lần đăng nhập đầu tiên đổ
104
+ // lỗi khó đoán, mà nhét placeholder thì có ngày nó lên production.
105
+ const dbName = name.replace(/-/g, "_");
106
+ const env = read(".env.example")
107
+ .replace(/^DATABASE_URL=.*$/m, `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/${dbName}"`)
108
+ .replace(/^BETTER_AUTH_SECRET=.*$/m, `BETTER_AUTH_SECRET="${randomBytes(32).toString("base64")}"`)
109
+ .replace(/^BETTER_AUTH_URL=.*$/m, `BETTER_AUTH_URL="http://localhost:${port}"`);
110
+ write(".env", env);
111
+
112
+ // ── Thương hiệu: một chỗ duy nhất đổi tên hiển thị toàn app ──────────────────
113
+ // Bỏ qua bước này thì app mới chạy lên vẫn đề "GoERP Starter" trên sidebar,
114
+ // trang đăng nhập và tiêu đề tab — người dựng app phải đi tìm mới sửa được.
115
+ const tenantPath = "src/configs/tenant.ts";
116
+ write(
117
+ tenantPath,
118
+ read(tenantPath)
119
+ .replace(/(\bid:\s*)"[^"]*"/, `$1"${name}"`)
120
+ .replace(/(\bname:\s*)"[^"]*"/, `$1"${title}"`)
121
+ .replace(/(\bcompanyName:\s*)"[^"]*"/, `$1"${title}"`),
122
+ );
123
+
124
+ // ── Tài liệu: điền tên app ───────────────────────────────────────────────────
125
+ write("AGENTS.md", read("AGENTS.md").replace(/<APP-NAME>/g, name));
126
+ write("README.md", read("README.md").replace(/^# GoERP starter app$/m, `# ${title}`));
127
+
128
+ // ── Xong ─────────────────────────────────────────────────────────────────────
129
+ const relative = path.relative(process.cwd(), targetDir);
130
+ // Đường dẫn tương đối chỉ dễ đọc khi app nằm gần; xa quá thì in tuyệt đối.
131
+ const rel = relative && relative.length < targetDir.length ? relative : targetDir;
132
+ console.log(`✓ Đã tạo ${name} tại ${targetDir}\n`);
133
+ console.log("Tiếp theo:\n");
134
+ console.log(` cd ${rel}`);
135
+ console.log(" pnpm install");
136
+ console.log(" # sửa DATABASE_URL trong .env cho trỏ đúng Postgres của bạn");
137
+ console.log(" pnpm prisma:deploy # dựng bảng (migration _init có sẵn)");
138
+ console.log(" pnpm seed # vai trò + tài khoản admin");
139
+ console.log(" pnpm rbac-sync # PHẢI chạy SAU seed, xem README");
140
+ console.log(` pnpm dev # http://localhost:${port}`);
141
+ console.log("\n.env đã có sẵn BETTER_AUTH_SECRET sinh ngẫu nhiên — đừng commit file này.");
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.58",
4
+ "version": "0.1.60",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -12,13 +12,15 @@
12
12
  "types": "./src/index.ts",
13
13
  "sideEffects": false,
14
14
  "bin": {
15
- "goerp-features": "./bin/goerp-features.mjs"
15
+ "goerp-features": "./bin/goerp-features.mjs",
16
+ "goerp-init": "./bin/goerp-init.mjs"
16
17
  },
17
18
  "files": [
18
19
  "src",
19
20
  "bin",
20
21
  "scripts",
21
22
  "features",
23
+ "templates",
22
24
  "README.md",
23
25
  "CHANGELOG.md",
24
26
  "PLATFORM.md"
@@ -88,8 +90,6 @@
88
90
  "./audit": "./src/audit/index.ts",
89
91
  "./rbac/role-service": "./src/rbac/role-service.ts",
90
92
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
91
- "./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
92
- "./infrastructure/cron/types": "./src/infrastructure/cron/types.ts",
93
93
  "./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
94
94
  "./auth/auth-service": "./src/auth/auth-service.ts",
95
95
  "./package.json": "./package.json",
@@ -0,0 +1,288 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ getBranchScopeContext,
5
+ resolveAmbientBranchScope,
6
+ runWithBranchScope,
7
+ runWithoutBranchScope,
8
+ type BranchScopeContext,
9
+ } from "../context";
10
+ import { createBranchGuardExtension } from "../guard";
11
+ import {
12
+ canAccessBranch,
13
+ clampBranchFilter,
14
+ clampIdFilter,
15
+ configureBranchScope,
16
+ getBranchScope,
17
+ scopedBranchWhere,
18
+ } from "../scope";
19
+ import { NO_BRANCH_ACCESS, type BranchScope } from "../types";
20
+
21
+ const scoped: BranchScope = { canViewAll: false, allowedBranchIds: ["b1", "b2"] };
22
+ const viewAll: BranchScope = { canViewAll: true };
23
+
24
+ interface FakeSession {
25
+ id?: string;
26
+ admin?: boolean;
27
+ }
28
+
29
+ function configure(rows: { branchId: string | null }[]) {
30
+ const findMany = vi.fn(async () => rows);
31
+ configureBranchScope<FakeSession>({
32
+ db: { userBranch: { findMany } },
33
+ getUserId: (s) => s.id,
34
+ canViewAll: (s) => Boolean(s.admin),
35
+ });
36
+ return findMany;
37
+ }
38
+
39
+ describe("getBranchScope", () => {
40
+ it("người xem-tất không tốn truy vấn user_branches", async () => {
41
+ const findMany = configure([]);
42
+ expect(await getBranchScope({ id: "u1", admin: true })).toEqual({
43
+ canViewAll: true,
44
+ });
45
+ expect(findMany).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("lấy đúng chi nhánh được gán", async () => {
49
+ configure([{ branchId: "b1" }, { branchId: "b2" }]);
50
+ expect(await getBranchScope({ id: "u1" })).toEqual({
51
+ canViewAll: false,
52
+ allowedBranchIds: ["b1", "b2"],
53
+ });
54
+ });
55
+
56
+ it("chưa gán chi nhánh nào → sentinel = thấy 0 dòng, KHÔNG phải thấy tất", async () => {
57
+ configure([]);
58
+ expect(await getBranchScope({ id: "u1" })).toEqual({
59
+ canViewAll: false,
60
+ allowedBranchIds: [NO_BRANCH_ACCESS],
61
+ });
62
+ });
63
+
64
+ it("session không có userId → sentinel (không tra DB bằng undefined)", async () => {
65
+ const findMany = configure([{ branchId: "b1" }]);
66
+ expect(await getBranchScope({})).toEqual({
67
+ canViewAll: false,
68
+ allowedBranchIds: [NO_BRANCH_ACCESS],
69
+ });
70
+ expect(findMany).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+
74
+ describe("scopedBranchWhere", () => {
75
+ it("xem tất → không thêm điều kiện", () => {
76
+ expect(scopedBranchWhere(viewAll)).toEqual({});
77
+ });
78
+
79
+ it("có phạm vi → CN được gán + bản ghi chưa gắn CN (dùng chung)", () => {
80
+ expect(scopedBranchWhere(scoped)).toEqual({
81
+ OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }],
82
+ });
83
+ });
84
+
85
+ it("scope qua quan hệ cho model không có cột branchId", () => {
86
+ expect(scopedBranchWhere(scoped, "warehouse")).toEqual({
87
+ OR: [
88
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
89
+ { warehouse: { branchId: null } },
90
+ ],
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("canAccessBranch", () => {
96
+ it("xem tất → qua hết", () => {
97
+ expect(canAccessBranch(viewAll, "x")).toBe(true);
98
+ });
99
+ it("bản ghi không gắn CN = dùng chung", () => {
100
+ expect(canAccessBranch(scoped, null)).toBe(true);
101
+ });
102
+ it("đúng/sai theo danh sách được gán", () => {
103
+ expect(canAccessBranch(scoped, "b1")).toBe(true);
104
+ expect(canAccessBranch(scoped, "x")).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("kẹp bộ lọc client gửi lên", () => {
109
+ it("clampBranchFilter: giữ nguyên khi xem tất, lọc bỏ CN ngoài phạm vi", () => {
110
+ expect(clampBranchFilter(["x"], viewAll)).toEqual(["x"]);
111
+ expect(clampBranchFilter(["b1", "x"], scoped)).toEqual(["b1"]);
112
+ });
113
+
114
+ it("clampBranchFilter: toàn bộ ngoài phạm vi → sentinel, không rơi về không-lọc", () => {
115
+ expect(clampBranchFilter(["x", "y"], scoped)).toEqual([NO_BRANCH_ACCESS]);
116
+ });
117
+
118
+ it("clampBranchFilter: không lọc gì → undefined (phạm vi đã lo)", () => {
119
+ expect(clampBranchFilter(undefined, scoped)).toBeUndefined();
120
+ expect(clampBranchFilter("", scoped)).toBeUndefined();
121
+ });
122
+
123
+ it("clampIdFilter: ngoài danh sách cho phép → sentinel", () => {
124
+ expect(clampIdFilter(["w1", "x"], ["w1", "w2"])).toEqual(["w1"]);
125
+ expect(clampIdFilter(["x"], ["w1"])).toEqual([NO_BRANCH_ACCESS]);
126
+ });
127
+ });
128
+
129
+ describe("context", () => {
130
+ it("ngoài context → undefined (extension bất động cho cron/script)", () => {
131
+ expect(getBranchScopeContext()).toBeUndefined();
132
+ expect(resolveAmbientBranchScope()).toBeUndefined();
133
+ });
134
+
135
+ it("resolve lười và cache trong 1 request", async () => {
136
+ const resolve = vi.fn(async () => scoped);
137
+ await runWithBranchScope({ resolve }, async () => {
138
+ expect(resolve).not.toHaveBeenCalled();
139
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
140
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
141
+ expect(resolve).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
144
+
145
+ it("context sống qua await bên trong (PrismaPromise phải await TRONG context)", async () => {
146
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
147
+ await Promise.resolve();
148
+ expect(getBranchScopeContext()).toBeDefined();
149
+ });
150
+ });
151
+
152
+ it("runWithoutBranchScope thoát guard rồi trả lại context", async () => {
153
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
154
+ await runWithoutBranchScope(async () => {
155
+ await Promise.resolve();
156
+ expect(getBranchScopeContext()).toBeUndefined();
157
+ expect(resolveAmbientBranchScope()).toBeUndefined();
158
+ });
159
+ expect(getBranchScopeContext()).toBeDefined();
160
+ });
161
+ });
162
+
163
+ /**
164
+ * PrismaPromise lười: truy vấn chạy ở lần `.then()` đầu tiên, không phải lúc
165
+ * gọi `db.receipt.count()`. Giả lập đúng như vậy để chốt rằng callback KHÔNG
166
+ * async cũng chạy trong đúng phạm vi.
167
+ */
168
+ function lazyQuery(): Promise<BranchScopeContext | undefined> {
169
+ let started: Promise<BranchScopeContext | undefined> | undefined;
170
+ const thenable: PromiseLike<BranchScopeContext | undefined> = {
171
+ then(onFulfilled, onRejected) {
172
+ // Ngữ cảnh được chốt tại đây — chỗ đầu tiên ai đó await.
173
+ started ??= Promise.resolve(getBranchScopeContext());
174
+ return started.then(onFulfilled, onRejected);
175
+ },
176
+ };
177
+ // PrismaPromise cũng khai kiểu Promise nhưng chỉ chạy ở `.then` đầu tiên.
178
+ return thenable as Promise<BranchScopeContext | undefined>;
179
+ }
180
+
181
+ it("callback KHÔNG async trả PrismaPromise vẫn chạy TRONG context", async () => {
182
+ const ctx = await runWithBranchScope({ resolve: async () => scoped }, () =>
183
+ lazyQuery(),
184
+ );
185
+ expect(ctx).toBeDefined();
186
+ });
187
+
188
+ it("runWithoutBranchScope: callback KHÔNG async vẫn thoát guard", async () => {
189
+ const ctx = await runWithBranchScope(
190
+ { resolve: async () => scoped },
191
+ async () => runWithoutBranchScope(() => lazyQuery()),
192
+ );
193
+ expect(ctx).toBeUndefined();
194
+ });
195
+
196
+ it("giá trị đồng bộ đi qua nguyên vẹn (không bị bọc thành promise)", () => {
197
+ expect(runWithoutBranchScope(() => 42)).toBe(42);
198
+ });
199
+ });
200
+
201
+ describe("createBranchGuardExtension", () => {
202
+ const ext = createBranchGuardExtension({
203
+ models: ["Receipt", "GoodsReceipt"],
204
+ buildWhere: (model, scope) =>
205
+ model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
206
+ });
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ const run = ext.query.$allModels.$allOperations as (a: any) => Promise<any>;
209
+
210
+ let seen: unknown;
211
+ const query = vi.fn(async (args: unknown) => {
212
+ seen = args;
213
+ return "ok";
214
+ });
215
+
216
+ beforeEach(() => {
217
+ seen = undefined;
218
+ query.mockClear();
219
+ });
220
+
221
+ const call = (model: string, operation: string, args: unknown) =>
222
+ run({ model, operation, args, query });
223
+
224
+ it("model ngoài danh sách → đi thẳng", async () => {
225
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
226
+ call("Customer", "findMany", { where: { name: "a" } }),
227
+ );
228
+ expect(seen).toEqual({ where: { name: "a" } });
229
+ });
230
+
231
+ it("thao tác ghi và findUnique → không đụng vào", async () => {
232
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
233
+ await call("Receipt", "findUnique", { where: { id: "r1" } });
234
+ expect(seen).toEqual({ where: { id: "r1" } });
235
+ await call("Receipt", "update", { where: { id: "r1" }, data: {} });
236
+ expect(seen).toEqual({ where: { id: "r1" }, data: {} });
237
+ });
238
+ });
239
+
240
+ it("ngoài context (cron/script) → không cắt dữ liệu", async () => {
241
+ await call("Receipt", "findMany", { where: { a: 1 } });
242
+ expect(seen).toEqual({ where: { a: 1 } });
243
+ });
244
+
245
+ it("xem tất → không thêm điều kiện", async () => {
246
+ await runWithBranchScope({ resolve: async () => viewAll }, () =>
247
+ call("Receipt", "findMany", { where: { a: 1 } }),
248
+ );
249
+ expect(seen).toEqual({ where: { a: 1 } });
250
+ });
251
+
252
+ it("AND thêm điều kiện chi nhánh, GIỮ nguyên where cũ", async () => {
253
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
254
+ call("Receipt", "findMany", { where: { status: "paid" } }),
255
+ );
256
+ expect(seen).toEqual({
257
+ where: {
258
+ AND: [
259
+ { status: "paid" },
260
+ { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
261
+ ],
262
+ },
263
+ });
264
+ });
265
+
266
+ it("không có where sẵn → guard trở thành where", async () => {
267
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
268
+ call("Receipt", "count", {}),
269
+ );
270
+ expect(seen).toEqual({
271
+ where: { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
272
+ });
273
+ });
274
+
275
+ it("buildWhere riêng cho model không có cột branchId (chèn cột không tồn tại = Prisma ném lỗi cho MỌI truy vấn)", async () => {
276
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
277
+ call("GoodsReceipt", "findMany", {}),
278
+ );
279
+ expect(seen).toEqual({
280
+ where: {
281
+ OR: [
282
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
283
+ { warehouse: { branchId: null } },
284
+ ],
285
+ },
286
+ });
287
+ });
288
+ });
@@ -0,0 +1,66 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ import type { BranchScope } from "./types";
4
+
5
+ /**
6
+ * Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
7
+ * xác thực; extension branch-guard đọc ở đây.
8
+ *
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.
11
+ *
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.
15
+ */
16
+
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
+ }
38
+
39
+ export function runWithBranchScope<T>(
40
+ context: BranchScopeContext,
41
+ fn: () => T | Promise<T>,
42
+ ): T | Promise<T> {
43
+ return store.run(context, () => pinToCurrentContext(fn()));
44
+ }
45
+
46
+ export function getBranchScopeContext(): BranchScopeContext | undefined {
47
+ return store.getStore();
48
+ }
49
+
50
+ /** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
51
+ 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;
56
+ }
57
+
58
+ /**
59
+ * 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 là 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.
63
+ */
64
+ export function runWithoutBranchScope<T>(fn: () => T): T {
65
+ return store.exit(() => pinToCurrentContext(fn()));
66
+ }