@goplusvn/core 0.1.70 → 0.1.72

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 (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -43,3 +43,40 @@ export function getRegistryResourceCodes(): string[] {
43
43
  export function isLookupOpenToAuthenticated(code: string): boolean {
44
44
  return resourceByCode.get(code)?.lookupPolicy === "authenticated"
45
45
  }
46
+
47
+ /** Metadata action cho trang phân quyền / catalog (nhãn tiếng Việt). */
48
+ export interface ActionMetaEntry {
49
+ label?: string
50
+ description?: string
51
+ }
52
+
53
+ const BASE_ACTION_META: Record<string, ActionMetaEntry> = {
54
+ view: { label: "Xem" },
55
+ create: { label: "Tạo" },
56
+ update: { label: "Sửa" },
57
+ delete: { label: "Xóa" },
58
+ export: { label: "Xuất" },
59
+ import: { label: "Nhập" },
60
+ }
61
+
62
+ let actionMetaCache: Record<string, ActionMetaEntry> | null = null
63
+
64
+ export function getActionMetaMap(): Record<string, ActionMetaEntry> {
65
+ if (actionMetaCache) return actionMetaCache
66
+ const map: Record<string, ActionMetaEntry> = { ...BASE_ACTION_META }
67
+ for (const feature of permissionRegistry) {
68
+ for (const a of feature.customActions ?? []) {
69
+ map[a.code] = { label: a.name, description: a.description }
70
+ }
71
+ }
72
+ actionMetaCache = map
73
+ return map
74
+ }
75
+
76
+ export function getLookupPolicyMap(): Record<string, LookupPolicy> {
77
+ const map: Record<string, LookupPolicy> = {}
78
+ resourceByCode.forEach((r, code) => {
79
+ map[code] = r.lookupPolicy ?? "view-only"
80
+ })
81
+ return map
82
+ }
@@ -25,6 +25,17 @@ const masterDataPermissions: FeaturePermissions = {
25
25
  staff: ["view", "export"],
26
26
  },
27
27
  },
28
+ {
29
+ code: "job-title",
30
+ name: "Chức danh",
31
+ group: "Danh mục",
32
+ icon: "Briefcase",
33
+ order: 2,
34
+ actions: ["view", "create", "update", "delete", "export", "import"],
35
+ // Chức danh đổ vào picker hồ sơ nhân sự.
36
+ lookupPolicy: "authenticated",
37
+ defaultGrants: { admin: "*", staff: ["view"] },
38
+ },
28
39
  ],
29
40
  }
30
41
 
@@ -0,0 +1,37 @@
1
+ import { navigations } from "@/data/navigations"
2
+
3
+ /**
4
+ * CÂY MENU cho các màn phân quyền (RoleFormPage, ResourceCatalogPage) — dựng
5
+ * từ navigations. PURE DATA (serializable), dùng được cả server lẫn client.
6
+ */
7
+ export interface MenuTreeItem {
8
+ title: string
9
+ href?: string
10
+ icon?: string
11
+ resource: string
12
+ }
13
+ export interface MenuTreeSection {
14
+ title: string
15
+ icon?: string
16
+ items: MenuTreeItem[]
17
+ }
18
+
19
+ export const MENU_TREE: MenuTreeSection[] = navigations
20
+ .map((section) => ({
21
+ title: section.title,
22
+ icon: (section as { iconName?: string }).iconName,
23
+ items: ((section.items ?? []) as unknown as Array<Record<string, unknown>>)
24
+ .filter((item) => item.resource && item.href)
25
+ .map((item) => ({
26
+ title: item.title as string,
27
+ href: item.href as string,
28
+ icon: item.iconName as string | undefined,
29
+ resource: item.resource as string,
30
+ })),
31
+ }))
32
+ .filter((s) => s.items.length > 0)
33
+
34
+ /** resource → href của trang chính (ActionCatalogPage dùng để link chéo). */
35
+ export const RESOURCE_PAGES: Record<string, string> = Object.fromEntries(
36
+ MENU_TREE.flatMap((s) => s.items.map((i) => [i.resource, i.href ?? ""]))
37
+ )
@@ -64,6 +64,35 @@ const systemPermissions: FeaturePermissions = {
64
64
  },
65
65
 
66
66
  // ── Cấu hình ─────────────────────────────────────────────────────────
67
+ {
68
+ code: "system-category",
69
+ name: "Danh mục hệ thống",
70
+ group: "Hệ thống",
71
+ icon: "FolderTree",
72
+ order: 17,
73
+ actions: ["view", "create", "update", "delete"],
74
+ // Danh mục dùng chung đổ vào form/picker toàn app.
75
+ lookupPolicy: "authenticated",
76
+ defaultGrants: { admin: "*" },
77
+ },
78
+ {
79
+ code: "system-alert",
80
+ name: "Cảnh báo hệ thống",
81
+ group: "Hệ thống",
82
+ icon: "Bell",
83
+ order: 18,
84
+ actions: ["view", "create", "update", "delete", "export"],
85
+ defaultGrants: { admin: "*" },
86
+ },
87
+ {
88
+ code: "system-cache",
89
+ name: "Bộ nhớ đệm",
90
+ group: "Hệ thống",
91
+ icon: "Database",
92
+ order: 19,
93
+ actions: ["view", "update"],
94
+ defaultGrants: { admin: "*" },
95
+ },
67
96
  {
68
97
  code: "system-setting",
69
98
  name: "Cấu hình chung",
@@ -10,7 +10,8 @@ export const tenant: TenantConfig = {
10
10
  branding: {
11
11
  // logo: "/logo.png",
12
12
  companyName: "GoERP Starter",
13
- tagline: "Powered by @goerp/core",
13
+ // Dòng phụ dưới tên app ở sidebar/đăng nhập — để "" ẩn hẳn.
14
+ tagline: "",
14
15
  // primaryColor: "262 83% 58%", // HSL triple → reskins --primary at runtime
15
16
  // favicon: "/favicon.ico",
16
17
  },
@@ -1,8 +1,81 @@
1
1
  import type { DictionaryType } from "@goerp/core/hooks"
2
2
 
3
3
  /**
4
- * i18n dictionary for the shell. The sidebar falls back to the raw nav title
5
- * when a key is missing, so an empty object works to start. Grow it into
6
- * `{ navigation: { ... }, label: { ... } }` as you localize.
4
+ * i18n dictionary của shell + CRUD engine.
5
+ *
6
+ * Block `crud` BẮT BUỘC có: engine CRUD của core tra nhãn qua các key
7
+ * `crud.common.*` / `crud.messages.*` — thiếu thì UI hiện nguyên raw key
8
+ * (vd chip trạng thái hiện "crud.common.options.active" thay vì "Hoạt động").
9
+ * Sidebar thì fall back về title trong navigations nên `navigation` để trống
10
+ * được; localize dần bằng cách thêm `{ navigation: {...}, label: {...} }`.
7
11
  */
8
- export const dictionary: DictionaryType = {}
12
+ export const dictionary: DictionaryType = {
13
+ crud: {
14
+ common: {
15
+ create: "Tạo",
16
+ edit: "Sửa",
17
+ delete: "Xóa",
18
+ save: "Lưu",
19
+ saving: "Đang lưu...",
20
+ cancel: "Hủy",
21
+ submit: "Gửi",
22
+ update: "Cập nhật",
23
+ search: "Tìm kiếm...",
24
+ filter: "Lọc",
25
+ filters: "Bộ lọc",
26
+ export: "Xuất",
27
+ import: "Nhập",
28
+ reset: "Đặt lại",
29
+ clear: "Xóa",
30
+ clearAll: "Xóa tất cả",
31
+ actions: "Hành động",
32
+ noData: "Không có dữ liệu",
33
+ loading: "Đang tải...",
34
+ success: "Thành công",
35
+ error: "Lỗi",
36
+ confirm: "Xác nhận",
37
+ close: "Đóng",
38
+ detail: "Chi tiết",
39
+ selectAll: "Chọn tất cả",
40
+ selected: "đã chọn",
41
+ item: "mục",
42
+ items: "mục",
43
+ add: "Thêm",
44
+ creating: "Đang tạo...",
45
+ updating: "Đang cập nhật...",
46
+ deleting: "Đang xóa...",
47
+ savedSuccessfully: "Đã lưu thành công!",
48
+ createdSuccessfully: "Đã tạo thành công!",
49
+ updatedSuccessfully: "Đã cập nhật thành công!",
50
+ deleteSelected: "Xóa đã chọn",
51
+ addNew: "Thêm mới",
52
+ confirmDeleteTitle: "Xóa {{entity}}?",
53
+ confirmBulkDeleteTitle: "Xóa {{count}} {{entities}}?",
54
+ confirmDeleteDescription:
55
+ 'Bạn có chắc chắn muốn xóa "{{name}}"? Hành động này không thể hoàn tác.',
56
+ confirmBulkDeleteDescription:
57
+ "Bạn có chắc chắn muốn xóa {{count}} {{entities}}? Hành động này không thể hoàn tác.",
58
+ cannotBeUndone: "Hành động này không thể hoàn tác.",
59
+ options: {
60
+ active: "Hoạt động",
61
+ inactive: "Tạm ngưng",
62
+ },
63
+ },
64
+ messages: {
65
+ created: "{{entity}} đã được tạo thành công",
66
+ updated: "{{entity}} đã được cập nhật thành công",
67
+ deleted: "{{entity}} đã được xóa thành công",
68
+ bulkDeleted: "{{count}} {{entities}} đã được xóa thành công",
69
+ deleteConfirm: "Bạn có chắc chắn muốn xóa {{entity}} này không?",
70
+ bulkDeleteConfirm:
71
+ "Bạn có chắc chắn muốn xóa {{count}} {{entities}} không?",
72
+ deleteFailed: "Không thể xóa {{entity}}",
73
+ saveFailed: "Không thể lưu {{entity}}",
74
+ loadFailed: "Không thể tải dữ liệu. Vui lòng thử lại.",
75
+ exportSuccess: 'File "{{filename}}" đã được tải xuống thành công',
76
+ exportFailed: "Xuất dữ liệu thất bại. Vui lòng thử lại.",
77
+ importSuccess: "{{count}} {{entities}} đã được nhập thành công",
78
+ importFailed: "Nhập dữ liệu thất bại. Vui lòng thử lại.",
79
+ },
80
+ },
81
+ }
@@ -25,19 +25,78 @@ export const navigations: NavigationType[] = [
25
25
  href: "/crud/departments",
26
26
  resource: "department",
27
27
  },
28
+ {
29
+ title: "Chức danh",
30
+ iconName: "Briefcase",
31
+ href: "/crud/job-titles",
32
+ resource: "job-title",
33
+ },
28
34
  ],
29
35
  },
30
36
  {
31
37
  title: "Hệ thống",
32
38
  iconName: "ShieldCheck",
33
39
  items: [
34
- { title: "Vai trò", iconName: "ShieldCheck", href: "/roles", resource: "role" },
40
+ {
41
+ title: "Người dùng",
42
+ iconName: "Users",
43
+ href: "/users",
44
+ resource: "user",
45
+ },
46
+ {
47
+ title: "Phiên đăng nhập",
48
+ iconName: "KeyRound",
49
+ href: "/security/sessions",
50
+ resource: "user",
51
+ },
52
+ {
53
+ title: "Vai trò",
54
+ iconName: "ShieldCheck",
55
+ href: "/roles",
56
+ resource: "role",
57
+ },
58
+ {
59
+ title: "Tài nguyên",
60
+ iconName: "Boxes",
61
+ href: "/resources",
62
+ resource: "resource",
63
+ },
64
+ {
65
+ title: "Hành động",
66
+ iconName: "MousePointerClick",
67
+ href: "/actions",
68
+ resource: "action",
69
+ },
70
+ {
71
+ title: "Hồ sơ công ty",
72
+ iconName: "Building",
73
+ href: "/admin/system/company",
74
+ resource: "system-setting",
75
+ },
35
76
  {
36
77
  title: "Cấu hình",
37
78
  iconName: "Settings2",
38
79
  href: "/admin/system/settings",
39
80
  resource: "system-setting",
40
81
  },
82
+ {
83
+ title: "Danh mục hệ thống",
84
+ iconName: "FolderTree",
85
+ href: "/system-categories",
86
+ resource: "system-category",
87
+ },
88
+ {
89
+ title: "Cảnh báo hệ thống",
90
+ iconName: "Bell",
91
+ href: "/crud/system-alerts",
92
+ resource: "system-alert",
93
+ },
94
+ {
95
+ title: "Bộ nhớ đệm",
96
+ iconName: "Database",
97
+ href: "/admin/system/cache",
98
+ resource: "system-cache",
99
+ },
41
100
  {
42
101
  title: "Nhật ký hoạt động",
43
102
  iconName: "Scroll",
@@ -42,23 +42,12 @@ export async function register() {
42
42
  }
43
43
 
44
44
  /**
45
- * BẪY lịch chạy: bộ hẹn giờ của core quy cron về `setInterval` TỪ LÚC BOOT —
46
- * "0 1 * * *" thành "cứ 24 giờ kể từ khi khởi động", không phải "1 giờ sáng".
47
- * Container deploy vài lần một ngày thì job hằng ngày gần như không bao giờ
48
- * chạy đúng giờ mong muốn.
45
+ * Từ core 0.1.71, biểu thức cron 5 trường chạy ĐÚNG GIỜ ĐỊA PHƯƠNG của tiến
46
+ * trình ("0 1 * * *" = 1 giờ sáng thật Dockerfile đã set TZ=Asia/Ho_Chi_Minh)
47
+ * nên job hằng ngày cứ khai lịch thẳng, KHÔNG cần mẹo gác giờ nữa.
49
48
  *
50
- * Cách chữa: hẹn MỖI GIỜ (`0 * * * *`) rồi tự gác bằng hàm này. Mỗi giờ đồng
51
- * hồ chỉ đúng một tick nên không chạy trùng.
52
- *
53
- * cronManager.addJob({
54
- * name: "daily-report",
55
- * cronTime: "0 * * * *",
56
- * start: true,
57
- * onTick: async () => {
58
- * if (!isLocalHour(1)) return
59
- * await sendDailyReport()
60
- * },
61
- * })
49
+ * Hàm dưới chỉ còn cho ca đặc thù: job muốn gác theo MỘT múi giờ KHÁC múi giờ
50
+ * tiến trình (vd server chạy UTC nhưng nghiệp vụ theo giờ VN).
62
51
  */
63
52
  export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
64
53
  return (
@@ -0,0 +1,25 @@
1
+ import { checkPermission } from "@goerp/core/auth"
2
+
3
+ import type { Session } from "@/types/session"
4
+
5
+ import { getSession } from "@/lib/auth"
6
+
7
+ /**
8
+ * Cổng cho SERVER ACTION — tương đương apiHandler({resource, action}) của tầng
9
+ * API. Action là endpoint POST thật sự (mọi client đã đăng nhập gọi được) nên
10
+ * PHẢI gác quyền; guardrail `auth/server-action-bare-session` cấm gọi
11
+ * getSession() trần trong file "use server" để ép đi qua đây.
12
+ *
13
+ * const session = await requirePermission("system-setting", "update")
14
+ */
15
+ export async function requirePermission(
16
+ resource: string,
17
+ action = "view"
18
+ ): Promise<Session> {
19
+ const session = await getSession()
20
+ if (!session?.user) throw new Error("Unauthorized")
21
+ if (!checkPermission(session, resource, action)) {
22
+ throw new Error(`Forbidden: ${resource}:${action}`)
23
+ }
24
+ return session
25
+ }
@@ -15,13 +15,11 @@
15
15
  import { NextResponse } from "next/server"
16
16
  import { withAuditContext } from "@goerp/core/audit"
17
17
  import { checkPermission as coreCheckPermission } from "@goerp/core/auth"
18
- import {
19
- createApiHandler,
20
- type ApiHandlerMiddleware,
21
- } from "@goerp/core/auth/api-handler"
18
+ import { createApiHandler } from "@goerp/core/auth/api-handler"
22
19
 
23
- import type { NextRequest } from "next/server"
24
20
  import type { Session } from "@/types/session"
21
+ import type { ApiHandlerMiddleware } from "@goerp/core/auth/api-handler"
22
+ import type { NextRequest } from "next/server"
25
23
 
26
24
  import { getRegistryResource } from "@/configs/permissions"
27
25
  import { getSession } from "@/lib/auth"
@@ -40,7 +38,7 @@ interface HandlerContext<TParams = Record<string, string>> {
40
38
 
41
39
  type Handler<TParams = Record<string, string>> = (
42
40
  req: NextRequest,
43
- ctx: HandlerContext<TParams>,
41
+ ctx: HandlerContext<TParams>
44
42
  ) => Promise<Response>
45
43
 
46
44
  interface ApiHandlerOptions {
@@ -70,7 +68,10 @@ function assertResourceDeclared(resource: string) {
70
68
  * trong lib/prisma.ts đọc context này; thiếu nó thì mọi dòng audit_logs đều
71
69
  * ghi tác nhân rỗng.
72
70
  */
73
- const auditContext: ApiHandlerMiddleware<Session> = async (next, { req, session }) => {
71
+ const auditContext: ApiHandlerMiddleware<Session> = async (
72
+ next,
73
+ { req, session }
74
+ ) => {
74
75
  if (!session) return next()
75
76
  const ip =
76
77
  req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
@@ -78,7 +79,7 @@ const auditContext: ApiHandlerMiddleware<Session> = async (next, { req, session
78
79
  undefined
79
80
  return (await withAuditContext(
80
81
  { userId: session.user.id, userName: session.user.name, ip },
81
- () => next(),
82
+ () => next()
82
83
  )) as Response
83
84
  }
84
85
 
@@ -87,11 +88,14 @@ const auditContext: ApiHandlerMiddleware<Session> = async (next, { req, session
87
88
  * biết user được xem chi nhánh nào. Phạm vi được tính LƯỜI — request không đụng
88
89
  * model nào bị guard thì `resolve` không bao giờ chạy.
89
90
  */
90
- const branchScope: ApiHandlerMiddleware<Session> = async (next, { session }) => {
91
+ const branchScope: ApiHandlerMiddleware<Session> = async (
92
+ next,
93
+ { session }
94
+ ) => {
91
95
  if (!session) return next()
92
96
  return (await runWithBranchScope(
93
97
  { resolve: () => getBranchScope(session) },
94
- () => next(),
98
+ () => next()
95
99
  )) as Response
96
100
  }
97
101
 
@@ -108,7 +112,8 @@ async function onApiError(error: unknown, req: Request): Promise<Response> {
108
112
  userAgent: req.headers.get("user-agent") || undefined,
109
113
  })
110
114
 
111
- const message = error instanceof Error ? error.message : "Internal Server Error"
115
+ const message =
116
+ error instanceof Error ? error.message : "Internal Server Error"
112
117
  return NextResponse.json(
113
118
  {
114
119
  error: message,
@@ -116,9 +121,14 @@ async function onApiError(error: unknown, req: Request): Promise<Response> {
116
121
  // đang in — production thì tuyệt đối không lộ.
117
122
  ...(process.env.NODE_ENV === "production"
118
123
  ? {}
119
- : { debug: { message, stack: error instanceof Error ? error.stack : undefined } }),
124
+ : {
125
+ debug: {
126
+ message,
127
+ stack: error instanceof Error ? error.stack : undefined,
128
+ },
129
+ }),
120
130
  },
121
- { status: 500 },
131
+ { status: 500 }
122
132
  )
123
133
  }
124
134
 
@@ -128,9 +138,7 @@ const coreApiHandler = createApiHandler<Session>({
128
138
  const session = await getSession()
129
139
  return session?.user ? session : null
130
140
  },
131
- checkPermission: (session, resource, action) =>
132
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
- coreCheckPermission(session as any, resource, action),
141
+ checkPermission: coreCheckPermission,
134
142
  assertResource: assertResourceDeclared,
135
143
  wrap: [auditContext, branchScope],
136
144
  onError: onApiError,
@@ -138,7 +146,7 @@ const coreApiHandler = createApiHandler<Session>({
138
146
 
139
147
  export function apiHandler<TParams = Record<string, string>>(
140
148
  handler: Handler<TParams>,
141
- options?: ApiHandlerOptions,
149
+ options?: ApiHandlerOptions
142
150
  ) {
143
151
  const route = coreApiHandler(
144
152
  (req, ctx) =>
@@ -147,11 +155,11 @@ export function apiHandler<TParams = Record<string, string>>(
147
155
  session: ctx.session ?? ({} as Session),
148
156
  params: ctx.params as Promise<TParams>,
149
157
  }),
150
- options,
158
+ options
151
159
  )
152
160
  return async (
153
161
  req: NextRequest,
154
162
  // Route tĩnh (không có segment động) được Next gọi mà không kèm ctx.
155
- routeCtx?: { params: Promise<TParams> },
163
+ routeCtx?: { params: Promise<TParams> }
156
164
  ): Promise<Response> => route(req, routeCtx as never)
157
165
  }
@@ -50,7 +50,9 @@ export const getSession = cache(async (): Promise<Session | null> => {
50
50
  return {
51
51
  user: result.user,
52
52
  expires:
53
- expiresAt instanceof Date ? expiresAt.toISOString() : String(expiresAt ?? ""),
53
+ expiresAt instanceof Date
54
+ ? expiresAt.toISOString()
55
+ : String(expiresAt ?? ""),
54
56
  } as unknown as Session
55
57
  })
56
58
 
@@ -6,6 +6,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"
6
6
  import { APIError } from "better-auth/api"
7
7
  import { customSession } from "better-auth/plugins"
8
8
 
9
+ import { tenant } from "@/configs/tenant"
9
10
  import { db } from "@/lib/prisma"
10
11
  import { loadUserAccess } from "@/lib/rbac/access"
11
12
 
@@ -28,6 +29,13 @@ function buildAuth() {
28
29
  secret: process.env.BETTER_AUTH_SECRET,
29
30
  database: prismaAdapter(db, { provider: "postgresql" }),
30
31
 
32
+ // Cookie mang tên riêng của app (`<tenant.id>.session_token`): cookie theo
33
+ // HOST không phân biệt port, nên dev nhiều app GoERP trên cùng localhost mà
34
+ // dùng tên mặc định `better-auth.*` là app này mở khoá proxy app kia.
35
+ // proxy.ts phải truyền CÙNG prefix vào getSessionCookie — đổi một nơi thì
36
+ // đổi cả hai.
37
+ advanced: { cookiePrefix: tenant.id },
38
+
31
39
  rateLimit: {
32
40
  enabled: true,
33
41
  window: 60,
@@ -27,7 +27,7 @@ import type { Session } from "@/types/session"
27
27
  export function canViewAllBranches(session: Session): boolean {
28
28
  return Boolean(
29
29
  session.user?.roles?.includes("admin") ||
30
- checkPermission(session as never, "branch", "view-all-branches"),
30
+ checkPermission(session, "branch", "view-all-branches")
31
31
  )
32
32
  }
33
33
 
@@ -1,13 +1,8 @@
1
- import { createServerCrudService, getModelName } from "@goerp/core/crud/server"
1
+ import { createServerCrudService } from "@goerp/core/crud/server"
2
2
 
3
3
  import { db } from "@/lib/prisma"
4
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
- })
5
+ // Engine CRUD generic ở CORE — app chỉ tiêm prisma. Tên model Prisma khai
6
+ // ngay trong từng entity config (`modelName: "jobTitle"`…), không còn
7
+ // MODEL_MAP song song ở đây.
8
+ export const crudService = createServerCrudService({ prisma: db })
@@ -1,7 +1,8 @@
1
- import {
2
- createErrorLogger,
3
- type ErrorLoggerDb,
4
- type ServerErrorContext,
1
+ import { createErrorLogger } from "@goerp/core/errors/server-error"
2
+
3
+ import type {
4
+ ErrorLoggerDb,
5
+ ServerErrorContext,
5
6
  } from "@goerp/core/errors/server-error"
6
7
 
7
8
  import { db } from "@/lib/prisma"
@@ -12,9 +12,12 @@ type LogContext = Record<string, unknown>
12
12
  * được stack — bọc lại ở đây thay vì bắt mỗi call site tự trải ra.
13
13
  */
14
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),
15
+ info: (message: string, context?: LogContext) =>
16
+ coreLogger.info(message, context),
17
+ warn: (message: string, context?: LogContext) =>
18
+ coreLogger.warn(message, context),
19
+ debug: (message: string, context?: LogContext) =>
20
+ coreLogger.debug(message, context),
18
21
  error: (message: string, error?: unknown, context?: LogContext) =>
19
22
  coreLogger.error(message, {
20
23
  ...context,
@@ -22,7 +25,11 @@ export const logger = {
22
25
  ? {
23
26
  error:
24
27
  error instanceof Error
25
- ? { name: error.name, message: error.message, stack: error.stack }
28
+ ? {
29
+ name: error.name,
30
+ message: error.message,
31
+ stack: error.stack,
32
+ }
26
33
  : error,
27
34
  }
28
35
  : {}),
@@ -22,14 +22,13 @@ import { getSession } from "@/lib/auth"
22
22
  export async function requirePageAccess(
23
23
  lang: string,
24
24
  resource: string,
25
- action = "view",
25
+ action = "view"
26
26
  ): Promise<Session> {
27
27
  const session = await getSession()
28
28
  const locale = lang || i18n.defaultLocale
29
29
 
30
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}`)
31
+ if (!checkPermission(session, resource, action)) redirect(`/${locale}`)
33
32
 
34
33
  return session
35
34
  }
@@ -1,8 +1,8 @@
1
- import { PrismaPg } from "@prisma/adapter-pg"
2
- import { Prisma, PrismaClient } from "@prisma/client"
3
1
  import { createAuditExtension } from "@goerp/core/audit"
4
2
  import { createBranchGuardExtension } from "@goerp/core/branch-scope"
5
3
  import { configureSettingsService } from "@goerp/core/system/services/settings-service"
4
+ import { PrismaPg } from "@prisma/adapter-pg"
5
+ import { Prisma, PrismaClient } from "@prisma/client"
6
6
 
7
7
  /**
8
8
  * COMPOSITION ROOT của app — nơi duy nhất tạo Prisma client và cắm dây cho các
@@ -14,8 +14,9 @@ import { configureSettingsService } from "@goerp/core/system/services/settings-s
14
14
  * 3. bọc branch-guard (lưới an toàn phạm vi chi nhánh cho thao tác ĐỌC);
15
15
  * 4. gọi configureSettingsService(db) để core đọc SystemConfig thật.
16
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.
17
+ * Các seam còn lại cấu hình chỗ chúng sống: tasks src/server/tasks/,
18
+ * notifications → src/server/services/notification-service.ts, cron
19
+ * src/lib/cron/db-cron-manager.ts (khởi động từ src/instrumentation.ts).
19
20
  */
20
21
  const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
21
22
 
@@ -70,10 +71,18 @@ export const db = rawClient
70
71
  rawClient,
71
72
  // 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
73
  // 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
- }),
74
+ skipModels: [
75
+ "AuditLog",
76
+ "ErrorLog",
77
+ "Session",
78
+ "Account",
79
+ "Verification",
80
+ ],
81
+ })
75
82
  )
76
- .$extends(createBranchGuardExtension({ models: GUARDED_MODELS })) as unknown as PrismaClient
83
+ .$extends(
84
+ createBranchGuardExtension({ models: GUARDED_MODELS })
85
+ ) as unknown as PrismaClient
77
86
 
78
87
  // Core đọc SystemConfig qua service này. Thiếu dòng dưới thì mọi getSettings()
79
88
  // lặng lẽ trả giá trị mặc định và saveSettings() ném lỗi.