@goplusvn/core 0.1.71 → 0.1.73

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 (150) hide show
  1. package/package.json +2 -1
  2. package/src/auth/index.ts +5 -1
  3. package/src/branding/__tests__/identity.test.ts +101 -0
  4. package/src/branding/__tests__/logo-route.test.ts +143 -0
  5. package/src/branding/identity.ts +74 -0
  6. package/src/branding/index.ts +36 -0
  7. package/src/branding/logo-route.ts +249 -0
  8. package/src/branding/pwa.tsx +146 -0
  9. package/src/branding/types.ts +45 -0
  10. package/src/configs/entities/departments.config.ts +1 -0
  11. package/src/configs/entities/material-categories.config.ts +1 -0
  12. package/src/crud/components/crud-export-button.tsx +3 -2
  13. package/src/crud/components/crud-page.tsx +56 -25
  14. package/src/crud/components/crud-row-actions.tsx +16 -4
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/pages/entity-crud-page.tsx +11 -8
  31. package/src/crud/server-service.test.ts +112 -0
  32. package/src/crud/server-service.ts +52 -27
  33. package/src/crud/server.ts +11 -1
  34. package/src/guardrails/rules/design.ts +3 -2
  35. package/src/styles/base.css +16 -0
  36. package/src/types/index.ts +75 -10
  37. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  38. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  39. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  40. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  41. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  42. package/src/ui/data-display/index.tsx +1 -0
  43. package/src/ui/data-display/use-client-pagination.ts +48 -0
  44. package/src/ui/errors/error-view.tsx +80 -87
  45. package/src/ui/errors/index.ts +6 -1
  46. package/src/ui/errors/not-found-view.tsx +89 -16
  47. package/src/ui/errors/status-page.tsx +132 -0
  48. package/src/ui/layout/logo.tsx +5 -0
  49. package/src/ui/pages/not-found.tsx +11 -27
  50. package/templates/starter-app/AGENTS.md +1 -1
  51. package/templates/starter-app/next.config.mjs +8 -0
  52. package/templates/starter-app/package.json +1 -0
  53. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  54. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  55. package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
  56. package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
  57. package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
  58. package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
  59. package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
  60. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  61. package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
  62. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
  63. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
  64. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
  65. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +38 -11
  66. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
  67. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  68. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  69. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
  70. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
  71. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  72. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
  75. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
  76. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
  77. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
  78. package/templates/starter-app/src/app/[lang]/layout.tsx +27 -2
  79. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  80. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  81. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  82. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  83. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  84. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  85. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  86. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  87. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  88. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  89. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  90. package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
  91. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  92. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  93. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  94. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  95. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  96. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  97. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  98. package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
  99. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  100. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  101. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  102. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  103. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  104. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  105. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
  106. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
  107. package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
  109. package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
  110. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  111. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  112. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  113. package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
  114. package/templates/starter-app/src/app/api/users/route.ts +11 -9
  115. package/templates/starter-app/src/app/globals.css +0 -1
  116. package/templates/starter-app/src/app/layout.tsx +42 -1
  117. package/templates/starter-app/src/app/manifest.ts +23 -19
  118. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  119. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  120. package/templates/starter-app/src/configs/entities/index.ts +1 -1
  121. package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
  122. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
  123. package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
  124. package/templates/starter-app/src/configs/i18n.ts +6 -6
  125. package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
  126. package/templates/starter-app/src/configs/tenant.ts +6 -1
  127. package/templates/starter-app/src/data/dictionary.ts +2 -1
  128. package/templates/starter-app/src/data/navigations.ts +12 -2
  129. package/templates/starter-app/src/lib/action-guard.ts +3 -4
  130. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  131. package/templates/starter-app/src/lib/auth.ts +3 -1
  132. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  133. package/templates/starter-app/src/lib/crud/index.ts +5 -12
  134. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  135. package/templates/starter-app/src/lib/logger.ts +11 -4
  136. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  137. package/templates/starter-app/src/lib/prisma.ts +13 -5
  138. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  139. package/templates/starter-app/src/lib/storage.ts +3 -1
  140. package/templates/starter-app/src/providers/index.tsx +13 -2
  141. package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
  142. package/templates/starter-app/src/proxy.ts +3 -1
  143. package/templates/starter-app/src/server/services/company-service.ts +4 -2
  144. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  145. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  146. package/templates/starter-app/src/server/services/user-service.ts +2 -2
  147. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  148. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  149. package/templates/starter-app/tsconfig.json +8 -0
  150. package/templates/starter-app/vitest.config.ts +17 -1
package/src/crud/index.ts CHANGED
@@ -28,8 +28,11 @@ export * from "./components/crud-virtual-table";
28
28
  export { EntityCrudPage } from "./pages/entity-crud-page";
29
29
 
30
30
  // Re-export lib logic
31
+ export * from "./lib/coerce";
31
32
  export * from "./lib/crud-service";
32
33
  export * from "./lib/crud-utils";
34
+ export * from "./lib/entity-endpoints";
35
+ export * from "./lib/errors";
33
36
  export * from "./lib/crud-validator";
34
37
  export * from "./lib/data-loader";
35
38
  export * from "./lib/field-calculator";
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { coerceFieldValue } from "./coerce";
3
+ import type { FieldConfig } from "../../types";
4
+
5
+ const field = (over: Partial<FieldConfig>): FieldConfig => ({
6
+ name: "f",
7
+ label: "F",
8
+ type: "text",
9
+ ...over,
10
+ });
11
+
12
+ describe("coerceFieldValue", () => {
13
+ it("giữ nguyên undefined/null", () => {
14
+ const f = field({ type: "number" });
15
+ expect(coerceFieldValue(f, undefined)).toBeUndefined();
16
+ expect(coerceFieldValue(f, null)).toBeNull();
17
+ });
18
+
19
+ it("number: chuỗi số → number, chuỗi rỗng → null, chuỗi rác giữ nguyên", () => {
20
+ const f = field({ type: "number" });
21
+ expect(coerceFieldValue(f, "5")).toBe(5);
22
+ expect(coerceFieldValue(f, "5.5")).toBe(5.5);
23
+ expect(coerceFieldValue(f, "")).toBeNull();
24
+ expect(coerceFieldValue(f, " ")).toBeNull();
25
+ expect(coerceFieldValue(f, "abc")).toBe("abc");
26
+ expect(coerceFieldValue(f, 7)).toBe(7);
27
+ });
28
+
29
+ it("boolean: các chuỗi truthy quy ước → true", () => {
30
+ const f = field({ type: "boolean" });
31
+ expect(coerceFieldValue(f, "true")).toBe(true);
32
+ expect(coerceFieldValue(f, "active")).toBe(true);
33
+ expect(coerceFieldValue(f, "1")).toBe(true);
34
+ expect(coerceFieldValue(f, "on")).toBe(true);
35
+ expect(coerceFieldValue(f, "false")).toBe(false);
36
+ expect(coerceFieldValue(f, "inactive")).toBe(false);
37
+ });
38
+
39
+ it("switch có cặp option → map về value của option, không phải boolean", () => {
40
+ const f = field({
41
+ type: "switch",
42
+ options: [
43
+ { label: "Hoạt động", value: "active" },
44
+ { label: "Vô hiệu", value: "inactive" },
45
+ ],
46
+ });
47
+ expect(coerceFieldValue(f, "active")).toBe("active");
48
+ expect(coerceFieldValue(f, "inactive")).toBe("inactive");
49
+ expect(coerceFieldValue(f, true)).toBe("active");
50
+ expect(coerceFieldValue(f, false)).toBe("inactive");
51
+ });
52
+
53
+ it("date/datetime: chuỗi ISO → Date, chuỗi rỗng → null, chuỗi rác giữ nguyên", () => {
54
+ const f = field({ type: "datetime" });
55
+ const out = coerceFieldValue(f, "2026-01-02T00:00:00.000Z");
56
+ expect(out).toBeInstanceOf(Date);
57
+ expect((out as Date).toISOString()).toBe("2026-01-02T00:00:00.000Z");
58
+ expect(coerceFieldValue(f, "")).toBeNull();
59
+ expect(coerceFieldValue(f, "not-a-date")).toBe("not-a-date");
60
+ });
61
+
62
+ it('FK: "" → undefined cho relation và *Id dạng select', () => {
63
+ expect(
64
+ coerceFieldValue(field({ name: "department", type: "relation" }), ""),
65
+ ).toBeUndefined();
66
+ expect(
67
+ coerceFieldValue(field({ name: "departmentId", type: "select" }), ""),
68
+ ).toBeUndefined();
69
+ });
70
+
71
+ it('*Id scalar (kiểu text, không phải FK) giữ nguyên ""', () => {
72
+ expect(
73
+ coerceFieldValue(field({ name: "citizenId", type: "text" }), ""),
74
+ ).toBe("");
75
+ });
76
+
77
+ it("mảng: ép từng phần tử (giá trị filter in/notIn)", () => {
78
+ const f = field({ type: "integer" });
79
+ expect(coerceFieldValue(f, ["1", "2", 3])).toEqual([1, 2, 3]);
80
+ });
81
+
82
+ it('FK snake_case: "" → undefined cho *_id/*_ids dạng select', () => {
83
+ expect(
84
+ coerceFieldValue(field({ name: "meal_time_id", type: "select" }), ""),
85
+ ).toBeUndefined();
86
+ expect(
87
+ coerceFieldValue(
88
+ field({ name: "default_meal_time_ids", type: "multiselect" }),
89
+ "",
90
+ ),
91
+ ).toBeUndefined();
92
+ });
93
+
94
+ it('valueType "int": select/multiselect chở chuỗi → number (FK Int autoincrement)', () => {
95
+ const f = field({
96
+ name: "meal_time_id",
97
+ type: "select",
98
+ valueType: "int",
99
+ });
100
+ expect(coerceFieldValue(f, "3")).toBe(3);
101
+ expect(coerceFieldValue(f, 3)).toBe(3);
102
+ expect(coerceFieldValue(f, "")).toBeUndefined(); // FK "" = bỏ trống
103
+ expect(coerceFieldValue(f, "abc")).toBe("abc"); // rác giữ nguyên cho validate
104
+
105
+ const multi = field({
106
+ name: "default_meal_time_ids",
107
+ type: "multiselect",
108
+ valueType: "int",
109
+ });
110
+ expect(coerceFieldValue(multi, ["1", "2", 3])).toEqual([1, 2, 3]);
111
+ });
112
+
113
+ it('valueType "int" trên select KHÔNG phải FK: "" → null (cột Int nullable)', () => {
114
+ const f = field({ name: "level", type: "select", valueType: "int" });
115
+ expect(coerceFieldValue(f, "2")).toBe(2);
116
+ expect(coerceFieldValue(f, "")).toBeNull();
117
+ });
118
+ });
@@ -0,0 +1,95 @@
1
+ import type { FieldConfig } from "../../types";
2
+
3
+ function optionValue(option: unknown): unknown {
4
+ return typeof option === "object" && option !== null && "value" in option
5
+ ? (option as { value: unknown }).value
6
+ : option;
7
+ }
8
+
9
+ /**
10
+ * Ép kiểu MỘT giá trị theo FieldConfig — điểm coercion tập trung, dùng chung
11
+ * cho body create/update (mutation-builder) và giá trị filter từ query string
12
+ * (route-handlers). Client gửi chuỗi (form/URL) là chuyện thường; không ép thì
13
+ * Prisma nổ type mismatch với cột Int/Boolean/DateTime.
14
+ *
15
+ * Trả `undefined` nghĩa là "coi như bỏ trống" — với cột FK, chuỗi rỗng ""
16
+ * phải thành undefined chứ không được lọt xuống DB (nổ P2003).
17
+ */
18
+ export function coerceFieldValue(field: FieldConfig, value: unknown): unknown {
19
+ if (value === undefined || value === null) return value;
20
+ if (Array.isArray(value)) {
21
+ return value.map((v) => coerceFieldValue(field, v));
22
+ }
23
+
24
+ // BẪY empty-string FK: "" vào cột FK là "bỏ trống", không phải giá trị —
25
+ // trả undefined để mutation disconnect/bỏ key, filter thì bỏ qua điều kiện.
26
+ // CHỈ áp cho field thực sự là FK (relation, hoặc *Id/*_id dạng select) —
27
+ // nhận CẢ HAI convention camelCase (`branchId`) và snake_case
28
+ // (`meal_time_id`, `default_meal_time_ids`); *Id scalar kiểu text
29
+ // (citizenId, targetId…) giữ nguyên "".
30
+ const fkName =
31
+ (field.name.endsWith("Id") && field.name.length > 2) ||
32
+ /_ids?$/.test(field.name);
33
+ const isForeignKey =
34
+ field.type === "relation" ||
35
+ Boolean(field.relation) ||
36
+ (fkName && (field.type === "select" || field.type === "multiselect"));
37
+ if (value === "" && isForeignKey) {
38
+ return undefined;
39
+ }
40
+
41
+ // Cột Int đứng sau select/multiselect/relation (FK autoincrement): `type`
42
+ // không nói lên kiểu cột nên phải khai `valueType: "int"` trong FieldConfig.
43
+ // Chuỗi không phải số giữ nguyên cho tầng validate/Prisma báo.
44
+ if (field.valueType === "int" && typeof value === "string") {
45
+ if (value.trim() === "") return null;
46
+ const num = Number(value);
47
+ return Number.isNaN(num) ? value : num;
48
+ }
49
+
50
+ switch (field.type) {
51
+ case "number":
52
+ case "integer": {
53
+ if (typeof value !== "string") return value;
54
+ if (value.trim() === "") return null;
55
+ const num = Number(value);
56
+ // Chuỗi không phải số: giữ nguyên cho tầng validate/Prisma báo, không âm thầm NaN.
57
+ return Number.isNaN(num) ? value : num;
58
+ }
59
+
60
+ case "boolean":
61
+ case "switch": {
62
+ let isTrue: boolean;
63
+ if (typeof value === "string") {
64
+ const lv = value.toLowerCase();
65
+ isTrue =
66
+ lv === "true" || lv === "active" || value === "1" || value === "on";
67
+ } else {
68
+ isTrue = Boolean(value);
69
+ }
70
+ // switch có cặp option = cột string 2 trạng thái (active/inactive…),
71
+ // map về value của option chứ không phải boolean.
72
+ if (
73
+ field.type === "switch" &&
74
+ field.options &&
75
+ field.options.length >= 2
76
+ ) {
77
+ return isTrue
78
+ ? optionValue(field.options[0])
79
+ : optionValue(field.options[1]);
80
+ }
81
+ return isTrue;
82
+ }
83
+
84
+ case "date":
85
+ case "datetime": {
86
+ if (typeof value !== "string") return value;
87
+ if (value.trim() === "") return null;
88
+ const date = new Date(value);
89
+ return Number.isNaN(date.getTime()) ? value : date;
90
+ }
91
+
92
+ default:
93
+ return value;
94
+ }
95
+ }
@@ -31,6 +31,10 @@ export function buildQueryString(params: CrudQueryParams): string {
31
31
  searchParams.append("filters", JSON.stringify(params.filters));
32
32
  }
33
33
 
34
+ if (params.filterTree) {
35
+ searchParams.append("filterTree", JSON.stringify(params.filterTree));
36
+ }
37
+
34
38
  return searchParams.toString();
35
39
  }
36
40
 
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getEntityEndpoints } from "./entity-endpoints";
3
+ import type { EntityConfig } from "../../types";
4
+
5
+ const base: EntityConfig = {
6
+ name: "job-title",
7
+ label: "Chức danh",
8
+ pluralLabel: "Chức danh",
9
+ apiEndpoint: "/api/crud/job-titles",
10
+ idField: "id",
11
+ displayField: "name",
12
+ fields: [],
13
+ };
14
+
15
+ describe("getEntityEndpoints", () => {
16
+ it("default: suy từ apiEndpoint theo cùng một quy tắc", () => {
17
+ expect(getEntityEndpoints(base)).toEqual({
18
+ import: "/api/crud/job-titles/import",
19
+ export: "/api/crud/job-titles/export",
20
+ });
21
+ });
22
+
23
+ it("apiEndpoint có query params: giữ query sau path import/export", () => {
24
+ const config = { ...base, apiEndpoint: "/api/stocks?type=in" };
25
+ expect(getEntityEndpoints(config)).toEqual({
26
+ import: "/api/stocks/import?type=in",
27
+ export: "/api/stocks/export?type=in",
28
+ });
29
+ });
30
+
31
+ it("config.endpoints override từng endpoint riêng lẻ", () => {
32
+ const config = {
33
+ ...base,
34
+ endpoints: { export: "/api/reports/job-titles/xlsx" },
35
+ };
36
+ expect(getEntityEndpoints(config)).toEqual({
37
+ import: "/api/crud/job-titles/import",
38
+ export: "/api/reports/job-titles/xlsx",
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,23 @@
1
+ import type { EntityConfig } from "../../types";
2
+
3
+ /**
4
+ * Endpoint import/export của entity — MỘT quy tắc duy nhất cho cả ImportDialog
5
+ * lẫn CrudExportButton (trước đây hai nơi tự build hai kiểu khác nhau → 404).
6
+ *
7
+ * Default: `${base}/import|/export` với base = apiEndpoint bỏ query string,
8
+ * query params (nếu có) được giữ lại nối sau path. Override từng cái qua
9
+ * `config.endpoints`.
10
+ */
11
+ export function getEntityEndpoints(config: EntityConfig): {
12
+ import: string;
13
+ export: string;
14
+ } {
15
+ const [base, query] = config.apiEndpoint.split("?");
16
+ const derive = (suffix: string) =>
17
+ `${base}/${suffix}${query ? `?${query}` : ""}`;
18
+
19
+ return {
20
+ import: config.endpoints?.import ?? derive("import"),
21
+ export: config.endpoints?.export ?? derive("export"),
22
+ };
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Lỗi request CRUD do client gửi sai (id sai kiểu, filters/filterTree hỏng…).
3
+ * Route-handler bắt instanceof để trả đúng status (mặc định 400) thay vì rơi
4
+ * xuống onError/500 chung.
5
+ */
6
+ export class CrudRequestError extends Error {
7
+ readonly status: number;
8
+
9
+ constructor(message: string, status = 400) {
10
+ super(message);
11
+ this.name = "CrudRequestError";
12
+ this.status = status;
13
+ }
14
+ }
@@ -0,0 +1,99 @@
1
+ import type { EntityConfig, FieldConfig, ImportOptions } from "../../types";
2
+
3
+ import { parseFile } from "./import-export-service";
4
+
5
+ /**
6
+ * Đọc body của một route import ở CẢ HAI dạng client đang tồn tại:
7
+ *
8
+ * - multipart FormData `{file, format}` — ImportDialog của core gửi lên
9
+ * (file xlsx/csv/json parse ngay tại server bằng parseFile);
10
+ * - JSON array — đường cũ: client tự parse Excel rồi POST thẳng mảng dòng.
11
+ *
12
+ * Sau khi parse, header mỗi dòng được dịch về TÊN CỘT DB, nhận cả 3 kiểu:
13
+ * header legacy khai tường minh (`legacyHeaders`, vd tiếng Việt của file mẫu
14
+ * hệ cũ), label khai trong EntityConfig (file mẫu do ImportDialog sinh dùng
15
+ * label) và tên cột máy. Cột không khớp mapping nào bị bỏ qua.
16
+ *
17
+ * Trả `null` khi body không đọc được thành mảng dòng (thiếu file, JSON không
18
+ * phải array…) — caller trả 400.
19
+ */
20
+ export async function readImportRows(
21
+ req: Request,
22
+ config: EntityConfig,
23
+ legacyHeaders: Record<string, string> = {},
24
+ ): Promise<Record<string, unknown>[] | null> {
25
+ const contentType = req.headers.get("content-type") ?? "";
26
+ let rawRows: unknown;
27
+
28
+ if (contentType.includes("multipart/form-data")) {
29
+ const form = await req.formData();
30
+ // Duck-type thay vì `instanceof File` — File có thể đến từ realm khác
31
+ // (undici vs jsdom/global) và instanceof xuyên realm luôn false.
32
+ const file = form.get("file") as File | string | null;
33
+ if (file === null || typeof file === "string") return null;
34
+ const formatField = form.get("format");
35
+ const format =
36
+ formatField === "csv" || formatField === "json" || formatField === "xlsx"
37
+ ? formatField
38
+ : inferFormat(file.name);
39
+ rawRows = await parseFile(file, format);
40
+ } else {
41
+ try {
42
+ rawRows = await req.json();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ if (!Array.isArray(rawRows)) return null;
49
+
50
+ // Header legacy khai tường minh thắng trước; label/tên cột từ config bù sau.
51
+ const headerToColumn: Record<string, string> = { ...legacyHeaders };
52
+ for (const field of config.fields) {
53
+ headerToColumn[field.name] ??= field.name;
54
+ headerToColumn[field.label] ??= field.name;
55
+ }
56
+
57
+ return rawRows.map((row) => {
58
+ const out: Record<string, unknown> = {};
59
+ for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
60
+ const column = headerToColumn[key.trim()];
61
+ if (column) out[column] = value;
62
+ }
63
+ return out;
64
+ });
65
+ }
66
+
67
+ function inferFormat(fileName: string): ImportOptions["format"] {
68
+ const name = fileName.toLowerCase();
69
+ if (name.endsWith(".csv")) return "csv";
70
+ if (name.endsWith(".json")) return "json";
71
+ return "xlsx";
72
+ }
73
+
74
+ /**
75
+ * Ép ô boolean từ file import: nhận boolean thật (đường JSON cũ), chuỗi
76
+ * true/false/1/0/x…, và cả LABEL của option boolean trong config ("Đang làm
77
+ * việc", "Ngừng bán"…). Không nhận diện được → undefined (để zod default lo).
78
+ */
79
+ export function coerceBooleanCell(
80
+ value: unknown,
81
+ field?: FieldConfig,
82
+ ): boolean | undefined {
83
+ if (typeof value === "boolean") return value;
84
+ if (value === undefined || value === null || value === "") return undefined;
85
+ const v = String(value).trim().toLowerCase();
86
+ if (["true", "1", "yes", "y", "x", "có", "co"].includes(v)) return true;
87
+ if (["false", "0", "no", "n", "không", "khong"].includes(v)) return false;
88
+ for (const opt of field?.options ?? []) {
89
+ if (
90
+ typeof opt === "object" &&
91
+ opt !== null &&
92
+ typeof opt.value === "boolean" &&
93
+ String(opt.label).trim().toLowerCase() === v
94
+ ) {
95
+ return opt.value;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { prepareMutationData } from "./mutation-builder";
3
+ import type { EntityConfig } from "../../types";
4
+
5
+ const logger = { warn: vi.fn(), error: vi.fn() };
6
+ const SYSTEM_FIELDS = ["id", "createdAt", "updatedAt", "createdBy", "updatedBy"];
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ const makeConfig = (over: Partial<EntityConfig> = {}): EntityConfig => ({
10
+ name: "job-title",
11
+ label: "Chức danh",
12
+ pluralLabel: "Chức danh",
13
+ apiEndpoint: "/api/crud/job-titles",
14
+ idField: "id",
15
+ displayField: "name",
16
+ fields: [
17
+ { name: "id", label: "ID", type: "text" },
18
+ { name: "name", label: "Tên", type: "text" },
19
+ { name: "level", label: "Cấp", type: "number" },
20
+ { name: "active", label: "Kích hoạt", type: "boolean" },
21
+ { name: "departmentId", label: "Phòng ban", type: "select" },
22
+ ],
23
+ ...over,
24
+ });
25
+
26
+ const prepare = (
27
+ data: Record<string, unknown>,
28
+ mode: "create" | "update",
29
+ config?: EntityConfig,
30
+ ) => prepareMutationData({ data, config, mode, systemFields: SYSTEM_FIELDS, logger });
31
+
32
+ describe("prepareMutationData", () => {
33
+ describe("id theo idKind", () => {
34
+ it('idKind mặc định ("string"): create thiếu id thì sinh UUID', () => {
35
+ const out = prepare({ name: "A" }, "create", makeConfig());
36
+ expect(out.id).toMatch(UUID_RE);
37
+ });
38
+
39
+ it("không có config: giữ hành vi cũ, vẫn sinh UUID", () => {
40
+ const out = prepare({ name: "A" }, "create");
41
+ expect(out.id).toMatch(UUID_RE);
42
+ });
43
+
44
+ it("idKind int: KHÔNG BAO GIỜ sinh id (DB autoincrement)", () => {
45
+ const out = prepare({ name: "A" }, "create", makeConfig({ idKind: "int" }));
46
+ expect("id" in out).toBe(false);
47
+ });
48
+
49
+ it('idKind int: id rỗng "" từ form bị bỏ hẳn key', () => {
50
+ const out = prepare({ id: "", name: "A" }, "create", makeConfig({ idKind: "int" }));
51
+ expect("id" in out).toBe(false);
52
+ });
53
+
54
+ it("idKind int: id chuỗi số được ép Number", () => {
55
+ const out = prepare({ id: "12", name: "A" }, "create", makeConfig({ idKind: "int" }));
56
+ expect(out.id).toBe(12);
57
+ });
58
+
59
+ it("idField tùy biến: sinh UUID vào đúng cột id của entity", () => {
60
+ const config = makeConfig({
61
+ idField: "code",
62
+ fields: [
63
+ { name: "code", label: "Mã", type: "text" },
64
+ { name: "name", label: "Tên", type: "text" },
65
+ ],
66
+ });
67
+ const out = prepare({ name: "A" }, "create", config);
68
+ expect(out.code).toMatch(UUID_RE);
69
+ expect("id" in out).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe("coercion theo config.fields", () => {
74
+ it("ép number/boolean từ chuỗi form", () => {
75
+ const out = prepare(
76
+ { name: "A", level: "5", active: "true" },
77
+ "create",
78
+ makeConfig(),
79
+ );
80
+ expect(out.level).toBe(5);
81
+ expect(out.active).toBe(true);
82
+ });
83
+
84
+ it("lọc field ngoài config kèm cảnh báo", () => {
85
+ const out = prepare({ name: "A", hacker: "x" }, "create", makeConfig());
86
+ expect("hacker" in out).toBe(false);
87
+ expect(logger.warn).toHaveBeenCalled();
88
+ });
89
+ });
90
+
91
+ describe("empty-string FK", () => {
92
+ it('create: departmentId "" bị bỏ, không connect', () => {
93
+ const out = prepare({ name: "A", departmentId: "" }, "create", makeConfig());
94
+ expect("departmentId" in out).toBe(false);
95
+ expect("department" in out).toBe(false);
96
+ });
97
+
98
+ it('update: departmentId "" → disconnect relation', () => {
99
+ const out = prepare({ name: "A", departmentId: "" }, "update", makeConfig());
100
+ expect("departmentId" in out).toBe(false);
101
+ expect(out.department).toEqual({ disconnect: true });
102
+ });
103
+
104
+ it("departmentId có giá trị → connect", () => {
105
+ const out = prepare(
106
+ { name: "A", departmentId: "dep-1" },
107
+ "create",
108
+ makeConfig(),
109
+ );
110
+ expect(out.department).toEqual({ connect: { id: "dep-1" } });
111
+ expect("departmentId" in out).toBe(false);
112
+ });
113
+ });
114
+ });
@@ -1,5 +1,7 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  import type { EntityConfig } from "../../types";
2
3
  import type { ServerCrudLogger } from "../server-service";
4
+ import { coerceFieldValue } from "./coerce";
3
5
 
4
6
  export interface PrepareMutationDataOptions {
5
7
  data: any;
@@ -14,12 +16,16 @@ export interface PrepareMutationDataOptions {
14
16
  export function prepareMutationData(options: PrepareMutationDataOptions) {
15
17
  const { data, config, mode, systemFields, relationFieldSkip, logger, touchUpdatedAtOnCreate } = options;
16
18
 
19
+ const idField = config?.idField ?? "id";
20
+ const idKind = config?.idKind ?? "string";
21
+
17
22
  let filtered = data;
18
-
23
+
19
24
  if (config) {
20
25
  // 1. Filter valid fields
21
26
  const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
22
27
  for (const f of systemFields) valid.add(f);
28
+ valid.add(idField);
23
29
  filtered = {};
24
30
  for (const [k, v] of Object.entries(data)) {
25
31
  if (valid.has(k)) {
@@ -29,42 +35,28 @@ export function prepareMutationData(options: PrepareMutationDataOptions) {
29
35
  }
30
36
  }
31
37
 
32
- // 2. Cast field values (boolean, number)
38
+ // 2. Ép kiểu tập trung theo config.fields (number/boolean/switch/date,
39
+ // "" cho FK → undefined) — xem coerceFieldValue.
33
40
  for (const field of config.fields) {
34
- const value = filtered[field.name];
35
- if (value === undefined || value === null) continue;
36
-
37
- if (field.type === "boolean" || field.type === "switch") {
38
- let isTrue: boolean;
39
- if (typeof value === "string") {
40
- const lv = value.toLowerCase();
41
- isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
42
- } else {
43
- isTrue = Boolean(value);
44
- }
45
-
46
- if (field.type === "switch" && field.options && field.options.length >= 2) {
47
- filtered[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
48
- } else {
49
- filtered[field.name] = isTrue;
50
- }
51
- } else if (field.type === "number" || (field.type as string) === "integer") {
52
- if (typeof value === "string") {
53
- if (value.trim() === "") {
54
- filtered[field.name] = null;
55
- } else {
56
- const num = Number(value);
57
- if (!isNaN(num)) filtered[field.name] = num;
58
- }
59
- }
60
- }
41
+ if (!(field.name in filtered)) continue;
42
+ filtered[field.name] = coerceFieldValue(field, filtered[field.name]);
61
43
  }
62
44
  }
63
45
 
64
46
  // 3. System defaults for create
65
47
  if (mode === "create") {
66
- if (!filtered.id) {
67
- filtered.id = crypto.randomUUID();
48
+ if (idKind === "int") {
49
+ // DB autoincrement — KHÔNG BAO GIỜ tự sinh id; id rỗng từ form thì bỏ
50
+ // hẳn key, id chuỗi số thì ép Number cho Prisma Int.
51
+ const raw = filtered[idField];
52
+ if (raw === undefined || raw === null || raw === "") {
53
+ delete filtered[idField];
54
+ } else if (typeof raw === "string") {
55
+ const num = Number(raw);
56
+ if (Number.isInteger(num)) filtered[idField] = num;
57
+ }
58
+ } else if (!filtered[idField]) {
59
+ filtered[idField] = crypto.randomUUID();
68
60
  }
69
61
  if (touchUpdatedAtOnCreate && !filtered.updatedAt) {
70
62
  filtered.updatedAt = new Date();
@@ -74,21 +66,22 @@ export function prepareMutationData(options: PrepareMutationDataOptions) {
74
66
  // 4. Transform relation fields (*Id -> connect/disconnect)
75
67
  const skip = new Set([
76
68
  "id",
69
+ idField,
77
70
  "createdBy",
78
71
  "updatedBy",
79
72
  "citizenId",
80
73
  "targetId",
81
74
  ...(relationFieldSkip ?? []),
82
75
  ]);
83
-
76
+
84
77
  const prismaData = { ...filtered };
85
78
  for (const key of Object.keys(prismaData)) {
86
79
  if (skip.has(key)) continue;
87
-
80
+
88
81
  if (key.endsWith("Id") && key.length > 2) {
89
82
  const rel = key.slice(0, -2);
90
83
  const value = prismaData[key];
91
-
84
+
92
85
  if (value && typeof value === "string" && value.trim() !== "") {
93
86
  prismaData[rel] = { connect: { id: value } };
94
87
  delete prismaData[key];