@goplusvn/core 0.1.70 → 0.1.71

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 (86) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +1 -1
  4. package/src/auth/proxy-gate.ts +21 -2
  5. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  6. package/src/cron/cron-schedule.ts +128 -0
  7. package/src/cron/simple-cron-job.ts +83 -69
  8. package/src/crud/components/crud-table.tsx +13 -1
  9. package/src/crud/lib/translate-config.ts +16 -2
  10. package/src/guardrails/index.ts +1 -0
  11. package/src/guardrails/rules/auth.ts +54 -1
  12. package/src/guardrails/types.ts +17 -8
  13. package/src/providers/brand-theme.ts +20 -0
  14. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  15. package/src/security/index.ts +2 -0
  16. package/src/security/pages/sessions-page.tsx +516 -0
  17. package/src/ui/auth/sign-in-form.tsx +64 -9
  18. package/src/ui/errors/error-view.tsx +164 -0
  19. package/src/ui/errors/index.ts +2 -0
  20. package/src/ui/errors/not-found-view.tsx +44 -0
  21. package/src/ui/index.tsx +1 -0
  22. package/src/ui/layout/logo.tsx +54 -19
  23. package/src/user/pages/users-client-page.tsx +12 -7
  24. package/templates/starter-app/.env.example +4 -0
  25. package/templates/starter-app/README.md +6 -2
  26. package/templates/starter-app/husky/pre-commit +10 -0
  27. package/templates/starter-app/package.json +27 -2
  28. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  29. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  30. package/templates/starter-app/prisma/seed.ts +7 -5
  31. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  32. package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
  33. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  34. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
  35. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
  36. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
  37. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
  38. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  39. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  40. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
  48. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  49. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  50. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  51. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  52. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  53. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  54. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  55. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  56. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  57. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  58. package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
  59. package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
  60. package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
  61. package/templates/starter-app/src/app/api/users/route.ts +139 -0
  62. package/templates/starter-app/src/app/global-error.tsx +135 -0
  63. package/templates/starter-app/src/app/icon.svg +6 -0
  64. package/templates/starter-app/src/app/manifest.ts +26 -0
  65. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  66. package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
  67. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
  68. package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
  69. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  70. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  71. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  72. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  73. package/templates/starter-app/src/configs/tenant.ts +2 -1
  74. package/templates/starter-app/src/data/dictionary.ts +76 -4
  75. package/templates/starter-app/src/data/navigations.ts +49 -0
  76. package/templates/starter-app/src/instrumentation.ts +5 -16
  77. package/templates/starter-app/src/lib/action-guard.ts +26 -0
  78. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  79. package/templates/starter-app/src/lib/crud/index.ts +2 -0
  80. package/templates/starter-app/src/lib/prisma.ts +3 -2
  81. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  82. package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
  83. package/templates/starter-app/src/proxy.ts +8 -3
  84. package/templates/starter-app/src/server/services/company-service.ts +47 -0
  85. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  86. package/templates/starter-app/tsconfig.json +2 -0
@@ -0,0 +1,101 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+ import { getAuth } from "@/lib/better-auth"
5
+ import { serverError } from "@/lib/errors/server-error"
6
+ import { db } from "@/lib/prisma"
7
+
8
+ /**
9
+ * GET /api/security/sessions — QUẢN TRỊ phiên đăng nhập toàn hệ thống
10
+ * (bảng `sessions` của Better Auth). Gate user:view. Search: tên/email user,
11
+ * IP, user-agent. Phân trang chuẩn {items,total,page,pageSize}. KHÔNG trả
12
+ * token (nhạy cảm) — thu hồi dùng id.
13
+ */
14
+ export const GET = apiHandler(
15
+ async (req, { session }) => {
16
+ try {
17
+ const sp = req.nextUrl.searchParams
18
+ const search = sp.get("search")?.trim() || ""
19
+ const page = Math.max(Number(sp.get("page") || 1), 1)
20
+ const pageSize = Math.min(
21
+ Math.max(Number(sp.get("pageSize") || 20), 1),
22
+ 100,
23
+ )
24
+
25
+ // Token của phiên hiện tại (đánh dấu "thiết bị này" — so sánh server-side,
26
+ // không lộ token ra client)
27
+ const current = await getAuth().api.getSession({ headers: req.headers })
28
+ const currentToken = current?.session?.token ?? null
29
+
30
+ const where = {
31
+ expiresAt: { gt: new Date() }, // chỉ phiên còn hiệu lực
32
+ ...(search
33
+ ? {
34
+ OR: [
35
+ {
36
+ ipAddress: { contains: search, mode: "insensitive" as const },
37
+ },
38
+ {
39
+ userAgent: { contains: search, mode: "insensitive" as const },
40
+ },
41
+ {
42
+ user: {
43
+ OR: [
44
+ {
45
+ name: {
46
+ contains: search,
47
+ mode: "insensitive" as const,
48
+ },
49
+ },
50
+ {
51
+ email: {
52
+ contains: search,
53
+ mode: "insensitive" as const,
54
+ },
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ ],
60
+ }
61
+ : {}),
62
+ }
63
+
64
+ const [total, rows] = await Promise.all([
65
+ db.session.count({ where }),
66
+ db.session.findMany({
67
+ where,
68
+ include: { user: { select: { name: true, email: true } } },
69
+ orderBy: { updatedAt: "desc" },
70
+ skip: (page - 1) * pageSize,
71
+ take: pageSize,
72
+ }),
73
+ ])
74
+
75
+ return NextResponse.json({
76
+ items: rows.map((r) => ({
77
+ id: r.id,
78
+ userId: r.userId,
79
+ userName: r.user?.name ?? null,
80
+ userEmail: r.user?.email ?? null,
81
+ ipAddress: r.ipAddress,
82
+ userAgent: r.userAgent,
83
+ createdAt: r.createdAt,
84
+ updatedAt: r.updatedAt,
85
+ expiresAt: r.expiresAt,
86
+ isCurrent: currentToken !== null && r.token === currentToken,
87
+ isMine: r.userId === session.user.id,
88
+ impersonatedBy: null,
89
+ })),
90
+ total,
91
+ page,
92
+ pageSize,
93
+ })
94
+ } catch (error) {
95
+ return serverError(error, req, {
96
+ message: "Không tải được danh sách phiên",
97
+ })
98
+ }
99
+ },
100
+ { resource: "user", action: "view" },
101
+ )
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from "next/server"
2
+
3
+ import { apiHandler } from "@/lib/api-handler"
4
+
5
+ /**
6
+ * STUB — template không có model Supplier. UnifiedProfileDialog của core
7
+ * fetch cứng /api/suppliers?pageSize=100 (tab "Nhà cung cấp" khi userType =
8
+ * supplier); trả rỗng để dialog không vỡ. App nào có nhà cung cấp thì thay
9
+ * bằng query thật (shape: { data: [{ id, name }] }).
10
+ */
11
+ export const GET = apiHandler(async () => {
12
+ return NextResponse.json({ data: [] })
13
+ })
@@ -0,0 +1,154 @@
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
+ { description: { contains: search, mode: "insensitive" as const } },
34
+ ],
35
+ }
36
+ : {},
37
+ status && status !== "all" ? { status } : {},
38
+ groupCode && groupCode !== "all" ? { groupCode } : {},
39
+ ],
40
+ }
41
+
42
+ const [total, items] = await Promise.all([
43
+ db.systemCategory.count({ where }),
44
+ db.systemCategory.findMany({
45
+ where,
46
+ orderBy: [{ order: "asc" }, { updatedAt: "desc" }],
47
+ skip: (page - 1) * pageSize,
48
+ take: pageSize,
49
+ }),
50
+ ])
51
+
52
+ return NextResponse.json({ total, page, pageSize, items })
53
+ },
54
+ { resource: "system-category", action: "view" },
55
+ )
56
+
57
+ export const POST = apiHandler(
58
+ async (req, { session }) => {
59
+ const body = await req.json()
60
+ const { code, name, groupCode } = body as Record<string, unknown>
61
+
62
+ if (!code || !name || !groupCode) {
63
+ return NextResponse.json(
64
+ { error: "Mã, tên và nhóm danh mục là bắt buộc", field: !code ? "code" : !name ? "name" : "groupCode" },
65
+ { status: 400 },
66
+ )
67
+ }
68
+
69
+ const existing = await db.systemCategory.findUnique({ where: { code: String(code) } })
70
+ if (existing) {
71
+ return NextResponse.json({ error: "Mã danh mục đã tồn tại", field: "code" }, { status: 400 })
72
+ }
73
+
74
+ const userId = session.user.id
75
+ const created = await db.systemCategory.create({
76
+ data: {
77
+ id: crypto.randomUUID(),
78
+ code: String(code),
79
+ name: String(name),
80
+ groupCode: String(groupCode),
81
+ description: String(body.description || ""),
82
+ status: String(body.status || "active"),
83
+ order: body.order ? Number(body.order) : null,
84
+ createdBy: userId,
85
+ updatedAt: new Date(),
86
+ updatedBy: userId,
87
+ },
88
+ })
89
+
90
+ return NextResponse.json(created, { status: 201 })
91
+ },
92
+ { resource: "system-category", action: "create" },
93
+ )
94
+
95
+ export const PUT = apiHandler(
96
+ async (req, { session }) => {
97
+ const body = await req.json()
98
+ const id = idFromRequest(req, body)
99
+ if (!id) {
100
+ return NextResponse.json({ error: "ID là bắt buộc", field: "id" }, { status: 400 })
101
+ }
102
+
103
+ const { code, name, groupCode } = body as Record<string, unknown>
104
+ if (!code || !name || !groupCode) {
105
+ return NextResponse.json(
106
+ { error: "Mã, tên và nhóm danh mục là bắt buộc", field: !code ? "code" : !name ? "name" : "groupCode" },
107
+ { status: 400 },
108
+ )
109
+ }
110
+
111
+ // Mã phải duy nhất — trừ chính bản ghi đang sửa.
112
+ const existing = await db.systemCategory.findFirst({
113
+ where: { code: String(code), id: { not: id } },
114
+ })
115
+ if (existing) {
116
+ return NextResponse.json({ error: "Mã danh mục đã tồn tại", field: "code" }, { status: 400 })
117
+ }
118
+
119
+ const updated = await db.systemCategory.update({
120
+ where: { id },
121
+ data: {
122
+ code: String(code),
123
+ name: String(name),
124
+ groupCode: String(groupCode),
125
+ description: String(body.description || ""),
126
+ status: String(body.status || "active"),
127
+ order: body.order ? Number(body.order) : null,
128
+ updatedAt: new Date(),
129
+ updatedBy: session.user.id,
130
+ },
131
+ })
132
+
133
+ return NextResponse.json(updated)
134
+ },
135
+ { resource: "system-category", action: "update" },
136
+ )
137
+
138
+ export const DELETE = apiHandler(
139
+ async (req) => {
140
+ const id = idFromRequest(req)
141
+ if (!id) {
142
+ return NextResponse.json({ error: "ID là bắt buộc" }, { status: 400 })
143
+ }
144
+
145
+ const existing = await db.systemCategory.findUnique({ where: { id } })
146
+ if (!existing) {
147
+ return NextResponse.json({ error: "Không tìm thấy danh mục" }, { status: 404 })
148
+ }
149
+
150
+ await db.systemCategory.delete({ where: { id } })
151
+ return NextResponse.json({ message: "Đã xóa danh mục" })
152
+ },
153
+ { resource: "system-category", action: "delete" },
154
+ )
@@ -0,0 +1,131 @@
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({ where: { code: String(code) } })
43
+ if (existing) {
44
+ return NextResponse.json({ error: "Mã nhóm đã tồn tại", field: "code" }, { status: 400 })
45
+ }
46
+
47
+ const userId = session.user.id
48
+ const created = await db.systemCategoryGroup.create({
49
+ data: {
50
+ id: crypto.randomUUID(),
51
+ code: String(code),
52
+ name: String(name),
53
+ description: String(body.description || ""),
54
+ status: String(body.status || "active"),
55
+ createdBy: userId,
56
+ updatedAt: new Date(),
57
+ updatedBy: userId,
58
+ },
59
+ })
60
+
61
+ return NextResponse.json(created, { status: 201 })
62
+ },
63
+ { resource: "system-category", action: "create" },
64
+ )
65
+
66
+ export const PUT = apiHandler(
67
+ async (req, { session }) => {
68
+ const body = await req.json()
69
+ const id = idFromRequest(req, body)
70
+ if (!id) {
71
+ return NextResponse.json({ error: "ID là bắt buộc", field: "id" }, { status: 400 })
72
+ }
73
+
74
+ const { code, name } = body as Record<string, unknown>
75
+ if (!code || !name) {
76
+ return NextResponse.json(
77
+ { error: "Mã và tên nhóm là bắt buộc", field: !code ? "code" : "name" },
78
+ { status: 400 },
79
+ )
80
+ }
81
+
82
+ const existing = await db.systemCategoryGroup.findFirst({
83
+ where: { code: String(code), id: { not: id } },
84
+ })
85
+ if (existing) {
86
+ return NextResponse.json({ error: "Mã nhóm đã tồn tại", field: "code" }, { status: 400 })
87
+ }
88
+
89
+ const updated = await db.systemCategoryGroup.update({
90
+ where: { id },
91
+ data: {
92
+ code: String(code),
93
+ name: String(name),
94
+ description: String(body.description || ""),
95
+ status: String(body.status || "active"),
96
+ updatedAt: new Date(),
97
+ updatedBy: session.user.id,
98
+ },
99
+ })
100
+
101
+ return NextResponse.json(updated)
102
+ },
103
+ { resource: "system-category", action: "update" },
104
+ )
105
+
106
+ export const DELETE = apiHandler(
107
+ async (req) => {
108
+ const id = idFromRequest(req)
109
+ if (!id) {
110
+ return NextResponse.json({ error: "ID là bắt buộc" }, { status: 400 })
111
+ }
112
+
113
+ const group = await db.systemCategoryGroup.findUnique({ where: { id } })
114
+ if (!group) {
115
+ return NextResponse.json({ error: "Không tìm thấy nhóm danh mục" }, { status: 404 })
116
+ }
117
+
118
+ // Chặn xoá nhóm còn mục con — tránh mồ côi groupCode.
119
+ const childCount = await db.systemCategory.count({ where: { groupCode: group.code } })
120
+ if (childCount > 0) {
121
+ return NextResponse.json(
122
+ { error: `Nhóm còn ${childCount} danh mục con, hãy xóa hoặc chuyển chúng trước` },
123
+ { status: 400 },
124
+ )
125
+ }
126
+
127
+ await db.systemCategoryGroup.delete({ where: { id } })
128
+ return NextResponse.json({ message: "Đã xóa nhóm danh mục" })
129
+ },
130
+ { resource: "system-category", action: "delete" },
131
+ )
@@ -0,0 +1,222 @@
1
+ import { NextResponse } from "next/server"
2
+ import { bumpPermissionsVersion, invalidateUserPermissions } from "@goerp/core/rbac"
3
+ import bcrypt from "bcryptjs"
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
+ * /api/users/[id] — xem / cập nhật / vô hiệu hóa một người dùng.
11
+ *
12
+ * PUT nhận payload của UnifiedProfileDialog (core): name/email/status +
13
+ * roleCodes + branchIds/defaultBranchId + password (tùy chọn). Field
14
+ * vinhhoa-specific (phone, departmentId, jobTitleId…) bị bỏ qua — template
15
+ * không có bảng profile.
16
+ */
17
+
18
+ export const GET = apiHandler(
19
+ async (req, { params }) => {
20
+ try {
21
+ const { id } = await params
22
+ const user = await db.user.findUnique({
23
+ where: { id },
24
+ select: {
25
+ id: true,
26
+ name: true,
27
+ email: true,
28
+ image: true,
29
+ isActive: true,
30
+ createdAt: true,
31
+ updatedAt: true,
32
+ userRoles: { select: { roleCode: true } },
33
+ userBranches: {
34
+ select: { branchId: true, isDefault: true },
35
+ orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
36
+ },
37
+ },
38
+ })
39
+ if (!user) {
40
+ return NextResponse.json(
41
+ { error: "Không tìm thấy người dùng" },
42
+ { status: 404 },
43
+ )
44
+ }
45
+ const { userRoles, userBranches, ...base } = user
46
+ return NextResponse.json({
47
+ ...base,
48
+ roleCodes: userRoles.map((ur) => ur.roleCode),
49
+ branchIds: userBranches.map((ub) => ub.branchId),
50
+ defaultBranchId:
51
+ userBranches.find((ub) => ub.isDefault)?.branchId ??
52
+ userBranches[0]?.branchId ??
53
+ null,
54
+ })
55
+ } catch (error) {
56
+ return serverError(error, req, { message: "Lỗi tải thông tin người dùng" })
57
+ }
58
+ },
59
+ { resource: "user", action: "view" },
60
+ )
61
+
62
+ export const PUT = apiHandler(
63
+ async (req, { params }) => {
64
+ try {
65
+ const { id } = await params
66
+ const body = await req.json()
67
+
68
+ const name = typeof body.name === "string" ? body.name.trim() : ""
69
+ const email =
70
+ typeof body.email === "string" ? body.email.toLowerCase().trim() : ""
71
+ const password = typeof body.password === "string" ? body.password : ""
72
+ const isActive =
73
+ typeof body.status === "string" ? body.status === "active" : undefined
74
+ const roleCodes: string[] | null = Array.isArray(body.roleCodes)
75
+ ? body.roleCodes
76
+ : null
77
+ const branchIds: string[] | null = Array.isArray(body.branchIds)
78
+ ? body.branchIds
79
+ : null
80
+
81
+ if (!name) {
82
+ return NextResponse.json(
83
+ { error: "Tên là bắt buộc", field: "name" },
84
+ { status: 400 },
85
+ )
86
+ }
87
+ if (!email) {
88
+ return NextResponse.json(
89
+ { error: "Email là bắt buộc", field: "email" },
90
+ { status: 400 },
91
+ )
92
+ }
93
+ if (password && password.length < 6) {
94
+ return NextResponse.json(
95
+ { error: "Mật khẩu phải có ít nhất 6 ký tự", field: "password" },
96
+ { status: 400 },
97
+ )
98
+ }
99
+
100
+ const duplicated = await db.user.findFirst({
101
+ where: { email, id: { not: id } },
102
+ select: { id: true },
103
+ })
104
+ if (duplicated) {
105
+ return NextResponse.json(
106
+ { error: "Email đã tồn tại", field: "email" },
107
+ { status: 400 },
108
+ )
109
+ }
110
+
111
+ const hashedPassword = password ? await bcrypt.hash(password, 10) : null
112
+
113
+ const updated = await db.$transaction(async (tx) => {
114
+ const user = await tx.user.update({
115
+ where: { id },
116
+ data: {
117
+ name,
118
+ email,
119
+ ...(isActive === undefined ? {} : { isActive }),
120
+ },
121
+ })
122
+
123
+ if (hashedPassword) {
124
+ // Mật khẩu sống ở Account credential; đổi xong thu hồi mọi phiên
125
+ // đang mở của người này.
126
+ await tx.account.upsert({
127
+ where: {
128
+ providerId_accountId: { providerId: "credential", accountId: id },
129
+ },
130
+ update: { password: hashedPassword },
131
+ create: {
132
+ userId: id,
133
+ providerId: "credential",
134
+ accountId: id,
135
+ password: hashedPassword,
136
+ },
137
+ })
138
+ await tx.session.deleteMany({ where: { userId: id } })
139
+ }
140
+
141
+ if (roleCodes) {
142
+ await tx.userRole.deleteMany({ where: { userId: id } })
143
+ if (roleCodes.length > 0) {
144
+ await tx.userRole.createMany({
145
+ data: roleCodes.map((roleCode, index) => ({
146
+ userId: id,
147
+ roleCode,
148
+ isPrimary: index === 0,
149
+ })),
150
+ skipDuplicates: true,
151
+ })
152
+ }
153
+ }
154
+
155
+ if (branchIds) {
156
+ await tx.userBranch.deleteMany({ where: { userId: id } })
157
+ if (branchIds.length > 0) {
158
+ const defaultBranchId =
159
+ typeof body.defaultBranchId === "string" &&
160
+ branchIds.includes(body.defaultBranchId)
161
+ ? body.defaultBranchId
162
+ : branchIds[0]
163
+ await tx.userBranch.createMany({
164
+ data: branchIds.map((branchId) => ({
165
+ userId: id,
166
+ branchId,
167
+ isDefault: branchId === defaultBranchId,
168
+ })),
169
+ skipDuplicates: true,
170
+ })
171
+ }
172
+ }
173
+
174
+ return user
175
+ })
176
+
177
+ // Đổi vai trò thì phiên đang mở phải nhận quyền mới: xóa cache quyền +
178
+ // bump permissions-version để watcher phía client tự reload.
179
+ if (roleCodes) {
180
+ await invalidateUserPermissions(id)
181
+ await bumpPermissionsVersion(db)
182
+ }
183
+
184
+ return NextResponse.json({
185
+ id: updated.id,
186
+ name: updated.name,
187
+ email: updated.email,
188
+ isActive: updated.isActive,
189
+ })
190
+ } catch (error) {
191
+ return serverError(error, req, { message: "Lỗi cập nhật người dùng" })
192
+ }
193
+ },
194
+ { resource: "user", action: "update" },
195
+ )
196
+
197
+ export const DELETE = apiHandler(
198
+ async (req, { params }) => {
199
+ try {
200
+ const { id } = await params
201
+ const user = await db.user.findUnique({ where: { id }, select: { id: true } })
202
+ if (!user) {
203
+ return NextResponse.json(
204
+ { error: "Không tìm thấy người dùng" },
205
+ { status: 404 },
206
+ )
207
+ }
208
+
209
+ // Vô hiệu hóa thay vì xóa cứng: người dùng dính vào audit_logs và các
210
+ // chứng từ nghiệp vụ của app — xóa hẳn là mất dấu "ai đã làm gì".
211
+ await db.$transaction(async (tx) => {
212
+ await tx.user.update({ where: { id }, data: { isActive: false } })
213
+ await tx.session.deleteMany({ where: { userId: id } })
214
+ })
215
+
216
+ return NextResponse.json({ success: true })
217
+ } catch (error) {
218
+ return serverError(error, req, { message: "Lỗi vô hiệu hóa người dùng" })
219
+ }
220
+ },
221
+ { resource: "user", action: "delete" },
222
+ )