@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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +16 -7
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +10 -0
- 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` →
|
package/bin/goerp-init.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
10
|
+
// `Session` PHẢI export từ đây: đây là 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
|
package/src/auth/proxy-gate.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
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 ?? "/";
|
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
30
|
+
private timerId: NodeJS.Timeout | null = null;
|
|
19
31
|
private running = false;
|
|
20
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
);
|
|
84
|
-
|
|
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
|
|
117
|
+
if (!this.running) return;
|
|
108
118
|
|
|
109
119
|
logger.info(`Stopping cron job: ${this.name}`);
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|