@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,23 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { db } from "@/lib/prisma"
6
+
7
+ /** Đánh dấu đã/chưa xử lý một dòng lỗi. */
8
+ export const PATCH = apiHandler(
9
+ async (req, { params }) => {
10
+ try {
11
+ const { id } = await params
12
+ const { resolved } = await req.json()
13
+ const log = await db.errorLog.update({
14
+ where: { id },
15
+ data: { resolved: Boolean(resolved) },
16
+ })
17
+ return NextResponse.json({ success: true, log })
18
+ } catch (error) {
19
+ return serverError(error, req, { message: "Không cập nhật được trạng thái lỗi" })
20
+ }
21
+ },
22
+ { resource: "error-log", action: "update" },
23
+ )
@@ -0,0 +1,124 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import type { NextRequest } from "next/server"
4
+
5
+ import { apiHandler } from "@/lib/api-handler"
6
+ import { serverError } from "@/lib/errors/server-error"
7
+ import { db } from "@/lib/prisma"
8
+
9
+ /**
10
+ * Nhật ký lỗi cho trang /admin/system/error-logs (ErrorLogsPage của core).
11
+ * Lỗi được GHI ở nơi khác (`serverError` / `onRequestError`); route này chỉ đọc
12
+ * và dọn.
13
+ */
14
+
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ type Where = Record<string, any>
17
+
18
+ export const GET = apiHandler(
19
+ async (req) => {
20
+ try {
21
+ const sp = new URL(req.url).searchParams
22
+ const search = sp.get("search") || undefined
23
+ const severity = sp.get("severity") || undefined
24
+ const module = sp.get("module") || undefined
25
+ const resolved = sp.get("resolved")
26
+ const startDate = sp.get("startDate")
27
+ const endDate = sp.get("endDate")
28
+ const skip = parseInt(sp.get("skip") || "0", 10)
29
+ const take = Math.min(parseInt(sp.get("take") || "50", 10), 100)
30
+
31
+ const where: Where = {}
32
+ if (search) {
33
+ where.OR = [
34
+ { message: { contains: search, mode: "insensitive" } },
35
+ { errorId: { contains: search, mode: "insensitive" } },
36
+ { code: { contains: search, mode: "insensitive" } },
37
+ { module: { contains: search, mode: "insensitive" } },
38
+ ]
39
+ }
40
+ if (severity && severity !== "all") where.severity = severity
41
+ if (module && module !== "all") where.module = module
42
+ if (resolved === "true") where.resolved = true
43
+ else if (resolved === "false") where.resolved = false
44
+ if (startDate || endDate) {
45
+ where.createdAt = {}
46
+ if (startDate) where.createdAt.gte = new Date(startDate)
47
+ if (endDate) where.createdAt.lte = new Date(endDate)
48
+ }
49
+
50
+ const startOfToday = new Date()
51
+ startOfToday.setHours(0, 0, 0, 0)
52
+
53
+ const [logs, total, todayCount, unresolvedCount, moduleStats] = await Promise.all([
54
+ db.errorLog.findMany({ where, orderBy: { lastSeenAt: "desc" }, skip, take }),
55
+ db.errorLog.count({ where }),
56
+ db.errorLog.count({ where: { createdAt: { gte: startOfToday } } }),
57
+ db.errorLog.count({ where: { resolved: false } }),
58
+ db.errorLog.groupBy({
59
+ by: ["module"],
60
+ _count: { module: true },
61
+ where: { resolved: false },
62
+ orderBy: { _count: { module: "desc" } },
63
+ take: 8,
64
+ }),
65
+ ])
66
+
67
+ // ErrorLog không có quan hệ tới User (lỗi có thể xảy ra khi chưa đăng
68
+ // nhập) — tra tên riêng thay vì include.
69
+ const userIds = [...new Set(logs.map((l) => l.userId).filter(Boolean))] as string[]
70
+ const nameById = new Map<string, string>()
71
+ if (userIds.length) {
72
+ const users = await db.user.findMany({
73
+ where: { id: { in: userIds } },
74
+ select: { id: true, name: true },
75
+ })
76
+ for (const u of users) nameById.set(u.id, u.name || u.id)
77
+ }
78
+
79
+ return NextResponse.json({
80
+ data: logs.map((l) => ({
81
+ ...l,
82
+ userName: l.userId ? (nameById.get(l.userId) ?? l.userId) : null,
83
+ })),
84
+ meta: {
85
+ total,
86
+ skip,
87
+ take,
88
+ hasMore: skip + take < total,
89
+ todayCount,
90
+ unresolvedCount,
91
+ moduleStats: moduleStats.map((m) => ({
92
+ module: m.module || "unknown",
93
+ count: m._count.module,
94
+ })),
95
+ },
96
+ })
97
+ } catch (error) {
98
+ return serverError(error, req, { message: "Không tải được nhật ký lỗi" })
99
+ }
100
+ },
101
+ { resource: "error-log", action: "view" },
102
+ )
103
+
104
+ /** Dọn log: ?mode=resolved (đã xử lý) hoặc ?mode=older_than&days=30. */
105
+ export const DELETE = apiHandler(
106
+ async (req: NextRequest) => {
107
+ try {
108
+ const sp = new URL(req.url).searchParams
109
+ const mode = sp.get("mode")
110
+ const days = parseInt(sp.get("days") || "30", 10)
111
+
112
+ const where =
113
+ mode === "resolved"
114
+ ? { resolved: true }
115
+ : { createdAt: { lt: new Date(Date.now() - days * 864e5) } }
116
+
117
+ const { count } = await db.errorLog.deleteMany({ where })
118
+ return NextResponse.json({ success: true, deleted: count })
119
+ } catch (error) {
120
+ return serverError(error, req, { message: "Không xóa được nhật ký lỗi" })
121
+ }
122
+ },
123
+ { resource: "error-log", action: "delete" },
124
+ )
@@ -0,0 +1,25 @@
1
+ import { createFileProxyHandler } from "@/lib/storage"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+
5
+ /**
6
+ * GET /api/files/[...key] — phục vụ tập tin từ kho qua app server.
7
+ *
8
+ * Đi qua app chứ không cho trình duyệt gọi thẳng vào kho, vì hai lẽ: MinIO/S3
9
+ * nội bộ thường không mở ra Internet, và mỗi object cần luật quyền riêng.
10
+ *
11
+ * Hiện tại chỉ gác PHIÊN. Khi kho có tập tin nhạy cảm, thêm seam `authorize`:
12
+ *
13
+ * createFileProxyHandler<Session>({
14
+ * authorize: ({ key, session }) => {
15
+ * if (key.startsWith("hop-dong/")) {
16
+ * return checkPermission(session, "contract", "view")
17
+ * }
18
+ * return true
19
+ * },
20
+ * })
21
+ *
22
+ * Trả `false` → 403; trả `{ status: 404 }` khi không muốn lộ cả sự tồn tại
23
+ * của key.
24
+ */
25
+ export const GET = apiHandler<{ key: string[] }>(createFileProxyHandler())
@@ -0,0 +1,29 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { markAllRead, markRead } from "@/server/services/notification-service"
6
+
7
+ /**
8
+ * POST /api/notifications/read
9
+ * { ids: string[] } → đánh dấu đã đọc các tin đó
10
+ * { all: true } → đánh dấu đã đọc tất cả
11
+ * Luôn scope theo session.user.id — không ai đọc hộ hộp thư người khác.
12
+ */
13
+ export const POST = apiHandler(async (req, { session }) => {
14
+ try {
15
+ const body = await req.json().catch(() => ({}))
16
+ const userId = session.user.id
17
+
18
+ if (body?.all === true) {
19
+ return NextResponse.json({ count: await markAllRead(userId) })
20
+ }
21
+
22
+ const ids: string[] = Array.isArray(body?.ids)
23
+ ? body.ids.filter((id: unknown): id is string => typeof id === "string")
24
+ : []
25
+ return NextResponse.json({ count: await markRead(userId, ids) })
26
+ } catch (error) {
27
+ return serverError(error, req, { message: "Không cập nhật được thông báo" })
28
+ }
29
+ })
@@ -0,0 +1,26 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { listNotifications } from "@/server/services/notification-service"
6
+
7
+ /**
8
+ * GET /api/notifications?cursor=&take=&filter=unread|all
9
+ * Hộp thư của CHÍNH người đang đăng nhập — luôn scope theo session.user.id,
10
+ * nên chỉ cần đăng nhập chứ không cần quyền trên resource nào.
11
+ */
12
+ export const GET = apiHandler(async (req, { session }) => {
13
+ try {
14
+ const sp = new URL(req.url).searchParams
15
+ const takeRaw = Number(sp.get("take"))
16
+
17
+ const result = await listNotifications(session.user.id, {
18
+ cursor: sp.get("cursor") || undefined,
19
+ take: Number.isFinite(takeRaw) && takeRaw > 0 ? takeRaw : undefined,
20
+ filter: sp.get("filter") === "unread" ? "unread" : "all",
21
+ })
22
+ return NextResponse.json(result)
23
+ } catch (error) {
24
+ return serverError(error, req, { message: "Không tải được thông báo" })
25
+ }
26
+ })
@@ -0,0 +1,14 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { getUnreadCount } from "@/server/services/notification-service"
6
+
7
+ /** GET /api/notifications/unread-count → { count }. Endpoint nhẹ cho chuông poll. */
8
+ export const GET = apiHandler(async (req, { session }) => {
9
+ try {
10
+ return NextResponse.json({ count: await getUnreadCount(session.user.id) })
11
+ } catch (error) {
12
+ return serverError(error, req, { message: "Không đếm được thông báo" })
13
+ }
14
+ })
@@ -0,0 +1,11 @@
1
+ import { createPermissionsVersionHandler } from "@goerp/core/rbac/route-handlers"
2
+
3
+ import { getSession } from "@/lib/auth"
4
+ import { db } from "@/lib/prisma"
5
+
6
+ /**
7
+ * GET /api/rbac/permissions-version — client poll (PermissionsVersionWatcher
8
+ * của core). Số version đổi ⇒ trình duyệt tự nạp lại phiên, nên admin sửa
9
+ * quyền là người dùng thấy ngay, không phải đăng xuất/đăng nhập lại.
10
+ */
11
+ export const { GET } = createPermissionsVersionHandler({ prisma: db, getSession })
@@ -0,0 +1,14 @@
1
+ import { getCrudPermissions } from "@goerp/core/crud/server"
2
+ import { createRoleItemHandlers } from "@goerp/core/rbac/route-handlers"
3
+
4
+ import { getSession } from "@/lib/auth"
5
+ import { serverError } from "@/lib/errors/server-error"
6
+ import { db } from "@/lib/prisma"
7
+
8
+ /** GET / PUT (thay toàn bộ bộ quyền) / DELETE (chặn nếu còn người dùng). */
9
+ export const { GET, PUT, DELETE } = createRoleItemHandlers({
10
+ prisma: db,
11
+ getSession,
12
+ getCrudPermissions,
13
+ onError: (error, req) => serverError(error, req, { message: "Lỗi xử lý vai trò" }),
14
+ })
@@ -0,0 +1,18 @@
1
+ import { getCrudPermissions } from "@goerp/core/crud/server"
2
+ import { createRolesCollectionHandlers } from "@goerp/core/rbac/route-handlers"
3
+
4
+ import { getSession } from "@/lib/auth"
5
+ import { serverError } from "@/lib/errors/server-error"
6
+ import { db } from "@/lib/prisma"
7
+
8
+ /**
9
+ * GET (danh sách + số người dùng) và POST (tạo vai trò kèm bộ quyền) — cả hai
10
+ * đến từ factory của core, app chỉ tiêm prisma + nguồn session. Đổi vai trò
11
+ * cũng bump `permissions-version` để phiên đang mở tự nhận quyền mới.
12
+ */
13
+ export const { GET, POST } = createRolesCollectionHandlers({
14
+ prisma: db,
15
+ getSession,
16
+ getCrudPermissions,
17
+ onError: (error, req) => serverError(error, req, { message: "Lỗi xử lý vai trò" }),
18
+ })
@@ -0,0 +1,52 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { db } from "@/lib/prisma"
6
+
7
+ /**
8
+ * GET /api/tasks/[id]/download[?file=error] — tải file kết quả.
9
+ *
10
+ * CHỦ tác vụ mới tải được (`createdBy` trong mệnh đề where, không phải kiểm tra
11
+ * sau khi đọc): file xuất thường là dữ liệu kinh doanh, đừng để đoán id là lấy
12
+ * được. `?file=error` lấy file liệt kê dòng lỗi của tác vụ nhập.
13
+ */
14
+ export const GET = apiHandler(async (req, { session, params }) => {
15
+ try {
16
+ const { id } = await params
17
+ const task = await db.backgroundTask.findFirst({
18
+ where: { id, createdBy: session.user.id },
19
+ })
20
+ if (!task || task.status !== "success") {
21
+ return NextResponse.json(
22
+ { error: "Không tìm thấy tác vụ hoặc tác vụ chưa hoàn tất" },
23
+ { status: 404 },
24
+ )
25
+ }
26
+
27
+ const result = (task.result ?? {}) as {
28
+ fileKey?: string
29
+ fileName?: string
30
+ contentType?: string
31
+ errorFileKey?: string
32
+ errorFileName?: string
33
+ }
34
+ const wantError = new URL(req.url).searchParams.get("file") === "error"
35
+ const fileKey = wantError ? result.errorFileKey : result.fileKey
36
+ const fileName = (wantError ? result.errorFileName : result.fileName) || "download"
37
+ if (!fileKey) {
38
+ return NextResponse.json({ error: "Tác vụ không có file kết quả" }, { status: 404 })
39
+ }
40
+
41
+ const { readTaskFile } = await import("@/server/tasks")
42
+ const buffer = await readTaskFile(fileKey)
43
+ return new NextResponse(new Uint8Array(buffer), {
44
+ headers: {
45
+ "Content-Type": result.contentType || "application/octet-stream",
46
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
47
+ },
48
+ })
49
+ } catch (error) {
50
+ return serverError(error, req, { message: "Không tải được file tác vụ" })
51
+ }
52
+ })
@@ -0,0 +1,37 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { serverError } from "@/lib/errors/server-error"
5
+ import { db } from "@/lib/prisma"
6
+
7
+ /**
8
+ * GET /api/tasks?take=20 — tác vụ nền của CHÍNH người đang đăng nhập, mới nhất
9
+ * trước. Không khai resource: đây là dữ liệu cá nhân, chỉ cần đăng nhập; phạm
10
+ * vi được ép bằng `createdBy` chứ không bằng quyền.
11
+ */
12
+ export const GET = apiHandler(async (req, { session }) => {
13
+ try {
14
+ const takeRaw = Number(new URL(req.url).searchParams.get("take"))
15
+ const take = Number.isFinite(takeRaw) && takeRaw > 0 ? Math.min(takeRaw, 100) : 20
16
+
17
+ const tasks = await db.backgroundTask.findMany({
18
+ where: { createdBy: session.user.id },
19
+ orderBy: { createdAt: "desc" },
20
+ take,
21
+ select: {
22
+ id: true,
23
+ type: true,
24
+ title: true,
25
+ status: true,
26
+ progress: true,
27
+ result: true,
28
+ error: true,
29
+ createdAt: true,
30
+ finishedAt: true,
31
+ },
32
+ })
33
+ return NextResponse.json({ tasks })
34
+ } catch (error) {
35
+ return serverError(error, req, { message: "Không tải được danh sách tác vụ" })
36
+ }
37
+ })
@@ -0,0 +1,15 @@
1
+ import { createUploadHandler } from "@/lib/storage"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+
5
+ /**
6
+ * POST /api/upload — nhận tập tin đính kèm, trả `{ url, key }`.
7
+ *
8
+ * Engine ở `@goerp/core/storage`: giới hạn 25MB, whitelist đuôi, làm sạch tên
9
+ * (giữ dấu tiếng Việt), gom theo thư mục ngày. URL trả về LUÔN là
10
+ * `/api/files/<key>` dù kho đang là đĩa local hay S3 — đổi kho về sau không
11
+ * làm hỏng URL đã lưu trong DB.
12
+ *
13
+ * Đổi hạn mức/định dạng: `createUploadHandler({ maxBytes, allowedExtensions })`.
14
+ */
15
+ export const POST = apiHandler(createUploadHandler())
@@ -0,0 +1,15 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ /* The whole design system (Radix → shadcn → Plane tokens, status, charts,
5
+ sidebar, scrollbar) comes from core in one import. */
6
+ @import "@goerp/core/styles/base.css";
7
+
8
+ /* Tailwind v4 must scan core's component classes (this app's own src is
9
+ auto-scanned). */
10
+ @source "../../node_modules/@goerp/core/src/**/*.tsx";
11
+ @source "../../node_modules/@goplusvn/core/src/**/*.tsx";
12
+
13
+ /* App brand overrides. Core's default brand is indigo. Re-skin either here
14
+ statically, or at runtime via `branding.primaryColor` on <TenantProvider>. */
15
+ /* :root { --primary: 262 83% 58%; --sidebar-background: 262 60% 20%; } */
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import "./globals.css"
4
+
5
+ export default function RootLayout({ children }: { children: ReactNode }) {
6
+ return (
7
+ <html lang="vi" suppressHydrationWarning>
8
+ <body
9
+ suppressHydrationWarning
10
+ className="bg-background text-foreground antialiased"
11
+ >
12
+ {children}
13
+ </body>
14
+ </html>
15
+ )
16
+ }
@@ -0,0 +1,8 @@
1
+ import { redirect } from "next/navigation"
2
+
3
+ import { i18n } from "@/configs/i18n"
4
+
5
+ // Root → default locale. All app pages live under /[lang]/(main)/.
6
+ export default function RootRedirect() {
7
+ redirect(`/${i18n.defaultLocale}`)
8
+ }
@@ -0,0 +1,30 @@
1
+ 'use client'
2
+
3
+ import type { NavigationType } from '@goerp/core/types'
4
+ import { NotificationBell } from '@goerp/core/notification/ui'
5
+ import { MainLayout } from '@goerp/core/ui'
6
+
7
+ export function MainLayoutWrapper({
8
+ children,
9
+ dictionary,
10
+ navigation,
11
+ }: {
12
+ children: React.ReactNode
13
+ dictionary: Record<string, unknown>
14
+ navigation: NavigationType[]
15
+ }) {
16
+ return (
17
+ <MainLayout
18
+ dictionary={dictionary}
19
+ navigation={navigation}
20
+ onGlobalSearch={async () => []}
21
+ searchResults={[]}
22
+ searchLoading={false}
23
+ // Chuông đọc /api/notifications* — bỏ prop này thì người dùng không có
24
+ // đường nào thấy thông báo mà notify() đã ghi.
25
+ notificationSlot={<NotificationBell />}
26
+ >
27
+ {children}
28
+ </MainLayout>
29
+ )
30
+ }
@@ -0,0 +1,24 @@
1
+ import { departmentsConfig } from "@goerp/core/configs/entities"
2
+
3
+ import type { EntityConfig } from "@goerp/core/types"
4
+
5
+ const CRUD_ENDPOINT = "/api/crud/departments"
6
+
7
+ /**
8
+ * Reuse core's canonical "Phòng ban" config; override only what's app-specific.
9
+ * Feed this to the core CRUD engine (@goerp/core/crud) to get a full
10
+ * table/form/import/export page for the Department entity.
11
+ */
12
+ export const departmentConfig: EntityConfig = {
13
+ ...departmentsConfig,
14
+ // Trỏ vào route CRUD generic (/api/crud/[entity]) — được phục vụ bởi engine core
15
+ // qua src/lib/crud + src/app/api/crud. Đổi nếu bạn tự viết route riêng.
16
+ apiEndpoint: CRUD_ENDPOINT,
17
+ // Repoint the self-referencing "parent department" dropdown at the same
18
+ // generic endpoint (core's default points at /api/departments).
19
+ fields: departmentsConfig.fields.map((field) =>
20
+ field.dataSource?.type === "api"
21
+ ? { ...field, dataSource: { ...field.dataSource, endpoint: CRUD_ENDPOINT } }
22
+ : field,
23
+ ),
24
+ }
@@ -0,0 +1,13 @@
1
+ import type { EntityConfig } from "@goerp/core/types"
2
+
3
+ import { departmentConfig } from "./department.config"
4
+
5
+ // Đăng ký entity → cấu hình. Khóa = số nhiều, khớp URL /crud/<key> + apiEndpoint.
6
+ // Thêm entity mới: import config rồi thêm 1 dòng ở đây.
7
+ export const entityConfigs: Record<string, EntityConfig> = {
8
+ departments: departmentConfig,
9
+ }
10
+
11
+ export function getEntityConfig(entity: string): EntityConfig | undefined {
12
+ return entityConfigs[entity]
13
+ }
@@ -0,0 +1,12 @@
1
+ export const i18n = {
2
+ defaultLocale: 'vi',
3
+ locales: ['vi', 'en'] as const,
4
+ localeDirection: {
5
+ vi: 'ltr',
6
+ en: 'ltr',
7
+ } as const,
8
+ localeNames: {
9
+ vi: 'vietnamese',
10
+ en: 'english',
11
+ } as const,
12
+ } as const
@@ -0,0 +1,45 @@
1
+ import masterDataPermissions from "./master-data.permissions"
2
+ import systemPermissions from "./system.permissions"
3
+
4
+ import type { FeaturePermissions, LookupPolicy, ResourceDeclaration } from "./types"
5
+
6
+ export type { FeaturePermissions, LookupPolicy, ResourceDeclaration }
7
+
8
+ /**
9
+ * Registry hợp nhất — thêm phân hệ mới thì import file khai báo và nối vào
10
+ * mảng dưới đây. Giữ file này DỮ LIỆU THUẦN (xem types.ts).
11
+ */
12
+ export const permissionRegistry: readonly FeaturePermissions[] = [
13
+ systemPermissions,
14
+ masterDataPermissions,
15
+ ]
16
+
17
+ const resourceByCode = new Map<string, ResourceDeclaration>()
18
+ const featureByResourceCode = new Map<string, string>()
19
+ for (const feature of permissionRegistry) {
20
+ for (const resource of feature.resources) {
21
+ const owner = featureByResourceCode.get(resource.code)
22
+ if (owner) {
23
+ // Trùng mã resource là lỗi cấu hình im lặng nguy hiểm nhất: hai phân hệ
24
+ // tưởng mình sở hữu cùng một quyền. Ném ngay lúc nạp module.
25
+ throw new Error(
26
+ `Permission registry: resource "${resource.code}" khai trùng ở "${feature.feature}" và "${owner}"`,
27
+ )
28
+ }
29
+ resourceByCode.set(resource.code, resource)
30
+ featureByResourceCode.set(resource.code, feature.feature)
31
+ }
32
+ }
33
+
34
+ export function getRegistryResource(code: string): ResourceDeclaration | undefined {
35
+ return resourceByCode.get(code)
36
+ }
37
+
38
+ export function getRegistryResourceCodes(): string[] {
39
+ return [...resourceByCode.keys()]
40
+ }
41
+
42
+ /** Resource nào cho phép mọi người đã đăng nhập đọc để đổ combo/picker. */
43
+ export function isLookupOpenToAuthenticated(code: string): boolean {
44
+ return resourceByCode.get(code)?.lookupPolicy === "authenticated"
45
+ }
@@ -0,0 +1,31 @@
1
+ import type { FeaturePermissions } from "./types"
2
+
3
+ /**
4
+ * Danh mục nghiệp vụ của app — MẪU. Xoá Department và khai resource của bạn
5
+ * theo đúng khuôn: mã resource ở đây phải trùng chuỗi bạn truyền vào
6
+ * checkPermission và trùng `permissionResource` trong EntityConfig, nếu không
7
+ * nút bấm sẽ ẩn vĩnh viễn mà không báo lỗi.
8
+ */
9
+ const masterDataPermissions: FeaturePermissions = {
10
+ feature: "master-data",
11
+ description: "Danh mục dùng chung",
12
+ resources: [
13
+ {
14
+ code: "department",
15
+ name: "Phòng ban",
16
+ group: "Danh mục",
17
+ icon: "Building2",
18
+ order: 1,
19
+ actions: ["view", "create", "update", "delete", "export", "import"],
20
+ // Phòng ban xuất hiện trong nhiều combo (hồ sơ nhân sự, phân bổ chi phí…)
21
+ // nên cho mọi người đăng nhập đọc được danh sách để đổ picker.
22
+ lookupPolicy: "authenticated",
23
+ defaultGrants: {
24
+ admin: "*",
25
+ staff: ["view", "export"],
26
+ },
27
+ },
28
+ ],
29
+ }
30
+
31
+ export default masterDataPermissions