@goplusvn/core 0.1.48 → 0.1.50

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.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // CLI `goerp-features` — đồng bộ feature CÓ BẢNG DB của @goerp/core vào app.
3
+ //
4
+ // pnpm goerp-features status # feature/bước nào còn thiếu
5
+ // pnpm goerp-features sync # copy schema + materialize migrations
6
+ // pnpm goerp-features sync --only background-tasks
7
+ //
8
+ // Sau sync: chạy `pnpm prisma migrate deploy` (flow deploy hiện có) rồi
9
+ // `pnpm prisma generate`. SQL của core viết IDEMPOTENT nên app đã có bảng
10
+ // từ trước chạy lại vô hại.
11
+
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import { featureStatus, syncFeatures } from "../scripts/feature-sync.mjs";
16
+
17
+ const featuresRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "features");
18
+ const appRoot = process.cwd();
19
+
20
+ const [, , command = "status", ...rest] = process.argv;
21
+ const onlyIdx = rest.indexOf("--only");
22
+ const only = onlyIdx >= 0 ? rest.slice(onlyIdx + 1).filter((a) => !a.startsWith("-")) : undefined;
23
+
24
+ if (command === "status") {
25
+ const statuses = featureStatus({ featuresRoot, appRoot });
26
+ if (statuses.length === 0) {
27
+ console.log("Không có feature nào trong @goerp/core/features.");
28
+ process.exit(0);
29
+ }
30
+ for (const f of statuses) {
31
+ const pending = f.steps.filter((s) => !s.materialized).length;
32
+ console.log(
33
+ `${pending === 0 && (!f.hasSchema || f.schemaSynced) ? "✓" : "•"} ${f.feature}` +
34
+ ` (schema: ${f.hasSchema ? (f.schemaSynced ? "đã sync" : "CHƯA sync") : "—"};` +
35
+ ` migrations: ${f.steps.length - pending}/${f.steps.length})`
36
+ );
37
+ for (const s of f.steps.filter((s) => !s.materialized)) {
38
+ console.log(` ↳ thiếu bước ${s.num}_${s.slug}`);
39
+ }
40
+ }
41
+ process.exit(0);
42
+ }
43
+
44
+ if (command === "sync") {
45
+ const actions = syncFeatures({ featuresRoot, appRoot, only });
46
+ if (actions.length === 0) {
47
+ console.log("✓ Không có gì để sync — app đã đủ schema + migrations.");
48
+ } else {
49
+ for (const a of actions) {
50
+ if (a.type === "schema") console.log(`✓ schema ${a.feature} → ${a.target}`);
51
+ else console.log(`✓ migrate ${a.feature} + ${a.step} → prisma/migrations/${a.dir}`);
52
+ }
53
+ console.log(
54
+ "\nTiếp theo: pnpm prisma migrate deploy && pnpm prisma generate"
55
+ );
56
+ }
57
+ process.exit(0);
58
+ }
59
+
60
+ console.error(`Lệnh không hợp lệ: ${command}. Dùng: status | sync [--only <feature>…]`);
61
+ process.exit(1);
@@ -0,0 +1,33 @@
1
+ # @goerp/core features — tính năng có bảng DB
2
+
3
+ Mỗi thư mục con là một feature core ship kèm **bảng DB**. App tiêu thụ chạy:
4
+
5
+ ```bash
6
+ pnpm goerp-features status # xem thiếu gì
7
+ pnpm goerp-features sync # copy schema + materialize migrations
8
+ pnpm prisma migrate deploy && pnpm prisma generate
9
+ ```
10
+
11
+ `sync` copy `schema.prisma` → `prisma/schema/goerp-<feature>.prisma` (file
12
+ GENERATED — đừng sửa tay) và tạo thư mục migration
13
+ `<timestamp>_goerp_<feature>_<NNN>_<slug>/` cho bước nào app chưa có. App giữ
14
+ **một** hệ migration duy nhất (`prisma migrate deploy`), không có DDL lúc boot.
15
+
16
+ ## Luật viết feature (tác giả core PHẢI theo)
17
+
18
+ 1. **SQL idempotent tuyệt đối** — `CREATE TABLE IF NOT EXISTS`,
19
+ `CREATE INDEX IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`.
20
+ Lý do: app từng tự tạo bảng bằng migration tay (vinhhoa) chạy lại phải vô hại.
21
+ 2. **Bước đã publish là BẤT BIẾN** — không sửa file `NNN_*.sql` cũ; đổi schema
22
+ thì thêm bước mới `NNN+1_*.sql` (và cập nhật `schema.prisma` fragment khớp).
23
+ 3. Tên bước: `NNN_slug.sql` (NNN ≥ 3 chữ số, slug kebab-case).
24
+ 4. `schema.prisma` fragment chỉ chứa model của feature này, dùng `@map`/`@@map`
25
+ snake_case như convention chung.
26
+ 5. Feature cần seed/RBAC: ghi rõ trong README của feature — seed chạy phía app
27
+ (Permission Registry của app là nguồn sự thật quyền).
28
+
29
+ ## Feature hiện có
30
+
31
+ - `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
32
+ (export/import chạy nền). Runtime hiện ở app (vinhhoa `src/server/tasks`);
33
+ sẽ dời lên core ở đợt F1.
@@ -0,0 +1,23 @@
1
+ -- goerp feature: background-tasks — bước 0001 (idempotent: app đã tự tạo bảng
2
+ -- từ trước chạy lại vô hại; quy ước MỌI bước SQL của core đều IF NOT EXISTS).
3
+ CREATE TABLE IF NOT EXISTS "background_tasks" (
4
+ "id" TEXT NOT NULL,
5
+ "type" TEXT NOT NULL,
6
+ "title" TEXT NOT NULL,
7
+ "status" TEXT NOT NULL DEFAULT 'pending',
8
+ "progress" INTEGER NOT NULL DEFAULT 0,
9
+ "params" JSONB,
10
+ "result" JSONB,
11
+ "error" TEXT,
12
+ "created_by" TEXT NOT NULL,
13
+ "branch_id" TEXT,
14
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ "started_at" TIMESTAMP(3),
16
+ "finished_at" TIMESTAMP(3),
17
+
18
+ CONSTRAINT "background_tasks_pkey" PRIMARY KEY ("id")
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS "background_tasks_created_by_created_at_idx" ON "background_tasks"("created_by", "created_at");
22
+
23
+ CREATE INDEX IF NOT EXISTS "background_tasks_status_idx" ON "background_tasks"("status");
@@ -0,0 +1,22 @@
1
+ // Tác vụ nền (export/import lớn…): hàng đợi trong DB, worker in-process.
2
+ // status: pending → running → success | error.
3
+ // result: { fileKey?, fileName?, rowCount?, errorFileKey?, summary? }.
4
+ model BackgroundTask {
5
+ id String @id @default(cuid()) @map("id")
6
+ type String @map("type")
7
+ title String @map("title")
8
+ status String @default("pending") @map("status")
9
+ progress Int @default(0) @map("progress")
10
+ params Json? @map("params")
11
+ result Json? @map("result")
12
+ error String? @map("error")
13
+ createdBy String @map("created_by")
14
+ branchId String? @map("branch_id")
15
+ createdAt DateTime @default(now()) @map("created_at")
16
+ startedAt DateTime? @map("started_at")
17
+ finishedAt DateTime? @map("finished_at")
18
+
19
+ @@index([createdBy, createdAt])
20
+ @@index([status])
21
+ @@map("background_tasks")
22
+ }
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.48",
4
+ "version": "0.1.50",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -11,8 +11,14 @@
11
11
  "module": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "sideEffects": false,
14
+ "bin": {
15
+ "goerp-features": "./bin/goerp-features.mjs"
16
+ },
14
17
  "files": [
15
18
  "src",
19
+ "bin",
20
+ "scripts",
21
+ "features",
16
22
  "README.md",
17
23
  "CHANGELOG.md",
18
24
  "PLATFORM.md"
@@ -0,0 +1,145 @@
1
+ // Feature sync — bộ máy cho CLI `goerp-features` (bin/goerp-features.mjs).
2
+ //
3
+ // BÀI TOÁN: @goerp/core ship tính năng CÓ BẢNG DB (background-tasks,
4
+ // notifications, error-logs…) qua npm; mỗi app tiêu thụ (vinhhoa, thingtodo,
5
+ // app init mới) trước đây phải chép tay schema + migration → thiếu sót, lệch.
6
+ //
7
+ // THIẾT KẾ (chọn "materialize vào prisma/migrations của app" thay vì ledger
8
+ // runtime kiểu NocoBase — không DDL lúc boot, app giữ MỘT hệ migration duy
9
+ // nhất `prisma migrate deploy`):
10
+ // - Core ship mỗi feature tại features/<name>/:
11
+ // schema.prisma — fragment model (copy vào schema dir của app)
12
+ // migrations/NNN_slug.sql — bước SQL đánh số, PHẢI viết IDEMPOTENT
13
+ // (CREATE TABLE IF NOT EXISTS…) để app đã có
14
+ // bảng từ trước (vinhhoa) chạy lại vô hại.
15
+ // - `sync`: copy fragment → prisma/schema/goerp-<feature>.prisma (đè, có
16
+ // header GENERATED) + với mỗi bước CHƯA materialize, tạo thư mục
17
+ // <timestamp>_goerp_<feature>_<NNN>_<slug>/migration.sql. Nhận bước đã
18
+ // materialize bằng ĐUÔI TÊN (bỏ qua timestamp) nên chạy lại idempotent.
19
+ // - `status`: liệt kê feature/bước, cái nào pending.
20
+ //
21
+ // Logic thuần (fs qua tham số path) để unit-test được bằng vitest trên tmp dir.
22
+
23
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ /** features/<name>/migrations/NNN_slug.sql → [{num, slug, file}] theo thứ tự. */
27
+ export function listFeatureSteps(featureDir) {
28
+ const dir = join(featureDir, "migrations");
29
+ if (!existsSync(dir)) return [];
30
+ return readdirSync(dir)
31
+ .filter((f) => /^\d{3,}_[a-z0-9-]+\.sql$/.test(f))
32
+ .sort()
33
+ .map((f) => {
34
+ const m = f.match(/^(\d{3,})_([a-z0-9-]+)\.sql$/);
35
+ return { num: m[1], slug: m[2], file: join(dir, f) };
36
+ });
37
+ }
38
+
39
+ /** Tên mọi feature trong thư mục features/ của core. */
40
+ export function listFeatures(featuresRoot) {
41
+ if (!existsSync(featuresRoot)) return [];
42
+ return readdirSync(featuresRoot, { withFileTypes: true })
43
+ .filter((e) => e.isDirectory())
44
+ .map((e) => e.name)
45
+ .sort();
46
+ }
47
+
48
+ /** Đuôi định danh của một bước — phần tên KHÔNG đổi giữa các app/timestamp. */
49
+ export function stepDirSuffix(feature, step) {
50
+ return `goerp_${feature.replace(/-/g, "_")}_${step.num}_${step.slug.replace(/-/g, "_")}`;
51
+ }
52
+
53
+ /** Bước đã được materialize vào prisma/migrations của app chưa? */
54
+ export function isStepMaterialized(appMigrationsDir, feature, step) {
55
+ if (!existsSync(appMigrationsDir)) return false;
56
+ const suffix = `_${stepDirSuffix(feature, step)}`;
57
+ return readdirSync(appMigrationsDir).some((d) => d.endsWith(suffix));
58
+ }
59
+
60
+ /**
61
+ * Timestamp prefix kiểu prisma (YYYYMMDDHHMMSS). `offset` giây cộng thêm để
62
+ * nhiều bước sinh trong cùng lần sync giữ đúng thứ tự.
63
+ */
64
+ export function migrationTimestamp(now, offset = 0) {
65
+ const d = new Date(now.getTime() + offset * 1000);
66
+ const p = (n, w = 2) => String(n).padStart(w, "0");
67
+ return (
68
+ `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
69
+ `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Trạng thái toàn bộ feature so với một app.
75
+ * → [{feature, steps: [{num, slug, materialized}] , schemaTarget, schemaSynced}]
76
+ */
77
+ export function featureStatus({ featuresRoot, appRoot }) {
78
+ const migrationsDir = join(appRoot, "prisma", "migrations");
79
+ return listFeatures(featuresRoot).map((feature) => {
80
+ const featureDir = join(featuresRoot, feature);
81
+ const schemaSource = join(featureDir, "schema.prisma");
82
+ const schemaTarget = join(appRoot, "prisma", "schema", `goerp-${feature}.prisma`);
83
+ const schemaSynced =
84
+ existsSync(schemaSource) &&
85
+ existsSync(schemaTarget) &&
86
+ readFileSync(schemaTarget, "utf8").includes(readFileSync(schemaSource, "utf8").trim());
87
+ return {
88
+ feature,
89
+ hasSchema: existsSync(schemaSource),
90
+ schemaTarget,
91
+ schemaSynced,
92
+ steps: listFeatureSteps(featureDir).map((step) => ({
93
+ ...step,
94
+ materialized: isStepMaterialized(migrationsDir, feature, step),
95
+ })),
96
+ };
97
+ });
98
+ }
99
+
100
+ const GENERATED_HEADER = (feature) =>
101
+ `// GENERATED từ @goerp/core (features/${feature}/schema.prisma) — ĐỪNG sửa tay.\n` +
102
+ `// Nâng cấp core xong chạy: pnpm goerp-features sync\n\n`;
103
+
104
+ /**
105
+ * Đồng bộ 1 app: copy schema fragments + materialize các bước SQL còn thiếu.
106
+ * Trả về log hành động (để CLI in + test assert). KHÔNG chạy prisma — app tự
107
+ * `prisma migrate deploy` (giữ nguyên flow deploy hiện có).
108
+ *
109
+ * @param {{ featuresRoot: string, appRoot: string, now?: Date, only?: string[] }} options
110
+ */
111
+ export function syncFeatures({ featuresRoot, appRoot, now = new Date(), only = [] }) {
112
+ const actions = [];
113
+ const migrationsDir = join(appRoot, "prisma", "migrations");
114
+ let offset = 0;
115
+
116
+ for (const feature of listFeatures(featuresRoot)) {
117
+ if (only.length > 0 && !only.includes(feature)) continue;
118
+ const featureDir = join(featuresRoot, feature);
119
+
120
+ // 1) Schema fragment → prisma/schema/goerp-<feature>.prisma (đè)
121
+ const schemaSource = join(featureDir, "schema.prisma");
122
+ if (existsSync(schemaSource)) {
123
+ const schemaDir = join(appRoot, "prisma", "schema");
124
+ mkdirSync(schemaDir, { recursive: true });
125
+ const target = join(schemaDir, `goerp-${feature}.prisma`);
126
+ const content = GENERATED_HEADER(feature) + readFileSync(schemaSource, "utf8");
127
+ const changed = !existsSync(target) || readFileSync(target, "utf8") !== content;
128
+ if (changed) {
129
+ writeFileSync(target, content);
130
+ actions.push({ type: "schema", feature, target });
131
+ }
132
+ }
133
+
134
+ // 2) Materialize các bước SQL chưa có
135
+ for (const step of listFeatureSteps(featureDir)) {
136
+ if (isStepMaterialized(migrationsDir, feature, step)) continue;
137
+ const dirName = `${migrationTimestamp(now, offset++)}_${stepDirSuffix(feature, step)}`;
138
+ const dir = join(migrationsDir, dirName);
139
+ mkdirSync(dir, { recursive: true });
140
+ cpSync(step.file, join(dir, "migration.sql"));
141
+ actions.push({ type: "migration", feature, step: `${step.num}_${step.slug}`, dir: dirName });
142
+ }
143
+ }
144
+ return actions;
145
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { compileFilterTree } from "../lib/filter-tree";
4
+
5
+ const allowAll = { isAllowed: () => true };
6
+
7
+ describe("compileFilterTree", () => {
8
+ it("leaf đơn → điều kiện Prisma", () => {
9
+ expect(
10
+ compileFilterTree(
11
+ { field: "status", operator: "eq", value: "paid" },
12
+ allowAll,
13
+ ),
14
+ ).toEqual({ status: "paid" });
15
+ });
16
+
17
+ it("khoảng gte+lte CÙNG field không đè nhau (điểm yếu của filter phẳng)", () => {
18
+ expect(
19
+ compileFilterTree(
20
+ {
21
+ $and: [
22
+ { field: "total", operator: "gte", value: 1000 },
23
+ { field: "total", operator: "lte", value: 5000 },
24
+ ],
25
+ },
26
+ allowAll,
27
+ ),
28
+ ).toEqual({
29
+ AND: [{ total: { gte: 1000 } }, { total: { lte: 5000 } }],
30
+ });
31
+ });
32
+
33
+ it("$or lồng trong $and + dotted relation path", () => {
34
+ expect(
35
+ compileFilterTree(
36
+ {
37
+ $and: [
38
+ { field: "branchId", operator: "eq", value: "b1" },
39
+ {
40
+ $or: [
41
+ { field: "status", operator: "eq", value: "paid" },
42
+ { field: "customer.name", operator: "contains", value: "an" },
43
+ ],
44
+ },
45
+ ],
46
+ },
47
+ allowAll,
48
+ ),
49
+ ).toEqual({
50
+ AND: [
51
+ { branchId: "b1" },
52
+ {
53
+ OR: [
54
+ { status: "paid" },
55
+ { customer: { name: { contains: "an", mode: "insensitive" } } },
56
+ ],
57
+ },
58
+ ],
59
+ });
60
+ });
61
+
62
+ it("field bị cấm → bỏ leaf + báo onDisallowed, phần còn lại giữ nguyên", () => {
63
+ const disallowed: string[] = [];
64
+ const where = compileFilterTree(
65
+ {
66
+ $and: [
67
+ { field: "secretCost", operator: "gt", value: 0 },
68
+ { field: "status", operator: "eq", value: "paid" },
69
+ ],
70
+ },
71
+ {
72
+ isAllowed: (f) => f !== "secretCost",
73
+ onDisallowed: (f) => disallowed.push(f),
74
+ },
75
+ );
76
+ expect(where).toEqual({ status: "paid" });
77
+ expect(disallowed).toEqual(["secretCost"]);
78
+ });
79
+
80
+ it("value rỗng → bỏ leaf; isNull/isNotNull không cần value", () => {
81
+ expect(
82
+ compileFilterTree({ field: "status", operator: "eq", value: "" }, allowAll),
83
+ ).toBeNull();
84
+ expect(
85
+ compileFilterTree({ field: "deletedAt", operator: "isNull" }, allowAll),
86
+ ).toEqual({ deletedAt: null });
87
+ expect(
88
+ compileFilterTree({ field: "email", operator: "isNotNull" }, allowAll),
89
+ ).toEqual({ email: { not: null } });
90
+ });
91
+
92
+ it("nhóm 1 phần tử được rút gọn, nhóm rỗng → null", () => {
93
+ expect(
94
+ compileFilterTree(
95
+ { $or: [{ field: "status", operator: "eq", value: "paid" }] },
96
+ allowAll,
97
+ ),
98
+ ).toEqual({ status: "paid" });
99
+ expect(compileFilterTree({ $and: [] }, allowAll)).toBeNull();
100
+ });
101
+
102
+ it("chặn cây quá sâu và quá nhiều điều kiện", () => {
103
+ let node: Record<string, unknown> = {
104
+ field: "a",
105
+ operator: "eq",
106
+ value: 1,
107
+ };
108
+ for (let i = 0; i < 6; i++) node = { $and: [node] };
109
+ expect(() => compileFilterTree(node, allowAll)).toThrow(/độ sâu/);
110
+
111
+ const leaves = Array.from({ length: 31 }, (_, i) => ({
112
+ field: `f${i}`,
113
+ operator: "eq",
114
+ value: i,
115
+ }));
116
+ expect(() => compileFilterTree({ $and: leaves }, allowAll)).toThrow(/điều kiện/);
117
+ });
118
+
119
+ it("cấu trúc sai → throw (route trả 4xx, không âm thầm bỏ lọc)", () => {
120
+ expect(() => compileFilterTree("x", allowAll)).toThrow();
121
+ expect(() => compileFilterTree({ $and: "x" }, allowAll)).toThrow();
122
+ expect(() => compileFilterTree({ operator: "eq" }, allowAll)).toThrow();
123
+ });
124
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Filter tree DSL → Prisma where.
3
+ *
4
+ * Bổ khuyết cho filter phẳng `ActiveFilter[]` của list(): phẳng chỉ AND ngầm
5
+ * và hai điều kiện cùng field ĐÈ nhau (không diễn đạt được khoảng gte+lte hay
6
+ * nhóm OR). Tree cho phép:
7
+ *
8
+ * { $and: [ { field: "total", operator: "gte", value: 1e6 },
9
+ * { field: "total", operator: "lte", value: 5e6 },
10
+ * { $or: [ { field: "status", operator: "eq", value: "paid" },
11
+ * { field: "customer.name", operator: "contains", value: "an" } ] } ] }
12
+ *
13
+ * An toàn: mọi leaf đi qua CÙNG guard `isAllowed` với filter phẳng (field
14
+ * ngoài config bị bỏ + cảnh báo); chặn sâu (depth) và số leaf để không nhận
15
+ * cây tuỳ ý từ client.
16
+ */
17
+
18
+ export interface FilterTreeLeaf {
19
+ field: string;
20
+ operator: string;
21
+ value?: unknown;
22
+ }
23
+
24
+ export type FilterTreeNode =
25
+ | { $and: FilterTreeNode[] }
26
+ | { $or: FilterTreeNode[] }
27
+ | FilterTreeLeaf;
28
+
29
+ const MAX_DEPTH = 4;
30
+ const MAX_LEAVES = 30;
31
+
32
+ /** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
33
+ export function conditionForOperator(op: string, value: unknown): unknown {
34
+ switch (op) {
35
+ case "contains":
36
+ return { contains: value, mode: "insensitive" };
37
+ case "in":
38
+ return { in: value };
39
+ case "notIn":
40
+ return { notIn: value };
41
+ case "eq":
42
+ return value;
43
+ case "ne":
44
+ return { not: value };
45
+ case "gt":
46
+ return { gt: value };
47
+ case "gte":
48
+ return { gte: value };
49
+ case "lt":
50
+ return { lt: value };
51
+ case "lte":
52
+ return { lte: value };
53
+ case "startsWith":
54
+ return { startsWith: value, mode: "insensitive" };
55
+ case "endsWith":
56
+ return { endsWith: value, mode: "insensitive" };
57
+ case "isNull":
58
+ return null;
59
+ case "isNotNull":
60
+ return { not: null };
61
+ default:
62
+ return value;
63
+ }
64
+ }
65
+
66
+ /** isNull/isNotNull không cần value — mọi toán tử khác value rỗng là bỏ leaf. */
67
+ const VALUELESS_OPS = new Set(["isNull", "isNotNull"]);
68
+
69
+ export interface CompileFilterTreeOptions {
70
+ /** Cùng guard với filter phẳng: field/relation phải nằm trong entity config. */
71
+ isAllowed: (name: string) => boolean;
72
+ /** Gọi khi một leaf bị bỏ (field cấm) — để log cảnh báo. */
73
+ onDisallowed?: (field: string) => void;
74
+ }
75
+
76
+ /**
77
+ * Compile tree → Prisma where. Trả `null` khi cây rỗng/không còn leaf hợp lệ
78
+ * (caller bỏ qua). Throw khi cây sai cấu trúc hoặc vượt giới hạn — route trả
79
+ * 400 cho client sửa, không âm thầm nuốt.
80
+ */
81
+ export function compileFilterTree(
82
+ node: unknown,
83
+ options: CompileFilterTreeOptions,
84
+ ): Record<string, unknown> | null {
85
+ const budget = { leaves: 0 };
86
+ return compileNode(node, options, 0, budget);
87
+ }
88
+
89
+ function compileNode(
90
+ node: unknown,
91
+ options: CompileFilterTreeOptions,
92
+ depth: number,
93
+ budget: { leaves: number },
94
+ ): Record<string, unknown> | null {
95
+ if (node === null || node === undefined) return null;
96
+ if (typeof node !== "object" || Array.isArray(node)) {
97
+ throw new Error("filterTree: node phải là object");
98
+ }
99
+ if (depth > MAX_DEPTH) {
100
+ throw new Error(`filterTree: vượt độ sâu tối đa ${MAX_DEPTH}`);
101
+ }
102
+
103
+ const group = node as { $and?: unknown; $or?: unknown };
104
+ if (group.$and !== undefined || group.$or !== undefined) {
105
+ const isAnd = group.$and !== undefined;
106
+ const children = isAnd ? group.$and : group.$or;
107
+ if (!Array.isArray(children)) {
108
+ throw new Error(`filterTree: ${isAnd ? "$and" : "$or"} phải là mảng`);
109
+ }
110
+ const compiled = children
111
+ .map((child) => compileNode(child, options, depth + 1, budget))
112
+ .filter((c): c is Record<string, unknown> => c !== null);
113
+ if (compiled.length === 0) return null;
114
+ if (compiled.length === 1) return compiled[0];
115
+ return isAnd ? { AND: compiled } : { OR: compiled };
116
+ }
117
+
118
+ // Leaf
119
+ const leaf = node as FilterTreeLeaf;
120
+ if (typeof leaf.field !== "string" || typeof leaf.operator !== "string") {
121
+ throw new Error("filterTree: leaf cần { field, operator }");
122
+ }
123
+ if (++budget.leaves > MAX_LEAVES) {
124
+ throw new Error(`filterTree: vượt ${MAX_LEAVES} điều kiện`);
125
+ }
126
+
127
+ const { field, operator, value } = leaf;
128
+ if (
129
+ !VALUELESS_OPS.has(operator) &&
130
+ (value === undefined ||
131
+ value === null ||
132
+ value === "" ||
133
+ (Array.isArray(value) && value.length === 0))
134
+ ) {
135
+ return null;
136
+ }
137
+ if (!options.isAllowed(field)) {
138
+ options.onDisallowed?.(field);
139
+ return null;
140
+ }
141
+
142
+ const condition = conditionForOperator(operator, value);
143
+ // Dotted path → lồng theo relation: "customer.name" → { customer: { name: cond } }
144
+ const parts = field.split(".");
145
+ let out: Record<string, unknown> = { [parts.pop()!]: condition };
146
+ while (parts.length) {
147
+ out = { [parts.pop()!]: out };
148
+ }
149
+ return out;
150
+ }
@@ -15,6 +15,7 @@
15
15
  // getModelName: (e) => getModelName(e, MODEL_MAP),
16
16
  // });
17
17
 
18
+ import { compileFilterTree } from "./lib/filter-tree";
18
19
  import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
19
20
  import { serializeDecimalFields } from "../utils/serialize";
20
21
 
@@ -260,6 +261,19 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
260
261
  else target[key] = value;
261
262
  }
262
263
  }
264
+ // Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
265
+ // cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
266
+ // nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
267
+ if (params.filterTree) {
268
+ const compiled = compileFilterTree(params.filterTree, {
269
+ isAllowed,
270
+ onDisallowed: (field) =>
271
+ log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
272
+ });
273
+ if (compiled) {
274
+ where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
275
+ }
276
+ }
263
277
 
264
278
  const orderBy: any = {};
265
279
  const applySort = (field: string, direction: any) => {
@@ -24,3 +24,4 @@ export {
24
24
  createCrudItemHandlers,
25
25
  } from './crud-route-handlers'
26
26
  export type { CrudHandlerDeps } from './crud-route-handlers'
27
+ export { compileFilterTree, conditionForOperator, type FilterTreeLeaf, type FilterTreeNode } from "./lib/filter-tree";
@@ -0,0 +1,135 @@
1
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { featureStatus, migrationTimestamp, syncFeatures } from "../../../scripts/feature-sync.mjs";
8
+
9
+ let featuresRoot: string;
10
+ let appRoot: string;
11
+
12
+ function writeFeature(name: string, steps: Record<string, string>, schema?: string) {
13
+ const dir = join(featuresRoot, name);
14
+ mkdirSync(join(dir, "migrations"), { recursive: true });
15
+ if (schema) writeFileSync(join(dir, "schema.prisma"), schema);
16
+ for (const [file, sql] of Object.entries(steps)) {
17
+ writeFileSync(join(dir, "migrations", file), sql);
18
+ }
19
+ }
20
+
21
+ beforeEach(() => {
22
+ featuresRoot = mkdtempSync(join(tmpdir(), "goerp-features-"));
23
+ appRoot = mkdtempSync(join(tmpdir(), "goerp-app-"));
24
+ mkdirSync(join(appRoot, "prisma", "migrations"), { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(featuresRoot, { recursive: true, force: true });
29
+ rmSync(appRoot, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("syncFeatures", () => {
33
+ it("materialize schema fragment + bước SQL vào app trắng", () => {
34
+ writeFeature(
35
+ "background-tasks",
36
+ { "0001_init.sql": "CREATE TABLE IF NOT EXISTS t(id text);" },
37
+ "model BackgroundTask { id String @id }"
38
+ );
39
+
40
+ const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
41
+ expect(actions.map((a: { type: string }) => a.type)).toEqual(["schema", "migration"])
42
+
43
+ // Schema fragment có header GENERATED
44
+ const schema = readFileSync(
45
+ join(appRoot, "prisma", "schema", "goerp-background-tasks.prisma"),
46
+ "utf8"
47
+ )
48
+ expect(schema).toContain("GENERATED từ @goerp/core")
49
+ expect(schema).toContain("model BackgroundTask")
50
+
51
+ // Migration folder đúng tên <ts>_goerp_<feature>_<step>
52
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations"))
53
+ expect(dirs).toEqual(["20260731000000_goerp_background_tasks_0001_init"])
54
+ expect(
55
+ readFileSync(
56
+ join(appRoot, "prisma", "migrations", dirs[0], "migration.sql"),
57
+ "utf8"
58
+ )
59
+ ).toContain("CREATE TABLE IF NOT EXISTS")
60
+ })
61
+
62
+ it("idempotent: sync lần 2 không tạo gì thêm (bất kể timestamp khác)", () => {
63
+ writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
64
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
65
+ const again = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-01T09:09:09Z") })
66
+ expect(again).toEqual([])
67
+ expect(readdirSync(join(appRoot, "prisma", "migrations"))).toHaveLength(1)
68
+ })
69
+
70
+ it("nâng cấp core thêm bước mới → chỉ materialize bước thiếu, giữ thứ tự", () => {
71
+ writeFeature("background-tasks", { "0001_init.sql": "SELECT 1;" })
72
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
73
+
74
+ writeFileSync(
75
+ join(featuresRoot, "background-tasks", "migrations", "0002_add-col.sql"),
76
+ "ALTER TABLE t ADD COLUMN IF NOT EXISTS x text;"
77
+ )
78
+ const actions = syncFeatures({ featuresRoot, appRoot, now: new Date("2026-08-02T00:00:00Z") })
79
+ expect(actions).toHaveLength(1)
80
+ expect(actions[0].step).toBe("0002_add-col")
81
+
82
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
83
+ expect(dirs[0]).toContain("0001_init")
84
+ expect(dirs[1]).toContain("0002_add_col")
85
+ })
86
+
87
+ it("nhiều bước trong 1 lần sync giữ thứ tự qua timestamp tăng dần", () => {
88
+ writeFeature("f-a", { "0001_one.sql": "SELECT 1;", "0002_two.sql": "SELECT 2;" })
89
+ syncFeatures({ featuresRoot, appRoot, now: new Date("2026-07-31T00:00:00Z") })
90
+ const dirs = readdirSync(join(appRoot, "prisma", "migrations")).sort()
91
+ expect(dirs[0]).toContain("0001_one")
92
+ expect(dirs[1]).toContain("0002_two")
93
+ expect(dirs[0] < dirs[1]).toBe(true)
94
+ })
95
+
96
+ it("--only giới hạn feature; schema đổi nội dung thì được ghi đè", () => {
97
+ writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
98
+ writeFeature("f-b", { "0001_b.sql": "SELECT 1;" })
99
+ const actions = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
100
+ expect(actions.every((a: { feature: string }) => a.feature === "f-a")).toBe(true)
101
+
102
+ // core bump: schema fragment đổi → sync ghi đè bản GENERATED
103
+ writeFileSync(
104
+ join(featuresRoot, "f-a", "schema.prisma"),
105
+ "model A { id String @id\n extra String? }"
106
+ )
107
+ const again = syncFeatures({ featuresRoot, appRoot, only: ["f-a"] })
108
+ expect(again.some((a: { type: string }) => a.type === "schema")).toBe(true)
109
+ expect(
110
+ readFileSync(join(appRoot, "prisma", "schema", "goerp-f-a.prisma"), "utf8")
111
+ ).toContain("extra String?")
112
+ })
113
+ })
114
+
115
+ describe("featureStatus", () => {
116
+ it("báo pending trước sync, đủ sau sync", () => {
117
+ writeFeature("f-a", { "0001_a.sql": "SELECT 1;" }, "model A { id String @id }")
118
+ let status = featureStatus({ featuresRoot, appRoot })
119
+ expect(status[0].steps[0].materialized).toBe(false)
120
+ expect(status[0].schemaSynced).toBe(false)
121
+
122
+ syncFeatures({ featuresRoot, appRoot })
123
+ status = featureStatus({ featuresRoot, appRoot })
124
+ expect(status[0].steps[0].materialized).toBe(true)
125
+ expect(status[0].schemaSynced).toBe(true)
126
+ })
127
+ })
128
+
129
+ describe("migrationTimestamp", () => {
130
+ it("dạng YYYYMMDDHHMMSS UTC + offset giây", () => {
131
+ const now = new Date("2026-07-31T01:02:03Z")
132
+ expect(migrationTimestamp(now)).toBe("20260731010203")
133
+ expect(migrationTimestamp(now, 2)).toBe("20260731010205")
134
+ })
135
+ })
@@ -621,6 +621,8 @@ export interface CrudQueryParams {
621
621
  search?: string;
622
622
  sort?: SortingState;
623
623
  filters?: ActiveFilter[];
624
+ /** Bộ lọc nâng cao dạng cây $and/$or — xem crud/lib/filter-tree. */
625
+ filterTree?: import("../crud/lib/filter-tree").FilterTreeNode;
624
626
  }
625
627
 
626
628
  export interface CrudResponse<T = unknown> {