@goplusvn/core 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/PLATFORM.md +18 -0
  2. package/bin/goerp-init.mjs +141 -0
  3. package/package.json +4 -4
  4. package/src/cron/db-cron-manager.ts +3 -3
  5. package/src/cron/index.ts +2 -2
  6. package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
  7. package/src/crud/lib/mutation-builder.ts +105 -0
  8. package/src/crud/lib/query-builder.ts +119 -0
  9. package/src/crud/server-service.ts +35 -163
  10. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  11. package/src/infrastructure/index.ts +4 -7
  12. package/src/ui/management/index.ts +3 -2
  13. package/templates/starter-app/.dockerignore +41 -0
  14. package/templates/starter-app/.env.example +25 -0
  15. package/templates/starter-app/AGENTS.md +52 -0
  16. package/templates/starter-app/Dockerfile +74 -0
  17. package/templates/starter-app/README.md +141 -0
  18. package/templates/starter-app/gitignore +9 -0
  19. package/templates/starter-app/next.config.mjs +50 -0
  20. package/templates/starter-app/package.json +55 -0
  21. package/templates/starter-app/postcss.config.mjs +5 -0
  22. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  23. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  24. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  25. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  26. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  27. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  28. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  29. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  30. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  31. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  32. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  33. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  34. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  35. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  36. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  37. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  38. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  39. package/templates/starter-app/prisma/seed.ts +127 -0
  40. package/templates/starter-app/prisma.config.ts +20 -0
  41. package/templates/starter-app/public/.gitkeep +2 -0
  42. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  43. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  54. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  55. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  56. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  57. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  58. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  59. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  60. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  61. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  62. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  63. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  64. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  66. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  67. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  68. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  69. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  70. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  71. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  72. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  73. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  74. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  75. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  76. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  77. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  78. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  79. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  80. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  81. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  82. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  83. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  84. package/templates/starter-app/src/app/globals.css +15 -0
  85. package/templates/starter-app/src/app/layout.tsx +16 -0
  86. package/templates/starter-app/src/app/page.tsx +8 -0
  87. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  88. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  89. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  90. package/templates/starter-app/src/configs/i18n.ts +12 -0
  91. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  92. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  93. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  94. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  95. package/templates/starter-app/src/configs/tenant.ts +18 -0
  96. package/templates/starter-app/src/data/dictionary.ts +8 -0
  97. package/templates/starter-app/src/data/navigations.ts +61 -0
  98. package/templates/starter-app/src/instrumentation.ts +105 -0
  99. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  100. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  101. package/templates/starter-app/src/lib/auth.ts +62 -0
  102. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  103. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  104. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  105. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  106. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  107. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  108. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  109. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  110. package/templates/starter-app/src/lib/logger.ts +30 -0
  111. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  112. package/templates/starter-app/src/lib/prisma.ts +80 -0
  113. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  114. package/templates/starter-app/src/lib/storage.ts +28 -0
  115. package/templates/starter-app/src/providers/index.tsx +54 -0
  116. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  117. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  118. package/templates/starter-app/src/proxy.ts +45 -0
  119. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  120. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  121. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  122. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  123. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  124. package/templates/starter-app/src/types/session.ts +29 -0
  125. package/templates/starter-app/tsconfig.json +47 -0
  126. package/templates/starter-app/vitest.config.ts +17 -0
  127. package/src/infrastructure/cron/index.ts +0 -6
  128. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  129. package/src/infrastructure/event-bus/index.ts +0 -2
  130. package/src/infrastructure/event-bus/types.ts +0 -22
  131. package/src/infrastructure/lock/decorators.ts +0 -67
  132. package/src/infrastructure/lock/index.ts +0 -2
  133. package/src/infrastructure/lock/lock-manager.ts +0 -33
  134. package/src/plugin/apps-registry.ts +0 -97
  135. package/src/plugin/index.ts +0 -5
  136. package/src/plugin/types.ts +0 -41
  137. package/src/ui/management/audit-log-page.tsx +0 -14
  138. package/src/ui/management/job-management.tsx +0 -308
  139. package/src/workflow/activity-timeline.tsx +0 -412
  140. package/src/workflow/approval-workflow.tsx +0 -31
  141. package/src/workflow/index.ts +0 -2
  142. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -0,0 +1,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,6 +0,0 @@
1
- export {
2
- cronJobManager,
3
- CronJobManagerImpl,
4
- SimpleCronJob,
5
- } from "./cron-manager";
6
- export type { CronJob, CronJobOptions, CronJobManager } from "./types";
@@ -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,2 +0,0 @@
1
- export { eventBus, EventBusImpl } from "./event-bus";
2
- export type { EventBus, EventHandler, EventSubscription } from "./types";
@@ -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
- }