@goplusvn/core 0.1.59 → 0.1.61

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 (142) 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/crud/lib/mutation-builder.ts +105 -0
  8. package/src/crud/lib/query-builder.ts +119 -0
  9. package/src/crud/server-service.ts +35 -163
  10. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  11. package/src/infrastructure/index.ts +4 -7
  12. package/src/ui/management/index.ts +3 -2
  13. package/templates/starter-app/.dockerignore +41 -0
  14. package/templates/starter-app/.env.example +25 -0
  15. package/templates/starter-app/AGENTS.md +52 -0
  16. package/templates/starter-app/Dockerfile +74 -0
  17. package/templates/starter-app/README.md +141 -0
  18. package/templates/starter-app/gitignore +9 -0
  19. package/templates/starter-app/next.config.mjs +50 -0
  20. package/templates/starter-app/package.json +55 -0
  21. package/templates/starter-app/postcss.config.mjs +5 -0
  22. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  23. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  24. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  25. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  26. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  27. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  28. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  29. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  30. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  31. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  32. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  33. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  34. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  35. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  36. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  37. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  38. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  39. package/templates/starter-app/prisma/seed.ts +127 -0
  40. package/templates/starter-app/prisma.config.ts +20 -0
  41. package/templates/starter-app/public/.gitkeep +2 -0
  42. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  43. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  54. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  55. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  56. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  57. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  58. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  59. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  60. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  61. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  62. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  63. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  64. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  66. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  67. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  68. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  69. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  70. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  71. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  72. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  73. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  74. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  75. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  76. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  77. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  78. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  79. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  80. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  81. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  82. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  83. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  84. package/templates/starter-app/src/app/globals.css +15 -0
  85. package/templates/starter-app/src/app/layout.tsx +16 -0
  86. package/templates/starter-app/src/app/page.tsx +8 -0
  87. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  88. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  89. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  90. package/templates/starter-app/src/configs/i18n.ts +12 -0
  91. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  92. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  93. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  94. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  95. package/templates/starter-app/src/configs/tenant.ts +18 -0
  96. package/templates/starter-app/src/data/dictionary.ts +8 -0
  97. package/templates/starter-app/src/data/navigations.ts +61 -0
  98. package/templates/starter-app/src/instrumentation.ts +105 -0
  99. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  100. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  101. package/templates/starter-app/src/lib/auth.ts +62 -0
  102. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  103. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  104. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  105. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  106. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  107. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  108. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  109. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  110. package/templates/starter-app/src/lib/logger.ts +30 -0
  111. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  112. package/templates/starter-app/src/lib/prisma.ts +80 -0
  113. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  114. package/templates/starter-app/src/lib/storage.ts +28 -0
  115. package/templates/starter-app/src/providers/index.tsx +54 -0
  116. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  117. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  118. package/templates/starter-app/src/proxy.ts +45 -0
  119. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  120. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  121. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  122. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  123. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  124. package/templates/starter-app/src/types/session.ts +29 -0
  125. package/templates/starter-app/tsconfig.json +47 -0
  126. package/templates/starter-app/vitest.config.ts +17 -0
  127. package/src/infrastructure/cron/index.ts +0 -6
  128. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  129. package/src/infrastructure/event-bus/index.ts +0 -2
  130. package/src/infrastructure/event-bus/types.ts +0 -22
  131. package/src/infrastructure/lock/decorators.ts +0 -67
  132. package/src/infrastructure/lock/index.ts +0 -2
  133. package/src/infrastructure/lock/lock-manager.ts +0 -33
  134. package/src/plugin/apps-registry.ts +0 -97
  135. package/src/plugin/index.ts +0 -5
  136. package/src/plugin/types.ts +0 -41
  137. package/src/ui/management/audit-log-page.tsx +0 -14
  138. package/src/ui/management/job-management.tsx +0 -308
  139. package/src/workflow/activity-timeline.tsx +0 -412
  140. package/src/workflow/approval-workflow.tsx +0 -31
  141. package/src/workflow/index.ts +0 -2
  142. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -0,0 +1,131 @@
1
+ import type { FeaturePermissions } from "./types"
2
+
3
+ /**
4
+ * Quản trị hệ thống — nhóm resource đi kèm các trang có sẵn của @goerp/core
5
+ * (phân quyền, cấu hình, nhật ký, tác vụ nền). Giữ nguyên MÃ resource: chúng
6
+ * khớp với đường dẫn trang và với chuỗi checkPermission trong route.
7
+ *
8
+ * Bộ action khai theo NGHĨA thực: nhật ký thì không có create/update, tác vụ
9
+ * nền thì không xoá bằng tay…
10
+ */
11
+ const systemPermissions: FeaturePermissions = {
12
+ feature: "system",
13
+ description: "Quản trị hệ thống: phân quyền, cấu hình, nhật ký",
14
+ customActions: [
15
+ {
16
+ code: "cancel",
17
+ name: "Hủy",
18
+ description: "Dừng một tác vụ nền đang chạy hoặc đang chờ.",
19
+ },
20
+ {
21
+ code: "view-all-branches",
22
+ name: "Xem mọi chi nhánh",
23
+ description:
24
+ "Bỏ giới hạn phạm vi dữ liệu: thấy chứng từ của mọi chi nhánh, không chỉ chi nhánh được gán (src/lib/branch-scope.ts).",
25
+ },
26
+ ],
27
+ resources: [
28
+ // ── Phân quyền ───────────────────────────────────────────────────────
29
+ {
30
+ code: "role",
31
+ name: "Vai trò",
32
+ group: "Phân quyền",
33
+ icon: "ShieldCheck",
34
+ order: 10,
35
+ actions: ["view", "create", "update", "delete", "export"],
36
+ defaultGrants: { admin: "*" },
37
+ },
38
+ {
39
+ code: "resource",
40
+ name: "Tài nguyên",
41
+ group: "Phân quyền",
42
+ icon: "Database",
43
+ order: 11,
44
+ actions: ["view", "create", "update", "delete"],
45
+ defaultGrants: { admin: "*" },
46
+ },
47
+ {
48
+ code: "action",
49
+ name: "Hành động",
50
+ group: "Phân quyền",
51
+ icon: "Zap",
52
+ order: 12,
53
+ actions: ["view", "create", "update", "delete"],
54
+ defaultGrants: { admin: "*" },
55
+ },
56
+ {
57
+ code: "user",
58
+ name: "Người dùng",
59
+ group: "Phân quyền",
60
+ icon: "Users",
61
+ order: 13,
62
+ actions: ["view", "create", "update", "delete", "export"],
63
+ defaultGrants: { admin: "*" },
64
+ },
65
+
66
+ // ── Cấu hình ─────────────────────────────────────────────────────────
67
+ {
68
+ code: "system-setting",
69
+ name: "Cấu hình chung",
70
+ group: "Hệ thống",
71
+ icon: "Settings",
72
+ order: 20,
73
+ actions: ["view", "create", "update", "delete", "export"],
74
+ defaultGrants: { admin: "*" },
75
+ },
76
+ {
77
+ code: "branch",
78
+ name: "Chi nhánh",
79
+ group: "Hệ thống",
80
+ icon: "Building",
81
+ order: 21,
82
+ // "view-all-branches" là quyền PHẠM VI DỮ LIỆU, không phải một màn hình:
83
+ // cấp cho vai trò nào thì vai trò đó thoát bộ lọc chi nhánh ở mọi nơi.
84
+ actions: ["view", "create", "update", "delete", "view-all-branches"],
85
+ // Mọi người đều cần đọc danh sách chi nhánh để đổ vào bộ lọc/picker.
86
+ lookupPolicy: "authenticated",
87
+ defaultGrants: { admin: "*" },
88
+ },
89
+
90
+ // ── Nhật ký & giám sát ───────────────────────────────────────────────
91
+ {
92
+ code: "audit-log",
93
+ name: "Nhật ký hoạt động",
94
+ group: "Hệ thống",
95
+ icon: "Scroll",
96
+ order: 30,
97
+ // Nhật ký chỉ đọc/xuất/dọn — không create/update.
98
+ actions: ["view", "delete", "export"],
99
+ defaultGrants: { admin: "*" },
100
+ },
101
+ {
102
+ code: "error-log",
103
+ name: "Nhật ký lỗi",
104
+ group: "Hệ thống",
105
+ icon: "Bug",
106
+ order: 31,
107
+ actions: ["view", "update", "delete"],
108
+ defaultGrants: { admin: "*" },
109
+ },
110
+ {
111
+ code: "system-job",
112
+ name: "Tác vụ định kỳ",
113
+ group: "Hệ thống",
114
+ icon: "Cpu",
115
+ order: 32,
116
+ actions: ["view", "update"],
117
+ defaultGrants: { admin: "*" },
118
+ },
119
+ {
120
+ code: "background-task",
121
+ name: "Tác vụ nền",
122
+ group: "Hệ thống",
123
+ icon: "ListChecks",
124
+ order: 33,
125
+ actions: ["view", "cancel"],
126
+ defaultGrants: { admin: "*", staff: ["view"] },
127
+ },
128
+ ],
129
+ }
130
+
131
+ export default systemPermissions
@@ -0,0 +1,63 @@
1
+ /**
2
+ * PERMISSION REGISTRY — nguồn chân lý DUY NHẤT về quyền của app.
3
+ *
4
+ * Mỗi phân hệ có một file `<feature>.permissions.ts` khai đủ resource, action,
5
+ * nhóm hiển thị và grant mặc định. `scripts/rbac-sync.ts` đọc registry rồi
6
+ * upsert xuống DB lúc deploy — nhờ vậy không còn cảnh mỗi tính năng mới lại
7
+ * thêm một script vá RBAC rời rạc, rồi quên chạy trên production.
8
+ *
9
+ * RÀNG BUỘC: file trong thư mục này phải là DỮ LIỆU THUẦN — không import
10
+ * prisma/next/react. Script tsx và test đọc chúng trực tiếp, kéo theo app là
11
+ * hỏng cả hai.
12
+ */
13
+
14
+ /**
15
+ * Chính sách đọc-để-tra-cứu: cho phép đổ dữ liệu vào combo/picker mà không cần
16
+ * quyền `view` của trang quản lý. Áp tại GET danh sách, KHÔNG áp cho trang quản lý.
17
+ *
18
+ * - "authenticated": ai đăng nhập cũng tra được (danh mục vô hại: kho, đơn vị tính…)
19
+ * - "view-only": không có đường tắt, đọc là phải có `<resource>:view`. MẶC ĐỊNH.
20
+ */
21
+ export type LookupPolicy = "authenticated" | "view-only"
22
+
23
+ export interface ActionDeclaration {
24
+ code: string
25
+ name: string
26
+ /** Dùng làm gì, ở trang nào — hiện thành tooltip trên trang phân quyền. */
27
+ description?: string
28
+ }
29
+
30
+ export interface ResourceDeclaration {
31
+ /** Mã resource — PHẢI khớp chuỗi dùng trong checkPermission và navigations. */
32
+ code: string
33
+ /** Tên hiển thị trên trang phân quyền. */
34
+ name: string
35
+ /** Phân hệ — ma trận quyền nhóm theo cột này. */
36
+ group: string
37
+ description?: string
38
+ icon?: string
39
+ order?: number
40
+ /**
41
+ * Action áp dụng cho resource. Action ngoài bộ chuẩn
42
+ * (view/create/update/delete/export/import) phải khai trong `customActions`
43
+ * của feature, nếu không rbac-sync sẽ dừng và báo lỗi.
44
+ */
45
+ actions: readonly string[]
46
+ /** Mặc định "view-only". */
47
+ lookupPolicy?: LookupPolicy
48
+ /**
49
+ * Grant mặc định roleCode → action ("*" = mọi action đã khai). CHỈ seed khi
50
+ * resource xuất hiện LẦN ĐẦU — sync không bao giờ ghi đè chỉnh sửa của admin.
51
+ */
52
+ defaultGrants?: Readonly<Record<string, readonly string[] | "*">>
53
+ }
54
+
55
+ export interface FeaturePermissions {
56
+ /** Định danh phân hệ, trùng tên file (bỏ đuôi .permissions.ts). */
57
+ feature: string
58
+ /** Mô tả ngắn — hiện thành nhãn nguồn gốc trên trang Tài nguyên. */
59
+ description?: string
60
+ /** Action riêng của phân hệ (approve, confirm-payment…). */
61
+ customActions?: readonly ActionDeclaration[]
62
+ resources: readonly ResourceDeclaration[]
63
+ }
@@ -0,0 +1,18 @@
1
+ import type { TenantConfig } from "@goerp/core/providers"
2
+
3
+ /**
4
+ * Branding + tenant config for this app. Editing this re-skins the whole app —
5
+ * logo, name and (via `primaryColor`) the brand color, with no globals.css edit.
6
+ */
7
+ export const tenant: TenantConfig = {
8
+ id: "starter",
9
+ name: "GoERP Starter",
10
+ branding: {
11
+ // logo: "/logo.png",
12
+ companyName: "GoERP Starter",
13
+ tagline: "Powered by @goerp/core",
14
+ // primaryColor: "262 83% 58%", // HSL triple → reskins --primary at runtime
15
+ // favicon: "/favicon.ico",
16
+ },
17
+ currency: "VND",
18
+ }
@@ -0,0 +1,8 @@
1
+ import type { DictionaryType } from "@goerp/core/hooks"
2
+
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.
7
+ */
8
+ export const dictionary: DictionaryType = {}
@@ -0,0 +1,61 @@
1
+ import type { NavigationType } from "@goerp/core/types"
2
+
3
+ /**
4
+ * Sidebar navigation. Each top-level entry is a GROUP (title + iconName) with
5
+ * `items`. `iconName` is a lucide icon name (PascalCase). `resource` ties an
6
+ * item to an RBAC resource code so it hides when the user lacks permission.
7
+ *
8
+ * Entity pages go through the generic route /crud/<key> (see
9
+ * src/app/[lang]/(main)/crud/[entity]/page.tsx) — the `<key>` must match the
10
+ * registry key in src/configs/entities and the CRUD model map.
11
+ */
12
+ export const navigations: NavigationType[] = [
13
+ {
14
+ title: "Tổng quan",
15
+ iconName: "LayoutDashboard",
16
+ items: [{ title: "Trang chủ", iconName: "House", href: "/" }],
17
+ },
18
+ {
19
+ title: "Quản lý",
20
+ iconName: "Settings",
21
+ items: [
22
+ {
23
+ title: "Phòng ban",
24
+ iconName: "Building2",
25
+ href: "/crud/departments",
26
+ resource: "department",
27
+ },
28
+ ],
29
+ },
30
+ {
31
+ title: "Hệ thống",
32
+ iconName: "ShieldCheck",
33
+ items: [
34
+ { title: "Vai trò", iconName: "ShieldCheck", href: "/roles", resource: "role" },
35
+ {
36
+ title: "Cấu hình",
37
+ iconName: "Settings2",
38
+ href: "/admin/system/settings",
39
+ resource: "system-setting",
40
+ },
41
+ {
42
+ title: "Nhật ký hoạt động",
43
+ iconName: "Scroll",
44
+ href: "/admin/system/audit",
45
+ resource: "audit-log",
46
+ },
47
+ {
48
+ title: "Nhật ký lỗi",
49
+ iconName: "Bug",
50
+ href: "/admin/system/error-logs",
51
+ resource: "error-log",
52
+ },
53
+ {
54
+ title: "Tác vụ định kỳ",
55
+ iconName: "Cpu",
56
+ href: "/admin/system/jobs",
57
+ resource: "system-job",
58
+ },
59
+ ],
60
+ },
61
+ ]
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Điểm khởi động phía server của Next.js. Chạy MỘT LẦN mỗi tiến trình, trước
3
+ * request đầu tiên — chỗ duy nhất hợp lý để bật cron và dọn tác vụ mồ côi.
4
+ */
5
+
6
+ export async function register() {
7
+ // Hook này cũng chạy trên edge runtime, nơi không có Prisma. Chặn sớm.
8
+ if (process.env.NEXT_RUNTIME !== "nodejs") return
9
+
10
+ const { cronManager } = await import("./lib/cron/db-cron-manager")
11
+ const { logger } = await import("./lib/logger")
12
+
13
+ try {
14
+ await cronManager.init()
15
+
16
+ // Tác vụ nền còn "running" là của tiến trình ĐÃ CHẾT (deploy/restart giữa
17
+ // chừng) — đánh dấu lỗi để user chạy lại, thay vì để quay vòng vĩnh viễn.
18
+ void import("@/server/tasks")
19
+ .then(({ reclaimStaleTasks }) => reclaimStaleTasks())
20
+ .catch((e) => logger.error("Reclaim background tasks failed", e))
21
+
22
+ // ── Việc định kỳ ────────────────────────────────────────────────────
23
+ // Thêm job của bạn ở đây. Lịch chạy lưu trong bảng `system_jobs`, admin
24
+ // đổi được trên /admin/system/jobs mà không cần deploy lại; `cronTime` bên
25
+ // dưới chỉ là giá trị mặc định cho lần tạo đầu tiên.
26
+ //
27
+ // Job PHẢI idempotent: container restart có thể làm nó chạy lại.
28
+ cronManager.addJob({
29
+ name: "system-heartbeat",
30
+ cronTime: "0 * * * *",
31
+ start: true,
32
+ onTick: async () => {
33
+ logger.debug("heartbeat")
34
+ },
35
+ })
36
+
37
+ logger.info("System jobs registered")
38
+ } catch (error) {
39
+ // Cron hỏng thì app vẫn phải phục vụ được request.
40
+ logger.error("Failed to register system jobs", error)
41
+ }
42
+ }
43
+
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.
49
+ *
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ỉ có đú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
+ * })
62
+ */
63
+ export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
64
+ return (
65
+ Number(
66
+ new Intl.DateTimeFormat("en-GB", {
67
+ hour: "numeric",
68
+ hour12: false,
69
+ timeZone,
70
+ }).format(new Date()),
71
+ ) === hour
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Lưới an toàn cho lỗi KHÔNG được bắt: route handler ném ra ngoài, lỗi render
77
+ * RSC… Chỗ nào tự bắt rồi trả response thì dùng `serverError()`; hook này lo
78
+ * phần còn lại để một cú crash vẫn để lại stack trong `error_logs` chứ không
79
+ * chỉ trôi qua stdout.
80
+ */
81
+ export async function onRequestError(
82
+ error: unknown,
83
+ request: { path?: string; method?: string; headers?: Record<string, string> },
84
+ context: { routePath?: string; routeType?: string; routerKind?: string },
85
+ ) {
86
+ // logServerError kéo Prisma vào — không có trên edge runtime.
87
+ if (process.env.NEXT_RUNTIME !== "nodejs") return
88
+ try {
89
+ const { logServerError } = await import("./lib/errors/log-server-error")
90
+ logServerError(error, {
91
+ url: request?.path,
92
+ method: request?.method,
93
+ userAgent: request?.headers?.["user-agent"],
94
+ severity: "error",
95
+ extra: {
96
+ source: "onRequestError",
97
+ routePath: context?.routePath,
98
+ routeType: context?.routeType,
99
+ routerKind: context?.routerKind,
100
+ },
101
+ })
102
+ } catch {
103
+ // Không bao giờ để việc ghi lỗi tự ném lỗi bên trong hook xử lý lỗi.
104
+ }
105
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * CỔNG của tầng API. Mọi route handler đi qua đây thay vì tự viết chuỗi
3
+ * `getSession → 401 → checkPermission → 403 → try/catch`.
4
+ *
5
+ * export const GET = apiHandler(handler) // chỉ cần đăng nhập
6
+ * export const POST = apiHandler(handler, { resource: "department", action: "create" })
7
+ * export const GET = apiHandler(handler, { public: true }) // không gác
8
+ *
9
+ * Khung ở @goerp/core/auth/api-handler; file này cắm phần thuộc về app:
10
+ * - nguồn session + luật quyền,
11
+ * - kiểm resource có trong Permission Registry (bắt lỗi gõ sai ngay lúc load),
12
+ * - audit-context (ai đang thao tác — extension Prisma đọc để ghi audit_logs),
13
+ * - ghi lỗi vào error_logs.
14
+ */
15
+ import { NextResponse } from "next/server"
16
+ import { withAuditContext } from "@goerp/core/audit"
17
+ import { checkPermission as coreCheckPermission } from "@goerp/core/auth"
18
+ import {
19
+ createApiHandler,
20
+ type ApiHandlerMiddleware,
21
+ } from "@goerp/core/auth/api-handler"
22
+
23
+ import type { NextRequest } from "next/server"
24
+ import type { Session } from "@/types/session"
25
+
26
+ import { getRegistryResource } from "@/configs/permissions"
27
+ import { getSession } from "@/lib/auth"
28
+ import { getBranchScope, runWithBranchScope } from "@/lib/branch-scope"
29
+ import { logServerError } from "@/lib/errors/log-server-error"
30
+
31
+ /**
32
+ * `TParams` mở ra cho route catch-all: `/api/files/[...key]` nhận
33
+ * `{ key: string[] }` chứ không phải `Record<string, string>`, khai cứng thì
34
+ * validator sinh tự động của Next báo lỗi kiểu ở mọi route dạng đó.
35
+ */
36
+ interface HandlerContext<TParams = Record<string, string>> {
37
+ session: Session
38
+ params: Promise<TParams>
39
+ }
40
+
41
+ type Handler<TParams = Record<string, string>> = (
42
+ req: NextRequest,
43
+ ctx: HandlerContext<TParams>,
44
+ ) => Promise<Response>
45
+
46
+ interface ApiHandlerOptions {
47
+ resource?: string
48
+ action?: string
49
+ /** true → endpoint public, bỏ qua cả xác thực. */
50
+ public?: boolean
51
+ }
52
+
53
+ /**
54
+ * Resource phải tồn tại trong Permission Registry. Gõ sai một ký tự sẽ khiến
55
+ * quyền KHÔNG BAO GIỜ khớp — nút biến mất, API trả 403, mà không có thông báo
56
+ * nào. Chạy lúc đăng ký handler (module load), không phải mỗi request: dev/test
57
+ * ném lỗi ngay, production chỉ cảnh báo để drift không làm sập app đang chạy.
58
+ */
59
+ function assertResourceDeclared(resource: string) {
60
+ if (getRegistryResource(resource)) return
61
+ const message =
62
+ `[api-handler] resource "${resource}" chưa khai trong Permission Registry ` +
63
+ `(src/configs/permissions) — thêm vào <feature>.permissions.ts rồi chạy pnpm rbac-sync.`
64
+ if (process.env.NODE_ENV === "production") console.warn(message)
65
+ else throw new Error(message)
66
+ }
67
+
68
+ /**
69
+ * Gắn "ai đang thao tác" vào AsyncLocalStorage cho cả request. Extension audit
70
+ * trong lib/prisma.ts đọc context này; thiếu nó thì mọi dòng audit_logs đều
71
+ * ghi tác nhân rỗng.
72
+ */
73
+ const auditContext: ApiHandlerMiddleware<Session> = async (next, { req, session }) => {
74
+ if (!session) return next()
75
+ const ip =
76
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
77
+ req.headers.get("x-real-ip") ||
78
+ undefined
79
+ return (await withAuditContext(
80
+ { userId: session.user.id, userName: session.user.name, ip },
81
+ () => next(),
82
+ )) as Response
83
+ }
84
+
85
+ /**
86
+ * Mở context phạm vi chi nhánh cho cả request để branch-guard (lib/prisma.ts)
87
+ * biết user được xem chi nhánh nào. Phạm vi được tính LƯỜI — request không đụng
88
+ * model nào bị guard thì `resolve` không bao giờ chạy.
89
+ */
90
+ const branchScope: ApiHandlerMiddleware<Session> = async (next, { session }) => {
91
+ if (!session) return next()
92
+ return (await runWithBranchScope(
93
+ { resolve: () => getBranchScope(session) },
94
+ () => next(),
95
+ )) as Response
96
+ }
97
+
98
+ /** Lỗi chưa bắt của route: in ra terminal + lưu error_logs + 500. */
99
+ async function onApiError(error: unknown, req: Request): Promise<Response> {
100
+ console.error(`[API] ${req.method} ${req.url} error:`, error)
101
+
102
+ const session = await getSession().catch(() => null)
103
+ // Fire-and-forget — việc ghi log không được làm chậm phản hồi lỗi.
104
+ logServerError(error, {
105
+ url: req.url,
106
+ method: req.method,
107
+ userId: session?.user?.id,
108
+ userAgent: req.headers.get("user-agent") || undefined,
109
+ })
110
+
111
+ const message = error instanceof Error ? error.message : "Internal Server Error"
112
+ return NextResponse.json(
113
+ {
114
+ error: message,
115
+ // Ở dev trả kèm stack để showError() phía client hiện đúng thứ terminal
116
+ // đang in — production thì tuyệt đối không lộ.
117
+ ...(process.env.NODE_ENV === "production"
118
+ ? {}
119
+ : { debug: { message, stack: error instanceof Error ? error.stack : undefined } }),
120
+ },
121
+ { status: 500 },
122
+ )
123
+ }
124
+
125
+ const coreApiHandler = createApiHandler<Session>({
126
+ // Session có nhưng thiếu user thì coi như chưa đăng nhập.
127
+ getSession: async () => {
128
+ const session = await getSession()
129
+ return session?.user ? session : null
130
+ },
131
+ checkPermission: (session, resource, action) =>
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ coreCheckPermission(session as any, resource, action),
134
+ assertResource: assertResourceDeclared,
135
+ wrap: [auditContext, branchScope],
136
+ onError: onApiError,
137
+ })
138
+
139
+ export function apiHandler<TParams = Record<string, string>>(
140
+ handler: Handler<TParams>,
141
+ options?: ApiHandlerOptions,
142
+ ) {
143
+ const route = coreApiHandler(
144
+ (req, ctx) =>
145
+ handler(req as NextRequest, {
146
+ // Đường public không có session; handler public không được đọc user.
147
+ session: ctx.session ?? ({} as Session),
148
+ params: ctx.params as Promise<TParams>,
149
+ }),
150
+ options,
151
+ )
152
+ return async (
153
+ req: NextRequest,
154
+ // Route tĩnh (không có segment động) được Next gọi mà không kèm ctx.
155
+ routeCtx?: { params: Promise<TParams> },
156
+ ): Promise<Response> => route(req, routeCtx as never)
157
+ }
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import { customSessionClient } from "better-auth/client/plugins"
4
+ import { createAuthClient } from "better-auth/react"
5
+
6
+ import type { AuthBridgeClient } from "@goerp/core/ui"
7
+ import type { getAuth } from "@/lib/better-auth"
8
+
9
+ /**
10
+ * Client Better Auth + cầu nối sang `AuthBridgeClient` của core.
11
+ *
12
+ * Core cố tình KHÔNG phụ thuộc thư viện auth nào: nó chỉ gọi qua bridge này,
13
+ * được mount ở src/providers/index.tsx. Object bridge phải là hằng số suốt
14
+ * vòng đời app (nó chứa một hook) — đừng tạo mới trong component.
15
+ */
16
+ export const authClient = createAuthClient({
17
+ basePath: "/api/better-auth",
18
+ plugins: [customSessionClient<ReturnType<typeof getAuth>>()],
19
+ })
20
+
21
+ export const authBridgeClient: AuthBridgeClient = {
22
+ useSession: () => {
23
+ const s = authClient.useSession()
24
+ return {
25
+ data: s.data ? { user: s.data.user, expires: s.data.session?.expiresAt } : null,
26
+ status: s.isPending
27
+ ? "loading"
28
+ : s.data
29
+ ? "authenticated"
30
+ : "unauthenticated",
31
+ update: async () => {
32
+ await s.refetch()
33
+ },
34
+ }
35
+ },
36
+ signInWithCredentials: async ({ email, password }) => {
37
+ const { error } = await authClient.signIn.email({ email, password })
38
+ if (!error) return { error: null }
39
+ // Rate-limit của Better Auth trả 429 với message tiếng Anh — dịch cho người dùng.
40
+ if (error.status === 429) {
41
+ return {
42
+ error: "Bạn đã thử đăng nhập quá nhiều lần. Vui lòng đợi khoảng 1 phút rồi thử lại.",
43
+ }
44
+ }
45
+ return { error: error.message ?? "Đăng nhập thất bại" }
46
+ },
47
+ signOut: async (options) => {
48
+ await authClient.signOut()
49
+ if (typeof window !== "undefined") {
50
+ // Tab (URL + bộ lọc) sống trong sessionStorage qua cả hard reload — dọn
51
+ // khi đăng xuất để người đăng nhập sau không thấy tab của người trước.
52
+ sessionStorage.removeItem("tab-navigation-state")
53
+ sessionStorage.removeItem("tab-content-cache")
54
+ if (options?.callbackUrl) window.location.href = options.callbackUrl
55
+ }
56
+ },
57
+ }
@@ -0,0 +1,62 @@
1
+ import { cache } from "react"
2
+ import { headers } from "next/headers"
3
+
4
+ import type { Session } from "@/types/session"
5
+
6
+ /**
7
+ * ⚠️ CHỈ DEV: bỏ qua đăng nhập để dựng vỏ app trước khi có tài khoản thật.
8
+ * Guard `NODE_ENV !== "production"` khiến biến này vô hiệu trên bản build
9
+ * production dù có set env — đừng gỡ guard đó.
10
+ */
11
+ const BYPASS_AUTH =
12
+ process.env.NODE_ENV !== "production" &&
13
+ (process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1")
14
+
15
+ /**
16
+ * getSession — nguồn session DUY NHẤT phía server. Mọi route/page gọi hàm này,
17
+ * đừng gọi thẳng `getAuth().api.getSession` chỗ khác.
18
+ *
19
+ * `cache()` của React gom mọi lần gọi trong CÙNG một request về một truy vấn.
20
+ */
21
+ export const getSession = cache(async (): Promise<Session | null> => {
22
+ if (BYPASS_AUTH) {
23
+ return {
24
+ user: {
25
+ id: "dev-user",
26
+ email: "dev@example.com",
27
+ name: "Dev User",
28
+ avatar: null,
29
+ status: "active",
30
+ roles: ["admin"], // vai trò admin ⇒ core mở toàn quyền, khỏi cần seed
31
+ branchId: null,
32
+ branches: [],
33
+ permissions: [],
34
+ },
35
+ expires: new Date(Date.now() + 30 * 864e5).toISOString(),
36
+ }
37
+ }
38
+
39
+ // Import ĐỘNG có chủ đích — đừng đổi thành static. Turbopack dev có race lúc
40
+ // cold compile: hai request đầu ép module này compile song song và binding
41
+ // tĩnh tới better-auth có thể đóng băng ở undefined ("getAuth is not a
42
+ // function" ở mọi API route, chỉ hết khi sửa chạm file). Import động phân
43
+ // giải lúc runtime nên miễn nhiễm; bản production đã nạp sẵn module nên
44
+ // await gần như bằng 0.
45
+ const { getAuth } = await import("@/lib/better-auth")
46
+ const result = await getAuth().api.getSession({ headers: await headers() })
47
+ if (!result?.user) return null
48
+
49
+ const expiresAt = result.session?.expiresAt
50
+ return {
51
+ user: result.user,
52
+ expires:
53
+ expiresAt instanceof Date ? expiresAt.toISOString() : String(expiresAt ?? ""),
54
+ } as unknown as Session
55
+ })
56
+
57
+ /** Dùng trong API route/server action khi bắt buộc phải có người dùng. */
58
+ export async function requireUser() {
59
+ const session = await getSession()
60
+ if (!session?.user?.id) throw new Error("Unauthorized")
61
+ return session.user
62
+ }