@goplusvn/core 0.1.59 → 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 (139) hide show
  1. package/PLATFORM.md +18 -0
  2. package/bin/goerp-init.mjs +141 -0
  3. package/package.json +4 -4
  4. package/src/cron/db-cron-manager.ts +3 -3
  5. package/src/cron/index.ts +2 -2
  6. package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
  7. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  8. package/src/infrastructure/index.ts +4 -7
  9. package/src/ui/management/index.ts +3 -2
  10. package/templates/starter-app/.dockerignore +41 -0
  11. package/templates/starter-app/.env.example +25 -0
  12. package/templates/starter-app/AGENTS.md +52 -0
  13. package/templates/starter-app/Dockerfile +74 -0
  14. package/templates/starter-app/README.md +141 -0
  15. package/templates/starter-app/gitignore +9 -0
  16. package/templates/starter-app/next.config.mjs +50 -0
  17. package/templates/starter-app/package.json +55 -0
  18. package/templates/starter-app/postcss.config.mjs +5 -0
  19. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  20. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  21. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  22. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  23. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  24. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  25. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  26. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  27. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  28. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  29. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  30. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  31. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  32. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  33. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  34. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  35. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  36. package/templates/starter-app/prisma/seed.ts +127 -0
  37. package/templates/starter-app/prisma.config.ts +20 -0
  38. package/templates/starter-app/public/.gitkeep +2 -0
  39. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  40. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  53. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  54. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  55. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  56. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  57. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  58. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  59. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  60. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  61. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  62. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  63. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  64. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  66. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  67. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  68. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  69. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  70. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  71. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  72. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  73. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  74. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  75. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  76. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  77. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  78. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  79. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  80. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  81. package/templates/starter-app/src/app/globals.css +15 -0
  82. package/templates/starter-app/src/app/layout.tsx +16 -0
  83. package/templates/starter-app/src/app/page.tsx +8 -0
  84. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  85. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  86. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  87. package/templates/starter-app/src/configs/i18n.ts +12 -0
  88. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  89. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  90. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  91. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  92. package/templates/starter-app/src/configs/tenant.ts +18 -0
  93. package/templates/starter-app/src/data/dictionary.ts +8 -0
  94. package/templates/starter-app/src/data/navigations.ts +61 -0
  95. package/templates/starter-app/src/instrumentation.ts +105 -0
  96. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  97. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  98. package/templates/starter-app/src/lib/auth.ts +62 -0
  99. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  100. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  101. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  102. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  103. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  104. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  105. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  106. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  107. package/templates/starter-app/src/lib/logger.ts +30 -0
  108. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  109. package/templates/starter-app/src/lib/prisma.ts +80 -0
  110. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  111. package/templates/starter-app/src/lib/storage.ts +28 -0
  112. package/templates/starter-app/src/providers/index.tsx +54 -0
  113. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  114. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  115. package/templates/starter-app/src/proxy.ts +45 -0
  116. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  117. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  118. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  119. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  120. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  121. package/templates/starter-app/src/types/session.ts +29 -0
  122. package/templates/starter-app/tsconfig.json +47 -0
  123. package/templates/starter-app/vitest.config.ts +17 -0
  124. package/src/infrastructure/cron/index.ts +0 -6
  125. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  126. package/src/infrastructure/event-bus/index.ts +0 -2
  127. package/src/infrastructure/event-bus/types.ts +0 -22
  128. package/src/infrastructure/lock/decorators.ts +0 -67
  129. package/src/infrastructure/lock/index.ts +0 -2
  130. package/src/infrastructure/lock/lock-manager.ts +0 -33
  131. package/src/plugin/apps-registry.ts +0 -97
  132. package/src/plugin/index.ts +0 -5
  133. package/src/plugin/types.ts +0 -41
  134. package/src/ui/management/audit-log-page.tsx +0 -14
  135. package/src/ui/management/job-management.tsx +0 -308
  136. package/src/workflow/activity-timeline.tsx +0 -412
  137. package/src/workflow/approval-workflow.tsx +0 -31
  138. package/src/workflow/index.ts +0 -2
  139. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -0,0 +1,107 @@
1
+ import "server-only"
2
+
3
+ import bcrypt from "bcryptjs"
4
+ import { betterAuth } from "better-auth"
5
+ import { prismaAdapter } from "better-auth/adapters/prisma"
6
+ import { APIError } from "better-auth/api"
7
+ import { customSession } from "better-auth/plugins"
8
+
9
+ import { db } from "@/lib/prisma"
10
+ import { loadUserAccess } from "@/lib/rbac/access"
11
+
12
+ /**
13
+ * BETTER AUTH — hệ xác thực duy nhất của app. basePath `/api/better-auth`
14
+ * (prefix này nằm trong danh sách public của src/proxy.ts, đừng đổi một nơi mà
15
+ * quên nơi kia).
16
+ *
17
+ * `customSession` là mấu chốt: nó nhồi `roles` + `permissions` vào session.user
18
+ * để `@goerp/core` gate được UI và CRUD mà không phải query DB ở mỗi lần kiểm
19
+ * quyền. Bỏ plugin này thì mọi thứ trong app hiện ra "không có quyền".
20
+ */
21
+ function buildAuth() {
22
+ const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3010"
23
+
24
+ return betterAuth({
25
+ basePath: "/api/better-auth",
26
+ baseURL,
27
+ trustedOrigins: [baseURL],
28
+ secret: process.env.BETTER_AUTH_SECRET,
29
+ database: prismaAdapter(db, { provider: "postgresql" }),
30
+
31
+ rateLimit: {
32
+ enabled: true,
33
+ window: 60,
34
+ max: 300,
35
+ // Nhiều nhân viên chung một IP văn phòng (NAT) nên trần chung phải rộng;
36
+ // chỉ siết đúng endpoint đăng nhập để chặn dò mật khẩu.
37
+ customRules: { "/sign-in/email": { window: 60, max: 10 } },
38
+ },
39
+
40
+ emailAndPassword: {
41
+ enabled: true,
42
+ // ERP nội bộ: tài khoản do quản trị tạo, không cho tự đăng ký.
43
+ disableSignUp: true,
44
+ // bcrypt thay scrypt mặc định — để hash trong DB đọc được bởi mọi công cụ
45
+ // quen thuộc và tương thích với dữ liệu import từ hệ cũ.
46
+ password: {
47
+ hash: async (password) => bcrypt.hash(password, 10),
48
+ verify: async ({ hash, password }) => bcrypt.compare(password, hash),
49
+ },
50
+ },
51
+
52
+ user: { modelName: "user" },
53
+ session: {
54
+ modelName: "session",
55
+ expiresIn: 60 * 60 * 24 * 7,
56
+ updateAge: 60 * 60 * 24,
57
+ },
58
+ account: { modelName: "account" },
59
+ verification: { modelName: "verification" },
60
+
61
+ databaseHooks: {
62
+ session: {
63
+ create: {
64
+ // Khoá tài khoản phải chặn ngay từ lúc tạo phiên, không đợi tới lúc
65
+ // gọi API mới từ chối.
66
+ before: async (session) => {
67
+ const u = await db.user.findUnique({
68
+ where: { id: session.userId },
69
+ select: { isActive: true },
70
+ })
71
+ if (!u?.isActive) {
72
+ throw new APIError("FORBIDDEN", {
73
+ message: "Tài khoản đã bị khóa. Liên hệ quản trị viên.",
74
+ })
75
+ }
76
+ return { data: session }
77
+ },
78
+ },
79
+ },
80
+ },
81
+
82
+ plugins: [
83
+ customSession(async ({ user, session }) => {
84
+ const access = await loadUserAccess(user.id)
85
+ return {
86
+ session,
87
+ user: {
88
+ ...user,
89
+ avatar: user.image ?? null,
90
+ status: access.status,
91
+ roles: access.roles,
92
+ permissions: access.permissions,
93
+ branches: access.branches,
94
+ branchId: access.branchId,
95
+ },
96
+ }
97
+ }),
98
+ ],
99
+ })
100
+ }
101
+
102
+ // Singleton LƯỜI: chạm `db` ở module scope tạo vòng import với lib/prisma
103
+ // (audit extension) và ném TDZ "Cannot access 'db' before initialization".
104
+ let _auth: ReturnType<typeof buildAuth> | null = null
105
+ export function getAuth() {
106
+ return (_auth ??= buildAuth())
107
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * CỬA DUY NHẤT của phạm vi dữ liệu theo chi nhánh. Page/route/service import
3
+ * từ đây, KHÔNG import thẳng `@goerp/core/branch-scope` (cần chạy qua
4
+ * `configureBranchScope` bên dưới trước, nếu không sẽ ném lỗi "chưa configure").
5
+ *
6
+ * Hai lớp bảo vệ, cố ý chồng lên nhau:
7
+ * - LỚP 1 (chỗ nào cũng phải làm): `getBranchScope(session)` +
8
+ * `scopedBranchWhere(scope)` nhét vào `where` của truy vấn.
9
+ * - LỚP 2 (lưới an toàn): branch-guard extension trong lib/prisma.ts tự AND
10
+ * thêm điều kiện cho các model được khai — route mới quên lọc thì cũng không
11
+ * lộ dữ liệu.
12
+ *
13
+ * const scope = await getBranchScope(session)
14
+ * const rows = await db.invoice.findMany({ where: { ...scopedBranchWhere(scope), status } })
15
+ */
16
+ import "server-only"
17
+
18
+ import { checkPermission } from "@goerp/core/auth"
19
+ import { configureBranchScope } from "@goerp/core/branch-scope"
20
+
21
+ import type { Session } from "@/types/session"
22
+
23
+ /**
24
+ * Luật "xem mọi chi nhánh" của app — sửa cho khớp vai trò/quyền của bạn.
25
+ * Ở đây: quản trị, hoặc vai trò được cấp `view-all-branches`.
26
+ */
27
+ export function canViewAllBranches(session: Session): boolean {
28
+ return Boolean(
29
+ session.user?.roles?.includes("admin") ||
30
+ checkPermission(session as never, "branch", "view-all-branches"),
31
+ )
32
+ }
33
+
34
+ configureBranchScope<Session>({
35
+ getUserId: (session) => session.user?.id,
36
+ canViewAll: canViewAllBranches,
37
+ // Session đã mang sẵn danh sách chi nhánh (loadUserAccess nhồi vào lúc đăng
38
+ // nhập) nên không cần truy vấn user_branches mỗi request. Nếu app bỏ trường
39
+ // đó khỏi session, xoá dòng này và truyền `db` thay thế.
40
+ getAllowedBranchIds: (session) => session.user?.branches,
41
+ })
42
+
43
+ export {
44
+ NO_BRANCH_ACCESS,
45
+ canAccessBranch,
46
+ clampBranchFilter,
47
+ clampIdFilter,
48
+ getBranchScope,
49
+ runWithBranchScope,
50
+ runWithoutBranchScope,
51
+ scopedBranchWhere,
52
+ type BranchScope,
53
+ } from "@goerp/core/branch-scope"
@@ -0,0 +1,42 @@
1
+ import { configureCronManager } from "@goerp/core/cron"
2
+
3
+ import type { CronDb } from "@goerp/core/cron"
4
+
5
+ import { logger } from "@/lib/logger"
6
+ import { db } from "@/lib/prisma"
7
+
8
+ /**
9
+ * Cắm cron của app vào engine @goerp/core/cron. Lịch chạy + lịch sử thực thi
10
+ * nằm ở DB (`system_jobs`, `job_execution_logs` — ship qua `goerp-features
11
+ * sync`), nên admin đổi lịch trên UI mà không cần deploy lại.
12
+ *
13
+ * Import file này (không import thẳng @goerp/core/cron) để side-effect cấu
14
+ * hình luôn chạy trước khi ai đó gọi cronManager.
15
+ */
16
+
17
+ // Core gửi meta kiểu `unknown` (object, hoặc chính error). Logger app nhận
18
+ // context là Record nên gói lại thay vì ép kiểu bừa.
19
+ const asContext = (meta: unknown): Record<string, unknown> | undefined => {
20
+ if (meta === undefined || meta === null || meta === "") return undefined
21
+ return meta && typeof meta === "object" && !(meta instanceof Error)
22
+ ? (meta as Record<string, unknown>)
23
+ : { meta }
24
+ }
25
+
26
+ configureCronManager({
27
+ db: db as unknown as CronDb,
28
+ logger: {
29
+ info: (message, meta) => logger.info(message, asContext(meta)),
30
+ warn: (message, meta) => logger.warn(message, asContext(meta)),
31
+ // logger.error của app nhận (message, error?, context?) — giữ nguyên stack.
32
+ error: (message, meta) => logger.error(message, meta),
33
+ },
34
+ })
35
+
36
+ export {
37
+ cronManager,
38
+ DbCronJobManager,
39
+ getJobExecutionContext,
40
+ type ActionLog,
41
+ type JobExecutionContext,
42
+ } from "@goerp/core/cron"
@@ -0,0 +1,13 @@
1
+ import { createServerCrudService, getModelName } from "@goerp/core/crud/server"
2
+
3
+ import { db } from "@/lib/prisma"
4
+
5
+ // Engine CRUD generic ở CORE — app chỉ tiêm prisma + map entity(số nhiều)→model Prisma.
6
+ const MODEL_MAP: Record<string, string> = {
7
+ departments: "department",
8
+ }
9
+
10
+ export const crudService = createServerCrudService({
11
+ prisma: db,
12
+ getModelName: (entity) => getModelName(entity, MODEL_MAP),
13
+ })
@@ -0,0 +1,2 @@
1
+ // AppError + Prisma error mapping live in core (generic, client-safe).
2
+ export { AppError, toAppError } from "@goerp/core/errors/app-error"
@@ -0,0 +1,5 @@
1
+ export {
2
+ withErrorHandler,
3
+ createErrorResponse,
4
+ errorResponse,
5
+ } from "@goerp/core/errors/error-handler"
@@ -0,0 +1,15 @@
1
+ import {
2
+ createErrorLogger,
3
+ type ErrorLoggerDb,
4
+ type ServerErrorContext,
5
+ } from "@goerp/core/errors/server-error"
6
+
7
+ import { db } from "@/lib/prisma"
8
+
9
+ // Binds core's fingerprint-deduped error logger to this app's `error_logs`
10
+ // table (the Prisma `ErrorLog` model in prisma/schema.prisma).
11
+ export type { ServerErrorContext }
12
+
13
+ export const { logServerError } = createErrorLogger(
14
+ db as unknown as ErrorLoggerDb
15
+ )
@@ -0,0 +1,11 @@
1
+ import { buildServerError } from "@goerp/core/errors/server-error"
2
+
3
+ import { logServerError } from "./log-server-error"
4
+
5
+ /**
6
+ * The canonical catch-block helper:
7
+ * } catch (error) { return serverError(error, request, { message: "..." }) }
8
+ * Maps Prisma errors → friendly message + stable errorId, persists the stack to
9
+ * `error_logs`, returns structured JSON with an `X-Error-Id` header.
10
+ */
11
+ export const serverError = buildServerError({ logServerError })
@@ -0,0 +1,30 @@
1
+ import { logger as coreLogger } from "@goerp/core/infrastructure/logger"
2
+
3
+ type LogContext = Record<string, unknown>
4
+
5
+ /**
6
+ * Log ra terminal/stdout. Đây KHÔNG phải nơi lưu lỗi để tra cứu — lỗi có ích
7
+ * cho việc điều tra đi qua `serverError()` / `logServerError()` và nằm ở bảng
8
+ * `error_logs` (trang /admin/system/error-logs đọc bảng đó).
9
+ *
10
+ * Ký hiệu `error(message, error?, context?)` khác core một chút (core chỉ nhận
11
+ * `(message, context)`) để chỗ gọi truyền thẳng đối tượng Error mà vẫn giữ
12
+ * được stack — bọc lại ở đây thay vì bắt mỗi call site tự trải ra.
13
+ */
14
+ export const logger = {
15
+ info: (message: string, context?: LogContext) => coreLogger.info(message, context),
16
+ warn: (message: string, context?: LogContext) => coreLogger.warn(message, context),
17
+ debug: (message: string, context?: LogContext) => coreLogger.debug(message, context),
18
+ error: (message: string, error?: unknown, context?: LogContext) =>
19
+ coreLogger.error(message, {
20
+ ...context,
21
+ ...(error !== undefined
22
+ ? {
23
+ error:
24
+ error instanceof Error
25
+ ? { name: error.name, message: error.message, stack: error.stack }
26
+ : error,
27
+ }
28
+ : {}),
29
+ }),
30
+ }
@@ -0,0 +1,35 @@
1
+ import "server-only"
2
+
3
+ import { redirect } from "next/navigation"
4
+ import { checkPermission } from "@goerp/core/auth"
5
+
6
+ import type { Session } from "@/types/session"
7
+
8
+ import { i18n } from "@/configs/i18n"
9
+ import { getSession } from "@/lib/auth"
10
+
11
+ /**
12
+ * Gác một TRANG theo quyền. `src/proxy.ts` mới chỉ chặn "chưa đăng nhập";
13
+ * còn "đăng nhập rồi nhưng không có quyền" phải chặn tại đây, nếu không người
14
+ * dùng vẫn mở được trang và chỉ thấy API trả 403 — trải nghiệm tệ và lộ cấu
15
+ * trúc màn hình.
16
+ *
17
+ * const session = await requirePageAccess(lang, "error-log")
18
+ *
19
+ * Trả về session để trang khỏi gọi `getSession()` lần nữa (React `cache()` gom
20
+ * chung một request nên cũng không tốn thêm truy vấn).
21
+ */
22
+ export async function requirePageAccess(
23
+ lang: string,
24
+ resource: string,
25
+ action = "view",
26
+ ): Promise<Session> {
27
+ const session = await getSession()
28
+ const locale = lang || i18n.defaultLocale
29
+
30
+ if (!session?.user) redirect(`/${locale}/sign-in`)
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ if (!checkPermission(session as any, resource, action)) redirect(`/${locale}`)
33
+
34
+ return session
35
+ }
@@ -0,0 +1,80 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg"
2
+ import { Prisma, PrismaClient } from "@prisma/client"
3
+ import { createAuditExtension } from "@goerp/core/audit"
4
+ import { createBranchGuardExtension } from "@goerp/core/branch-scope"
5
+ import { configureSettingsService } from "@goerp/core/system/services/settings-service"
6
+
7
+ /**
8
+ * COMPOSITION ROOT của app — nơi duy nhất tạo Prisma client và cắm dây cho các
9
+ * dịch vụ của core. Import `db` từ đây, đừng `new PrismaClient()` chỗ khác.
10
+ *
11
+ * Ba việc file này làm, theo đúng thứ tự:
12
+ * 1. tạo client (Prisma 7 = driver adapter, connection string ở DATABASE_URL);
13
+ * 2. bọc audit extension (ghi audit_logs cho mọi create/update/delete);
14
+ * 3. bọc branch-guard (lưới an toàn phạm vi chi nhánh cho thao tác ĐỌC);
15
+ * 4. gọi configureSettingsService(db) để core đọc SystemConfig thật.
16
+ *
17
+ * Các seam còn lại (tasks / notifications / cron) cần import nặng hơn nên nằm ở
18
+ * src/server/bootstrap.ts, chạy từ instrumentation.
19
+ */
20
+ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
21
+
22
+ function createClient() {
23
+ const connectionString = process.env.DATABASE_URL
24
+ if (!connectionString) {
25
+ throw new Error("DATABASE_URL chưa được set (xem .env.example)")
26
+ }
27
+ return new PrismaClient({
28
+ adapter: new PrismaPg({ connectionString }),
29
+ log: [
30
+ { emit: "stdout", level: "error" },
31
+ { emit: "stdout", level: "warn" },
32
+ ],
33
+ errorFormat: "minimal",
34
+ })
35
+ }
36
+
37
+ // Dev hot-reload nạp lại module liên tục; không cache thì mỗi lần reload mở
38
+ // thêm một pool TCP và Postgres hết slot sau vài chục lần sửa file.
39
+ const rawClient = globalForPrisma.prisma ?? createClient()
40
+ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = rawClient
41
+
42
+ /**
43
+ * Audit: extension chộp mọi lệnh ghi và chép sang audit_logs. `rawClient`
44
+ * truyền vào là client CHƯA extend — dùng client đã extend để ghi log sẽ đệ quy
45
+ * vô hạn. `prisma` phải là namespace của client app (core không import client
46
+ * cụ thể nào).
47
+ */
48
+ /**
49
+ * Branch-guard (LỚP 2): với mỗi model khai dưới đây, thao tác ĐỌC trong một
50
+ * request đã xác thực bị AND thêm `branchId ∈ chi nhánh được phép OR null`.
51
+ * Trang/route vẫn nên lọc tường minh bằng `scopedBranchWhere` (lớp 1) — đây chỉ
52
+ * là lưới hứng chỗ quên.
53
+ *
54
+ * Khai thêm model: điền tên model (đúng chữ như trong schema) vào mảng. Model
55
+ * PHẢI có cột `branchId`, hoặc phải khai đường quan hệ trong `buildWhere`:
56
+ *
57
+ * models: ["Invoice", "GoodsReceipt"],
58
+ * buildWhere: (model, scope) =>
59
+ * model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
60
+ *
61
+ * Khai một model KHÔNG có branchId là Prisma ném lỗi validate ở MỌI truy vấn
62
+ * đọc của user bị scope — mất trắng màn hình, không chỉ mất vài dòng.
63
+ */
64
+ const GUARDED_MODELS: string[] = []
65
+
66
+ export const db = rawClient
67
+ .$extends(
68
+ createAuditExtension({
69
+ prisma: Prisma,
70
+ rawClient,
71
+ // Bảng ghi rất nhiều mà không có giá trị điều tra thì loại ra, kẻo dấu vết
72
+ // nghiệp vụ bị nhấn chìm (vinhhoa đo được 1 bảng chiếm 64% số dòng audit).
73
+ skipModels: ["AuditLog", "ErrorLog", "Session", "Account", "Verification"],
74
+ }),
75
+ )
76
+ .$extends(createBranchGuardExtension({ models: GUARDED_MODELS })) as unknown as PrismaClient
77
+
78
+ // Core đọc SystemConfig qua service này. Thiếu dòng dưới thì mọi getSettings()
79
+ // lặng lẽ trả giá trị mặc định và saveSettings() ném lỗi.
80
+ configureSettingsService(db as never)
@@ -0,0 +1,87 @@
1
+ import "server-only"
2
+
3
+ import { db } from "@/lib/prisma"
4
+
5
+ import type { Permission } from "@/types/session"
6
+
7
+ export interface UserAccess {
8
+ roles: string[]
9
+ permissions: Permission[]
10
+ branches: string[]
11
+ branchId: string | null
12
+ status: string
13
+ }
14
+
15
+ /**
16
+ * Nạp toàn bộ ngữ cảnh phân quyền của một user trong MỘT truy vấn, để
17
+ * `customSession` của Better Auth nhồi vào session.
18
+ *
19
+ * Hình dạng câu query bên dưới là HỢP ĐỒNG với @goerp/core: core kỳ vọng
20
+ * `UserRole → Role → RolePermission{resourceCode, actionCode}`. Đổi tên field
21
+ * trong prisma/schema/rbac.prisma thì phải đổi cả ở đây.
22
+ */
23
+ export async function loadUserAccess(userId: string): Promise<UserAccess> {
24
+ const user = await db.user.findUnique({
25
+ where: { id: userId },
26
+ select: {
27
+ isActive: true,
28
+ userRoles: {
29
+ select: {
30
+ roleCode: true,
31
+ role: {
32
+ select: {
33
+ rolePermissions: {
34
+ select: { resourceCode: true, actionCode: true },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ userBranches: {
41
+ select: { branchId: true, isDefault: true },
42
+ },
43
+ },
44
+ })
45
+
46
+ if (!user) {
47
+ return {
48
+ roles: [],
49
+ permissions: [],
50
+ branches: [],
51
+ branchId: null,
52
+ status: "suspended",
53
+ }
54
+ }
55
+
56
+ const roles = user.userRoles.map((ur) => ur.roleCode)
57
+
58
+ // Nhiều vai trò thường chồng lấn quyền — khử trùng lặp để session không phình
59
+ // (một user 5 vai trò dễ có vài trăm dòng trùng, session cookie có trần dung lượng).
60
+ const seen = new Set<string>()
61
+ const permissions: Permission[] = []
62
+ for (const ur of user.userRoles) {
63
+ for (const rp of ur.role?.rolePermissions ?? []) {
64
+ const key = `${rp.resourceCode}:${rp.actionCode}`
65
+ if (seen.has(key)) continue
66
+ seen.add(key)
67
+ permissions.push({
68
+ resourceCode: rp.resourceCode,
69
+ actionCode: rp.actionCode,
70
+ })
71
+ }
72
+ }
73
+
74
+ const branches = user.userBranches.map((ub) => ub.branchId)
75
+ const branchId =
76
+ user.userBranches.find((ub) => ub.isDefault)?.branchId ??
77
+ branches[0] ??
78
+ null
79
+
80
+ return {
81
+ roles,
82
+ permissions,
83
+ branches,
84
+ branchId,
85
+ status: user.isActive ? "active" : "suspended",
86
+ }
87
+ }
@@ -0,0 +1,28 @@
1
+ import { configureStorage, type StorageDb } from "@goerp/core/storage"
2
+
3
+ import { db } from "@/lib/prisma"
4
+
5
+ /**
6
+ * Composition root của kho tập tin — engine ở `@goerp/core/storage`.
7
+ *
8
+ * MỌI nơi trong app import kho qua file này, KHÔNG import thẳng từ core:
9
+ * engine là singleton phải `configureStorage` một lần, import thẳng thì lấy
10
+ * được hàm nhưng không kéo theo lời gọi cấu hình → lỗi "chưa configureStorage"
11
+ * đúng lúc chạy thật.
12
+ *
13
+ * Mặc định: ĐĨA LOCAL, thư mục `storage/files` (private, đã gitignore) — app
14
+ * mới chạy được ngay, không cần dựng MinIO.
15
+ *
16
+ * Chuyển sang S3/MinIO:
17
+ * 1. `pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @smithy/node-http-handler`
18
+ * 2. thêm `driver: createS3Driver()` (import từ `@goerp/core/storage/s3`)
19
+ * và `requireRemote: process.env.NODE_ENV === "production"` vào bên dưới,
20
+ * 3. khai các dòng `system_configs`: STORAGE_TYPE=s3, S3_ENDPOINT,
21
+ * S3_PUBLIC_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.
22
+ * URL tập tin không đổi (`/api/files/<key>`) nên dữ liệu cũ vẫn tra được.
23
+ */
24
+ configureStorage({
25
+ db: db as unknown as StorageDb,
26
+ })
27
+
28
+ export * from "@goerp/core/storage"
@@ -0,0 +1,54 @@
1
+ "use client"
2
+
3
+ import { SettingsProvider, TenantProvider } from "@goerp/core/providers"
4
+ import { TabContentCacheProvider, TabNavigationProvider } from "@goerp/core/ui"
5
+ import { AuthBridgeProvider } from "@goerp/core/ui/auth"
6
+ import { ErrorDialogProvider } from "@goerp/core/ui/feedback"
7
+ import { SidebarProvider } from "@goerp/core/ui/primitives"
8
+ import { Toaster } from "sonner"
9
+
10
+ import type { LocaleType } from "@goerp/core/types"
11
+ import type { ReactNode } from "react"
12
+
13
+ import { tenant } from "@/configs/tenant"
14
+ import { navigations } from "@/data/navigations"
15
+ import { authBridgeClient } from "@/lib/auth-client"
16
+
17
+ import { ModeProvider } from "./mode-provider"
18
+ import { ThemeProvider } from "./theme-provider"
19
+
20
+ export function Providers({
21
+ children,
22
+ locale,
23
+ }: {
24
+ children: ReactNode
25
+ locale: LocaleType
26
+ }) {
27
+ return (
28
+ <SettingsProvider locale={locale}>
29
+ {/* TenantProvider cấp branding (logo / tên / màu chủ đạo) cho Logo và cả
30
+ vỏ app. Sửa src/configs/tenant.ts là đổi da toàn bộ. */}
31
+ <TenantProvider tenant={tenant}>
32
+ <ModeProvider>
33
+ <ThemeProvider>
34
+ {/* Cầu nối auth: core không biết Better Auth là gì, nó chỉ gọi qua
35
+ client này. Object phải là hằng số (nó chứa hook) — đừng tạo
36
+ inline ở đây. */}
37
+ <AuthBridgeProvider client={authBridgeClient}>
38
+ <TabNavigationProvider navigations={navigations}>
39
+ <SidebarProvider>
40
+ <ErrorDialogProvider>
41
+ <TabContentCacheProvider>
42
+ {children}
43
+ <Toaster position="top-right" richColors closeButton />
44
+ </TabContentCacheProvider>
45
+ </ErrorDialogProvider>
46
+ </SidebarProvider>
47
+ </TabNavigationProvider>
48
+ </AuthBridgeProvider>
49
+ </ThemeProvider>
50
+ </ModeProvider>
51
+ </TenantProvider>
52
+ </SettingsProvider>
53
+ )
54
+ }
@@ -0,0 +1,31 @@
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+
5
+ import type { ReactNode } from "react"
6
+
7
+ /**
8
+ * Sáng/tối theo tuỳ chọn hệ điều hành. Cố ý viết bằng matchMedia thay vì kéo
9
+ * thêm thư viện: starter càng ít phụ thuộc càng dễ nâng cấp. Muốn người dùng
10
+ * tự chọn chế độ thì thay bằng next-themes và bỏ file này.
11
+ */
12
+ const MODES = ["light", "dark"] as const
13
+
14
+ export function ModeProvider({ children }: { children: ReactNode }) {
15
+ useEffect(() => {
16
+ const query = window.matchMedia("(prefers-color-scheme: dark)")
17
+
18
+ const apply = (isDark: boolean) => {
19
+ const root = document.documentElement
20
+ root.classList.remove(...MODES)
21
+ root.classList.add(isDark ? "dark" : "light")
22
+ }
23
+
24
+ apply(query.matches)
25
+ const onChange = (e: MediaQueryListEvent) => apply(e.matches)
26
+ query.addEventListener("change", onChange)
27
+ return () => query.removeEventListener("change", onChange)
28
+ }, [])
29
+
30
+ return <>{children}</>
31
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import type { ReactNode } from 'react'
6
+
7
+ const DEFAULT_THEME = 'blue'
8
+ const DEFAULT_RADIUS = 0.5
9
+
10
+ export function ThemeProvider({ children }: { children: ReactNode }) {
11
+ useEffect(() => {
12
+ const bodyElement = document.body
13
+ Array.from(bodyElement.classList)
14
+ .filter((c) => c.startsWith('theme-') || c.startsWith('radius-'))
15
+ .forEach((c) => bodyElement.classList.remove(c))
16
+ bodyElement.classList.add(`theme-${DEFAULT_THEME}`)
17
+ bodyElement.classList.add(`radius-${DEFAULT_RADIUS}`)
18
+ }, [])
19
+
20
+ return <>{children}</>
21
+ }