@goplusvn/core 0.1.75 → 0.1.77
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 +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Route-handler cho `/api/workspaces` — nơi phạm vi được cưỡng chế THẬT.
|
|
3
|
+
//
|
|
4
|
+
// Trang cây ở `pages/workspace-list-page.tsx` đã ẩn nút ở nhánh không quản trị
|
|
5
|
+
// được, nhưng ẩn nút không phải là hàng rào: ai cũng gọi thẳng API được. Entra
|
|
6
|
+
// từng thừa nhận đúng lỗi này (UI lọc, API thì không) — nên mọi kiểm tra ở đây
|
|
7
|
+
// lặp lại độc lập với UI, không tin gì từ client.
|
|
8
|
+
//
|
|
9
|
+
// Cách dùng (phía app):
|
|
10
|
+
// // src/app/api/workspaces/route.ts
|
|
11
|
+
// import { createWorkspaceCollectionHandlers } from "@goerp/core/workspace/route-handlers"
|
|
12
|
+
// export const { GET, POST } = createWorkspaceCollectionHandlers({
|
|
13
|
+
// prisma, getSession, canManageAll: (s) => checkPermission(s, "workspace", "update"),
|
|
14
|
+
// })
|
|
15
|
+
|
|
16
|
+
import { DelegationError, assertCanUpdateUser } from "./delegation";
|
|
17
|
+
import type { DelegationActor } from "./delegation";
|
|
18
|
+
import {
|
|
19
|
+
getWorkspaceConfig,
|
|
20
|
+
getWorkspaceScope,
|
|
21
|
+
isWorkspaceAdmin,
|
|
22
|
+
memberScopeWhere,
|
|
23
|
+
} from "./scope";
|
|
24
|
+
import {
|
|
25
|
+
buildTree,
|
|
26
|
+
createWorkspace,
|
|
27
|
+
deactivateSubtree,
|
|
28
|
+
moveWorkspace,
|
|
29
|
+
} from "./service";
|
|
30
|
+
import { WorkspaceTreeError } from "./tree";
|
|
31
|
+
import type { WorkspaceScope } from "./types";
|
|
32
|
+
|
|
33
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
34
|
+
|
|
35
|
+
export interface WorkspaceHandlerDeps {
|
|
36
|
+
prisma: any;
|
|
37
|
+
getSession: () => MaybePromise<any | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Quyền quản trị toàn hệ thống. Bỏ trống ⇒ suy từ `scope.canViewAll`, tức là
|
|
40
|
+
* "xem được tất" cũng "sửa được tất" — chỉ đúng với app một tổ chức.
|
|
41
|
+
*/
|
|
42
|
+
canManageAll?: (session: any) => MaybePromise<boolean>;
|
|
43
|
+
/** Gate đọc; bỏ trống = ai đăng nhập cũng đọc được cây (trong phạm vi của mình). */
|
|
44
|
+
canRead?: (session: any) => MaybePromise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Nhãn cấp của app, để GET trả kèm `kindLabel`.
|
|
47
|
+
*
|
|
48
|
+
* Bỏ trống thì cây tải lại sau mỗi lần thêm/sửa sẽ MẤT nhãn cấp — trang server
|
|
49
|
+
* render có nhãn, `reload()` thì không, và huy hiệu "Bộ phận" lặng lẽ biến mất
|
|
50
|
+
* cho tới khi người dùng F5.
|
|
51
|
+
*/
|
|
52
|
+
kinds?: { key: string; label: string }[];
|
|
53
|
+
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const json = (data: unknown, status = 200) =>
|
|
57
|
+
new Response(JSON.stringify(data), {
|
|
58
|
+
status,
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Lỗi cây là lỗi NGHIỆP VỤ (chu trình, quá sâu, sai cấp) → 400 kèm câu nguyên văn. */
|
|
63
|
+
function failure(error: unknown, req: Request, deps: WorkspaceHandlerDeps) {
|
|
64
|
+
if (error instanceof WorkspaceTreeError) {
|
|
65
|
+
return json({ error: error.message }, 400);
|
|
66
|
+
}
|
|
67
|
+
return deps.onError
|
|
68
|
+
? deps.onError(error, req)
|
|
69
|
+
: json({ error: "Internal error" }, 500);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Actor {
|
|
73
|
+
session: any;
|
|
74
|
+
scope: WorkspaceScope;
|
|
75
|
+
manageAll: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveActor(
|
|
79
|
+
deps: WorkspaceHandlerDeps,
|
|
80
|
+
): Promise<Actor | Response> {
|
|
81
|
+
const session = await deps.getSession();
|
|
82
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
83
|
+
if (deps.canRead && !(await deps.canRead(session))) {
|
|
84
|
+
return json({ error: "Forbidden" }, 403);
|
|
85
|
+
}
|
|
86
|
+
const scope = await getWorkspaceScope(session);
|
|
87
|
+
const manageAll = deps.canManageAll
|
|
88
|
+
? await deps.canManageAll(session)
|
|
89
|
+
: scope.canViewAll;
|
|
90
|
+
return { session, scope, manageAll };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Ghi được nút này không? Toàn quyền, hoặc là quản trị viên của đúng nhánh đó. */
|
|
94
|
+
function assertWritable(actor: Actor, workspaceId: string): Response | null {
|
|
95
|
+
if (actor.manageAll) return null;
|
|
96
|
+
if (isWorkspaceAdmin(actor.scope, workspaceId)) return null;
|
|
97
|
+
return json({ error: "Không gian này ngoài phạm vi quản trị của bạn." }, 403);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Cắt `path` / `parentId` trước khi trả về client.
|
|
102
|
+
*
|
|
103
|
+
* Cây đã dựng xong ở server nên UI không cần hai trường này, mà `path` là chuỗi
|
|
104
|
+
* `/{tổ-tiên}/…/{chính-nó}/` — gửi nguyên đi là lộ id của những nút cha mà người
|
|
105
|
+
* này không được thấy. Ít thông tin, nhưng vẫn là thông tin về cấu trúc tổ chức
|
|
106
|
+
* của tenant khác, và trường thừa thì không có lý do gì để rò.
|
|
107
|
+
*/
|
|
108
|
+
function toClient(nodes: any[]): any[] {
|
|
109
|
+
return nodes.map(({ path: _path, parentId: _parentId, ...node }) => ({
|
|
110
|
+
...node,
|
|
111
|
+
children: toClient(node.children ?? []),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** GET (cây) + POST (tạo nút) cho `/api/workspaces`. */
|
|
116
|
+
export function createWorkspaceCollectionHandlers(deps: WorkspaceHandlerDeps) {
|
|
117
|
+
async function GET(req: Request) {
|
|
118
|
+
try {
|
|
119
|
+
const actor = await resolveActor(deps);
|
|
120
|
+
if (actor instanceof Response) return actor;
|
|
121
|
+
|
|
122
|
+
const rows = await deps.prisma.workspace.findMany({
|
|
123
|
+
orderBy: { path: "asc" },
|
|
124
|
+
select: {
|
|
125
|
+
id: true,
|
|
126
|
+
code: true,
|
|
127
|
+
name: true,
|
|
128
|
+
kind: true,
|
|
129
|
+
parentId: true,
|
|
130
|
+
path: true,
|
|
131
|
+
depth: true,
|
|
132
|
+
isActive: true,
|
|
133
|
+
_count: { select: { members: true } },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Lọc ở SERVER, không ở trang. Người dùng chỉ nhận về nhánh mình thấy được
|
|
138
|
+
// — nếu không thì cây của mọi khách hàng khác nằm sẵn trong payload HTML.
|
|
139
|
+
const allowed = actor.scope.canViewAll
|
|
140
|
+
? null
|
|
141
|
+
: new Set(actor.scope.allowedIds ?? []);
|
|
142
|
+
const visible = allowed
|
|
143
|
+
? rows.filter((r: any) => allowed.has(r.id))
|
|
144
|
+
: rows;
|
|
145
|
+
|
|
146
|
+
// Số quản trị đếm riêng bằng groupBy: `_count` của Prisma không lọc được
|
|
147
|
+
// theo `isAdmin`, mà đây là con số phân biệt "có người cầm trịch nhánh
|
|
148
|
+
// này" với "một đống thành viên không ai quản" — không suy ra được từ
|
|
149
|
+
// tổng thành viên.
|
|
150
|
+
const adminRows = await deps.prisma.userWorkspace.groupBy({
|
|
151
|
+
by: ["workspaceId"],
|
|
152
|
+
where: { isAdmin: true },
|
|
153
|
+
_count: { _all: true },
|
|
154
|
+
});
|
|
155
|
+
const adminCounts = new Map<string, number>(
|
|
156
|
+
adminRows.map((r: any) => [r.workspaceId, r._count?._all ?? 0]),
|
|
157
|
+
);
|
|
158
|
+
const kindLabels = new Map<string, string>(
|
|
159
|
+
(deps.kinds ?? []).map((k) => [k.key, k.label]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const nodes = visible.map((r: any) => ({
|
|
163
|
+
id: r.id,
|
|
164
|
+
code: r.code,
|
|
165
|
+
name: r.name,
|
|
166
|
+
kind: r.kind,
|
|
167
|
+
kindLabel: kindLabels.get(r.kind) ?? undefined,
|
|
168
|
+
parentId: r.parentId,
|
|
169
|
+
path: r.path,
|
|
170
|
+
isActive: r.isActive,
|
|
171
|
+
memberCount: r._count?.members ?? 0,
|
|
172
|
+
adminCount: adminCounts.get(r.id) ?? 0,
|
|
173
|
+
}));
|
|
174
|
+
return json(toClient(buildTree(nodes)));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return failure(error, req, deps);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function POST(req: Request) {
|
|
181
|
+
try {
|
|
182
|
+
const actor = await resolveActor(deps);
|
|
183
|
+
if (actor instanceof Response) return actor;
|
|
184
|
+
|
|
185
|
+
const body = await req.json();
|
|
186
|
+
const parentId: string | null = body?.parentId ?? null;
|
|
187
|
+
|
|
188
|
+
// Tạo nút GỐC là việc của toàn quyền: nút gốc nằm ngoài mọi nhánh nên
|
|
189
|
+
// không ai "quản trị" nó, và cho phép thì admin khách hàng tự dựng được
|
|
190
|
+
// một tổ chức song song bên cạnh tổ chức của mình.
|
|
191
|
+
if (!parentId) {
|
|
192
|
+
if (!actor.manageAll) {
|
|
193
|
+
return json(
|
|
194
|
+
{ error: "Chỉ quản trị toàn hệ thống tạo được workspace gốc." },
|
|
195
|
+
403,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
const denied = assertWritable(actor, parentId);
|
|
200
|
+
if (denied) return denied;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const created = await createWorkspace(deps.prisma, {
|
|
204
|
+
code: String(body?.code ?? "").trim(),
|
|
205
|
+
name: String(body?.name ?? "").trim(),
|
|
206
|
+
kind: body?.kind,
|
|
207
|
+
parentId,
|
|
208
|
+
});
|
|
209
|
+
return json(created, 201);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return failure(error, req, deps);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { GET, POST };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** PATCH (sửa) + DELETE (ngừng hoạt động cả nhánh) cho `/api/workspaces/[id]`. */
|
|
219
|
+
export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
|
|
220
|
+
type Ctx = { params: Promise<{ id: string }> | { id: string } };
|
|
221
|
+
|
|
222
|
+
const readId = async (ctx: Ctx) => (await ctx.params).id;
|
|
223
|
+
|
|
224
|
+
async function PATCH(req: Request, ctx: Ctx) {
|
|
225
|
+
try {
|
|
226
|
+
const actor = await resolveActor(deps);
|
|
227
|
+
if (actor instanceof Response) return actor;
|
|
228
|
+
const id = await readId(ctx);
|
|
229
|
+
const denied = assertWritable(actor, id);
|
|
230
|
+
if (denied) return denied;
|
|
231
|
+
|
|
232
|
+
const body = await req.json();
|
|
233
|
+
const data: Record<string, unknown> = {};
|
|
234
|
+
if (body?.code !== undefined) data.code = String(body.code).trim();
|
|
235
|
+
if (body?.name !== undefined) data.name = String(body.name).trim();
|
|
236
|
+
if (body?.kind !== undefined) data.kind = body.kind;
|
|
237
|
+
if (body?.settings !== undefined) data.settings = body.settings;
|
|
238
|
+
// `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
|
|
239
|
+
// làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
|
|
240
|
+
|
|
241
|
+
const updated = await deps.prisma.workspace.update({
|
|
242
|
+
where: { id },
|
|
243
|
+
data,
|
|
244
|
+
});
|
|
245
|
+
return json(updated);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return failure(error, req, deps);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function DELETE(req: Request, ctx: Ctx) {
|
|
252
|
+
try {
|
|
253
|
+
const actor = await resolveActor(deps);
|
|
254
|
+
if (actor instanceof Response) return actor;
|
|
255
|
+
const id = await readId(ctx);
|
|
256
|
+
const denied = assertWritable(actor, id);
|
|
257
|
+
if (denied) return denied;
|
|
258
|
+
|
|
259
|
+
// Soft-delete: xoá cứng thì phạm vi của chứng từ cũ thành mồ côi.
|
|
260
|
+
const count = await deactivateSubtree(deps.prisma, id);
|
|
261
|
+
return json({ deactivated: count });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return failure(error, req, deps);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { PATCH, DELETE };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* GET (thành viên + tìm ứng viên) / POST (gán, đổi cờ) / DELETE (gỡ) cho
|
|
272
|
+
* `/api/workspaces/[id]/members`.
|
|
273
|
+
*
|
|
274
|
+
* Đây là đường DUY NHẤT ghi bảng membership từ giao diện. Mọi thao tác đi qua
|
|
275
|
+
* `assertCanUpdateUser` — không tự gọi lẻ từng luật D1…D7, vì quên một cái là
|
|
276
|
+
* thủng một bất biến, mà thủng thì không có gì báo.
|
|
277
|
+
*/
|
|
278
|
+
export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
279
|
+
type Ctx = { params: Promise<{ id: string }> | { id: string } };
|
|
280
|
+
|
|
281
|
+
const readId = async (ctx: Ctx) => (await ctx.params).id;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Actor cho tầng uỷ quyền.
|
|
285
|
+
*
|
|
286
|
+
* `permissions` rỗng + `rank` thấp nhất là CỐ Ý: endpoint này chỉ ghi
|
|
287
|
+
* membership, KHÔNG gán vai trò, nên D2 (trần quyền) và D4 (cấm vai trò nguy
|
|
288
|
+
* hiểm) không có gì để soi — hai luật đó đọc đúng hai trường này.
|
|
289
|
+
* **Đừng tái dùng actor này cho đường gán vai trò**: ở đó `permissions`/`rank`
|
|
290
|
+
* rỗng nghĩa là hai luật kia thành no-op, tức mở toang đường leo thang.
|
|
291
|
+
*/
|
|
292
|
+
const toDelegationActor = (actor: Actor): DelegationActor => ({
|
|
293
|
+
// Lấy id qua `getUserId` của config, KHÔNG đọc `session.user.id`: hình dạng
|
|
294
|
+
// session là chuyện của app (NextAuth, Better Auth, session tự chế…). Đoán
|
|
295
|
+
// sai thì `userId` thành chuỗi rỗng, và D3 — luật cấm tự nâng chính mình —
|
|
296
|
+
// im lặng không khớp ai cả.
|
|
297
|
+
userId: String(getWorkspaceConfig()?.getUserId(actor.session) ?? ""),
|
|
298
|
+
scope: actor.scope,
|
|
299
|
+
canManageAll: actor.manageAll,
|
|
300
|
+
permissions: new Set<string>(),
|
|
301
|
+
rank: Number.MAX_SAFE_INTEGER,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/** Lỗi uỷ quyền là 403 kèm nguyên văn câu giải thích — người dùng cần biết VÌ SAO. */
|
|
305
|
+
const delegationFailure = (error: unknown) =>
|
|
306
|
+
error instanceof DelegationError
|
|
307
|
+
? json({ error: error.message, code: error.code }, 403)
|
|
308
|
+
: null;
|
|
309
|
+
|
|
310
|
+
/** Membership ĐẦY ĐỦ của target, không phải phần giao với phạm vi actor (D5/D7 cần cả). */
|
|
311
|
+
async function loadTarget(userId: string) {
|
|
312
|
+
const user = await deps.prisma.user.findUnique({
|
|
313
|
+
where: { id: userId },
|
|
314
|
+
select: {
|
|
315
|
+
id: true,
|
|
316
|
+
isProtected: true,
|
|
317
|
+
permissionCeilingRoleId: true,
|
|
318
|
+
userWorkspaces: { select: { workspaceId: true } },
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
if (!user) return null;
|
|
322
|
+
return {
|
|
323
|
+
id: user.id,
|
|
324
|
+
isProtected: user.isProtected ?? false,
|
|
325
|
+
permissionCeilingRoleId: user.permissionCeilingRoleId ?? null,
|
|
326
|
+
workspaceIds: user.userWorkspaces.map(
|
|
327
|
+
(m: any) => m.workspaceId as string,
|
|
328
|
+
) as string[],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function GET(req: Request, ctx: Ctx) {
|
|
333
|
+
try {
|
|
334
|
+
const actor = await resolveActor(deps);
|
|
335
|
+
if (actor instanceof Response) return actor;
|
|
336
|
+
const id = await readId(ctx);
|
|
337
|
+
|
|
338
|
+
// Đọc thì chỉ cần THẤY được nút; ghi mới cần quản trị được nút.
|
|
339
|
+
if (
|
|
340
|
+
!actor.scope.canViewAll &&
|
|
341
|
+
!(actor.scope.allowedIds ?? []).includes(id)
|
|
342
|
+
) {
|
|
343
|
+
return json({ error: "Không gian này ngoài phạm vi của bạn." }, 403);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const url = new URL(req.url);
|
|
347
|
+
const query = (url.searchParams.get("q") ?? "").trim();
|
|
348
|
+
|
|
349
|
+
if (url.searchParams.get("candidates") === "1") {
|
|
350
|
+
const denied = assertWritable(actor, id);
|
|
351
|
+
if (denied) return denied;
|
|
352
|
+
|
|
353
|
+
// Ứng viên chỉ lấy trong nhánh mình QUẢN TRỊ (adminOnly), không phải
|
|
354
|
+
// nhánh mình xem được: xem được cả công ty mà thêm được ai cũng vào
|
|
355
|
+
// nhánh mình thì D1 chỉ còn là trang trí.
|
|
356
|
+
const rows = await deps.prisma.user.findMany({
|
|
357
|
+
where: {
|
|
358
|
+
AND: [
|
|
359
|
+
memberScopeWhere(actor.scope, { adminOnly: true }),
|
|
360
|
+
{ userWorkspaces: { none: { workspaceId: id } } },
|
|
361
|
+
query
|
|
362
|
+
? {
|
|
363
|
+
OR: [
|
|
364
|
+
{ name: { contains: query, mode: "insensitive" } },
|
|
365
|
+
{ email: { contains: query, mode: "insensitive" } },
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
: {},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
select: { id: true, name: true, email: true, isActive: true },
|
|
372
|
+
orderBy: { name: "asc" },
|
|
373
|
+
take: 20,
|
|
374
|
+
});
|
|
375
|
+
return json(rows);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const rows = await deps.prisma.userWorkspace.findMany({
|
|
379
|
+
where: { workspaceId: id },
|
|
380
|
+
select: {
|
|
381
|
+
isAdmin: true,
|
|
382
|
+
isDefault: true,
|
|
383
|
+
user: {
|
|
384
|
+
select: { id: true, name: true, email: true, isActive: true },
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
orderBy: [{ isAdmin: "desc" }, { user: { name: "asc" } }],
|
|
388
|
+
});
|
|
389
|
+
return json(
|
|
390
|
+
rows.map((r: any) => ({
|
|
391
|
+
userId: r.user.id,
|
|
392
|
+
name: r.user.name,
|
|
393
|
+
email: r.user.email,
|
|
394
|
+
isActive: r.user.isActive,
|
|
395
|
+
isAdmin: r.isAdmin,
|
|
396
|
+
isDefault: r.isDefault,
|
|
397
|
+
})),
|
|
398
|
+
);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return failure(error, req, deps);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Gán người vào không gian, hoặc đổi cờ quản trị / mặc định của người đã ở trong. */
|
|
405
|
+
async function POST(req: Request, ctx: Ctx) {
|
|
406
|
+
try {
|
|
407
|
+
const actor = await resolveActor(deps);
|
|
408
|
+
if (actor instanceof Response) return actor;
|
|
409
|
+
const id = await readId(ctx);
|
|
410
|
+
const denied = assertWritable(actor, id);
|
|
411
|
+
if (denied) return denied;
|
|
412
|
+
|
|
413
|
+
const body = await req.json();
|
|
414
|
+
const userId = String(body?.userId ?? "").trim();
|
|
415
|
+
if (!userId) return json({ error: "Thiếu userId." }, 400);
|
|
416
|
+
|
|
417
|
+
const target = await loadTarget(userId);
|
|
418
|
+
if (!target) return json({ error: "Không tìm thấy người dùng." }, 404);
|
|
419
|
+
|
|
420
|
+
const already = target.workspaceIds.includes(id);
|
|
421
|
+
const next = already ? target.workspaceIds : [...target.workspaceIds, id];
|
|
422
|
+
try {
|
|
423
|
+
assertCanUpdateUser(toDelegationActor(actor), target, {
|
|
424
|
+
workspaceIds: next,
|
|
425
|
+
});
|
|
426
|
+
} catch (error) {
|
|
427
|
+
const denied403 = delegationFailure(error);
|
|
428
|
+
if (denied403) return denied403;
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const isAdmin = Boolean(body?.isAdmin);
|
|
433
|
+
const isDefault = Boolean(body?.isDefault);
|
|
434
|
+
|
|
435
|
+
await deps.prisma.$transaction(async (tx: any) => {
|
|
436
|
+
// "Mặc định" là DUY NHẤT trên mỗi người: không hạ cờ cũ xuống thì lúc
|
|
437
|
+
// đăng nhập biết mở không gian nào.
|
|
438
|
+
if (isDefault) {
|
|
439
|
+
await tx.userWorkspace.updateMany({
|
|
440
|
+
where: { userId, isDefault: true, NOT: { workspaceId: id } },
|
|
441
|
+
data: { isDefault: false },
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
await tx.userWorkspace.upsert({
|
|
445
|
+
where: { userId_workspaceId: { userId, workspaceId: id } },
|
|
446
|
+
create: { userId, workspaceId: id, isAdmin, isDefault },
|
|
447
|
+
update: { isAdmin, isDefault },
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return json(
|
|
452
|
+
{ userId, workspaceId: id, isAdmin, isDefault },
|
|
453
|
+
already ? 200 : 201,
|
|
454
|
+
);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return failure(error, req, deps);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function DELETE(req: Request, ctx: Ctx) {
|
|
461
|
+
try {
|
|
462
|
+
const actor = await resolveActor(deps);
|
|
463
|
+
if (actor instanceof Response) return actor;
|
|
464
|
+
const id = await readId(ctx);
|
|
465
|
+
const denied = assertWritable(actor, id);
|
|
466
|
+
if (denied) return denied;
|
|
467
|
+
|
|
468
|
+
const url = new URL(req.url);
|
|
469
|
+
const userId = String(url.searchParams.get("userId") ?? "").trim();
|
|
470
|
+
if (!userId) return json({ error: "Thiếu userId." }, 400);
|
|
471
|
+
|
|
472
|
+
const target = await loadTarget(userId);
|
|
473
|
+
if (!target) return json({ error: "Không tìm thấy người dùng." }, 404);
|
|
474
|
+
if (!target.workspaceIds.includes(id)) {
|
|
475
|
+
return json({ removed: 0 });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const next = target.workspaceIds.filter((w) => w !== id);
|
|
479
|
+
try {
|
|
480
|
+
assertCanUpdateUser(toDelegationActor(actor), target, {
|
|
481
|
+
workspaceIds: next,
|
|
482
|
+
});
|
|
483
|
+
} catch (error) {
|
|
484
|
+
const denied403 = delegationFailure(error);
|
|
485
|
+
if (denied403) return denied403;
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Gỡ hết thì người này không còn phạm vi nào — với admin nhánh đó là ngõ
|
|
490
|
+
// cụt (D5 chặn mọi thao tác sau đó), nên chặn ngay và nói rõ.
|
|
491
|
+
if (!actor.manageAll && next.length === 0) {
|
|
492
|
+
return json(
|
|
493
|
+
{
|
|
494
|
+
error:
|
|
495
|
+
"Không thể gỡ workspace cuối cùng của người dùng — hãy chuyển họ sang workspace khác trước.",
|
|
496
|
+
},
|
|
497
|
+
400,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const result = await deps.prisma.userWorkspace.deleteMany({
|
|
502
|
+
where: { userId, workspaceId: id },
|
|
503
|
+
});
|
|
504
|
+
return json({ removed: result.count ?? 0 });
|
|
505
|
+
} catch (error) {
|
|
506
|
+
return failure(error, req, deps);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { GET, POST, DELETE };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** POST cho `/api/workspaces/[id]/move`. */
|
|
514
|
+
export function createWorkspaceMoveHandler(deps: WorkspaceHandlerDeps) {
|
|
515
|
+
type Ctx = { params: Promise<{ id: string }> | { id: string } };
|
|
516
|
+
|
|
517
|
+
async function POST(req: Request, ctx: Ctx) {
|
|
518
|
+
try {
|
|
519
|
+
const actor = await resolveActor(deps);
|
|
520
|
+
if (actor instanceof Response) return actor;
|
|
521
|
+
const { id } = await ctx.params;
|
|
522
|
+
|
|
523
|
+
const denied = assertWritable(actor, id);
|
|
524
|
+
if (denied) return denied;
|
|
525
|
+
|
|
526
|
+
const body = await req.json();
|
|
527
|
+
const newParentId: string | null = body?.newParentId ?? null;
|
|
528
|
+
|
|
529
|
+
// Phải quản trị được CẢ hai đầu. Chỉ kiểm nút bị chuyển thì admin nhánh
|
|
530
|
+
// đẩy được nhánh của mình sang dưới tổ chức khác — hoặc lên gốc, thành
|
|
531
|
+
// một tổ chức độc lập ngang hàng với tổ chức mẹ.
|
|
532
|
+
if (newParentId) {
|
|
533
|
+
const deniedTarget = assertWritable(actor, newParentId);
|
|
534
|
+
if (deniedTarget) return deniedTarget;
|
|
535
|
+
} else if (!actor.manageAll) {
|
|
536
|
+
return json(
|
|
537
|
+
{ error: "Chỉ quản trị toàn hệ thống chuyển được lên cấp cao nhất." },
|
|
538
|
+
403,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const moved = await moveWorkspace(deps.prisma, { id, newParentId });
|
|
543
|
+
return json({ updated: moved });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
return failure(error, req, deps);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return { POST };
|
|
550
|
+
}
|