@goplusvn/core 0.1.70 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -0,0 +1,179 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { db } from "@/lib/prisma"
5
+
6
+ /**
7
+ * Danh mục hệ thống (mục con trong nhóm). Component SystemCategoryPage của
8
+ * core gọi collection route này cho CẢ 4 thao tác — PUT/DELETE nhận `?id=`
9
+ * trên query (PUT còn kèm `id` trong body), không có route [id] riêng.
10
+ */
11
+
12
+ /** Lấy id từ query `?id=` hoặc body — core gửi cả hai nơi tuỳ thao tác. */
13
+ function idFromRequest(req: Request, body?: { id?: string }) {
14
+ return new URL(req.url).searchParams.get("id") || body?.id || ""
15
+ }
16
+
17
+ export const GET = apiHandler(
18
+ async (req) => {
19
+ const { searchParams } = new URL(req.url)
20
+ const page = Math.max(Number(searchParams.get("page") || 1), 1)
21
+ const pageSize = Math.min(Number(searchParams.get("pageSize") || 10), 100)
22
+ const search = (searchParams.get("q") || searchParams.get("search"))?.trim()
23
+ const status = searchParams.get("status")?.trim()
24
+ const groupCode = searchParams.get("groupCode")?.trim()
25
+
26
+ const where = {
27
+ AND: [
28
+ search
29
+ ? {
30
+ OR: [
31
+ { code: { contains: search, mode: "insensitive" as const } },
32
+ { name: { contains: search, mode: "insensitive" as const } },
33
+ {
34
+ description: {
35
+ contains: search,
36
+ mode: "insensitive" as const,
37
+ },
38
+ },
39
+ ],
40
+ }
41
+ : {},
42
+ status && status !== "all" ? { status } : {},
43
+ groupCode && groupCode !== "all" ? { groupCode } : {},
44
+ ],
45
+ }
46
+
47
+ const [total, items] = await Promise.all([
48
+ db.systemCategory.count({ where }),
49
+ db.systemCategory.findMany({
50
+ where,
51
+ orderBy: [{ order: "asc" }, { updatedAt: "desc" }],
52
+ skip: (page - 1) * pageSize,
53
+ take: pageSize,
54
+ }),
55
+ ])
56
+
57
+ return NextResponse.json({ total, page, pageSize, items })
58
+ },
59
+ { resource: "system-category", action: "view" }
60
+ )
61
+
62
+ export const POST = apiHandler(
63
+ async (req, { session }) => {
64
+ const body = await req.json()
65
+ const { code, name, groupCode } = body as Record<string, unknown>
66
+
67
+ if (!code || !name || !groupCode) {
68
+ return NextResponse.json(
69
+ {
70
+ error: "Mã, tên và nhóm danh mục là bắt buộc",
71
+ field: !code ? "code" : !name ? "name" : "groupCode",
72
+ },
73
+ { status: 400 }
74
+ )
75
+ }
76
+
77
+ const existing = await db.systemCategory.findUnique({
78
+ where: { code: String(code) },
79
+ })
80
+ if (existing) {
81
+ return NextResponse.json(
82
+ { error: "Mã danh mục đã tồn tại", field: "code" },
83
+ { status: 400 }
84
+ )
85
+ }
86
+
87
+ const userId = session.user.id
88
+ const created = await db.systemCategory.create({
89
+ data: {
90
+ id: crypto.randomUUID(),
91
+ code: String(code),
92
+ name: String(name),
93
+ groupCode: String(groupCode),
94
+ description: String(body.description || ""),
95
+ status: String(body.status || "active"),
96
+ order: body.order ? Number(body.order) : null,
97
+ createdBy: userId,
98
+ updatedAt: new Date(),
99
+ updatedBy: userId,
100
+ },
101
+ })
102
+
103
+ return NextResponse.json(created, { status: 201 })
104
+ },
105
+ { resource: "system-category", action: "create" }
106
+ )
107
+
108
+ export const PUT = apiHandler(
109
+ async (req, { session }) => {
110
+ const body = await req.json()
111
+ const id = idFromRequest(req, body)
112
+ if (!id) {
113
+ return NextResponse.json(
114
+ { error: "ID là bắt buộc", field: "id" },
115
+ { status: 400 }
116
+ )
117
+ }
118
+
119
+ const { code, name, groupCode } = body as Record<string, unknown>
120
+ if (!code || !name || !groupCode) {
121
+ return NextResponse.json(
122
+ {
123
+ error: "Mã, tên và nhóm danh mục là bắt buộc",
124
+ field: !code ? "code" : !name ? "name" : "groupCode",
125
+ },
126
+ { status: 400 }
127
+ )
128
+ }
129
+
130
+ // Mã phải duy nhất — trừ chính bản ghi đang sửa.
131
+ const existing = await db.systemCategory.findFirst({
132
+ where: { code: String(code), id: { not: id } },
133
+ })
134
+ if (existing) {
135
+ return NextResponse.json(
136
+ { error: "Mã danh mục đã tồn tại", field: "code" },
137
+ { status: 400 }
138
+ )
139
+ }
140
+
141
+ const updated = await db.systemCategory.update({
142
+ where: { id },
143
+ data: {
144
+ code: String(code),
145
+ name: String(name),
146
+ groupCode: String(groupCode),
147
+ description: String(body.description || ""),
148
+ status: String(body.status || "active"),
149
+ order: body.order ? Number(body.order) : null,
150
+ updatedAt: new Date(),
151
+ updatedBy: session.user.id,
152
+ },
153
+ })
154
+
155
+ return NextResponse.json(updated)
156
+ },
157
+ { resource: "system-category", action: "update" }
158
+ )
159
+
160
+ export const DELETE = apiHandler(
161
+ async (req) => {
162
+ const id = idFromRequest(req)
163
+ if (!id) {
164
+ return NextResponse.json({ error: "ID là bắt buộc" }, { status: 400 })
165
+ }
166
+
167
+ const existing = await db.systemCategory.findUnique({ where: { id } })
168
+ if (!existing) {
169
+ return NextResponse.json(
170
+ { error: "Không tìm thấy danh mục" },
171
+ { status: 404 }
172
+ )
173
+ }
174
+
175
+ await db.systemCategory.delete({ where: { id } })
176
+ return NextResponse.json({ message: "Đã xóa danh mục" })
177
+ },
178
+ { resource: "system-category", action: "delete" }
179
+ )
@@ -0,0 +1,149 @@
1
+ import { NextResponse } from "next/server"
2
+ import { getSystemCategoryGroupsData } from "@goerp/core/system/services/system-category-service"
3
+
4
+ import { apiHandler } from "@/lib/api-handler"
5
+ import { db } from "@/lib/prisma"
6
+
7
+ /**
8
+ * Nhóm danh mục hệ thống (sidebar trái của SystemCategoryPage). Cùng khuôn
9
+ * với /api/system-categories: PUT/DELETE nhận `?id=` trên query.
10
+ */
11
+
12
+ function idFromRequest(req: Request, body?: { id?: string }) {
13
+ return new URL(req.url).searchParams.get("id") || body?.id || ""
14
+ }
15
+
16
+ export const GET = apiHandler(
17
+ async (req) => {
18
+ const { searchParams } = new URL(req.url)
19
+ const result = await getSystemCategoryGroupsData(db, {
20
+ page: Math.max(Number(searchParams.get("page") || 1), 1),
21
+ pageSize: Math.min(Number(searchParams.get("pageSize") || 10), 100),
22
+ search: (searchParams.get("q") || searchParams.get("search"))?.trim(),
23
+ status: searchParams.get("status")?.trim(),
24
+ })
25
+ return NextResponse.json(result)
26
+ },
27
+ { resource: "system-category", action: "view" }
28
+ )
29
+
30
+ export const POST = apiHandler(
31
+ async (req, { session }) => {
32
+ const body = await req.json()
33
+ const { code, name } = body as Record<string, unknown>
34
+
35
+ if (!code || !name) {
36
+ return NextResponse.json(
37
+ { error: "Mã và tên nhóm là bắt buộc", field: !code ? "code" : "name" },
38
+ { status: 400 }
39
+ )
40
+ }
41
+
42
+ const existing = await db.systemCategoryGroup.findUnique({
43
+ where: { code: String(code) },
44
+ })
45
+ if (existing) {
46
+ return NextResponse.json(
47
+ { error: "Mã nhóm đã tồn tại", field: "code" },
48
+ { status: 400 }
49
+ )
50
+ }
51
+
52
+ const userId = session.user.id
53
+ const created = await db.systemCategoryGroup.create({
54
+ data: {
55
+ id: crypto.randomUUID(),
56
+ code: String(code),
57
+ name: String(name),
58
+ description: String(body.description || ""),
59
+ status: String(body.status || "active"),
60
+ createdBy: userId,
61
+ updatedAt: new Date(),
62
+ updatedBy: userId,
63
+ },
64
+ })
65
+
66
+ return NextResponse.json(created, { status: 201 })
67
+ },
68
+ { resource: "system-category", action: "create" }
69
+ )
70
+
71
+ export const PUT = apiHandler(
72
+ async (req, { session }) => {
73
+ const body = await req.json()
74
+ const id = idFromRequest(req, body)
75
+ if (!id) {
76
+ return NextResponse.json(
77
+ { error: "ID là bắt buộc", field: "id" },
78
+ { status: 400 }
79
+ )
80
+ }
81
+
82
+ const { code, name } = body as Record<string, unknown>
83
+ if (!code || !name) {
84
+ return NextResponse.json(
85
+ { error: "Mã và tên nhóm là bắt buộc", field: !code ? "code" : "name" },
86
+ { status: 400 }
87
+ )
88
+ }
89
+
90
+ const existing = await db.systemCategoryGroup.findFirst({
91
+ where: { code: String(code), id: { not: id } },
92
+ })
93
+ if (existing) {
94
+ return NextResponse.json(
95
+ { error: "Mã nhóm đã tồn tại", field: "code" },
96
+ { status: 400 }
97
+ )
98
+ }
99
+
100
+ const updated = await db.systemCategoryGroup.update({
101
+ where: { id },
102
+ data: {
103
+ code: String(code),
104
+ name: String(name),
105
+ description: String(body.description || ""),
106
+ status: String(body.status || "active"),
107
+ updatedAt: new Date(),
108
+ updatedBy: session.user.id,
109
+ },
110
+ })
111
+
112
+ return NextResponse.json(updated)
113
+ },
114
+ { resource: "system-category", action: "update" }
115
+ )
116
+
117
+ export const DELETE = apiHandler(
118
+ async (req) => {
119
+ const id = idFromRequest(req)
120
+ if (!id) {
121
+ return NextResponse.json({ error: "ID là bắt buộc" }, { status: 400 })
122
+ }
123
+
124
+ const group = await db.systemCategoryGroup.findUnique({ where: { id } })
125
+ if (!group) {
126
+ return NextResponse.json(
127
+ { error: "Không tìm thấy nhóm danh mục" },
128
+ { status: 404 }
129
+ )
130
+ }
131
+
132
+ // Chặn xoá nhóm còn mục con — tránh mồ côi groupCode.
133
+ const childCount = await db.systemCategory.count({
134
+ where: { groupCode: group.code },
135
+ })
136
+ if (childCount > 0) {
137
+ return NextResponse.json(
138
+ {
139
+ error: `Nhóm còn ${childCount} danh mục con, hãy xóa hoặc chuyển chúng trước`,
140
+ },
141
+ { status: 400 }
142
+ )
143
+ }
144
+
145
+ await db.systemCategoryGroup.delete({ where: { id } })
146
+ return NextResponse.json({ message: "Đã xóa nhóm danh mục" })
147
+ },
148
+ { resource: "system-category", action: "delete" }
149
+ )
@@ -20,7 +20,7 @@ export const GET = apiHandler(async (req, { session, params }) => {
20
20
  if (!task || task.status !== "success") {
21
21
  return NextResponse.json(
22
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 },
23
+ { status: 404 }
24
24
  )
25
25
  }
26
26
 
@@ -33,9 +33,13 @@ export const GET = apiHandler(async (req, { session, params }) => {
33
33
  }
34
34
  const wantError = new URL(req.url).searchParams.get("file") === "error"
35
35
  const fileKey = wantError ? result.errorFileKey : result.fileKey
36
- const fileName = (wantError ? result.errorFileName : result.fileName) || "download"
36
+ const fileName =
37
+ (wantError ? result.errorFileName : result.fileName) || "download"
37
38
  if (!fileKey) {
38
- return NextResponse.json({ error: "Tác vụ không có file kết quả" }, { status: 404 })
39
+ return NextResponse.json(
40
+ { error: "Tác vụ không có file kết quả" },
41
+ { status: 404 }
42
+ )
39
43
  }
40
44
 
41
45
  const { readTaskFile } = await import("@/server/tasks")
@@ -12,7 +12,8 @@ import { db } from "@/lib/prisma"
12
12
  export const GET = apiHandler(async (req, { session }) => {
13
13
  try {
14
14
  const takeRaw = Number(new URL(req.url).searchParams.get("take"))
15
- const take = Number.isFinite(takeRaw) && takeRaw > 0 ? Math.min(takeRaw, 100) : 20
15
+ const take =
16
+ Number.isFinite(takeRaw) && takeRaw > 0 ? Math.min(takeRaw, 100) : 20
16
17
 
17
18
  const tasks = await db.backgroundTask.findMany({
18
19
  where: { createdBy: session.user.id },
@@ -32,6 +33,8 @@ export const GET = apiHandler(async (req, { session }) => {
32
33
  })
33
34
  return NextResponse.json({ tasks })
34
35
  } catch (error) {
35
- return serverError(error, req, { message: "Không tải được danh sách tác vụ" })
36
+ return serverError(error, req, {
37
+ message: "Không tải được danh sách tác vụ",
38
+ })
36
39
  }
37
40
  })
@@ -1,6 +1,5 @@
1
- import { createUploadHandler } from "@/lib/storage"
2
-
3
1
  import { apiHandler } from "@/lib/api-handler"
2
+ import { createUploadHandler } from "@/lib/storage"
4
3
 
5
4
  /**
6
5
  * POST /api/upload — nhận tập tin đính kèm, trả `{ url, key }`.
@@ -0,0 +1,230 @@
1
+ import { NextResponse } from "next/server"
2
+ import {
3
+ bumpPermissionsVersion,
4
+ invalidateUserPermissions,
5
+ } from "@goerp/core/rbac"
6
+ import bcrypt from "bcryptjs"
7
+
8
+ import { apiHandler } from "@/lib/api-handler"
9
+ import { serverError } from "@/lib/errors/server-error"
10
+ import { db } from "@/lib/prisma"
11
+
12
+ /**
13
+ * /api/users/[id] — xem / cập nhật / vô hiệu hóa một người dùng.
14
+ *
15
+ * PUT nhận payload của UnifiedProfileDialog (core): name/email/status +
16
+ * roleCodes + branchIds/defaultBranchId + password (tùy chọn). Field
17
+ * vinhhoa-specific (phone, departmentId, jobTitleId…) bị bỏ qua — template
18
+ * không có bảng profile.
19
+ */
20
+
21
+ export const GET = apiHandler(
22
+ async (req, { params }) => {
23
+ try {
24
+ const { id } = await params
25
+ const user = await db.user.findUnique({
26
+ where: { id },
27
+ select: {
28
+ id: true,
29
+ name: true,
30
+ email: true,
31
+ image: true,
32
+ isActive: true,
33
+ createdAt: true,
34
+ updatedAt: true,
35
+ userRoles: { select: { roleCode: true } },
36
+ userBranches: {
37
+ select: { branchId: true, isDefault: true },
38
+ orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
39
+ },
40
+ },
41
+ })
42
+ if (!user) {
43
+ return NextResponse.json(
44
+ { error: "Không tìm thấy người dùng" },
45
+ { status: 404 }
46
+ )
47
+ }
48
+ const { userRoles, userBranches, ...base } = user
49
+ return NextResponse.json({
50
+ ...base,
51
+ roleCodes: userRoles.map((ur) => ur.roleCode),
52
+ branchIds: userBranches.map((ub) => ub.branchId),
53
+ defaultBranchId:
54
+ userBranches.find((ub) => ub.isDefault)?.branchId ??
55
+ userBranches[0]?.branchId ??
56
+ null,
57
+ })
58
+ } catch (error) {
59
+ return serverError(error, req, {
60
+ message: "Lỗi tải thông tin người dùng",
61
+ })
62
+ }
63
+ },
64
+ { resource: "user", action: "view" }
65
+ )
66
+
67
+ export const PUT = apiHandler(
68
+ async (req, { params }) => {
69
+ try {
70
+ const { id } = await params
71
+ const body = await req.json()
72
+
73
+ const name = typeof body.name === "string" ? body.name.trim() : ""
74
+ const email =
75
+ typeof body.email === "string" ? body.email.toLowerCase().trim() : ""
76
+ const password = typeof body.password === "string" ? body.password : ""
77
+ const isActive =
78
+ typeof body.status === "string" ? body.status === "active" : undefined
79
+ const roleCodes: string[] | null = Array.isArray(body.roleCodes)
80
+ ? body.roleCodes
81
+ : null
82
+ const branchIds: string[] | null = Array.isArray(body.branchIds)
83
+ ? body.branchIds
84
+ : null
85
+
86
+ if (!name) {
87
+ return NextResponse.json(
88
+ { error: "Tên là bắt buộc", field: "name" },
89
+ { status: 400 }
90
+ )
91
+ }
92
+ if (!email) {
93
+ return NextResponse.json(
94
+ { error: "Email là bắt buộc", field: "email" },
95
+ { status: 400 }
96
+ )
97
+ }
98
+ if (password && password.length < 6) {
99
+ return NextResponse.json(
100
+ { error: "Mật khẩu phải có ít nhất 6 ký tự", field: "password" },
101
+ { status: 400 }
102
+ )
103
+ }
104
+
105
+ const duplicated = await db.user.findFirst({
106
+ where: { email, id: { not: id } },
107
+ select: { id: true },
108
+ })
109
+ if (duplicated) {
110
+ return NextResponse.json(
111
+ { error: "Email đã tồn tại", field: "email" },
112
+ { status: 400 }
113
+ )
114
+ }
115
+
116
+ const hashedPassword = password ? await bcrypt.hash(password, 10) : null
117
+
118
+ const updated = await db.$transaction(async (tx) => {
119
+ const user = await tx.user.update({
120
+ where: { id },
121
+ data: {
122
+ name,
123
+ email,
124
+ ...(isActive === undefined ? {} : { isActive }),
125
+ },
126
+ })
127
+
128
+ if (hashedPassword) {
129
+ // Mật khẩu sống ở Account credential; đổi xong thu hồi mọi phiên
130
+ // đang mở của người này.
131
+ await tx.account.upsert({
132
+ where: {
133
+ providerId_accountId: { providerId: "credential", accountId: id },
134
+ },
135
+ update: { password: hashedPassword },
136
+ create: {
137
+ userId: id,
138
+ providerId: "credential",
139
+ accountId: id,
140
+ password: hashedPassword,
141
+ },
142
+ })
143
+ await tx.session.deleteMany({ where: { userId: id } })
144
+ }
145
+
146
+ if (roleCodes) {
147
+ await tx.userRole.deleteMany({ where: { userId: id } })
148
+ if (roleCodes.length > 0) {
149
+ await tx.userRole.createMany({
150
+ data: roleCodes.map((roleCode, index) => ({
151
+ userId: id,
152
+ roleCode,
153
+ isPrimary: index === 0,
154
+ })),
155
+ skipDuplicates: true,
156
+ })
157
+ }
158
+ }
159
+
160
+ if (branchIds) {
161
+ await tx.userBranch.deleteMany({ where: { userId: id } })
162
+ if (branchIds.length > 0) {
163
+ const defaultBranchId =
164
+ typeof body.defaultBranchId === "string" &&
165
+ branchIds.includes(body.defaultBranchId)
166
+ ? body.defaultBranchId
167
+ : branchIds[0]
168
+ await tx.userBranch.createMany({
169
+ data: branchIds.map((branchId) => ({
170
+ userId: id,
171
+ branchId,
172
+ isDefault: branchId === defaultBranchId,
173
+ })),
174
+ skipDuplicates: true,
175
+ })
176
+ }
177
+ }
178
+
179
+ return user
180
+ })
181
+
182
+ // Đổi vai trò thì phiên đang mở phải nhận quyền mới: xóa cache quyền +
183
+ // bump permissions-version để watcher phía client tự reload.
184
+ if (roleCodes) {
185
+ await invalidateUserPermissions(id)
186
+ await bumpPermissionsVersion(db)
187
+ }
188
+
189
+ return NextResponse.json({
190
+ id: updated.id,
191
+ name: updated.name,
192
+ email: updated.email,
193
+ isActive: updated.isActive,
194
+ })
195
+ } catch (error) {
196
+ return serverError(error, req, { message: "Lỗi cập nhật người dùng" })
197
+ }
198
+ },
199
+ { resource: "user", action: "update" }
200
+ )
201
+
202
+ export const DELETE = apiHandler(
203
+ async (req, { params }) => {
204
+ try {
205
+ const { id } = await params
206
+ const user = await db.user.findUnique({
207
+ where: { id },
208
+ select: { id: true },
209
+ })
210
+ if (!user) {
211
+ return NextResponse.json(
212
+ { error: "Không tìm thấy người dùng" },
213
+ { status: 404 }
214
+ )
215
+ }
216
+
217
+ // Vô hiệu hóa thay vì xóa cứng: người dùng dính vào audit_logs và các
218
+ // chứng từ nghiệp vụ của app — xóa hẳn là mất dấu "ai đã làm gì".
219
+ await db.$transaction(async (tx) => {
220
+ await tx.user.update({ where: { id }, data: { isActive: false } })
221
+ await tx.session.deleteMany({ where: { userId: id } })
222
+ })
223
+
224
+ return NextResponse.json({ success: true })
225
+ } catch (error) {
226
+ return serverError(error, req, { message: "Lỗi vô hiệu hóa người dùng" })
227
+ }
228
+ },
229
+ { resource: "user", action: "delete" }
230
+ )