@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.
- package/PLATFORM.md +18 -0
- package/bin/goerp-init.mjs +141 -0
- package/package.json +4 -4
- package/src/cron/db-cron-manager.ts +3 -3
- package/src/cron/index.ts +2 -2
- package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
- package/src/crud/lib/mutation-builder.ts +105 -0
- package/src/crud/lib/query-builder.ts +119 -0
- package/src/crud/server-service.ts +35 -163
- package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
- package/src/infrastructure/index.ts +4 -7
- package/src/ui/management/index.ts +3 -2
- package/templates/starter-app/.dockerignore +41 -0
- package/templates/starter-app/.env.example +25 -0
- package/templates/starter-app/AGENTS.md +52 -0
- package/templates/starter-app/Dockerfile +74 -0
- package/templates/starter-app/README.md +141 -0
- package/templates/starter-app/gitignore +9 -0
- package/templates/starter-app/next.config.mjs +50 -0
- package/templates/starter-app/package.json +55 -0
- package/templates/starter-app/postcss.config.mjs +5 -0
- package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
- package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
- package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
- package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
- package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
- package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
- package/templates/starter-app/prisma/schema/auth.prisma +87 -0
- package/templates/starter-app/prisma/schema/domain.prisma +24 -0
- package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
- package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
- package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
- package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
- package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
- package/templates/starter-app/prisma/schema/organization.prisma +31 -0
- package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
- package/templates/starter-app/prisma/schema/schema.prisma +8 -0
- package/templates/starter-app/prisma/schema/system.prisma +22 -0
- package/templates/starter-app/prisma/seed.ts +127 -0
- package/templates/starter-app/prisma.config.ts +20 -0
- package/templates/starter-app/public/.gitkeep +2 -0
- package/templates/starter-app/scripts/rbac-sync.ts +235 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
- package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
- package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
- package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
- package/templates/starter-app/src/app/api/roles/route.ts +18 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
- package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
- package/templates/starter-app/src/app/api/upload/route.ts +15 -0
- package/templates/starter-app/src/app/globals.css +15 -0
- package/templates/starter-app/src/app/layout.tsx +16 -0
- package/templates/starter-app/src/app/page.tsx +8 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
- package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
- package/templates/starter-app/src/configs/entities/index.ts +13 -0
- package/templates/starter-app/src/configs/i18n.ts +12 -0
- package/templates/starter-app/src/configs/permissions/index.ts +45 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
- package/templates/starter-app/src/configs/permissions/types.ts +63 -0
- package/templates/starter-app/src/configs/tenant.ts +18 -0
- package/templates/starter-app/src/data/dictionary.ts +8 -0
- package/templates/starter-app/src/data/navigations.ts +61 -0
- package/templates/starter-app/src/instrumentation.ts +105 -0
- package/templates/starter-app/src/lib/api-handler.ts +157 -0
- package/templates/starter-app/src/lib/auth-client.ts +57 -0
- package/templates/starter-app/src/lib/auth.ts +62 -0
- package/templates/starter-app/src/lib/better-auth.ts +107 -0
- package/templates/starter-app/src/lib/branch-scope.ts +53 -0
- package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
- package/templates/starter-app/src/lib/crud/index.ts +13 -0
- package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
- package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
- package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
- package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
- package/templates/starter-app/src/lib/logger.ts +30 -0
- package/templates/starter-app/src/lib/page-guard.ts +35 -0
- package/templates/starter-app/src/lib/prisma.ts +80 -0
- package/templates/starter-app/src/lib/rbac/access.ts +87 -0
- package/templates/starter-app/src/lib/storage.ts +28 -0
- package/templates/starter-app/src/providers/index.tsx +54 -0
- package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
- package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
- package/templates/starter-app/src/proxy.ts +45 -0
- package/templates/starter-app/src/server/services/notification-service.ts +31 -0
- package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
- package/templates/starter-app/src/server/tasks/index.ts +16 -0
- package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
- package/templates/starter-app/src/types/session.ts +29 -0
- package/templates/starter-app/tsconfig.json +47 -0
- package/templates/starter-app/vitest.config.ts +17 -0
- package/src/infrastructure/cron/index.ts +0 -6
- package/src/infrastructure/event-bus/event-bus.ts +0 -145
- package/src/infrastructure/event-bus/index.ts +0 -2
- package/src/infrastructure/event-bus/types.ts +0 -22
- package/src/infrastructure/lock/decorators.ts +0 -67
- package/src/infrastructure/lock/index.ts +0 -2
- package/src/infrastructure/lock/lock-manager.ts +0 -33
- package/src/plugin/apps-registry.ts +0 -97
- package/src/plugin/index.ts +0 -5
- package/src/plugin/types.ts +0 -41
- package/src/ui/management/audit-log-page.tsx +0 -14
- package/src/ui/management/job-management.tsx +0 -308
- package/src/workflow/activity-timeline.tsx +0 -412
- package/src/workflow/approval-workflow.tsx +0 -31
- package/src/workflow/index.ts +0 -2
- /package/src/{infrastructure/cron → cron}/types.ts +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createAuthProxy } from "@goerp/core/auth/proxy-gate"
|
|
2
|
+
import { getSessionCookie } from "better-auth/cookies"
|
|
3
|
+
|
|
4
|
+
import { i18n } from "@/configs/i18n"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cổng xác thực DEFAULT-DENY, dùng thẳng proxy-gate của core.
|
|
8
|
+
*
|
|
9
|
+
* Ý nghĩa: mọi đường dẫn /api đều đòi phiên đăng nhập TRỪ danh sách public bên
|
|
10
|
+
* dưới — nhờ vậy quên gọi getSession() trong một route mới cũng không thành lỗ
|
|
11
|
+
* hổng. Đây chỉ là rào authN thô; phân quyền chi tiết vẫn nằm ở từng route
|
|
12
|
+
* (checkPermission / CRUD engine).
|
|
13
|
+
*
|
|
14
|
+
* Middleware chạy ở Edge nên chỉ KIỂM TRA SỰ TỒN TẠI cookie phiên, không xác
|
|
15
|
+
* minh chữ ký (việc đó cần DB, làm ở route qua getSession).
|
|
16
|
+
*/
|
|
17
|
+
const BYPASS =
|
|
18
|
+
process.env.NODE_ENV !== "production" &&
|
|
19
|
+
(process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1")
|
|
20
|
+
|
|
21
|
+
export const proxy = createAuthProxy({
|
|
22
|
+
// Thêm prefix vào đây khi có endpoint tự xác thực (webhook, token khách…).
|
|
23
|
+
publicApiPrefixes: ["/api/better-auth", "/api/public"],
|
|
24
|
+
publicPages: [...i18n.locales.map((l) => `/${l}/sign-in`), "/sign-in"],
|
|
25
|
+
signInPath: `/${i18n.defaultLocale}/sign-in`,
|
|
26
|
+
homePath: `/${i18n.defaultLocale}`,
|
|
27
|
+
getToken: BYPASS
|
|
28
|
+
? async () => ({ dev: true })
|
|
29
|
+
: async (req) => getSessionCookie(req),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export default proxy
|
|
33
|
+
|
|
34
|
+
export const config = {
|
|
35
|
+
matcher: [
|
|
36
|
+
/*
|
|
37
|
+
* Khớp mọi đường dẫn TRỪ tài nguyên tĩnh. Lưu ý `api` KHÔNG bị loại — cổng
|
|
38
|
+
* default-deny ở trên phải chạy trước mọi API route.
|
|
39
|
+
* Loại trừ: _next/static, _next/image, thư mục asset, và mọi path kết thúc
|
|
40
|
+
* bằng đuôi tĩnh (thiếu vế này thì favicon/manifest/ảnh OG bị 307 về
|
|
41
|
+
* /sign-in và trình duyệt nhận HTML thay vì file).
|
|
42
|
+
*/
|
|
43
|
+
"/((?!_next/static|_next/image|icons|images|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|avif|json|xml|txt|webmanifest|wasm)$).*)",
|
|
44
|
+
],
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thông báo trong ứng dụng (chuông ở thanh trên). Engine ở
|
|
3
|
+
* @goerp/core/notification, bảng `notifications` + `push_subscriptions` ship
|
|
4
|
+
* qua `goerp-features sync`. File này chỉ cắm db.
|
|
5
|
+
*
|
|
6
|
+
* Muốn đẩy Web Push tới thiết bị đã cài PWA: thêm `afterNotify` bên dưới và
|
|
7
|
+
* dynamic-import service push của bạn, để đường chỉ-ghi-DB không kéo thư viện
|
|
8
|
+
* push vào bundle. Core đã fire-and-forget + catch nên push lỗi không làm hỏng
|
|
9
|
+
* nghiệp vụ đang chạy.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
configureNotificationService,
|
|
13
|
+
type NotificationDb,
|
|
14
|
+
} from "@goerp/core/notification"
|
|
15
|
+
|
|
16
|
+
import { db } from "@/lib/prisma"
|
|
17
|
+
|
|
18
|
+
configureNotificationService({
|
|
19
|
+
db: db as unknown as NotificationDb,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
getUnreadCount,
|
|
24
|
+
listNotifications,
|
|
25
|
+
markAllRead,
|
|
26
|
+
markRead,
|
|
27
|
+
notify,
|
|
28
|
+
type ListParams,
|
|
29
|
+
type NotificationType,
|
|
30
|
+
type NotifyInput,
|
|
31
|
+
} from "@goerp/core/notification"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nghiệp vụ của bảng `system_configs` — dùng chung cho 6 route dưới
|
|
3
|
+
* /api/admin/system/settings (trang SystemSettingsPage của core gọi tới).
|
|
4
|
+
*
|
|
5
|
+
* Gom vào một chỗ vì các quy tắc dưới đây phải giống nhau ở MỌI đường ghi:
|
|
6
|
+
* - `isReadOnly` chặn sửa/xóa (cấu hình do hệ thống làm chủ),
|
|
7
|
+
* - `isEncrypted` không bao giờ trả giá trị thật ra ngoài,
|
|
8
|
+
* - mọi thay đổi phải `revalidateTag("system-settings")`, nếu không
|
|
9
|
+
* `SettingsService` của core còn giữ bản cache cũ và người dùng tưởng lưu hụt.
|
|
10
|
+
*/
|
|
11
|
+
import { revalidateTag } from "next/cache"
|
|
12
|
+
|
|
13
|
+
import { db } from "@/lib/prisma"
|
|
14
|
+
|
|
15
|
+
const MASK = "••••••••"
|
|
16
|
+
|
|
17
|
+
/** Sai lệch do người dùng nhập — route bắt và trả đúng mã HTTP. */
|
|
18
|
+
export class ConfigError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
readonly status: number,
|
|
22
|
+
) {
|
|
23
|
+
super(message)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Trả về mô tả lỗi để route dịch sang HTTP, hoặc null nếu là lỗi ngoài dự kiến
|
|
29
|
+
* (khi đó route phải để `serverError` ghi lại stack thật).
|
|
30
|
+
*/
|
|
31
|
+
export function configErrorInfo(
|
|
32
|
+
error: unknown,
|
|
33
|
+
): { message: string; status: number } | null {
|
|
34
|
+
return error instanceof ConfigError
|
|
35
|
+
? { message: error.message, status: error.status }
|
|
36
|
+
: null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
const mask = (config: any) => ({
|
|
41
|
+
...config,
|
|
42
|
+
value: config.isEncrypted ? MASK : config.value,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function invalidate() {
|
|
46
|
+
revalidateTag("system-settings", "max")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function requireConfig(key: string) {
|
|
50
|
+
const existing = await db.systemConfig.findUnique({ where: { key } })
|
|
51
|
+
if (!existing) throw new ConfigError("Cấu hình không tồn tại", 404)
|
|
52
|
+
return existing
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listConfigs() {
|
|
56
|
+
const configs = await db.systemConfig.findMany({
|
|
57
|
+
where: { status: "active" },
|
|
58
|
+
orderBy: [{ category: "asc" }, { key: "asc" }],
|
|
59
|
+
})
|
|
60
|
+
return configs.map(mask)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConfigInput {
|
|
64
|
+
key?: string
|
|
65
|
+
value?: string
|
|
66
|
+
type?: string
|
|
67
|
+
category?: string
|
|
68
|
+
description?: string | null
|
|
69
|
+
isEncrypted?: boolean
|
|
70
|
+
isReadOnly?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createConfig(input: ConfigInput, userId: string) {
|
|
74
|
+
const key = input.key?.trim()
|
|
75
|
+
if (!key) throw new ConfigError("Thiếu key", 400)
|
|
76
|
+
// Key đi vào URL và code — giới hạn ký tự để không phải escape về sau.
|
|
77
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(key)) {
|
|
78
|
+
throw new ConfigError(
|
|
79
|
+
"Key chỉ được chứa chữ, số, dấu chấm, gạch dưới và gạch ngang",
|
|
80
|
+
400,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (await db.systemConfig.findUnique({ where: { key } })) {
|
|
84
|
+
throw new ConfigError("Cấu hình với key này đã tồn tại", 400)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const created = await db.systemConfig.create({
|
|
88
|
+
data: {
|
|
89
|
+
key,
|
|
90
|
+
value: input.value || "",
|
|
91
|
+
type: input.type || "string",
|
|
92
|
+
category: input.category || "general",
|
|
93
|
+
description: input.description || null,
|
|
94
|
+
isEncrypted: input.isEncrypted || false,
|
|
95
|
+
isReadOnly: input.isReadOnly || false,
|
|
96
|
+
status: "active",
|
|
97
|
+
createdBy: userId,
|
|
98
|
+
updatedBy: userId,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
invalidate()
|
|
102
|
+
return mask(created)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Đổi MỖI giá trị — đường dùng nhiều nhất trên UI. */
|
|
106
|
+
export async function updateConfigValue(key: string, value: unknown, userId: string) {
|
|
107
|
+
if (!key || value === undefined) throw new ConfigError("Thiếu key hoặc value", 400)
|
|
108
|
+
const existing = await requireConfig(key)
|
|
109
|
+
if (existing.isReadOnly) throw new ConfigError("Cấu hình này chỉ đọc", 403)
|
|
110
|
+
|
|
111
|
+
const updated = await db.systemConfig.update({
|
|
112
|
+
where: { key },
|
|
113
|
+
data: { value: String(value), updatedBy: userId },
|
|
114
|
+
})
|
|
115
|
+
invalidate()
|
|
116
|
+
return mask(updated)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sửa toàn bộ thuộc tính (dialog "Sửa cấu hình"). */
|
|
120
|
+
export async function updateConfigFull(input: ConfigInput, userId: string) {
|
|
121
|
+
const key = input.key
|
|
122
|
+
if (!key) throw new ConfigError("Thiếu key", 400)
|
|
123
|
+
const existing = await requireConfig(key)
|
|
124
|
+
|
|
125
|
+
const updated = await db.systemConfig.update({
|
|
126
|
+
where: { key },
|
|
127
|
+
data: {
|
|
128
|
+
value: input.value ?? existing.value,
|
|
129
|
+
type: input.type ?? existing.type,
|
|
130
|
+
category: input.category ?? existing.category,
|
|
131
|
+
description: input.description !== undefined ? input.description : existing.description,
|
|
132
|
+
isEncrypted: input.isEncrypted ?? existing.isEncrypted,
|
|
133
|
+
isReadOnly: input.isReadOnly ?? existing.isReadOnly,
|
|
134
|
+
updatedBy: userId,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
invalidate()
|
|
138
|
+
return mask(updated)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function deleteConfig(key: string) {
|
|
142
|
+
if (!key) throw new ConfigError("Thiếu key", 400)
|
|
143
|
+
const existing = await requireConfig(key)
|
|
144
|
+
if (existing.isReadOnly) throw new ConfigError("Không thể xóa cấu hình được bảo vệ", 403)
|
|
145
|
+
|
|
146
|
+
await db.systemConfig.delete({ where: { key } })
|
|
147
|
+
invalidate()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function toggleConfigStatus(key: string, status: string, userId: string) {
|
|
151
|
+
if (!key) throw new ConfigError("Thiếu key", 400)
|
|
152
|
+
if (!["active", "inactive"].includes(status)) {
|
|
153
|
+
throw new ConfigError("status phải là 'active' hoặc 'inactive'", 400)
|
|
154
|
+
}
|
|
155
|
+
await requireConfig(key)
|
|
156
|
+
|
|
157
|
+
const updated = await db.systemConfig.update({
|
|
158
|
+
where: { key },
|
|
159
|
+
data: { status, updatedBy: userId },
|
|
160
|
+
})
|
|
161
|
+
invalidate()
|
|
162
|
+
return mask(updated)
|
|
163
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MẪU tác vụ nền — xuất danh sách phòng ban ra CSV.
|
|
3
|
+
*
|
|
4
|
+
* Đây là khuôn để copy cho tác vụ thật của bạn. Ba điểm đáng chú ý:
|
|
5
|
+
* 1. Đọc theo TRANG rồi `setProgress` — 100k dòng không được nạp hết vào RAM.
|
|
6
|
+
* 2. Trả về `fileKey` từ `saveTaskFile` — UI tải file qua /api/tasks/[id]/file,
|
|
7
|
+
* không lộ đường dẫn thật.
|
|
8
|
+
* 3. Ném lỗi thoải mái: engine bắt, ghi status=error kèm message và báo chuông.
|
|
9
|
+
*/
|
|
10
|
+
import { registerTaskHandler, saveTaskFile } from "../task-runner"
|
|
11
|
+
|
|
12
|
+
import { db } from "@/lib/prisma"
|
|
13
|
+
|
|
14
|
+
import type { TaskContext, TaskFileResult } from "@goerp/core/tasks"
|
|
15
|
+
|
|
16
|
+
export const EXPORT_DEPARTMENTS_TASK = "export-departments"
|
|
17
|
+
|
|
18
|
+
const PAGE_SIZE = 500
|
|
19
|
+
|
|
20
|
+
/** CSV thủ công: bọc ngoặc kép + nhân đôi ngoặc bên trong (RFC 4180). */
|
|
21
|
+
function csvCell(value: unknown): string {
|
|
22
|
+
if (value === null || value === undefined) return ""
|
|
23
|
+
const text = value instanceof Date ? value.toISOString() : String(value)
|
|
24
|
+
return `"${text.replace(/"/g, '""')}"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerTaskHandler(
|
|
28
|
+
EXPORT_DEPARTMENTS_TASK,
|
|
29
|
+
async ({ setProgress }: TaskContext): Promise<TaskFileResult> => {
|
|
30
|
+
const total = await db.department.count()
|
|
31
|
+
const rows: string[] = [["Mã", "Tên", "Mô tả", "Thứ tự", "Trạng thái"].join(",")]
|
|
32
|
+
|
|
33
|
+
for (let skip = 0; skip < total; skip += PAGE_SIZE) {
|
|
34
|
+
const page = await db.department.findMany({
|
|
35
|
+
skip,
|
|
36
|
+
take: PAGE_SIZE,
|
|
37
|
+
orderBy: { order: "asc" },
|
|
38
|
+
})
|
|
39
|
+
for (const d of page) {
|
|
40
|
+
rows.push(
|
|
41
|
+
[d.code, d.name, d.description, d.order, d.status].map(csvCell).join(","),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
await setProgress(Math.round(((skip + page.length) / Math.max(total, 1)) * 100))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// BOM để Excel bản tiếng Việt không đọc UTF-8 thành ký tự lỗi.
|
|
48
|
+
const buffer = Buffer.from(`${rows.join("\n")}`, "utf8")
|
|
49
|
+
const fileName = "phong-ban.csv"
|
|
50
|
+
const fileKey = await saveTaskFile(
|
|
51
|
+
buffer,
|
|
52
|
+
`${EXPORT_DEPARTMENTS_TASK}/${fileName}`,
|
|
53
|
+
"text/csv",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return { fileKey, fileName, contentType: "text/csv", rowCount: total }
|
|
57
|
+
},
|
|
58
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Cổng vào của trung tâm tác vụ nền. Import file NÀY (đừng import thẳng
|
|
2
|
+
// task-runner) để side-effect đăng ký handler luôn chạy trước khi enqueue —
|
|
3
|
+
// enqueue một type chưa đăng ký sẽ ném lỗi ngay.
|
|
4
|
+
import "./handlers/export-departments"
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
enqueueTask,
|
|
8
|
+
readTaskFile,
|
|
9
|
+
reclaimStaleTasks,
|
|
10
|
+
registerTaskHandler,
|
|
11
|
+
saveTaskFile,
|
|
12
|
+
type TaskContext,
|
|
13
|
+
type TaskFileResult,
|
|
14
|
+
type TaskHandler,
|
|
15
|
+
} from "./task-runner"
|
|
16
|
+
export { EXPORT_DEPARTMENTS_TASK } from "./handlers/export-departments"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tác vụ nền — engine ở @goerp/core/tasks (bảng `background_tasks` ship qua
|
|
3
|
+
* `goerp-features sync`). Hàng đợi nằm trong DB, worker chạy NGAY trong tiến
|
|
4
|
+
* trình đang phục vụ request; server restart giữa chừng thì
|
|
5
|
+
* `reclaimStaleTasks()` (gọi từ instrumentation) đánh dấu lỗi để user chạy lại.
|
|
6
|
+
*
|
|
7
|
+
* Đừng import file này ở nơi khác — import `@/server/tasks` để các handler
|
|
8
|
+
* được đăng ký trước khi có ai enqueue.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
configureTaskRunner,
|
|
12
|
+
type TaskDb,
|
|
13
|
+
type TaskNotifyInput,
|
|
14
|
+
} from "@goerp/core/tasks"
|
|
15
|
+
|
|
16
|
+
import { db } from "@/lib/prisma"
|
|
17
|
+
|
|
18
|
+
configureTaskRunner({
|
|
19
|
+
db: db as unknown as TaskDb,
|
|
20
|
+
// Dynamic import: đường chỉ-ghi-DB không kéo tầng thông báo vào bundle.
|
|
21
|
+
notify: async (input: TaskNotifyInput) => {
|
|
22
|
+
const { notify } = await import("@/server/services/notification-service")
|
|
23
|
+
return notify(input)
|
|
24
|
+
},
|
|
25
|
+
// Chưa cắm `storage` → core ghi file kết quả vào <cwd>/storage/task-files
|
|
26
|
+
// (THƯ MỤC RIÊNG, không nằm trong public/ — file xuất thường chứa dữ liệu
|
|
27
|
+
// nhạy cảm). Khi có S3/MinIO thì thêm `storage: { save, read }` ở đây; trả
|
|
28
|
+
// `null` từ `save` là cách nói "để core fallback local".
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
enqueueTask,
|
|
33
|
+
readTaskFile,
|
|
34
|
+
reclaimStaleTasks,
|
|
35
|
+
registerTaskHandler,
|
|
36
|
+
saveTaskFile,
|
|
37
|
+
type TaskContext,
|
|
38
|
+
type TaskFileResult,
|
|
39
|
+
type TaskHandler,
|
|
40
|
+
} from "@goerp/core/tasks"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape session của app. `@goerp/core` đọc đúng ba trường `id`, `roles`,
|
|
3
|
+
* `permissions` trên `session.user` để gate CRUD và UI — đổi tên chúng là
|
|
4
|
+
* toàn bộ phân quyền im lặng trả về "không có quyền". Thêm trường riêng của
|
|
5
|
+
* app thì thoải mái.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Permission {
|
|
9
|
+
resourceCode: string
|
|
10
|
+
actionCode: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionUser {
|
|
14
|
+
id: string
|
|
15
|
+
email: string | null
|
|
16
|
+
name: string
|
|
17
|
+
avatar: string | null
|
|
18
|
+
status: string
|
|
19
|
+
roles: string[]
|
|
20
|
+
branchId: string | null
|
|
21
|
+
branches: string[]
|
|
22
|
+
permissions: Permission[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Session {
|
|
26
|
+
user: SessionUser
|
|
27
|
+
/** ISO datetime hết hạn phiên */
|
|
28
|
+
expires: string
|
|
29
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"module": "esnext",
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"incremental": true,
|
|
20
|
+
"plugins": [
|
|
21
|
+
{
|
|
22
|
+
"name": "next"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": [
|
|
27
|
+
"./src/*"
|
|
28
|
+
],
|
|
29
|
+
"@goerp/core": [
|
|
30
|
+
"./node_modules/@goerp/core/src"
|
|
31
|
+
],
|
|
32
|
+
"@goerp/core/*": [
|
|
33
|
+
"./node_modules/@goerp/core/src/*"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"include": [
|
|
38
|
+
"next-env.d.ts",
|
|
39
|
+
"**/*.ts",
|
|
40
|
+
"**/*.tsx",
|
|
41
|
+
".next/types/**/*.ts",
|
|
42
|
+
".next/dev/types/**/*.ts"
|
|
43
|
+
],
|
|
44
|
+
"exclude": [
|
|
45
|
+
"node_modules"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
|
|
3
|
+
import react from "@vitejs/plugin-react"
|
|
4
|
+
import { defineConfig } from "vitest/config"
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react()],
|
|
8
|
+
test: {
|
|
9
|
+
environment: "jsdom",
|
|
10
|
+
globals: true,
|
|
11
|
+
include: ["src/**/*.test.{ts,tsx}", "src/**/__tests__/**/*.{ts,tsx}"],
|
|
12
|
+
},
|
|
13
|
+
resolve: {
|
|
14
|
+
// Phải khai lại alias của tsconfig: vitest không đọc tsconfig paths.
|
|
15
|
+
alias: { "@": path.resolve(__dirname, "./src") },
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
EventBus as IEventBus,
|
|
3
|
-
EventHandler,
|
|
4
|
-
EventSubscription,
|
|
5
|
-
} from "./types";
|
|
6
|
-
import { createLogger } from "../logger";
|
|
7
|
-
|
|
8
|
-
const logger = createLogger("EventBus");
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Simple in-memory event bus for pub/sub pattern
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* ```typescript
|
|
15
|
-
* import { eventBus } from '@goerp/core/infrastructure';
|
|
16
|
-
*
|
|
17
|
-
* // Subscribe
|
|
18
|
-
* eventBus.on('order.created', async ({ orderId }) => {
|
|
19
|
-
* await notificationService.notify(orderId);
|
|
20
|
-
* });
|
|
21
|
-
*
|
|
22
|
-
* // Publish
|
|
23
|
-
* eventBus.emit('order.created', { orderId: '123' });
|
|
24
|
-
*
|
|
25
|
-
* // Async emit (wait for handlers)
|
|
26
|
-
* await eventBus.emitAsync('order.approved', { orderId: '123' });
|
|
27
|
-
* ```
|
|
28
|
-
*/
|
|
29
|
-
class EventBusImpl implements IEventBus {
|
|
30
|
-
private handlers = new Map<string, Set<EventHandler<unknown>>>();
|
|
31
|
-
private onceHandlers = new Map<string, Set<EventHandler<unknown>>>();
|
|
32
|
-
|
|
33
|
-
on<T>(event: string, handler: EventHandler<T>): EventSubscription {
|
|
34
|
-
if (!this.handlers.has(event)) {
|
|
35
|
-
this.handlers.set(event, new Set());
|
|
36
|
-
}
|
|
37
|
-
this.handlers.get(event)!.add(handler as EventHandler<unknown>);
|
|
38
|
-
|
|
39
|
-
logger.debug(`Subscribed to event: ${event}`);
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
unsubscribe: () => {
|
|
43
|
-
this.handlers.get(event)?.delete(handler as EventHandler<unknown>);
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
once<T>(event: string, handler: EventHandler<T>): EventSubscription {
|
|
49
|
-
if (!this.onceHandlers.has(event)) {
|
|
50
|
-
this.onceHandlers.set(event, new Set());
|
|
51
|
-
}
|
|
52
|
-
this.onceHandlers.get(event)!.add(handler as EventHandler<unknown>);
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
unsubscribe: () => {
|
|
56
|
-
this.onceHandlers.get(event)?.delete(handler as EventHandler<unknown>);
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
emit<T>(event: string, data: T): void {
|
|
62
|
-
logger.debug(`Emitting event: ${event}`);
|
|
63
|
-
|
|
64
|
-
const handlers = this.handlers.get(event);
|
|
65
|
-
const onceHandlers = this.onceHandlers.get(event);
|
|
66
|
-
|
|
67
|
-
if (handlers) {
|
|
68
|
-
for (const handler of handlers) {
|
|
69
|
-
try {
|
|
70
|
-
handler(data);
|
|
71
|
-
} catch (error) {
|
|
72
|
-
logger.error(`Error in event handler for ${event}`, {
|
|
73
|
-
error: String(error),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (onceHandlers) {
|
|
80
|
-
for (const handler of onceHandlers) {
|
|
81
|
-
try {
|
|
82
|
-
handler(data);
|
|
83
|
-
} catch (error) {
|
|
84
|
-
logger.error(`Error in once handler for ${event}`, {
|
|
85
|
-
error: String(error),
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
this.onceHandlers.delete(event);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async emitAsync<T>(event: string, data: T): Promise<void> {
|
|
94
|
-
logger.debug(`Emitting async event: ${event}`);
|
|
95
|
-
|
|
96
|
-
const handlers = this.handlers.get(event);
|
|
97
|
-
const onceHandlers = this.onceHandlers.get(event);
|
|
98
|
-
const promises: Promise<void>[] = [];
|
|
99
|
-
|
|
100
|
-
if (handlers) {
|
|
101
|
-
for (const handler of handlers) {
|
|
102
|
-
promises.push(
|
|
103
|
-
Promise.resolve(handler(data)).catch((error) => {
|
|
104
|
-
logger.error(`Error in async handler for ${event}`, {
|
|
105
|
-
error: String(error),
|
|
106
|
-
});
|
|
107
|
-
}),
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (onceHandlers) {
|
|
113
|
-
for (const handler of onceHandlers) {
|
|
114
|
-
promises.push(
|
|
115
|
-
Promise.resolve(handler(data)).catch((error) => {
|
|
116
|
-
logger.error(`Error in async once handler for ${event}`, {
|
|
117
|
-
error: String(error),
|
|
118
|
-
});
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
this.onceHandlers.delete(event);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
await Promise.all(promises);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
off(event: string): void {
|
|
129
|
-
this.handlers.delete(event);
|
|
130
|
-
this.onceHandlers.delete(event);
|
|
131
|
-
logger.debug(`Removed all handlers for event: ${event}`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
removeAllListeners(): void {
|
|
135
|
-
this.handlers.clear();
|
|
136
|
-
this.onceHandlers.clear();
|
|
137
|
-
logger.debug("Removed all event listeners");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Singleton instance
|
|
142
|
-
export const eventBus = new EventBusImpl();
|
|
143
|
-
|
|
144
|
-
// Export class for testing
|
|
145
|
-
export { EventBusImpl };
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// EventBus Types
|
|
2
|
-
export type EventHandler<T = unknown> = (data: T) => void | Promise<void>;
|
|
3
|
-
|
|
4
|
-
export interface EventSubscription {
|
|
5
|
-
/** Unsubscribe from the event */
|
|
6
|
-
unsubscribe(): void;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface EventBus {
|
|
10
|
-
/** Subscribe to an event */
|
|
11
|
-
on<T>(event: string, handler: EventHandler<T>): EventSubscription;
|
|
12
|
-
/** Subscribe to an event (fires only once) */
|
|
13
|
-
once<T>(event: string, handler: EventHandler<T>): EventSubscription;
|
|
14
|
-
/** Emit an event with data */
|
|
15
|
-
emit<T>(event: string, data: T): void;
|
|
16
|
-
/** Emit an event and wait for all handlers to complete */
|
|
17
|
-
emitAsync<T>(event: string, data: T): Promise<void>;
|
|
18
|
-
/** Remove all handlers for an event */
|
|
19
|
-
off(event: string): void;
|
|
20
|
-
/** Remove all handlers */
|
|
21
|
-
removeAllListeners(): void;
|
|
22
|
-
}
|