@goplusvn/core 0.1.76 → 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.
@@ -13,7 +13,14 @@
13
13
  // prisma, getSession, canManageAll: (s) => checkPermission(s, "workspace", "update"),
14
14
  // })
15
15
 
16
- import { getWorkspaceScope, isWorkspaceAdmin } from "./scope";
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";
17
24
  import {
18
25
  buildTree,
19
26
  createWorkspace,
@@ -35,6 +42,14 @@ export interface WorkspaceHandlerDeps {
35
42
  canManageAll?: (session: any) => MaybePromise<boolean>;
36
43
  /** Gate đọc; bỏ trống = ai đăng nhập cũng đọc được cây (trong phạm vi của mình). */
37
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 }[];
38
53
  onError?: (error: unknown, req: Request) => Response | Promise<Response>;
39
54
  }
40
55
 
@@ -128,15 +143,33 @@ export function createWorkspaceCollectionHandlers(deps: WorkspaceHandlerDeps) {
128
143
  ? rows.filter((r: any) => allowed.has(r.id))
129
144
  : rows;
130
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
+
131
162
  const nodes = visible.map((r: any) => ({
132
163
  id: r.id,
133
164
  code: r.code,
134
165
  name: r.name,
135
166
  kind: r.kind,
167
+ kindLabel: kindLabels.get(r.kind) ?? undefined,
136
168
  parentId: r.parentId,
137
169
  path: r.path,
138
170
  isActive: r.isActive,
139
171
  memberCount: r._count?.members ?? 0,
172
+ adminCount: adminCounts.get(r.id) ?? 0,
140
173
  }));
141
174
  return json(toClient(buildTree(nodes)));
142
175
  } catch (error) {
@@ -158,7 +191,7 @@ export function createWorkspaceCollectionHandlers(deps: WorkspaceHandlerDeps) {
158
191
  if (!parentId) {
159
192
  if (!actor.manageAll) {
160
193
  return json(
161
- { error: "Chỉ quản trị toàn hệ thống tạo được không gian gốc." },
194
+ { error: "Chỉ quản trị toàn hệ thống tạo được workspace gốc." },
162
195
  403,
163
196
  );
164
197
  }
@@ -234,6 +267,249 @@ export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
234
267
  return { PATCH, DELETE };
235
268
  }
236
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
+
237
513
  /** POST cho `/api/workspaces/[id]/move`. */
238
514
  export function createWorkspaceMoveHandler(deps: WorkspaceHandlerDeps) {
239
515
  type Ctx = { params: Promise<{ id: string }> | { id: string } };
@@ -79,7 +79,7 @@ function assertKindAllowed(
79
79
  if (!parent) return;
80
80
  if (parent.canHaveChildren === false) {
81
81
  throw new WorkspaceTreeError(
82
- `"${parent.label}" không được có không gian con.`,
82
+ `"${parent.label}" không được có workspace con.`,
83
83
  );
84
84
  }
85
85
  if (
@@ -140,7 +140,7 @@ export async function createWorkspace(
140
140
  select: { id: true, path: true, kind: true },
141
141
  });
142
142
  if (!parent) {
143
- throw new WorkspaceTreeError("Không tìm thấy không gian cha.");
143
+ throw new WorkspaceTreeError("Không tìm thấy workspace cha.");
144
144
  }
145
145
  assertKindAllowed(parent.kind, input.kind);
146
146
  parentPath = parent.path;
@@ -184,14 +184,14 @@ export async function moveWorkspace(
184
184
 
185
185
  const moved = nodes.find((n) => n.id === input.id);
186
186
  if (!moved)
187
- throw new WorkspaceTreeError("Không tìm thấy không gian cần chuyển.");
187
+ throw new WorkspaceTreeError("Không tìm thấy workspace cần chuyển.");
188
188
 
189
189
  assertNoCycle(input.id, input.newParentId, nodes);
190
190
 
191
191
  let newParentPath: string | null = null;
192
192
  if (input.newParentId) {
193
193
  const parent = nodes.find((n) => n.id === input.newParentId);
194
- if (!parent) throw new WorkspaceTreeError("Không tìm thấy không gian cha.");
194
+ if (!parent) throw new WorkspaceTreeError("Không tìm thấy workspace cha.");
195
195
  assertKindAllowed(parent.kind, moved.kind);
196
196
  newParentPath = parent.path;
197
197
  }
@@ -170,7 +170,7 @@ export function assertNoCycle(
170
170
  if (!parent || !moved) return;
171
171
  if (isInSubtree(parent.path, subtreePrefix(moved.path))) {
172
172
  throw new WorkspaceTreeError(
173
- "Không thể chuyển một không gian vào bên trong nhánh con của chính nó.",
173
+ "Không thể chuyển một workspace vào bên trong nhánh con của chính nó.",
174
174
  );
175
175
  }
176
176
  }
@@ -106,7 +106,7 @@ export const SCOPE_LEVELS: readonly ScopeLevel[] = [
106
106
  export const SCOPE_LEVEL_LABELS: Record<ScopeLevel, string> = {
107
107
  none: "Không",
108
108
  own: "Của tôi",
109
- workspace: "Không gian của tôi",
109
+ workspace: "Workspace của tôi",
110
110
  subtree: "Cả nhánh con",
111
111
  all: "Tất cả",
112
112
  };