@goplusvn/core 0.1.58 → 0.1.59

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/PLATFORM.md CHANGED
@@ -255,6 +255,71 @@ until `clearCache()`. The public endpoint is used for presigning only —
255
255
  a presigned signature is bound to the host that signed it, so signing with the
256
256
  internal endpoint yields URLs the browser cannot use.
257
257
 
258
+ ## Branch scope
259
+
260
+ Multi-branch data visibility (`@goerp/core/branch-scope`). Two layers, on
261
+ purpose — the second exists because the first is something a new route can
262
+ simply forget.
263
+
264
+ **Layer 1 — explicit filtering.** Every page/route/service that lists records
265
+ does `const scope = await getBranchScope(session)` then spreads
266
+ `scopedBranchWhere(scope)` into its `where`. `scopedBranchWhere` returns `{}`
267
+ for view-all users, so there is no branching at the call site. Related helpers:
268
+ `canAccessBranch(scope, row.branchId)` for detail pages, `clampBranchFilter`
269
+ for a branch filter the client sent (out-of-scope selections collapse to the
270
+ sentinel — 0 rows, never "no filter"), `clampIdFilter` for branch-owned things
271
+ like warehouses. Records with `branchId: null` are treated as shared and stay
272
+ visible to everyone.
273
+
274
+ **Layer 2 — the safety net.** `createBranchGuardExtension({ models })` is a
275
+ Prisma extension that ANDs the branch condition onto read operations
276
+ (`findMany`, `findFirst`, `findFirstOrThrow`, `count`, `aggregate`, `groupBy`)
277
+ for the declared models. It only acts inside a request context opened by the
278
+ app's api-handler, so RSC pages, cron, webhooks and scripts see unfiltered data
279
+ and must use layer 1. `findUnique*` is deliberately not guarded (its `where`
280
+ only takes unique fields) — guard detail pages with `canAccessBranch`.
281
+
282
+ Wiring, all in the app's composition root:
283
+
284
+ ```ts
285
+ // src/lib/branch-scope.ts — the ONE door; nothing else imports the core module
286
+ configureBranchScope<Session>({
287
+ getUserId: (session) => session.user?.id,
288
+ canViewAll: (session) => session.user.roles.includes("admin"),
289
+ getAllowedBranchIds: (session) => session.user.branches, // or pass `db` instead
290
+ })
291
+
292
+ // src/lib/prisma.ts
293
+ db = rawClient.$extends(createBranchGuardExtension({ models: ["Invoice"] }))
294
+
295
+ // src/lib/api-handler.ts (middleware) — resolve is lazy, so a request that
296
+ // never touches a guarded model costs nothing
297
+ runWithBranchScope({ resolve: () => getBranchScope(session) }, () => next())
298
+ ```
299
+
300
+ Either give `configureBranchScope` a `db` (it reads `user_branches`) or
301
+ `getAllowedBranchIds` when the session already carries the list.
302
+
303
+ Three traps, each of which has cost a real incident:
304
+
305
+ - **A guarded model MUST have a `branchId` column**, or you must route it
306
+ through a relation: `buildWhere: (model, scope) => model === "GoodsReceipt" ?
307
+ scopedBranchWhere(scope, "warehouse") : null`. Declare a model without the
308
+ column and Prisma throws a validation error on *every* read a scoped user
309
+ makes — a blank app, not a few missing rows.
310
+ - **Document numbering must escape the guard.** `MAX(number)` filtered by
311
+ branch produces duplicate numbers and a unique violation. Wrap those queries
312
+ in `runWithoutBranchScope(...)`.
313
+ - **"No branches assigned" means zero rows, not everything.** That is what
314
+ `NO_BRANCH_ACCESS` encodes; an empty `in: []` is ambiguous in Prisma and one
315
+ mistake there exposes the whole table.
316
+
317
+ `runWithBranchScope` / `runWithoutBranchScope` pin a returned thenable to the
318
+ context before handing it back, so `runWithoutBranchScope(() => db.doc.aggregate(…))`
319
+ works even though a PrismaPromise does not run until it is awaited — otherwise
320
+ the query would execute wherever the caller happened to await it, i.e. in the
321
+ wrong scope.
322
+
258
323
  ## Utils
259
324
 
260
325
  `@goerp/core/utils` (formatCurrency, formatDate, cn, …) plus the granular:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.58",
4
+ "version": "0.1.59",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,288 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ getBranchScopeContext,
5
+ resolveAmbientBranchScope,
6
+ runWithBranchScope,
7
+ runWithoutBranchScope,
8
+ type BranchScopeContext,
9
+ } from "../context";
10
+ import { createBranchGuardExtension } from "../guard";
11
+ import {
12
+ canAccessBranch,
13
+ clampBranchFilter,
14
+ clampIdFilter,
15
+ configureBranchScope,
16
+ getBranchScope,
17
+ scopedBranchWhere,
18
+ } from "../scope";
19
+ import { NO_BRANCH_ACCESS, type BranchScope } from "../types";
20
+
21
+ const scoped: BranchScope = { canViewAll: false, allowedBranchIds: ["b1", "b2"] };
22
+ const viewAll: BranchScope = { canViewAll: true };
23
+
24
+ interface FakeSession {
25
+ id?: string;
26
+ admin?: boolean;
27
+ }
28
+
29
+ function configure(rows: { branchId: string | null }[]) {
30
+ const findMany = vi.fn(async () => rows);
31
+ configureBranchScope<FakeSession>({
32
+ db: { userBranch: { findMany } },
33
+ getUserId: (s) => s.id,
34
+ canViewAll: (s) => Boolean(s.admin),
35
+ });
36
+ return findMany;
37
+ }
38
+
39
+ describe("getBranchScope", () => {
40
+ it("người xem-tất không tốn truy vấn user_branches", async () => {
41
+ const findMany = configure([]);
42
+ expect(await getBranchScope({ id: "u1", admin: true })).toEqual({
43
+ canViewAll: true,
44
+ });
45
+ expect(findMany).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("lấy đúng chi nhánh được gán", async () => {
49
+ configure([{ branchId: "b1" }, { branchId: "b2" }]);
50
+ expect(await getBranchScope({ id: "u1" })).toEqual({
51
+ canViewAll: false,
52
+ allowedBranchIds: ["b1", "b2"],
53
+ });
54
+ });
55
+
56
+ it("chưa gán chi nhánh nào → sentinel = thấy 0 dòng, KHÔNG phải thấy tất", async () => {
57
+ configure([]);
58
+ expect(await getBranchScope({ id: "u1" })).toEqual({
59
+ canViewAll: false,
60
+ allowedBranchIds: [NO_BRANCH_ACCESS],
61
+ });
62
+ });
63
+
64
+ it("session không có userId → sentinel (không tra DB bằng undefined)", async () => {
65
+ const findMany = configure([{ branchId: "b1" }]);
66
+ expect(await getBranchScope({})).toEqual({
67
+ canViewAll: false,
68
+ allowedBranchIds: [NO_BRANCH_ACCESS],
69
+ });
70
+ expect(findMany).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+
74
+ describe("scopedBranchWhere", () => {
75
+ it("xem tất → không thêm điều kiện", () => {
76
+ expect(scopedBranchWhere(viewAll)).toEqual({});
77
+ });
78
+
79
+ it("có phạm vi → CN được gán + bản ghi chưa gắn CN (dùng chung)", () => {
80
+ expect(scopedBranchWhere(scoped)).toEqual({
81
+ OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }],
82
+ });
83
+ });
84
+
85
+ it("scope qua quan hệ cho model không có cột branchId", () => {
86
+ expect(scopedBranchWhere(scoped, "warehouse")).toEqual({
87
+ OR: [
88
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
89
+ { warehouse: { branchId: null } },
90
+ ],
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("canAccessBranch", () => {
96
+ it("xem tất → qua hết", () => {
97
+ expect(canAccessBranch(viewAll, "x")).toBe(true);
98
+ });
99
+ it("bản ghi không gắn CN = dùng chung", () => {
100
+ expect(canAccessBranch(scoped, null)).toBe(true);
101
+ });
102
+ it("đúng/sai theo danh sách được gán", () => {
103
+ expect(canAccessBranch(scoped, "b1")).toBe(true);
104
+ expect(canAccessBranch(scoped, "x")).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("kẹp bộ lọc client gửi lên", () => {
109
+ it("clampBranchFilter: giữ nguyên khi xem tất, lọc bỏ CN ngoài phạm vi", () => {
110
+ expect(clampBranchFilter(["x"], viewAll)).toEqual(["x"]);
111
+ expect(clampBranchFilter(["b1", "x"], scoped)).toEqual(["b1"]);
112
+ });
113
+
114
+ it("clampBranchFilter: toàn bộ ngoài phạm vi → sentinel, không rơi về không-lọc", () => {
115
+ expect(clampBranchFilter(["x", "y"], scoped)).toEqual([NO_BRANCH_ACCESS]);
116
+ });
117
+
118
+ it("clampBranchFilter: không lọc gì → undefined (phạm vi đã lo)", () => {
119
+ expect(clampBranchFilter(undefined, scoped)).toBeUndefined();
120
+ expect(clampBranchFilter("", scoped)).toBeUndefined();
121
+ });
122
+
123
+ it("clampIdFilter: ngoài danh sách cho phép → sentinel", () => {
124
+ expect(clampIdFilter(["w1", "x"], ["w1", "w2"])).toEqual(["w1"]);
125
+ expect(clampIdFilter(["x"], ["w1"])).toEqual([NO_BRANCH_ACCESS]);
126
+ });
127
+ });
128
+
129
+ describe("context", () => {
130
+ it("ngoài context → undefined (extension bất động cho cron/script)", () => {
131
+ expect(getBranchScopeContext()).toBeUndefined();
132
+ expect(resolveAmbientBranchScope()).toBeUndefined();
133
+ });
134
+
135
+ it("resolve lười và cache trong 1 request", async () => {
136
+ const resolve = vi.fn(async () => scoped);
137
+ await runWithBranchScope({ resolve }, async () => {
138
+ expect(resolve).not.toHaveBeenCalled();
139
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
140
+ expect(await resolveAmbientBranchScope()).toBe(scoped);
141
+ expect(resolve).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
144
+
145
+ it("context sống qua await bên trong (PrismaPromise phải await TRONG context)", async () => {
146
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
147
+ await Promise.resolve();
148
+ expect(getBranchScopeContext()).toBeDefined();
149
+ });
150
+ });
151
+
152
+ it("runWithoutBranchScope thoát guard rồi trả lại context", async () => {
153
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
154
+ await runWithoutBranchScope(async () => {
155
+ await Promise.resolve();
156
+ expect(getBranchScopeContext()).toBeUndefined();
157
+ expect(resolveAmbientBranchScope()).toBeUndefined();
158
+ });
159
+ expect(getBranchScopeContext()).toBeDefined();
160
+ });
161
+ });
162
+
163
+ /**
164
+ * PrismaPromise lười: truy vấn chạy ở lần `.then()` đầu tiên, không phải lúc
165
+ * gọi `db.receipt.count()`. Giả lập đúng như vậy để chốt rằng callback KHÔNG
166
+ * async cũng chạy trong đúng phạm vi.
167
+ */
168
+ function lazyQuery(): Promise<BranchScopeContext | undefined> {
169
+ let started: Promise<BranchScopeContext | undefined> | undefined;
170
+ const thenable: PromiseLike<BranchScopeContext | undefined> = {
171
+ then(onFulfilled, onRejected) {
172
+ // Ngữ cảnh được chốt tại đây — chỗ đầu tiên ai đó await.
173
+ started ??= Promise.resolve(getBranchScopeContext());
174
+ return started.then(onFulfilled, onRejected);
175
+ },
176
+ };
177
+ // PrismaPromise cũng khai kiểu Promise nhưng chỉ chạy ở `.then` đầu tiên.
178
+ return thenable as Promise<BranchScopeContext | undefined>;
179
+ }
180
+
181
+ it("callback KHÔNG async trả PrismaPromise vẫn chạy TRONG context", async () => {
182
+ const ctx = await runWithBranchScope({ resolve: async () => scoped }, () =>
183
+ lazyQuery(),
184
+ );
185
+ expect(ctx).toBeDefined();
186
+ });
187
+
188
+ it("runWithoutBranchScope: callback KHÔNG async vẫn thoát guard", async () => {
189
+ const ctx = await runWithBranchScope(
190
+ { resolve: async () => scoped },
191
+ async () => runWithoutBranchScope(() => lazyQuery()),
192
+ );
193
+ expect(ctx).toBeUndefined();
194
+ });
195
+
196
+ it("giá trị đồng bộ đi qua nguyên vẹn (không bị bọc thành promise)", () => {
197
+ expect(runWithoutBranchScope(() => 42)).toBe(42);
198
+ });
199
+ });
200
+
201
+ describe("createBranchGuardExtension", () => {
202
+ const ext = createBranchGuardExtension({
203
+ models: ["Receipt", "GoodsReceipt"],
204
+ buildWhere: (model, scope) =>
205
+ model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
206
+ });
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ const run = ext.query.$allModels.$allOperations as (a: any) => Promise<any>;
209
+
210
+ let seen: unknown;
211
+ const query = vi.fn(async (args: unknown) => {
212
+ seen = args;
213
+ return "ok";
214
+ });
215
+
216
+ beforeEach(() => {
217
+ seen = undefined;
218
+ query.mockClear();
219
+ });
220
+
221
+ const call = (model: string, operation: string, args: unknown) =>
222
+ run({ model, operation, args, query });
223
+
224
+ it("model ngoài danh sách → đi thẳng", async () => {
225
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
226
+ call("Customer", "findMany", { where: { name: "a" } }),
227
+ );
228
+ expect(seen).toEqual({ where: { name: "a" } });
229
+ });
230
+
231
+ it("thao tác ghi và findUnique → không đụng vào", async () => {
232
+ await runWithBranchScope({ resolve: async () => scoped }, async () => {
233
+ await call("Receipt", "findUnique", { where: { id: "r1" } });
234
+ expect(seen).toEqual({ where: { id: "r1" } });
235
+ await call("Receipt", "update", { where: { id: "r1" }, data: {} });
236
+ expect(seen).toEqual({ where: { id: "r1" }, data: {} });
237
+ });
238
+ });
239
+
240
+ it("ngoài context (cron/script) → không cắt dữ liệu", async () => {
241
+ await call("Receipt", "findMany", { where: { a: 1 } });
242
+ expect(seen).toEqual({ where: { a: 1 } });
243
+ });
244
+
245
+ it("xem tất → không thêm điều kiện", async () => {
246
+ await runWithBranchScope({ resolve: async () => viewAll }, () =>
247
+ call("Receipt", "findMany", { where: { a: 1 } }),
248
+ );
249
+ expect(seen).toEqual({ where: { a: 1 } });
250
+ });
251
+
252
+ it("AND thêm điều kiện chi nhánh, GIỮ nguyên where cũ", async () => {
253
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
254
+ call("Receipt", "findMany", { where: { status: "paid" } }),
255
+ );
256
+ expect(seen).toEqual({
257
+ where: {
258
+ AND: [
259
+ { status: "paid" },
260
+ { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
261
+ ],
262
+ },
263
+ });
264
+ });
265
+
266
+ it("không có where sẵn → guard trở thành where", async () => {
267
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
268
+ call("Receipt", "count", {}),
269
+ );
270
+ expect(seen).toEqual({
271
+ where: { OR: [{ branchId: { in: ["b1", "b2"] } }, { branchId: null }] },
272
+ });
273
+ });
274
+
275
+ it("buildWhere riêng cho model không có cột branchId (chèn cột không tồn tại = Prisma ném lỗi cho MỌI truy vấn)", async () => {
276
+ await runWithBranchScope({ resolve: async () => scoped }, () =>
277
+ call("GoodsReceipt", "findMany", {}),
278
+ );
279
+ expect(seen).toEqual({
280
+ where: {
281
+ OR: [
282
+ { warehouse: { branchId: { in: ["b1", "b2"] } } },
283
+ { warehouse: { branchId: null } },
284
+ ],
285
+ },
286
+ });
287
+ });
288
+ });
@@ -0,0 +1,66 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ import type { BranchScope } from "./types";
4
+
5
+ /**
6
+ * Context của lưới an toàn lớp 2. `apiHandler` mở context cho MỌI request đã
7
+ * xác thực; extension branch-guard đọc ở đây.
8
+ *
9
+ * Phạm vi được resolve LƯỜI — lần đầu một model bị guard thực sự được đọc.
10
+ * Request không đụng model nào bị guard thì không tốn thêm truy vấn nào.
11
+ *
12
+ * File này cố ý không import phần scope: composition root của app tiêm hàm
13
+ * `resolve` vào lúc mở context, nhờ vậy context không kéo theo prisma và không
14
+ * tạo vòng import.
15
+ */
16
+
17
+ export interface BranchScopeContext {
18
+ resolve: () => Promise<BranchScope>;
19
+ /** Cache trong phạm vi 1 request — extension gán ở lần resolve đầu. */
20
+ cached?: Promise<BranchScope>;
21
+ }
22
+
23
+ const store = new AsyncLocalStorage<BranchScopeContext>();
24
+
25
+ /**
26
+ * PrismaPromise là LƯỜI: truy vấn chỉ thật sự chạy ở lần `.then()` đầu tiên.
27
+ * `run(ctx, () => db.receipt.count())` trả promise chưa chạy ra ngoài, người gọi
28
+ * `await` bên ngoài → truy vấn chạy NGOÀI context → guard im lặng không lọc
29
+ * (và với `exit` thì ngược lại: truy vấn cần toàn cục lại bị lọc → trùng số
30
+ * phiếu). Gọi `.then` ngay tại đây, khi còn ở trong/ngoài context đúng như ý,
31
+ * để việc chạy được ghim vào đúng phạm vi — bất kể người gọi await ở đâu.
32
+ */
33
+ function pinToCurrentContext<T>(result: T): T {
34
+ const thenable = result as { then?: unknown };
35
+ if (typeof thenable?.then !== "function") return result;
36
+ return (result as unknown as Promise<unknown>).then((value) => value) as T;
37
+ }
38
+
39
+ export function runWithBranchScope<T>(
40
+ context: BranchScopeContext,
41
+ fn: () => T | Promise<T>,
42
+ ): T | Promise<T> {
43
+ return store.run(context, () => pinToCurrentContext(fn()));
44
+ }
45
+
46
+ export function getBranchScopeContext(): BranchScopeContext | undefined {
47
+ return store.getStore();
48
+ }
49
+
50
+ /** Phạm vi của request hiện tại (resolve + cache); undefined khi ở ngoài context. */
51
+ export function resolveAmbientBranchScope(): Promise<BranchScope> | undefined {
52
+ const ctx = store.getStore();
53
+ if (!ctx) return undefined;
54
+ ctx.cached ??= ctx.resolve();
55
+ return ctx.cached;
56
+ }
57
+
58
+ /**
59
+ * Chạy `fn` NGOÀI lưới guard — cho truy vấn hạ tầng mà kết quả phải toàn cục
60
+ * bất kể phạm vi user. Ca kinh điển: đánh số phiếu (`MAX(number)` bị lọc theo
61
+ * chi nhánh là sinh trùng số → lỗi unique). Mọi `await` bên trong đều thoát
62
+ * guard, nên chỉ bọc đúng truy vấn cần toàn cục, đừng bọc cả handler.
63
+ */
64
+ export function runWithoutBranchScope<T>(fn: () => T): T {
65
+ return store.exit(() => pinToCurrentContext(fn()));
66
+ }
@@ -0,0 +1,100 @@
1
+ import { resolveAmbientBranchScope } from "./context";
2
+ import { scopedBranchWhere } from "./scope";
3
+
4
+ import type { BranchScope } from "./types";
5
+
6
+ /**
7
+ * LỚP 2 — lưới an toàn ở tầng ORM (mô hình Odoo/NocoBase): route mới quên lọc
8
+ * chi nhánh thì vẫn không lộ dữ liệu.
9
+ *
10
+ * Chỉ chạm thao tác ĐỌC, chỉ trên model được khai, và chỉ khi có
11
+ * BranchScopeContext — ngoài context (RSC page, cron, webhook, script) extension
12
+ * bất động, nên tác vụ nền không bị cắt dữ liệu bất ngờ.
13
+ *
14
+ * db.$extends(createBranchGuardExtension({
15
+ * models: ["Receipt", "Expense", "CashCount"],
16
+ * // model không có cột branchId thì khai đường đi riêng:
17
+ * buildWhere: (model, scope) =>
18
+ * model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
19
+ * }))
20
+ *
21
+ * CẢNH BÁO: model đưa vào `models` PHẢI có cột branchId, hoặc phải có nhánh
22
+ * riêng trong `buildWhere`. Chèn điều kiện lên cột không tồn tại là Prisma ném
23
+ * validation error cho MỌI thao tác đọc của user bị scope — nghĩa là hỏng cả
24
+ * nghiệp vụ, không chỉ hỏng bộ lọc.
25
+ */
26
+
27
+ export interface BranchGuardOptions {
28
+ /** Tên model Prisma (đúng chữ hoa/thường như trong schema). */
29
+ models: string[];
30
+ /**
31
+ * Điều kiện guard riêng theo model; trả `null` để dùng mặc định (cột
32
+ * branchId trực tiếp + cho phép null = dùng chung).
33
+ */
34
+ buildWhere?: (
35
+ model: string,
36
+ scope: BranchScope,
37
+ ) => Record<string, unknown> | null;
38
+ /** Mặc định: các thao tác đọc nhiều dòng. */
39
+ operations?: string[];
40
+ }
41
+
42
+ /**
43
+ * findUnique* cố ý không guard: `where` của nó chỉ nhận unique field, chèn thêm
44
+ * điều kiện là Prisma từ chối. Trang chi tiết guard bằng `canAccessBranch`.
45
+ * Mutation cũng không guard — create ghi vào chi nhánh của user, update/delete
46
+ * đi qua kiểm tra quyền riêng.
47
+ */
48
+ const DEFAULT_OPERATIONS = [
49
+ "findMany",
50
+ "findFirst",
51
+ "findFirstOrThrow",
52
+ "count",
53
+ "aggregate",
54
+ "groupBy",
55
+ ];
56
+
57
+ export function createBranchGuardExtension(options: BranchGuardOptions) {
58
+ const models = new Set(options.models);
59
+ const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
60
+ const buildWhere = options.buildWhere;
61
+
62
+ return {
63
+ name: "branch-guard",
64
+ query: {
65
+ $allModels: {
66
+ // Chữ ký thật do Prisma sinh theo schema của từng app; khai chặt ở core
67
+ // sẽ không assignable ở phía app (kiểu hẹp hơn, contravariance).
68
+ /* eslint-disable @typescript-eslint/no-explicit-any */
69
+ async $allOperations({
70
+ model,
71
+ operation,
72
+ args,
73
+ query,
74
+ }: {
75
+ model?: string;
76
+ operation: string;
77
+ args: any;
78
+ query: (args: any) => Promise<any>;
79
+ }): Promise<any> {
80
+ if (!model || !models.has(model)) return query(args);
81
+ if (!operations.has(operation)) return query(args);
82
+
83
+ const scopePromise = resolveAmbientBranchScope();
84
+ if (!scopePromise) return query(args);
85
+
86
+ const scope = await scopePromise;
87
+ if (scope.canViewAll) return query(args);
88
+
89
+ const guard = buildWhere?.(model, scope) ?? scopedBranchWhere(scope);
90
+ const prevWhere = args?.where;
91
+ return query({
92
+ ...args,
93
+ where: prevWhere ? { AND: [prevWhere, guard] } : guard,
94
+ });
95
+ },
96
+ /* eslint-enable @typescript-eslint/no-explicit-any */
97
+ },
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phạm vi dữ liệu theo chi nhánh — hai lớp:
3
+ *
4
+ * Lớp 1 (tường minh): `getBranchScope(session)` + `scopedBranchWhere(scope)`
5
+ * trong page/route/service. Đây mới là lớp làm việc chính.
6
+ *
7
+ * Lớp 2 (lưới an toàn): `createBranchGuardExtension` cắm vào Prisma, tự AND
8
+ * điều kiện chi nhánh cho thao tác đọc trên model được khai — cứu chỗ quên.
9
+ *
10
+ * App cấu hình một lần ở composition root và import phạm vi qua đúng file đó
11
+ * (`src/lib/branch-scope.ts`), giống kho tập tin: engine là singleton, import
12
+ * thẳng từ core sẽ lấy được hàm nhưng bỏ lỡ lời gọi cấu hình.
13
+ */
14
+ export {
15
+ configureBranchScope,
16
+ canViewAllBranches,
17
+ getBranchScope,
18
+ canAccessBranch,
19
+ scopedBranchWhere,
20
+ clampBranchFilter,
21
+ clampIdFilter,
22
+ } from "./scope";
23
+
24
+ export {
25
+ runWithBranchScope,
26
+ getBranchScopeContext,
27
+ resolveAmbientBranchScope,
28
+ runWithoutBranchScope,
29
+ type BranchScopeContext,
30
+ } from "./context";
31
+
32
+ export {
33
+ createBranchGuardExtension,
34
+ type BranchGuardOptions,
35
+ } from "./guard";
36
+
37
+ export {
38
+ NO_BRANCH_ACCESS,
39
+ type BranchScope,
40
+ type BranchScopeConfig,
41
+ type BranchScopeDb,
42
+ } from "./types";
@@ -0,0 +1,149 @@
1
+ import {
2
+ NO_BRANCH_ACCESS,
3
+ type BranchScope,
4
+ type BranchScopeConfig,
5
+ } from "./types";
6
+
7
+ /**
8
+ * LỚP 1 — lọc tường minh. Page/route/service gọi `getBranchScope(session)` rồi
9
+ * nhét `scopedBranchWhere(scope)` vào `where`. Lớp 2 (branch-guard extension)
10
+ * là lưới an toàn cho chỗ quên, KHÔNG phải thay thế lớp này: guard chỉ chạm các
11
+ * model được khai và chỉ trong request đi qua apiHandler.
12
+ */
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ let configured: BranchScopeConfig<any> | null = null;
16
+
17
+ export function configureBranchScope<TSession>(
18
+ config: BranchScopeConfig<TSession>,
19
+ ): void {
20
+ configured = config;
21
+ }
22
+
23
+ function requireConfigured() {
24
+ if (!configured) {
25
+ throw new Error(
26
+ "[branch-scope] chưa configureBranchScope(...) — gọi một lần ở composition root (src/lib/branch-scope.ts) rồi import phạm vi qua đúng file đó.",
27
+ );
28
+ }
29
+ return configured;
30
+ }
31
+
32
+ export function canViewAllBranches<TSession>(session: TSession): boolean {
33
+ return requireConfigured().canViewAll(session);
34
+ }
35
+
36
+ /**
37
+ * Phạm vi chi nhánh của user. Chưa được gán chi nhánh nào → sentinel, tức thấy
38
+ * 0 dòng — KHÔNG phải "thấy tất": mặc định an toàn là không thấy gì.
39
+ */
40
+ export async function getBranchScope<TSession>(
41
+ session: TSession,
42
+ ): Promise<BranchScope> {
43
+ const config = requireConfigured();
44
+ if (config.canViewAll(session)) return { canViewAll: true };
45
+
46
+ const branchIds = await readAllowedBranchIds(config, session);
47
+
48
+ return {
49
+ canViewAll: false,
50
+ allowedBranchIds: branchIds.length > 0 ? branchIds : [NO_BRANCH_ACCESS],
51
+ };
52
+ }
53
+
54
+ async function readAllowedBranchIds<TSession>(
55
+ config: BranchScopeConfig<TSession>,
56
+ session: TSession,
57
+ ): Promise<string[]> {
58
+ if (config.getAllowedBranchIds) {
59
+ const ids = await config.getAllowedBranchIds(session);
60
+ return (ids ?? []).filter((id): id is string => Boolean(id));
61
+ }
62
+
63
+ const userId = config.getUserId(session);
64
+ if (!userId) return [];
65
+
66
+ if (!config.db) {
67
+ throw new Error(
68
+ "[branch-scope] configureBranchScope cần `db` (hoặc `getAllowedBranchIds`) để biết user thuộc chi nhánh nào.",
69
+ );
70
+ }
71
+ const rows = await config.db.userBranch.findMany({
72
+ where: { userId },
73
+ select: { branchId: true },
74
+ });
75
+ return rows
76
+ .map((row) => (row as { branchId?: string | null }).branchId)
77
+ .filter((id): id is string => Boolean(id));
78
+ }
79
+
80
+ /**
81
+ * Guard trang chi tiết: user có được xem bản ghi thuộc chi nhánh này không?
82
+ * Bản ghi không gắn chi nhánh (null — dữ liệu cũ hoặc dùng chung) thì ai vào
83
+ * được trang đều xem được.
84
+ */
85
+ export function canAccessBranch(
86
+ scope: BranchScope,
87
+ branchId: string | null | undefined,
88
+ ): boolean {
89
+ if (scope.canViewAll) return true;
90
+ if (!branchId) return true;
91
+ return scope.allowedBranchIds!.includes(branchId);
92
+ }
93
+
94
+ /**
95
+ * Fragment `where` cho model có cột branchId: thuộc CN được phép HOẶC chưa gắn
96
+ * CN. `{}` khi xem được tất — nhét thẳng vào where là xong, không cần rẽ nhánh.
97
+ *
98
+ * `field` cho model scope qua quan hệ, ví dụ kho: `scopedBranchWhere(scope,
99
+ * "warehouse")` → `{ OR: [{ warehouse: { branchId: { in } } }, { warehouse: {
100
+ * branchId: null } }] }`.
101
+ */
102
+ export function scopedBranchWhere(
103
+ scope: BranchScope,
104
+ relation?: string,
105
+ ): Record<string, unknown> {
106
+ if (scope.canViewAll) return {};
107
+ const ids = scope.allowedBranchIds!;
108
+ const inClause = { branchId: { in: ids } };
109
+ const nullClause = { branchId: null };
110
+ return relation
111
+ ? { OR: [{ [relation]: inClause }, { [relation]: nullClause }] }
112
+ : { OR: [inClause, nullClause] };
113
+ }
114
+
115
+ /**
116
+ * Kẹp bộ lọc chi nhánh client gửi lên vào trong phạm vi được phép. `undefined`
117
+ * = client không lọc gì (phạm vi đã do allowedBranchIds lo). Chọn toàn CN ngoài
118
+ * phạm vi → sentinel: thấy 0 dòng, chứ KHÔNG rơi về "không lọc".
119
+ */
120
+ export function clampBranchFilter(
121
+ requested: string[] | string | null | undefined,
122
+ scope: BranchScope,
123
+ ): string[] | undefined {
124
+ const ids = normalizeIds(requested);
125
+ if (ids.length === 0) return undefined;
126
+ if (scope.canViewAll) return ids;
127
+ const valid = ids.filter((id) => scope.allowedBranchIds!.includes(id));
128
+ return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
129
+ }
130
+
131
+ /**
132
+ * Kẹp danh sách id client chọn vào danh sách được phép — dùng cho thực thể đi
133
+ * theo chi nhánh (kho, quầy, điểm bán). Ngoài danh sách → sentinel.
134
+ */
135
+ export function clampIdFilter(
136
+ requested: string[] | string | null | undefined,
137
+ allowedIds: string[],
138
+ ): string[] {
139
+ const valid = normalizeIds(requested).filter((id) => allowedIds.includes(id));
140
+ return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
141
+ }
142
+
143
+ function normalizeIds(
144
+ requested: string[] | string | null | undefined,
145
+ ): string[] {
146
+ return (Array.isArray(requested) ? requested : [requested]).filter(
147
+ (id): id is string => Boolean(id && id.trim()),
148
+ );
149
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Phạm vi dữ liệu theo chi nhánh — hợp đồng chung.
3
+ *
4
+ * Bài toán: app nhiều chi nhánh thì "danh sách phiếu thu" của kế toán CN A phải
5
+ * KHÁC của CN B, còn giám đốc thì thấy tất. Lọc bằng tay ở từng route là cách
6
+ * chắc chắn sẽ rò: chỉ cần một route mới quên `where branchId`.
7
+ */
8
+
9
+ /**
10
+ * Sentinel cho "user chưa được gán chi nhánh nào" và "client lọc toàn chi nhánh
11
+ * ngoài phạm vi". Là một chuỗi KHÔNG BAO GIỜ khớp branchId thật, nên
12
+ * `branchId: { in: [NO_BRANCH_ACCESS] }` trả 0 dòng. Không dùng mảng rỗng: Prisma
13
+ * hiểu `in: []` là "không có gì khớp" ở vài chỗ nhưng `undefined`/bỏ mệnh đề ở
14
+ * chỗ khác — nhầm một lần là lộ toàn bộ dữ liệu.
15
+ */
16
+ export const NO_BRANCH_ACCESS = "__NO_ACCESS__";
17
+
18
+ export interface BranchScope {
19
+ canViewAll: boolean;
20
+ /** undefined khi canViewAll — ngược lại LUÔN ≥1 phần tử (sentinel nếu user chưa gán CN). */
21
+ allowedBranchIds?: string[];
22
+ }
23
+
24
+ /** Chỉ cần đúng phần delegate `user_branches` mà scope dùng tới. */
25
+ export interface BranchScopeDb {
26
+ userBranch: {
27
+ findMany: (args: {
28
+ where: { userId: string };
29
+ select: { branchId: true };
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ }) => Promise<any[]>;
32
+ };
33
+ }
34
+
35
+ export interface BranchScopeConfig<TSession = unknown> {
36
+ /**
37
+ * Nguồn mặc định của phạm vi: bảng `user_branches`. Bỏ trống được NẾU đã khai
38
+ * `getAllowedBranchIds`.
39
+ */
40
+ db?: BranchScopeDb;
41
+ /**
42
+ * Session đã mang sẵn danh sách chi nhánh (app nhồi vào lúc đăng nhập) thì
43
+ * khai ở đây — khỏi phải truy vấn user_branches mỗi lần cần phạm vi. Trả mảng
44
+ * rỗng/null = user chưa được gán chi nhánh nào (→ sentinel, thấy 0 dòng).
45
+ */
46
+ getAllowedBranchIds?: (
47
+ session: TSession,
48
+ ) => string[] | null | undefined | Promise<string[] | null | undefined>;
49
+ /** Lấy id user từ session của app. */
50
+ getUserId: (session: TSession) => string | null | undefined;
51
+ /**
52
+ * "Xem mọi chi nhánh" — app tự quyết: vai trò quản trị, hoặc quyền
53
+ * `view-all-branches` trên resource nào đó. Trả true thì scope bỏ qua luôn
54
+ * truy vấn user_branches.
55
+ */
56
+ canViewAll: (session: TSession) => boolean;
57
+ }