@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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +1 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
- package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
- package/templates/starter-app/src/app/api/users/route.ts +139 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +76 -4
- package/templates/starter-app/src/data/navigations.ts +49 -0
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +26 -0
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/crud/index.ts +2 -0
- package/templates/starter-app/src/lib/prisma.ts +3 -2
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +47 -0
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- 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
|
+
)
|