@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.71 — Bịt gap từ pilot init spartronics: cron giờ thật, đăng nhập username, proxy publicPrefixes, template hết vấp
4
+
5
+ Nguồn: docs/INIT-PILOT-SPARTRONICS-GAPS.md (dựng app thật từ goerp-init 0.1.70
6
+ trên DB thật, port domain máy chấm công — 19 gap ghi nhận, đợt này bịt nhóm
7
+ gây vấp khi init; realtime/device-gate/provisionUsers/trang users/PWA/
8
+ translations để các batch sau).
9
+
10
+ **Core:**
11
+ - **Cron chạy ĐÚNG GIỜ** (`cron-schedule.ts`): biểu thức 5 trường nay hẹn
12
+ setTimeout tới lần khớp kế tiếp theo giờ địa phương — "0 1 * * *" là 1 giờ
13
+ sáng thật, hết cảnh "cứ 24h kể từ boot" khiến job hằng ngày trôi giờ và app
14
+ phải tự chế isLocalHour(). Khoảng đơn giản ("5m", "1h") giữ ngữ nghĩa cũ.
15
+ `nextDate()`/next_run trong DB nay là giờ thật. 9 test mới.
16
+ - **Logo hết 400**: bỏ default ma `/images/logos/goeat_logo.png` (di sản
17
+ goeat) — không cấu hình logo thì render monogram chữ cái đầu, không trỏ vào
18
+ file không tồn tại.
19
+ - **`SignInForm` nhận `identifier`** ("email" | "username" | "both") +
20
+ `identifierLabel`/`identifierPlaceholder` — app đăng nhập bằng mã nhân viên
21
+ (Better Auth username plugin) không phải fork form nữa. Mật khẩu ở form đăng
22
+ nhập chỉ đòi khác rỗng (luật độ phức tạp là chuyện lúc ĐẶT mật khẩu — siết ở
23
+ đây chặn nhầm mật khẩu cũ import từ hệ khác).
24
+ - **`createAuthProxy` thêm `publicPrefixes`** — tên chính danh cho prefix
25
+ công khai NGOÀI /api (route thiết bị /pub, /iclock, webhook); `publicApiPrefixes`
26
+ giữ nguyên làm alias. JSDoc nói rõ giới hạn cookie-presence ở Edge.
27
+ - **Guardrail mới `auth/route-outside-api-declared`**: route.ts ngoài app/api
28
+ (điểm mù cũ của api-route-gated) phải khai tiền tố trong danh sách công khai
29
+ của proxy hoặc tự gác.
30
+ - **GuardrailOptions nhận `readonly`** registry/navigations, NavigationGroupLike
31
+ bỏ index signature — app mới init hết fail type-check ngay từ đầu.
32
+ - `goerp-init` pin `@goerp/core` theo đúng version CLI đang chạy.
33
+
34
+ **Template starter-app:**
35
+ - Trang home tự `getSession()` → redirect sign-in (proxy chỉ kiểm tra SỰ TỒN
36
+ TẠI cookie — cookie rác/cookie app khác cùng localhost từng xem được dashboard).
37
+ - `advanced.cookiePrefix = tenant.id` + `getSessionCookie(req, { cookiePrefix })`
38
+ — mỗi app một tên cookie, hết va chạm khi dev nhiều app GoERP trên localhost.
39
+ - `pnpm.onlyBuiltDependencies` (pnpm 10 chặn build scripts của prisma/esbuild/sharp).
40
+ - `pnpm migration:new <ten>` — sinh migration qua shadow DB tự tạo/xoá
41
+ (Prisma 7.9 đã bỏ `--shadow-database-url`).
42
+ - Favicon mặc định `src/app/icon.svg`; sửa doc drift (README goerp-features
43
+ status|sync thay list|add; header seed.ts hết tự mâu thuẫn thứ tự seed→rbac-sync;
44
+ comment bootstrap.ts ma trong lib/prisma.ts); tsconfig paths ưu tiên source
45
+ core trong repo (co-dev) rồi mới node_modules.
46
+
47
+
3
48
  ## 0.1.70 — In A5 ngang: khai kích thước tường minh thay từ khóa `landscape`
4
49
 
5
50
  `PrintStyles` khổ `A5-Landscape` đổi `@page size: A5 landscape` →
@@ -83,6 +83,13 @@ const write = (p, content) => fs.writeFileSync(path.join(targetDir, p), content)
83
83
  // chấm ở đây — thiếu bước này thì `git add .` đầu tiên của app mới commit luôn
84
84
  // `.env` vừa sinh (kèm BETTER_AUTH_SECRET) và cả node_modules.
85
85
  fs.renameSync(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
86
+ // Cùng bẫy đóng gói: npm pack nuốt cả thư mục `.husky` (đã kiểm chứng bằng
87
+ // npm pack --dry-run) — ship `husky/`, trả lại dấu chấm ở đây. Hook chỉ sống
88
+ // khi app `git init` (prepare: "husky || true" bỏ qua êm khi chưa có .git).
89
+ const huskySrc = path.join(targetDir, "husky");
90
+ if (fs.existsSync(huskySrc)) {
91
+ fs.renameSync(huskySrc, path.join(targetDir, ".husky"));
92
+ }
86
93
 
87
94
  // "quan-ly-kho" → "Quan Ly Kho": nhãn hiển thị mặc định, chủ app sửa lại sau.
88
95
  const title = name
@@ -94,6 +101,14 @@ const title = name
94
101
  const pkg = JSON.parse(read("package.json"));
95
102
  pkg.name = name;
96
103
  pkg.version = "0.1.0";
104
+ // Pin @goerp/core theo ĐÚNG bản CLI đang chạy — template trong repo có thể
105
+ // ghi version cũ hơn (từng ship ^0.1.59 khi core đã 0.1.70).
106
+ const coreVersion = JSON.parse(
107
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"),
108
+ ).version;
109
+ if (pkg.dependencies?.["@goerp/core"]) {
110
+ pkg.dependencies["@goerp/core"] = `npm:@goplusvn/core@^${coreVersion}`;
111
+ }
97
112
  for (const key of ["dev", "start"]) {
98
113
  if (pkg.scripts?.[key]) pkg.scripts[key] = pkg.scripts[key].replace(/--port \d+/, `--port ${port}`);
99
114
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.70",
4
+ "version": "0.1.72",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -93,6 +93,7 @@
93
93
  "./rbac/role-service": "./src/rbac/role-service.ts",
94
94
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
95
95
  "./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
96
+ "./crud/server": "./src/crud/server.ts",
96
97
  "./auth/auth-service": "./src/auth/auth-service.ts",
97
98
  "./package.json": "./package.json",
98
99
  "./providers/brand-theme": "./src/providers/brand-theme.ts",
package/src/auth/index.ts CHANGED
@@ -7,7 +7,11 @@ export * from "./auth-service";
7
7
 
8
8
  // `Permission` từng được KHAI BÁO LẠI ở đây, y hệt bản trong ../types — hai
9
9
  // nguồn sự thật cho cùng một shape. Giờ chỉ còn re-export.
10
- export type { Permission, PermissionMap };
10
+ // `Session` PHẢI export từ đây: đây kiểu tham số của checkPermission/hasRole,
11
+ // không export thì mọi app phải viết `session as Parameters<typeof
12
+ // checkPermission>[0]` — Session của app ({ user: SessionUser }) vốn gán thẳng
13
+ // được vào `{ user?: unknown }`, không cần cast nào cả.
14
+ export type { Permission, PermissionMap, Session };
11
15
 
12
16
  // ============================================================================
13
17
  // Types
@@ -14,7 +14,15 @@ import { NextResponse } from "next/server";
14
14
  import type { NextRequest } from "next/server";
15
15
 
16
16
  export interface AuthProxyOptions {
17
- /** API prefixes served without a session (auth handler + public). Default: /api/auth, /api/public. */
17
+ /**
18
+ * Prefixes served WITHOUT a session — API public, webhook, và cả endpoint
19
+ * NGOÀI /api (thiết bị/máy móc không có cookie: /pub, /iclock…). Mỗi entry
20
+ * phải TỰ XÁC THỰC (allowlist serial, HMAC, token…). Được gộp với
21
+ * `publicApiPrefixes` (tên cũ, giữ để tương thích — nó luôn hoạt động với
22
+ * mọi prefix chứ không riêng /api).
23
+ */
24
+ publicPrefixes?: string[];
25
+ /** Tên cũ của `publicPrefixes`. Default khi cả hai vắng: /api/auth, /api/public. */
18
26
  publicApiPrefixes?: string[];
19
27
  /** Pages reachable while logged out. Default: /sign-in. */
20
28
  publicPages?: string[];
@@ -27,6 +35,13 @@ export interface AuthProxyOptions {
27
35
  * (Better Auth: `getSessionCookie` from "better-auth/cookies"; NextAuth:
28
36
  * `getToken` from "next-auth/jwt"). Default: presence of a known session
29
37
  * cookie, see SESSION_COOKIE_NAMES.
38
+ *
39
+ * ⚠ GIỚI HẠN: middleware chạy ở Edge nên đây là kiểm tra SỰ TỒN TẠI cookie,
40
+ * KHÔNG xác minh chữ ký/hạn — ai tự đặt cookie rác vẫn qua được lớp này.
41
+ * Vì vậy: (1) page server-query dữ liệu PHẢI tự gọi getSession() (xem trang
42
+ * home của starter-app); (2) dev nhiều app trên cùng localhost nên đặt
43
+ * `advanced.cookiePrefix` riêng cho từng app (Better Auth) kẻo cookie app
44
+ * này mở khoá proxy app kia — cookie theo HOST, không phân biệt port.
30
45
  */
31
46
  getToken?: (req: NextRequest) => Promise<unknown | null>;
32
47
  }
@@ -52,7 +67,11 @@ const SESSION_COOKIE_NAMES = [
52
67
  ];
53
68
 
54
69
  export function createAuthProxy(options: AuthProxyOptions = {}) {
55
- const publicApiPrefixes = options.publicApiPrefixes ?? ["/api/auth", "/api/public"];
70
+ const publicApiPrefixes = [
71
+ ...(options.publicPrefixes ?? []),
72
+ ...(options.publicApiPrefixes ??
73
+ (options.publicPrefixes ? [] : ["/api/auth", "/api/public"])),
74
+ ];
56
75
  const publicPages = options.publicPages ?? ["/sign-in"];
57
76
  const signInPath = options.signInPath ?? "/sign-in";
58
77
  const homePath = options.homePath ?? "/";
@@ -17,6 +17,7 @@ export const departmentsConfig: EntityConfig = {
17
17
  icon: Building2,
18
18
  description: "Quản lý cơ cấu tổ chức phòng ban",
19
19
  apiEndpoint: "/api/departments",
20
+ modelName: "department",
20
21
  idField: "id",
21
22
  displayField: "name",
22
23
 
@@ -11,6 +11,7 @@ export const materialCategoriesConfig: EntityConfig = {
11
11
  icon: FolderTree,
12
12
  description: "Quản lý danh mục nguyên vật liệu",
13
13
  apiEndpoint: "/api/material-categories",
14
+ modelName: "materialCategory",
14
15
  idField: "id",
15
16
  displayField: "name",
16
17
 
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ cronMatches,
5
+ nextCronDate,
6
+ parseCronExpression,
7
+ } from "../cron-schedule";
8
+
9
+ // Giờ ĐỊA PHƯƠNG của tiến trình test — dựng Date bằng constructor local cho khớp
10
+ // ngữ nghĩa của engine (container prod set TZ=Asia/Ho_Chi_Minh).
11
+ const at = (y: number, mo: number, d: number, h: number, mi: number) =>
12
+ new Date(y, mo - 1, d, h, mi, 0, 0);
13
+
14
+ describe("parseCronExpression", () => {
15
+ it("nhận đủ *, số, danh sách, khoảng, bước", () => {
16
+ expect(parseCronExpression("* * * * *")).toBeTruthy();
17
+ expect(parseCronExpression("0 1 * * *")).toBeTruthy();
18
+ expect(parseCronExpression("*/15 8-17 * * 1-5")).toBeTruthy();
19
+ expect(parseCronExpression("0,30 6,18 1 1,7 0")).toBeTruthy();
20
+ });
21
+
22
+ it("từ chối biểu thức hỏng", () => {
23
+ expect(parseCronExpression("khong phai cron")).toBeNull();
24
+ expect(parseCronExpression("* * * *")).toBeNull(); // 4 trường
25
+ expect(parseCronExpression("60 * * * *")).toBeNull(); // phút 60
26
+ expect(parseCronExpression("* 24 * * *")).toBeNull(); // giờ 24
27
+ });
28
+
29
+ it("dow 7 = Chủ nhật (chuẩn hoá về 0)", () => {
30
+ const f = parseCronExpression("0 0 * * 7")!;
31
+ // 2026-08-09 là Chủ nhật
32
+ expect(cronMatches(f, at(2026, 8, 9, 0, 0))).toBe(true);
33
+ expect(cronMatches(f, at(2026, 8, 10, 0, 0))).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("nextCronDate — CHỐT gap #8: giờ thật chứ không phải interval-từ-boot", () => {
38
+ it('"0 1 * * *" ra ĐÚNG 01:00 hôm sau, bất kể đang là mấy giờ', () => {
39
+ const f = parseCronExpression("0 1 * * *")!;
40
+ const next = nextCronDate(f, at(2026, 8, 7, 16, 33))!;
41
+ expect([next.getHours(), next.getMinutes()]).toEqual([1, 0]);
42
+ expect(next.getDate()).toBe(8);
43
+ });
44
+
45
+ it('"0 * * * *" ra đầu giờ kế tiếp, không phải "60 phút nữa"', () => {
46
+ const f = parseCronExpression("0 * * * *")!;
47
+ const next = nextCronDate(f, at(2026, 8, 7, 16, 33))!;
48
+ expect([next.getHours(), next.getMinutes()]).toEqual([17, 0]);
49
+ });
50
+
51
+ it('"* * * * *" ra phút kế tiếp', () => {
52
+ const f = parseCronExpression("* * * * *")!;
53
+ const next = nextCronDate(f, at(2026, 8, 7, 16, 33))!;
54
+ expect([next.getHours(), next.getMinutes()]).toEqual([16, 34]);
55
+ });
56
+
57
+ it("khoảng giờ + step + ngày-trong-tuần", () => {
58
+ // 16:33 thứ Sáu → lần khớp kế của */15 trong 8-17 T2-T6 là 16:45 thứ Sáu
59
+ const f = parseCronExpression("*/15 8-17 * * 1-5")!;
60
+ expect(nextCronDate(f, at(2026, 8, 7, 16, 33))).toEqual(at(2026, 8, 7, 16, 45));
61
+ // 17:50 thứ Sáu → nhảy sang 08:00 thứ Hai
62
+ expect(nextCronDate(f, at(2026, 8, 7, 17, 50))).toEqual(at(2026, 8, 10, 8, 0));
63
+ });
64
+
65
+ it("POSIX dom|dow: cả hai bị giới hạn thì khớp MỘT trong hai", () => {
66
+ // ngày 15 HOẶC Chủ nhật
67
+ const f = parseCronExpression("0 0 15 * 0")!;
68
+ expect(nextCronDate(f, at(2026, 8, 7, 12, 0))).toEqual(at(2026, 8, 9, 0, 0)); // CN 9/8 tới trước ngày 15
69
+ expect(nextCronDate(f, at(2026, 8, 13, 12, 0))).toEqual(at(2026, 8, 15, 0, 0));
70
+ });
71
+
72
+ it("biểu thức bất khả thi (30/2) trả null thay vì treo", () => {
73
+ const f = parseCronExpression("0 0 30 2 *")!;
74
+ expect(nextCronDate(f, at(2026, 1, 1, 0, 0))).toBeNull();
75
+ });
76
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Bộ phân giải biểu thức cron 5 trường (phút giờ ngày tháng thứ) — KHÔNG phụ
3
+ * thuộc ngoài, tính theo GIỜ ĐỊA PHƯƠNG của tiến trình (container goerp set
4
+ * `TZ=Asia/Ho_Chi_Minh` trong Dockerfile nên "0 1 * * *" là 1 giờ sáng VN).
5
+ *
6
+ * Ra đời để thay lối cũ "quy cron về setInterval-từ-lúc-boot": job hằng ngày
7
+ * trên container deploy vài lần/ngày gần như không bao giờ chạy đúng giờ, mọi
8
+ * app phải tự chế isLocalHour() để vá. Xem SimpleCronJob.
9
+ *
10
+ * Hỗ trợ mỗi trường: `*`, số, danh sách `a,b,c`, khoảng `a-b`, bước `*\/n` và
11
+ * `a-b/n`. Thứ (dow): 0 hoặc 7 đều là Chủ nhật. Không hỗ trợ tên (JAN/MON).
12
+ * Ngữ nghĩa dom/dow theo POSIX: cả hai cùng bị giới hạn thì khớp MỘT TRONG HAI.
13
+ */
14
+
15
+ export interface CronFields {
16
+ minute: Set<number>;
17
+ hour: Set<number>;
18
+ dayOfMonth: Set<number>;
19
+ month: Set<number>;
20
+ dayOfWeek: Set<number>;
21
+ /** Trường viết `*` (khác `*` kèm step) — cần cho ngữ nghĩa dom/dow POSIX. */
22
+ domIsWildcard: boolean;
23
+ dowIsWildcard: boolean;
24
+ }
25
+
26
+ function parseField(field: string, min: number, max: number): Set<number> | null {
27
+ const values = new Set<number>();
28
+ for (const part of field.split(",")) {
29
+ const stepMatch = part.match(/^(.+)\/(\d+)$/);
30
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
31
+ const range = stepMatch ? stepMatch[1] : part;
32
+ if (step < 1) return null;
33
+
34
+ let lo: number;
35
+ let hi: number;
36
+ if (range === "*") {
37
+ lo = min;
38
+ hi = max;
39
+ } else if (/^\d+$/.test(range)) {
40
+ lo = hi = parseInt(range, 10);
41
+ // "5/15" nghĩa là 5,20,35,50 (bắt đầu từ 5 tới max) — chỉ khi có step.
42
+ if (stepMatch) hi = max;
43
+ } else {
44
+ const m = range.match(/^(\d+)-(\d+)$/);
45
+ if (!m) return null;
46
+ lo = parseInt(m[1], 10);
47
+ hi = parseInt(m[2], 10);
48
+ }
49
+ if (lo < min || hi > max || lo > hi) return null;
50
+ for (let v = lo; v <= hi; v += step) values.add(v);
51
+ }
52
+ return values.size ? values : null;
53
+ }
54
+
55
+ /** Trả null nếu không phải biểu thức cron 5 trường hợp lệ. */
56
+ export function parseCronExpression(expr: string): CronFields | null {
57
+ const parts = expr.trim().split(/\s+/);
58
+ if (parts.length !== 5) return null;
59
+
60
+ const minute = parseField(parts[0], 0, 59);
61
+ const hour = parseField(parts[1], 0, 23);
62
+ const dayOfMonth = parseField(parts[2], 1, 31);
63
+ const month = parseField(parts[3], 1, 12);
64
+ // 7 = Chủ nhật (chuẩn hoá về 0 bên dưới).
65
+ const dayOfWeekRaw = parseField(parts[4], 0, 7);
66
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeekRaw) return null;
67
+
68
+ const dayOfWeek = new Set<number>();
69
+ for (const d of dayOfWeekRaw) dayOfWeek.add(d === 7 ? 0 : d);
70
+
71
+ return {
72
+ minute,
73
+ hour,
74
+ dayOfMonth,
75
+ month,
76
+ dayOfWeek,
77
+ domIsWildcard: parts[2] === "*",
78
+ dowIsWildcard: parts[4] === "*",
79
+ };
80
+ }
81
+
82
+ export function cronMatches(fields: CronFields, date: Date): boolean {
83
+ if (!fields.minute.has(date.getMinutes())) return false;
84
+ if (!fields.hour.has(date.getHours())) return false;
85
+ if (!fields.month.has(date.getMonth() + 1)) return false;
86
+
87
+ const domMatch = fields.dayOfMonth.has(date.getDate());
88
+ const dowMatch = fields.dayOfWeek.has(date.getDay());
89
+ // POSIX: cả dom lẫn dow bị giới hạn → OR; ngược lại → AND (vế wildcard luôn đúng).
90
+ if (!fields.domIsWildcard && !fields.dowIsWildcard) return domMatch || dowMatch;
91
+ return domMatch && dowMatch;
92
+ }
93
+
94
+ /**
95
+ * Lần khớp KẾ TIẾP sau `from` (đầu phút, không tính chính `from`).
96
+ * Trả null nếu không có trong 2 năm tới (biểu thức bất khả thi, vd 30/2).
97
+ */
98
+ export function nextCronDate(fields: CronFields, from = new Date()): Date | null {
99
+ const cursor = new Date(from.getTime());
100
+ cursor.setSeconds(0, 0);
101
+ cursor.setMinutes(cursor.getMinutes() + 1);
102
+
103
+ const LIMIT = 2 * 366 * 24 * 60; // phút
104
+ for (let i = 0; i < LIMIT; i++) {
105
+ if (cronMatches(fields, cursor)) return cursor;
106
+ // Nhảy nhanh: sai tháng → đầu tháng sau; sai ngày → đầu ngày sau; sai giờ
107
+ // → đầu giờ sau. Giảm vòng lặp từ hàng trăm nghìn xuống vài trăm.
108
+ if (!fields.month.has(cursor.getMonth() + 1)) {
109
+ cursor.setMonth(cursor.getMonth() + 1, 1);
110
+ cursor.setHours(0, 0, 0, 0);
111
+ } else if (
112
+ !(fields.domIsWildcard && fields.dowIsWildcard) &&
113
+ !(
114
+ (!fields.domIsWildcard && !fields.dowIsWildcard
115
+ ? fields.dayOfMonth.has(cursor.getDate()) || fields.dayOfWeek.has(cursor.getDay())
116
+ : fields.dayOfMonth.has(cursor.getDate()) && fields.dayOfWeek.has(cursor.getDay()))
117
+ )
118
+ ) {
119
+ cursor.setDate(cursor.getDate() + 1);
120
+ cursor.setHours(0, 0, 0, 0);
121
+ } else if (!fields.hour.has(cursor.getHours())) {
122
+ cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
123
+ } else {
124
+ cursor.setMinutes(cursor.getMinutes() + 1);
125
+ }
126
+ }
127
+ return null;
128
+ }
@@ -4,111 +4,124 @@ import type {
4
4
  CronJobOptions,
5
5
  } from "./types";
6
6
  import { createLogger } from "../infrastructure/logger";
7
+ import { nextCronDate, parseCronExpression } from "./cron-schedule";
8
+ import type { CronFields } from "./cron-schedule";
7
9
 
8
10
  const logger = createLogger("CronJobManager");
9
11
 
10
12
  /**
11
- * Simple interval-based cron job (no external dependencies)
12
- * Supports basic cron expressions: minute, hour, day, month, weekday
13
+ * Hẹn giờ cron KHÔNG phụ thuộc ngoài, hai chế độ:
14
+ *
15
+ * - Biểu thức cron 5 trường ("0 1 * * *"): hẹn `setTimeout` tới đúng lần khớp
16
+ * kế tiếp theo GIỜ ĐỊA PHƯƠNG (cron-schedule.ts) — "0 1 * * *" là 1 giờ
17
+ * sáng THẬT, không phải "cứ 24h kể từ lúc boot" như bản cũ. Hết một lần
18
+ * chạy tự hẹn lần kế tiếp; deploy lại giữa chừng cũng không trôi giờ.
19
+ * - Khoảng đơn giản ("30s", "5m", "1h", "1d"): giữ nguyên ngữ nghĩa cũ —
20
+ * lặp đều tính từ lúc start.
21
+ *
22
+ * Lý do bản cũ phải thay: nó quy MỌI biểu thức cron về setInterval-từ-boot,
23
+ * job hằng ngày trên container deploy vài lần/ngày gần như không bao giờ chạy
24
+ * đúng giờ, mọi app phải tự chế isLocalHour() để vá.
13
25
  */
14
26
  class SimpleCronJob implements CronJob {
15
27
  name: string;
16
28
  cronTime: string;
17
29
  private onTick: () => void | Promise<void>;
18
- private intervalId: NodeJS.Timeout | null = null;
30
+ private timerId: NodeJS.Timeout | null = null;
19
31
  private running = false;
20
- private intervalMs: number;
32
+ /** Chế độ khoảng đơn giản ("5m"…); null nghĩa là chạy theo cron thật. */
33
+ private intervalMs: number | null = null;
34
+ private cronFields: CronFields | null = null;
21
35
 
22
36
  constructor(options: CronJobOptions) {
23
37
  this.name = options.name;
24
38
  this.cronTime = options.cronTime;
25
39
  this.onTick = options.onTick;
26
- this.intervalMs = this.parseInterval(options.cronTime);
27
-
28
- if (options.start) {
29
- this.start();
30
- }
31
- }
32
40
 
33
- /**
34
- * Parse simple cron expressions to interval
35
- * Supports:
36
- * - '* * * * *' - every minute
37
- * - '0 * * * *' - every hour
38
- * - '0 0 * * *' - every day at midnight
39
- * - Also supports simple intervals: '5m', '1h', '1d'
40
- */
41
- private parseInterval(cronTime: string): number {
42
- // Simple interval format: 5m, 1h, 1d
43
- const simpleMatch = cronTime.match(/^(\d+)([smhd])$/);
41
+ const simpleMatch = options.cronTime.match(/^(\d+)([smhd])$/);
44
42
  if (simpleMatch) {
45
43
  const value = parseInt(simpleMatch[1], 10);
46
- const unit = simpleMatch[2];
47
- switch (unit) {
48
- case "s":
49
- return value * 1000;
50
- case "m":
51
- return value * 60 * 1000;
52
- case "h":
53
- return value * 60 * 60 * 1000;
54
- case "d":
55
- return value * 24 * 60 * 60 * 1000;
44
+ const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[
45
+ simpleMatch[2] as "s" | "m" | "h" | "d"
46
+ ];
47
+ this.intervalMs = value * unitMs;
48
+ } else {
49
+ this.cronFields = parseCronExpression(options.cronTime);
50
+ if (!this.cronFields) {
51
+ logger.warn(
52
+ `Could not parse cron expression "${options.cronTime}", defaulting to every minute`,
53
+ );
54
+ this.intervalMs = 60_000;
56
55
  }
57
56
  }
58
57
 
59
- // Parse cron expression
60
- const parts = cronTime.split(" ");
61
- if (parts.length >= 5) {
62
- const [minute, hour, dayOfMonth, ,] = parts;
63
-
64
- // Every minute
65
- if (minute === "*" && hour === "*") {
66
- return 60 * 1000; // 1 minute
67
- }
68
-
69
- // Every hour (minute = 0)
70
- if (minute !== "*" && hour === "*") {
71
- return 60 * 60 * 1000; // 1 hour
72
- }
58
+ if (options.start) {
59
+ this.start();
60
+ }
61
+ }
73
62
 
74
- // Every day (minute and hour specified)
75
- if (minute !== "*" && hour !== "*" && dayOfMonth === "*") {
76
- return 24 * 60 * 60 * 1000; // 1 day
77
- }
63
+ private async fire(): Promise<void> {
64
+ try {
65
+ await this.onTick();
66
+ } catch (error) {
67
+ logger.error(`Cron job "${this.name}" failed`, {
68
+ error: String(error),
69
+ });
78
70
  }
71
+ }
79
72
 
80
- // Default: every minute
81
- logger.warn(
82
- `Could not parse cron expression "${cronTime}", defaulting to every minute`,
83
- );
84
- return 60 * 1000;
73
+ /** Hẹn giờ tới lần khớp cron kế tiếp; chạy xong tự hẹn tiếp. */
74
+ private scheduleNextCron(): void {
75
+ if (!this.running || !this.cronFields) return;
76
+ const next = nextCronDate(this.cronFields);
77
+ if (!next) {
78
+ logger.warn(
79
+ `Cron job "${this.name}": biểu thức "${this.cronTime}" không có lần chạy nào trong 2 năm tới — dừng.`,
80
+ );
81
+ this.running = false;
82
+ return;
83
+ }
84
+ // setTimeout trần ~24.8 ngày (int32 ms) — chia chặng nếu xa hơn.
85
+ const delay = next.getTime() - Date.now();
86
+ const MAX_CHUNK = 2_000_000_000;
87
+ if (delay > MAX_CHUNK) {
88
+ this.timerId = setTimeout(() => this.scheduleNextCron(), MAX_CHUNK);
89
+ return;
90
+ }
91
+ this.timerId = setTimeout(async () => {
92
+ await this.fire();
93
+ this.scheduleNextCron();
94
+ }, Math.max(0, delay));
85
95
  }
86
96
 
87
97
  start(): void {
88
98
  if (this.running) return;
99
+ this.running = true;
100
+
101
+ if (this.cronFields) {
102
+ logger.info(`Starting cron job: ${this.name}`, {
103
+ cronTime: this.cronTime,
104
+ nextRun: this.nextDate()?.toISOString(),
105
+ });
106
+ this.scheduleNextCron();
107
+ return;
108
+ }
89
109
 
90
110
  logger.info(`Starting cron job: ${this.name}`, {
91
111
  interval: `${this.intervalMs}ms`,
92
112
  });
93
-
94
- this.running = true;
95
- this.intervalId = setInterval(async () => {
96
- try {
97
- await this.onTick();
98
- } catch (error) {
99
- logger.error(`Cron job "${this.name}" failed`, {
100
- error: String(error),
101
- });
102
- }
103
- }, this.intervalMs);
113
+ this.timerId = setInterval(() => void this.fire(), this.intervalMs!);
104
114
  }
105
115
 
106
116
  stop(): void {
107
- if (!this.running || !this.intervalId) return;
117
+ if (!this.running) return;
108
118
 
109
119
  logger.info(`Stopping cron job: ${this.name}`);
110
- clearInterval(this.intervalId);
111
- this.intervalId = null;
120
+ if (this.timerId) {
121
+ // clearTimeout/clearInterval dùng lẫn được — cùng pool timer của Node.
122
+ clearTimeout(this.timerId);
123
+ this.timerId = null;
124
+ }
112
125
  this.running = false;
113
126
  }
114
127
 
@@ -118,7 +131,8 @@ class SimpleCronJob implements CronJob {
118
131
 
119
132
  nextDate(): Date | null {
120
133
  if (!this.running) return null;
121
- return new Date(Date.now() + this.intervalMs);
134
+ if (this.cronFields) return nextCronDate(this.cronFields);
135
+ return new Date(Date.now() + this.intervalMs!);
122
136
  }
123
137
  }
124
138
 
@@ -53,8 +53,9 @@ export function CrudExportButton({
53
53
  search: activeSearch,
54
54
  });
55
55
 
56
- // Always force format to xlsx
57
- const url = `${endpoint}?${queryParams}&format=xlsx`;
56
+ // Always force format to xlsx. Endpoint từ getEntityEndpoints có thể đã
57
+ // mang sẵn query params — nối bằng "&" thay vì "?" thứ hai.
58
+ const url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${queryParams}&format=xlsx`;
58
59
 
59
60
  const response = await fetch(url);
60
61
  if (!response.ok) {