@goplusvn/core 0.1.74 → 0.1.76

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 (35) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/ui/auth/auth-layout.tsx +106 -82
  17. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  18. package/src/user/user-service.ts +64 -10
  19. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  20. package/src/workspace/__tests__/workspace-route-handlers.test.ts +414 -0
  21. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  22. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  23. package/src/workspace/components/scope-level-select.tsx +91 -0
  24. package/src/workspace/components/workspace-switcher.tsx +139 -0
  25. package/src/workspace/components/workspace-tree-view.tsx +260 -0
  26. package/src/workspace/context.ts +78 -0
  27. package/src/workspace/delegation.ts +400 -0
  28. package/src/workspace/guard.ts +138 -0
  29. package/src/workspace/index.ts +157 -0
  30. package/src/workspace/pages/workspace-list-page.tsx +430 -0
  31. package/src/workspace/route-handlers.ts +274 -0
  32. package/src/workspace/scope.ts +396 -0
  33. package/src/workspace/service.ts +301 -0
  34. package/src/workspace/tree.ts +193 -0
  35. package/src/workspace/types.ts +182 -0
@@ -0,0 +1,430 @@
1
+ "use client";
2
+
3
+ // Trang "Không gian làm việc" — cây tổ chức + thao tác nhánh.
4
+ //
5
+ // Dùng lại khung `RbacPageBar` / `RbacStickyToolbar` của các trang RBAC thay vì
6
+ // tự dựng bar riêng: đây là trang quản trị cùng họ, lệch khung một chút là người
7
+ // dùng nhận ra ngay (L1 — tái dùng, không hand-roll).
8
+ //
9
+ // API contract: GET/POST `/api/workspaces`, PATCH/DELETE `/api/workspaces/:id`,
10
+ // POST `/api/workspaces/:id/move`.
11
+ import * as React from "react";
12
+
13
+ import { Network } from "lucide-react";
14
+ import { toast } from "sonner";
15
+
16
+ import {
17
+ Button,
18
+ ConfirmDialog,
19
+ Dialog,
20
+ DialogContent,
21
+ DialogDescription,
22
+ DialogFooter,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ Input,
26
+ Label,
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ Skeleton,
33
+ } from "../../ui";
34
+ import { RbacPageBar } from "../../rbac/pages/lib/rbac-page-shell";
35
+ import { Search, X } from "lucide-react";
36
+ import { cn } from "../../utils";
37
+ import {
38
+ countTreeNodes,
39
+ WorkspaceTreeView,
40
+ } from "../components/workspace-tree-view";
41
+ import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
42
+ import type { WorkspaceKindConfig } from "../types";
43
+
44
+ export interface WorkspaceListPageProps {
45
+ /** Cây đã dựng sẵn ở server (`buildTree`) — trang này không tự gọi API lần đầu. */
46
+ initialTree: WorkspaceTreeNode[];
47
+ /** Nhãn cấp của app; rỗng = ẩn ô chọn cấp, mọi nút cùng một loại. */
48
+ kinds?: WorkspaceKindConfig[];
49
+ /** Nhánh người dùng được uỷ quyền quản trị (đã bung con cháu). */
50
+ adminIds?: string[];
51
+ canManageAll?: boolean;
52
+ canCreate?: boolean;
53
+ apiEndpoint?: string;
54
+ title?: string;
55
+ }
56
+
57
+ interface FormState {
58
+ mode: "create" | "edit";
59
+ id?: string;
60
+ parentId: string | null;
61
+ parentName?: string;
62
+ code: string;
63
+ name: string;
64
+ kind: string;
65
+ }
66
+
67
+ /** Nhãn con hợp lệ dưới một nút — theo `childKinds` của cấp cha. */
68
+ function allowedChildKinds(
69
+ kinds: WorkspaceKindConfig[],
70
+ parentKind?: string,
71
+ ): WorkspaceKindConfig[] {
72
+ if (!parentKind) return kinds;
73
+ const parent = kinds.find((k) => k.key === parentKind);
74
+ if (!parent?.childKinds) return kinds;
75
+ return kinds.filter((k) => parent.childKinds?.includes(k.key));
76
+ }
77
+
78
+ function findNode(
79
+ nodes: WorkspaceTreeNode[],
80
+ id: string,
81
+ ): WorkspaceTreeNode | null {
82
+ for (const node of nodes) {
83
+ if (node.id === id) return node;
84
+ const hit = findNode(node.children, id);
85
+ if (hit) return hit;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** Làm phẳng cây thành danh sách chọn "chuyển vào nhánh nào". */
91
+ function flatten(
92
+ nodes: WorkspaceTreeNode[],
93
+ depth = 0,
94
+ ): { id: string; name: string; depth: number }[] {
95
+ return nodes.flatMap((node) => [
96
+ { id: node.id, name: node.name, depth },
97
+ ...flatten(node.children, depth + 1),
98
+ ]);
99
+ }
100
+
101
+ /** Nút và toàn bộ con cháu — không cho chuyển một nhánh vào chính nó. */
102
+ function subtreeIds(node: WorkspaceTreeNode): string[] {
103
+ return [node.id, ...node.children.flatMap(subtreeIds)];
104
+ }
105
+
106
+ export function WorkspaceListPage({
107
+ initialTree,
108
+ kinds = [],
109
+ adminIds,
110
+ canManageAll = false,
111
+ canCreate = false,
112
+ apiEndpoint = "/api/workspaces",
113
+ title = "Không gian làm việc",
114
+ }: WorkspaceListPageProps) {
115
+ const [tree, setTree] = React.useState(initialTree);
116
+ const [search, setSearch] = React.useState("");
117
+ const [busy, setBusy] = React.useState(false);
118
+ const [form, setForm] = React.useState<FormState | null>(null);
119
+ const [moving, setMoving] = React.useState<WorkspaceTreeNode | null>(null);
120
+ const [moveTarget, setMoveTarget] = React.useState<string>("");
121
+ const [deactivating, setDeactivating] =
122
+ React.useState<WorkspaceTreeNode | null>(null);
123
+
124
+ const total = React.useMemo(() => countTreeNodes(tree), [tree]);
125
+ const flat = React.useMemo(() => flatten(tree), [tree]);
126
+
127
+ const reload = React.useCallback(async () => {
128
+ const res = await fetch(apiEndpoint, { cache: "no-store" });
129
+ if (!res.ok) {
130
+ toast.error("Không tải lại được danh sách không gian.");
131
+ return;
132
+ }
133
+ setTree(await res.json());
134
+ }, [apiEndpoint]);
135
+
136
+ /** Mọi lời gọi ghi đi qua đây để thông báo lỗi của server tới thẳng người dùng. */
137
+ const submit = React.useCallback(
138
+ async (url: string, method: string, body?: unknown) => {
139
+ setBusy(true);
140
+ try {
141
+ const res = await fetch(url, {
142
+ method,
143
+ headers: { "Content-Type": "application/json" },
144
+ body: body ? JSON.stringify(body) : undefined,
145
+ });
146
+ if (!res.ok) {
147
+ const payload = await res.json().catch(() => null);
148
+ throw new Error(payload?.error ?? "Thao tác không thành công.");
149
+ }
150
+ await reload();
151
+ return true;
152
+ } catch (error) {
153
+ toast.error(
154
+ error instanceof Error ? error.message : "Thao tác không thành công.",
155
+ );
156
+ return false;
157
+ } finally {
158
+ setBusy(false);
159
+ }
160
+ },
161
+ [reload],
162
+ );
163
+
164
+ const openCreate = (parent?: WorkspaceTreeNode) => {
165
+ const options = allowedChildKinds(kinds, parent?.kind);
166
+ setForm({
167
+ mode: "create",
168
+ parentId: parent?.id ?? null,
169
+ parentName: parent?.name,
170
+ code: "",
171
+ name: "",
172
+ kind: options[0]?.key ?? kinds[0]?.key ?? "unit",
173
+ });
174
+ };
175
+
176
+ const saveForm = async () => {
177
+ if (!form) return;
178
+ if (!form.code.trim() || !form.name.trim()) {
179
+ toast.error("Mã và tên không được để trống.");
180
+ return;
181
+ }
182
+ const ok =
183
+ form.mode === "create"
184
+ ? await submit(apiEndpoint, "POST", {
185
+ code: form.code.trim(),
186
+ name: form.name.trim(),
187
+ kind: form.kind,
188
+ parentId: form.parentId,
189
+ })
190
+ : await submit(`${apiEndpoint}/${form.id}`, "PATCH", {
191
+ code: form.code.trim(),
192
+ name: form.name.trim(),
193
+ kind: form.kind,
194
+ });
195
+ if (ok) {
196
+ toast.success(form.mode === "create" ? "Đã thêm." : "Đã cập nhật.");
197
+ setForm(null);
198
+ }
199
+ };
200
+
201
+ const confirmMove = async () => {
202
+ if (!moving) return;
203
+ const ok = await submit(`${apiEndpoint}/${moving.id}/move`, "POST", {
204
+ newParentId: moveTarget === "__root__" ? null : moveTarget,
205
+ });
206
+ if (ok) {
207
+ toast.success("Đã chuyển nhánh.");
208
+ setMoving(null);
209
+ }
210
+ };
211
+
212
+ const confirmDeactivate = async () => {
213
+ if (!deactivating) return;
214
+ const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
215
+ if (ok) {
216
+ toast.success("Đã ngừng hoạt động cả nhánh.");
217
+ setDeactivating(null);
218
+ }
219
+ };
220
+
221
+ // Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
222
+ const moveOptions = React.useMemo(() => {
223
+ if (!moving) return [];
224
+ const banned = new Set(subtreeIds(moving));
225
+ return flat.filter((item) => !banned.has(item.id));
226
+ }, [moving, flat]);
227
+
228
+ const kindOptions = React.useMemo(() => {
229
+ if (!form) return kinds;
230
+ const parent = form.parentId ? findNode(tree, form.parentId) : null;
231
+ return allowedChildKinds(kinds, parent?.kind);
232
+ }, [form, kinds, tree]);
233
+
234
+ return (
235
+ <div className="space-y-3">
236
+ <RbacPageBar
237
+ icon={<Network className="h-5 w-5 text-primary-foreground" />}
238
+ title={title}
239
+ subtitle={`${total} không gian`}
240
+ >
241
+ {canCreate ? (
242
+ <Button size="sm" onClick={() => openCreate()} disabled={busy}>
243
+ Thêm không gian gốc
244
+ </Button>
245
+ ) : null}
246
+ </RbacPageBar>
247
+
248
+ <div className="overflow-hidden rounded-xl border border-border bg-card lg:mx-3">
249
+ <div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur sm:px-4">
250
+ <div className="relative min-w-[140px] flex-1 sm:max-w-72">
251
+ <Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
252
+ <Input
253
+ placeholder="Tìm theo tên hoặc mã…"
254
+ className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm"
255
+ value={search}
256
+ onChange={(e) => setSearch(e.target.value)}
257
+ />
258
+ {search ? (
259
+ <button
260
+ type="button"
261
+ onClick={() => setSearch("")}
262
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
263
+ title="Xóa tìm kiếm"
264
+ >
265
+ <X className="h-3.5 w-3.5" />
266
+ </button>
267
+ ) : null}
268
+ </div>
269
+ <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
270
+ <span className="font-semibold tabular-nums text-foreground">
271
+ {total}
272
+ </span>{" "}
273
+ không gian
274
+ </p>
275
+ </div>
276
+
277
+ <div className={cn(busy && "pointer-events-none opacity-60")}>
278
+ {busy && tree.length === 0 ? (
279
+ <div className="space-y-2 p-4">
280
+ <Skeleton className="h-6 w-full" />
281
+ <Skeleton className="h-6 w-5/6" />
282
+ </div>
283
+ ) : (
284
+ <WorkspaceTreeView
285
+ nodes={tree}
286
+ adminIds={adminIds}
287
+ canManageAll={canManageAll}
288
+ search={search}
289
+ onAddChild={canCreate ? openCreate : undefined}
290
+ onEdit={(node) =>
291
+ setForm({
292
+ mode: "edit",
293
+ id: node.id,
294
+ parentId: null,
295
+ code: node.code,
296
+ name: node.name,
297
+ kind: node.kind ?? kinds[0]?.key ?? "unit",
298
+ })
299
+ }
300
+ onMove={(node) => {
301
+ setMoving(node);
302
+ setMoveTarget("__root__");
303
+ }}
304
+ onDeactivate={setDeactivating}
305
+ />
306
+ )}
307
+ </div>
308
+ </div>
309
+
310
+ <Dialog
311
+ open={form !== null}
312
+ onOpenChange={(open) => !open && setForm(null)}
313
+ >
314
+ <DialogContent>
315
+ <DialogHeader>
316
+ <DialogTitle>
317
+ {form?.mode === "create" ? "Thêm không gian" : "Sửa không gian"}
318
+ </DialogTitle>
319
+ {form?.parentName ? (
320
+ <DialogDescription>
321
+ Đặt dưới <strong>{form.parentName}</strong>
322
+ </DialogDescription>
323
+ ) : null}
324
+ </DialogHeader>
325
+ <div className="space-y-3">
326
+ <div className="space-y-1.5">
327
+ <Label htmlFor="ws-code">Mã</Label>
328
+ <Input
329
+ id="ws-code"
330
+ value={form?.code ?? ""}
331
+ onChange={(e) =>
332
+ setForm((f) => (f ? { ...f, code: e.target.value } : f))
333
+ }
334
+ placeholder="VD: SPA-QC"
335
+ />
336
+ </div>
337
+ <div className="space-y-1.5">
338
+ <Label htmlFor="ws-name">Tên</Label>
339
+ <Input
340
+ id="ws-name"
341
+ value={form?.name ?? ""}
342
+ onChange={(e) =>
343
+ setForm((f) => (f ? { ...f, name: e.target.value } : f))
344
+ }
345
+ placeholder="VD: Phòng QC"
346
+ />
347
+ </div>
348
+ {kindOptions.length > 1 ? (
349
+ <div className="space-y-1.5">
350
+ <Label htmlFor="ws-kind">Cấp</Label>
351
+ <Select
352
+ value={form?.kind}
353
+ onValueChange={(v) =>
354
+ setForm((f) => (f ? { ...f, kind: v } : f))
355
+ }
356
+ >
357
+ <SelectTrigger id="ws-kind">
358
+ <SelectValue placeholder="Chọn cấp" />
359
+ </SelectTrigger>
360
+ <SelectContent>
361
+ {kindOptions.map((kind) => (
362
+ <SelectItem key={kind.key} value={kind.key}>
363
+ {kind.label}
364
+ </SelectItem>
365
+ ))}
366
+ </SelectContent>
367
+ </Select>
368
+ </div>
369
+ ) : null}
370
+ </div>
371
+ <DialogFooter>
372
+ <Button variant="outline" onClick={() => setForm(null)}>
373
+ Hủy
374
+ </Button>
375
+ <Button onClick={saveForm} disabled={busy}>
376
+ Lưu
377
+ </Button>
378
+ </DialogFooter>
379
+ </DialogContent>
380
+ </Dialog>
381
+
382
+ <Dialog
383
+ open={moving !== null}
384
+ onOpenChange={(open) => !open && setMoving(null)}
385
+ >
386
+ <DialogContent>
387
+ <DialogHeader>
388
+ <DialogTitle>Chuyển nhánh</DialogTitle>
389
+ <DialogDescription>
390
+ Chuyển <strong>{moving?.name}</strong> cùng toàn bộ cấp dưới sang
391
+ nhánh khác.
392
+ </DialogDescription>
393
+ </DialogHeader>
394
+ <Select value={moveTarget} onValueChange={setMoveTarget}>
395
+ <SelectTrigger>
396
+ <SelectValue placeholder="Chọn nhánh cha" />
397
+ </SelectTrigger>
398
+ <SelectContent>
399
+ <SelectItem value="__root__">— Cấp cao nhất —</SelectItem>
400
+ {moveOptions.map((item) => (
401
+ <SelectItem key={item.id} value={item.id}>
402
+ {" ".repeat(item.depth * 2)}
403
+ {item.name}
404
+ </SelectItem>
405
+ ))}
406
+ </SelectContent>
407
+ </Select>
408
+ <DialogFooter>
409
+ <Button variant="outline" onClick={() => setMoving(null)}>
410
+ Hủy
411
+ </Button>
412
+ <Button onClick={confirmMove} disabled={busy}>
413
+ Chuyển
414
+ </Button>
415
+ </DialogFooter>
416
+ </DialogContent>
417
+ </Dialog>
418
+
419
+ <ConfirmDialog
420
+ open={deactivating !== null}
421
+ onOpenChange={(open) => !open && setDeactivating(null)}
422
+ title="Ngừng hoạt động không gian?"
423
+ description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ ngừng hoạt động. Dữ liệu cũ giữ nguyên, không bị xóa.`}
424
+ confirmText="Ngừng hoạt động"
425
+ variant="destructive"
426
+ onConfirm={confirmDeactivate}
427
+ />
428
+ </div>
429
+ );
430
+ }
@@ -0,0 +1,274 @@
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 { getWorkspaceScope, isWorkspaceAdmin } from "./scope";
17
+ import {
18
+ buildTree,
19
+ createWorkspace,
20
+ deactivateSubtree,
21
+ moveWorkspace,
22
+ } from "./service";
23
+ import { WorkspaceTreeError } from "./tree";
24
+ import type { WorkspaceScope } from "./types";
25
+
26
+ type MaybePromise<T> = T | Promise<T>;
27
+
28
+ export interface WorkspaceHandlerDeps {
29
+ prisma: any;
30
+ getSession: () => MaybePromise<any | null>;
31
+ /**
32
+ * Quyền quản trị toàn hệ thống. Bỏ trống ⇒ suy từ `scope.canViewAll`, tức là
33
+ * "xem được tất" cũng "sửa được tất" — chỉ đúng với app một tổ chức.
34
+ */
35
+ canManageAll?: (session: any) => MaybePromise<boolean>;
36
+ /** Gate đọc; bỏ trống = ai đăng nhập cũng đọc được cây (trong phạm vi của mình). */
37
+ canRead?: (session: any) => MaybePromise<boolean>;
38
+ onError?: (error: unknown, req: Request) => Response | Promise<Response>;
39
+ }
40
+
41
+ const json = (data: unknown, status = 200) =>
42
+ new Response(JSON.stringify(data), {
43
+ status,
44
+ headers: { "content-type": "application/json" },
45
+ });
46
+
47
+ /** 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. */
48
+ function failure(error: unknown, req: Request, deps: WorkspaceHandlerDeps) {
49
+ if (error instanceof WorkspaceTreeError) {
50
+ return json({ error: error.message }, 400);
51
+ }
52
+ return deps.onError
53
+ ? deps.onError(error, req)
54
+ : json({ error: "Internal error" }, 500);
55
+ }
56
+
57
+ interface Actor {
58
+ session: any;
59
+ scope: WorkspaceScope;
60
+ manageAll: boolean;
61
+ }
62
+
63
+ async function resolveActor(
64
+ deps: WorkspaceHandlerDeps,
65
+ ): Promise<Actor | Response> {
66
+ const session = await deps.getSession();
67
+ if (!session) return json({ error: "Unauthorized" }, 401);
68
+ if (deps.canRead && !(await deps.canRead(session))) {
69
+ return json({ error: "Forbidden" }, 403);
70
+ }
71
+ const scope = await getWorkspaceScope(session);
72
+ const manageAll = deps.canManageAll
73
+ ? await deps.canManageAll(session)
74
+ : scope.canViewAll;
75
+ return { session, scope, manageAll };
76
+ }
77
+
78
+ /** 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 đó. */
79
+ function assertWritable(actor: Actor, workspaceId: string): Response | null {
80
+ if (actor.manageAll) return null;
81
+ if (isWorkspaceAdmin(actor.scope, workspaceId)) return null;
82
+ return json({ error: "Không gian này ngoài phạm vi quản trị của bạn." }, 403);
83
+ }
84
+
85
+ /**
86
+ * Cắt `path` / `parentId` trước khi trả về client.
87
+ *
88
+ * Cây đã dựng xong ở server nên UI không cần hai trường này, mà `path` là chuỗi
89
+ * `/{tổ-tiên}/…/{chính-nó}/` — gửi nguyên đi là lộ id của những nút cha mà người
90
+ * 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
91
+ * của tenant khác, và trường thừa thì không có lý do gì để rò.
92
+ */
93
+ function toClient(nodes: any[]): any[] {
94
+ return nodes.map(({ path: _path, parentId: _parentId, ...node }) => ({
95
+ ...node,
96
+ children: toClient(node.children ?? []),
97
+ }));
98
+ }
99
+
100
+ /** GET (cây) + POST (tạo nút) cho `/api/workspaces`. */
101
+ export function createWorkspaceCollectionHandlers(deps: WorkspaceHandlerDeps) {
102
+ async function GET(req: Request) {
103
+ try {
104
+ const actor = await resolveActor(deps);
105
+ if (actor instanceof Response) return actor;
106
+
107
+ const rows = await deps.prisma.workspace.findMany({
108
+ orderBy: { path: "asc" },
109
+ select: {
110
+ id: true,
111
+ code: true,
112
+ name: true,
113
+ kind: true,
114
+ parentId: true,
115
+ path: true,
116
+ depth: true,
117
+ isActive: true,
118
+ _count: { select: { members: true } },
119
+ },
120
+ });
121
+
122
+ // Lọc ở SERVER, không ở trang. Người dùng chỉ nhận về nhánh mình thấy được
123
+ // — nếu không thì cây của mọi khách hàng khác nằm sẵn trong payload HTML.
124
+ const allowed = actor.scope.canViewAll
125
+ ? null
126
+ : new Set(actor.scope.allowedIds ?? []);
127
+ const visible = allowed
128
+ ? rows.filter((r: any) => allowed.has(r.id))
129
+ : rows;
130
+
131
+ const nodes = visible.map((r: any) => ({
132
+ id: r.id,
133
+ code: r.code,
134
+ name: r.name,
135
+ kind: r.kind,
136
+ parentId: r.parentId,
137
+ path: r.path,
138
+ isActive: r.isActive,
139
+ memberCount: r._count?.members ?? 0,
140
+ }));
141
+ return json(toClient(buildTree(nodes)));
142
+ } catch (error) {
143
+ return failure(error, req, deps);
144
+ }
145
+ }
146
+
147
+ async function POST(req: Request) {
148
+ try {
149
+ const actor = await resolveActor(deps);
150
+ if (actor instanceof Response) return actor;
151
+
152
+ const body = await req.json();
153
+ const parentId: string | null = body?.parentId ?? null;
154
+
155
+ // 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
156
+ // không ai "quản trị" nó, và cho phép thì admin khách hàng tự dựng được
157
+ // một tổ chức song song bên cạnh tổ chức của mình.
158
+ if (!parentId) {
159
+ if (!actor.manageAll) {
160
+ return json(
161
+ { error: "Chỉ quản trị toàn hệ thống tạo được không gian gốc." },
162
+ 403,
163
+ );
164
+ }
165
+ } else {
166
+ const denied = assertWritable(actor, parentId);
167
+ if (denied) return denied;
168
+ }
169
+
170
+ const created = await createWorkspace(deps.prisma, {
171
+ code: String(body?.code ?? "").trim(),
172
+ name: String(body?.name ?? "").trim(),
173
+ kind: body?.kind,
174
+ parentId,
175
+ });
176
+ return json(created, 201);
177
+ } catch (error) {
178
+ return failure(error, req, deps);
179
+ }
180
+ }
181
+
182
+ return { GET, POST };
183
+ }
184
+
185
+ /** PATCH (sửa) + DELETE (ngừng hoạt động cả nhánh) cho `/api/workspaces/[id]`. */
186
+ export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
187
+ type Ctx = { params: Promise<{ id: string }> | { id: string } };
188
+
189
+ const readId = async (ctx: Ctx) => (await ctx.params).id;
190
+
191
+ async function PATCH(req: Request, ctx: Ctx) {
192
+ try {
193
+ const actor = await resolveActor(deps);
194
+ if (actor instanceof Response) return actor;
195
+ const id = await readId(ctx);
196
+ const denied = assertWritable(actor, id);
197
+ if (denied) return denied;
198
+
199
+ const body = await req.json();
200
+ const data: Record<string, unknown> = {};
201
+ if (body?.code !== undefined) data.code = String(body.code).trim();
202
+ if (body?.name !== undefined) data.name = String(body.name).trim();
203
+ if (body?.kind !== undefined) data.kind = body.kind;
204
+ if (body?.settings !== undefined) data.settings = body.settings;
205
+ // `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
206
+ // làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
207
+
208
+ const updated = await deps.prisma.workspace.update({
209
+ where: { id },
210
+ data,
211
+ });
212
+ return json(updated);
213
+ } catch (error) {
214
+ return failure(error, req, deps);
215
+ }
216
+ }
217
+
218
+ async function DELETE(req: Request, ctx: Ctx) {
219
+ try {
220
+ const actor = await resolveActor(deps);
221
+ if (actor instanceof Response) return actor;
222
+ const id = await readId(ctx);
223
+ const denied = assertWritable(actor, id);
224
+ if (denied) return denied;
225
+
226
+ // Soft-delete: xoá cứng thì phạm vi của chứng từ cũ thành mồ côi.
227
+ const count = await deactivateSubtree(deps.prisma, id);
228
+ return json({ deactivated: count });
229
+ } catch (error) {
230
+ return failure(error, req, deps);
231
+ }
232
+ }
233
+
234
+ return { PATCH, DELETE };
235
+ }
236
+
237
+ /** POST cho `/api/workspaces/[id]/move`. */
238
+ export function createWorkspaceMoveHandler(deps: WorkspaceHandlerDeps) {
239
+ type Ctx = { params: Promise<{ id: string }> | { id: string } };
240
+
241
+ async function POST(req: Request, ctx: Ctx) {
242
+ try {
243
+ const actor = await resolveActor(deps);
244
+ if (actor instanceof Response) return actor;
245
+ const { id } = await ctx.params;
246
+
247
+ const denied = assertWritable(actor, id);
248
+ if (denied) return denied;
249
+
250
+ const body = await req.json();
251
+ const newParentId: string | null = body?.newParentId ?? null;
252
+
253
+ // Phải quản trị được CẢ hai đầu. Chỉ kiểm nút bị chuyển thì admin nhánh
254
+ // đẩy được nhánh của mình sang dưới tổ chức khác — hoặc lên gốc, thành
255
+ // một tổ chức độc lập ngang hàng với tổ chức mẹ.
256
+ if (newParentId) {
257
+ const deniedTarget = assertWritable(actor, newParentId);
258
+ if (deniedTarget) return deniedTarget;
259
+ } else if (!actor.manageAll) {
260
+ return json(
261
+ { error: "Chỉ quản trị toàn hệ thống chuyển được lên cấp cao nhất." },
262
+ 403,
263
+ );
264
+ }
265
+
266
+ const moved = await moveWorkspace(deps.prisma, { id, newParentId });
267
+ return json({ updated: moved });
268
+ } catch (error) {
269
+ return failure(error, req, deps);
270
+ }
271
+ }
272
+
273
+ return { POST };
274
+ }