@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
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getCrudPermissions, mergePermissions } from "./permissions";
3
+ import type { Session } from "../../types";
4
+
5
+ // Session non-admin với permissionMap gọn (resource → action codes) — đúng
6
+ // dạng session callback nạp vào JWT.
7
+ const staff = {
8
+ user: {
9
+ id: "u1",
10
+ permissionMap: { employee: ["view", "update", "reset-password"] },
11
+ },
12
+ } as unknown as Session;
13
+
14
+ const admin = {
15
+ user: { id: "a1", roles: ["SUPER_ADMIN"] },
16
+ } as unknown as Session;
17
+
18
+ describe("getCrudPermissions + extraActions (custom action từ registry)", () => {
19
+ it("điền custom action theo checkPermission, thiếu quyền = false", async () => {
20
+ const perms = await getCrudPermissions(staff, "employee", [
21
+ "reset-password",
22
+ "resync",
23
+ ]);
24
+ expect(perms.read).toBe(true);
25
+ expect(perms.update).toBe(true);
26
+ expect(perms.create).toBe(false);
27
+ expect(perms["reset-password"]).toBe(true);
28
+ expect(perms["resync"]).toBe(false);
29
+ });
30
+
31
+ it("admin có mọi custom action", async () => {
32
+ const perms = await getCrudPermissions(admin, "employee", ["resync"]);
33
+ expect(perms["resync"]).toBe(true);
34
+ });
35
+
36
+ it("extraActions trùng 8 action chuẩn không ghi đè kết quả chuẩn", async () => {
37
+ const perms = await getCrudPermissions(staff, "employee", ["update"]);
38
+ expect(perms.update).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("mergePermissions giữ custom action", () => {
43
+ it("không khai config permissions → trả nguyên map user (kể cả custom)", () => {
44
+ const user = { read: true, "reset-password": true };
45
+ expect(mergePermissions(undefined, user)).toEqual(user);
46
+ });
47
+
48
+ it("config permissions chỉ khai 1 key → custom action KHÔNG bị rơi mất", () => {
49
+ const merged = mergePermissions(
50
+ { create: false },
51
+ { create: true, read: true, "reset-password": true },
52
+ );
53
+ expect(merged.create).toBe(false);
54
+ expect(merged.read).toBe(true);
55
+ expect(merged["reset-password"]).toBe(true);
56
+ });
57
+ });
@@ -4,20 +4,29 @@ import type { Session } from "../../types";
4
4
  // Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
5
5
  // production, admin-role check chạy trước empty-permissions guard) — bản
6
6
  // divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
7
- import { getCrudPermissionsFromSession } from "../../auth";
7
+ import {
8
+ checkPermission,
9
+ getActionCode,
10
+ getCrudPermissionsFromSession,
11
+ } from "../../auth";
8
12
 
9
13
  /**
10
14
  * Get CRUD permissions for a user based on their session
11
15
  * Permissions are read from session (loaded from cache in session callback)
12
- * Uses memoization to return stable object references with TTL
16
+ *
17
+ * `extraActions`: custom action ngoài 8 action CRUD chuẩn (khai trong registry,
18
+ * vd "reset-password", "resync") — mỗi cái tra checkPermission và điền vào map
19
+ * trả về, để RowAction.permissionAction gate được theo ĐÚNG action code thay vì
20
+ * mượn tạm "update". BYPASS_AUTH/admin đã xử lý bên trong checkPermission.
13
21
  */
14
22
  export async function getCrudPermissions(
15
23
  session: Session | null,
16
24
  entity: string,
25
+ extraActions?: string[],
17
26
  ): Promise<CrudPermissions> {
18
27
  // Get permissions from session (already loaded in JWT)
19
28
  const perms = getCrudPermissionsFromSession(session, entity);
20
- return {
29
+ const result: CrudPermissions = {
21
30
  create: perms.create,
22
31
  read: perms.view,
23
32
  update: perms.update,
@@ -27,12 +36,20 @@ export async function getCrudPermissions(
27
36
  approve: perms.approve,
28
37
  reject: perms.reject,
29
38
  };
39
+ for (const action of extraActions ?? []) {
40
+ if (result[action] !== undefined) continue; // 8 action chuẩn đã có
41
+ result[action] = checkPermission(session, entity, getActionCode(action));
42
+ }
43
+ return result;
30
44
  }
31
45
 
32
46
  /**
33
47
  * Merge config permissions with user permissions
34
48
  * User permissions take precedence
35
49
  * Config permissions are optional, so undefined means "allow if user has permission"
50
+ *
51
+ * Merge theo TOÀN BỘ key của userPermissions (kể cả custom action) — bản cũ
52
+ * liệt kê cứng 8 key nên custom action bị rơi mất sau merge.
36
53
  */
37
54
  export function mergePermissions(
38
55
  configPermissions: EntityConfig["permissions"],
@@ -42,14 +59,9 @@ export function mergePermissions(
42
59
  return userPermissions;
43
60
  }
44
61
 
45
- return {
46
- create: (configPermissions.create ?? true) && userPermissions.create,
47
- read: (configPermissions.read ?? true) && userPermissions.read,
48
- update: (configPermissions.update ?? true) && userPermissions.update,
49
- delete: (configPermissions.delete ?? true) && userPermissions.delete,
50
- export: (configPermissions.export ?? true) && userPermissions.export,
51
- import: (configPermissions.import ?? true) && userPermissions.import,
52
- approve: (configPermissions.approve ?? true) && userPermissions.approve,
53
- reject: (configPermissions.reject ?? true) && userPermissions.reject,
54
- };
62
+ const result: CrudPermissions = {};
63
+ for (const key of Object.keys(userPermissions)) {
64
+ result[key] = (configPermissions[key] ?? true) && userPermissions[key];
65
+ }
66
+ return result;
55
67
  }
@@ -1,4 +1,5 @@
1
1
  import { compileFilterTree } from "./filter-tree";
2
+ import { CrudRequestError } from "./errors";
2
3
  import type { CrudQueryParams, EntityConfig } from "../../types";
3
4
 
4
5
  export interface BuildListQueryOptions {
@@ -76,12 +77,18 @@ export function buildListQuery({
76
77
  }
77
78
 
78
79
  if (filterTree) {
79
- const compiled = compileFilterTree(filterTree, {
80
- isAllowed,
81
- onDisallowed: (field) => {
82
- if (onDisallowedFilter) onDisallowedFilter(field);
83
- },
84
- });
80
+ let compiled;
81
+ try {
82
+ compiled = compileFilterTree(filterTree, {
83
+ isAllowed,
84
+ onDisallowed: (field) => {
85
+ if (onDisallowedFilter) onDisallowedFilter(field);
86
+ },
87
+ });
88
+ } catch (e) {
89
+ // Cây sai cấu trúc/vượt giới hạn là lỗi CLIENT — route trả 400, không 500.
90
+ throw new CrudRequestError(e instanceof Error ? e.message : "filterTree không hợp lệ");
91
+ }
85
92
  if (compiled) {
86
93
  where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
87
94
  }
@@ -110,15 +110,18 @@ export async function EntityCrudPage({
110
110
  dictionary,
111
111
  );
112
112
 
113
- // Get user permissions for this entity if available in session or passed explicitly?
114
- // In vinhhoa/page.tsx: getCrudPermissions(session, entity) is called.
115
- // We should probably rely on `session` having what we need OR generic permissions logic.
116
- // The `mergePermissions` below merges generic config perms with USER perms.
117
- // We need user perms.
118
-
119
- // We can import getCrudPermissions from ../lib/permissions if it effectively uses session
120
113
  const { getCrudPermissions } = await import("../lib/permissions");
121
- const userPermissions = await getCrudPermissions(session, entity);
114
+ // Custom action trong rowActions (permissionAction ngoài 8 action CRUD
115
+ // chuẩn) cũng phải vào map quyền — không gom thì nút bị fail-closed ẩn
116
+ // vĩnh viễn dù user CÓ quyền trong registry.
117
+ const extraActions = (config.rowActions ?? [])
118
+ .map((a) => a.permissionAction)
119
+ .filter((a): a is string => Boolean(a));
120
+ const userPermissions = await getCrudPermissions(
121
+ session,
122
+ config.permissionResource ?? entity,
123
+ extraActions,
124
+ );
122
125
 
123
126
  const permissions = mergePermissions(config.permissions, userPermissions);
124
127
 
@@ -0,0 +1,112 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { createServerCrudService } from "./server-service";
4
+ import { CrudRequestError } from "./lib/errors";
5
+ import type { EntityConfig } from "../types";
6
+
7
+ const makeModel = () => ({
8
+ findUnique: vi.fn(async () => ({ id: 1, name: "A" })),
9
+ findMany: vi.fn(async () => []),
10
+ count: vi.fn(async () => 0),
11
+ create: vi.fn(async ({ data }: any) => data),
12
+ update: vi.fn(async () => ({ id: 1 })),
13
+ delete: vi.fn(async () => ({ id: 1 })),
14
+ deleteMany: vi.fn(async () => ({ count: 2 })),
15
+ });
16
+
17
+ const makeConfig = (over: Partial<EntityConfig> = {}): EntityConfig => ({
18
+ name: "job-title",
19
+ label: "Chức danh",
20
+ pluralLabel: "Chức danh",
21
+ apiEndpoint: "/api/crud/job-titles",
22
+ idField: "id",
23
+ displayField: "name",
24
+ fields: [
25
+ { name: "id", label: "ID", type: "text" },
26
+ { name: "name", label: "Tên", type: "text" },
27
+ ],
28
+ ...over,
29
+ });
30
+
31
+ const logger = { warn: vi.fn(), error: vi.fn() };
32
+
33
+ describe("createServerCrudService", () => {
34
+ let model: ReturnType<typeof makeModel>;
35
+ let prisma: Record<string, any>;
36
+
37
+ beforeEach(() => {
38
+ model = makeModel();
39
+ // Model chỉ tồn tại dưới tên "jobTitle" — convention từ entity key khác
40
+ // ("weird-things") sẽ trượt nếu không đọc config.modelName.
41
+ prisma = { jobTitle: model };
42
+ });
43
+
44
+ it("ưu tiên config.modelName hơn convention getModelName", async () => {
45
+ const service = createServerCrudService({ prisma, logger });
46
+ const config = makeConfig({ modelName: "jobTitle" });
47
+ await service.getById("weird-things", "x", config);
48
+ expect(model.findUnique).toHaveBeenCalled();
49
+ });
50
+
51
+ it("thiếu modelName thì fallback convention (job-titles → jobTitle)", async () => {
52
+ const service = createServerCrudService({ prisma, logger });
53
+ await service.getById("job-titles", "x", makeConfig());
54
+ expect(model.findUnique).toHaveBeenCalledWith(
55
+ expect.objectContaining({ where: { id: "x" } }),
56
+ );
57
+ });
58
+
59
+ it("where dùng config.idField thay vì hardcode id", async () => {
60
+ const service = createServerCrudService({ prisma, logger });
61
+ const config = makeConfig({ modelName: "jobTitle", idField: "code" });
62
+ await service.delete("job-titles", "JT01", config);
63
+ expect(model.delete).toHaveBeenCalledWith({ where: { code: "JT01" } });
64
+ });
65
+
66
+ it("idKind int: id trên URL được ép Number cho mọi method", async () => {
67
+ const service = createServerCrudService({ prisma, logger });
68
+ const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
69
+
70
+ await service.getById("job-titles", "42", config);
71
+ expect(model.findUnique).toHaveBeenCalledWith(
72
+ expect.objectContaining({ where: { id: 42 } }),
73
+ );
74
+
75
+ await service.update("job-titles", "42", { name: "B" }, config);
76
+ expect(model.update).toHaveBeenCalledWith(
77
+ expect.objectContaining({ where: { id: 42 } }),
78
+ );
79
+
80
+ await service.delete("job-titles", "42", config);
81
+ expect(model.delete).toHaveBeenCalledWith({ where: { id: 42 } });
82
+ });
83
+
84
+ it("idKind int: id không phải số nguyên → CrudRequestError 400", async () => {
85
+ const service = createServerCrudService({ prisma, logger });
86
+ const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
87
+ await expect(service.getById("job-titles", "abc", config)).rejects.toThrow(
88
+ CrudRequestError,
89
+ );
90
+ expect(model.findUnique).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("deleteMany: ép cả mảng id, một phần tử hỏng là fail cả request", async () => {
94
+ const service = createServerCrudService({ prisma, logger });
95
+ const config = makeConfig({ modelName: "jobTitle", idKind: "int" });
96
+
97
+ await service.deleteMany("job-titles", ["1", "2"], config);
98
+ expect(model.deleteMany).toHaveBeenCalledWith({ where: { id: { in: [1, 2] } } });
99
+
100
+ await expect(
101
+ service.deleteMany("job-titles", ["1", "x"], config),
102
+ ).rejects.toThrow(CrudRequestError);
103
+ expect(model.deleteMany).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it("idKind mặc định: id giữ nguyên chuỗi (UUID)", async () => {
107
+ const service = createServerCrudService({ prisma, logger });
108
+ const config = makeConfig({ modelName: "jobTitle" });
109
+ await service.delete("job-titles", "uuid-1", config);
110
+ expect(model.delete).toHaveBeenCalledWith({ where: { id: "uuid-1" } });
111
+ });
112
+ });
@@ -7,16 +7,15 @@
7
7
  // lives here so every app stops re-implementing it.
8
8
  //
9
9
  // Usage (app side):
10
- // import { createServerCrudService, getModelName } from "@goerp/core/crud/server";
10
+ // import { createServerCrudService } from "@goerp/core/crud/server";
11
11
  // import { prisma } from "@/lib/prisma";
12
- // const MODEL_MAP = { customers: "customer", "fee-schedules": "feeSchedule" };
13
- // export const crudService = createServerCrudService({
14
- // prisma,
15
- // getModelName: (e) => getModelName(e, MODEL_MAP),
16
- // });
12
+ // export const crudService = createServerCrudService({ prisma });
13
+ // Tên model Prisma khai ngay trong EntityConfig (`modelName: "feeSchedule"`),
14
+ // không cần MODEL_MAP riêng; thiếu modelName thì fallback convention getModelName.
17
15
 
18
16
  import { buildListQuery } from "./lib/query-builder";
19
17
  import { prepareMutationData } from "./lib/mutation-builder";
18
+ import { CrudRequestError } from "./lib/errors";
20
19
  import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
21
20
  import { serializeDecimalFields } from "../utils/serialize";
22
21
 
@@ -24,14 +23,16 @@ type PrismaLike = Record<string, any>;
24
23
 
25
24
  export interface ServerCrudService {
26
25
  list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
27
- /** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id. */
26
+ /** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id.
27
+ * Mọi method nhận id dạng chuỗi thô từ URL; entity `idKind: "int"` được ép
28
+ * Number bên trong (sai định dạng → {@link CrudRequestError} 400). */
28
29
  getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
29
30
  /** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
30
31
  * advisory-lock (document-number yêu cầu cùng tx với insert). */
31
32
  create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
32
33
  update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
33
- delete(entity: string, id: string): Promise<any>;
34
- deleteMany(entity: string, ids: string[]): Promise<any>;
34
+ delete(entity: string, id: string, config?: EntityConfig): Promise<any>;
35
+ deleteMany(entity: string, ids: string[], config?: EntityConfig): Promise<any>;
35
36
  }
36
37
 
37
38
  export interface ServerCrudLogger {
@@ -79,13 +80,31 @@ const DEFAULT_SYSTEM_FIELDS = [
79
80
 
80
81
  const MAX_PAGE_SIZE = 200;
81
82
 
82
- // Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
83
- // irregular names pass a map: getModelName(entity, { "fee-schedules": "feeSchedule" }).
83
+ // Default plural→model resolver: strip trailing "s", camelCase kebab. Ưu tiên
84
+ // khai `modelName` ngay trong EntityConfig; map đây chỉ còn cho chỗ gọi cũ
85
+ // chưa có config trong tay.
84
86
  export function getModelName(entity: string, map?: Record<string, string>): string {
85
87
  if (map && map[entity]) return map[entity];
86
88
  return entity.replace(/s$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
87
89
  }
88
90
 
91
+ // Ép id từ URL theo idKind của entity — "abc" vào cột Int phải trả 400 rõ
92
+ // ràng thay vì để Prisma nổ 500.
93
+ function coerceIdParam(
94
+ entity: string,
95
+ config: EntityConfig | undefined,
96
+ id: string,
97
+ ): string | number {
98
+ if (config?.idKind !== "int") return id;
99
+ const num = Number(id);
100
+ if (!Number.isInteger(num)) {
101
+ throw new CrudRequestError(
102
+ `Id không hợp lệ cho "${entity}": "${id}" (idKind=int cần số nguyên)`,
103
+ );
104
+ }
105
+ return num;
106
+ }
107
+
89
108
  // Opt-in audit-name resolver. Overwrites createdByName/updatedByName from a User
90
109
  // model. `nameField` handles schema drift (vinhhoa: "name", wu: "fullName").
91
110
  export function createAuditUserNameResolver(opts: {
@@ -130,18 +149,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
130
149
 
131
150
  const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
132
151
 
133
- const model = (entity: string, client: PrismaLike = prisma) => {
134
- const name = resolveModel(entity);
152
+ // config.modelName thắng convention/MODEL_MAP một registry duy nhất
153
+ // entity config, app không phải nuôi map song song.
154
+ const model = (entity: string, config?: EntityConfig, client: PrismaLike = prisma) => {
155
+ const name = config?.modelName ?? resolveModel(entity);
135
156
  const m = client[name];
136
157
  if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
137
158
  return m;
138
159
  };
139
160
 
140
-
141
-
142
161
  return {
143
162
  async list(entity, config, params) {
144
- const prismaModel = model(entity);
163
+ const prismaModel = model(entity, config);
145
164
 
146
165
  const {
147
166
  where,
@@ -174,12 +193,13 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
174
193
  },
175
194
 
176
195
  async getById(entity, id, config) {
177
- const prismaModel = model(entity);
196
+ const prismaModel = model(entity, config);
178
197
  // Áp cùng include như list — detail dialog hiện label relation thay raw FK id.
179
198
  const include: any = {};
180
199
  if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
181
200
  const includeOption = Object.keys(include).length ? { include } : {};
182
- const result = await prismaModel.findUnique({ where: { id }, ...includeOption });
201
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
202
+ const result = await prismaModel.findUnique({ where, ...includeOption });
183
203
  if (!result) return null;
184
204
  const serialized = serializeDecimalFields(result);
185
205
  const [resolved] = await resolveAuditNames([serialized]);
@@ -187,7 +207,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
187
207
  },
188
208
 
189
209
  async create(entity, data, config, tx) {
190
- const prismaModel = model(entity, tx ?? prisma);
210
+ const prismaModel = model(entity, config, tx ?? prisma);
191
211
  try {
192
212
  const prismaData = prepareMutationData({
193
213
  data,
@@ -206,7 +226,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
206
226
  },
207
227
 
208
228
  async update(entity, id, data, config, tx) {
209
- const prismaModel = model(entity, tx ?? prisma);
229
+ const prismaModel = model(entity, config, tx ?? prisma);
230
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
210
231
  try {
211
232
  const prismaData = prepareMutationData({
212
233
  data,
@@ -216,27 +237,31 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
216
237
  relationFieldSkip: deps.relationFieldSkip,
217
238
  logger: log,
218
239
  });
219
- return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
240
+ return serializeDecimalFields(await prismaModel.update({ where, data: prismaData }));
220
241
  } catch (error) {
221
242
  log.error(`Error updating ${entity}:`, error);
222
243
  throw error;
223
244
  }
224
245
  },
225
246
 
226
- async delete(entity, id) {
227
- const prismaModel = model(entity);
247
+ async delete(entity, id, config) {
248
+ const prismaModel = model(entity, config);
249
+ const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
228
250
  try {
229
- return await prismaModel.delete({ where: { id } });
251
+ return await prismaModel.delete({ where });
230
252
  } catch (error) {
231
253
  log.error(`Error deleting ${entity}:`, error);
232
254
  throw error;
233
255
  }
234
256
  },
235
257
 
236
- async deleteMany(entity, ids) {
237
- const prismaModel = model(entity);
258
+ async deleteMany(entity, ids, config) {
259
+ const prismaModel = model(entity, config);
260
+ // Ép cả mảng — một phần tử sai định dạng là 400 cả request, không xóa nửa vời.
261
+ const idField = config?.idField ?? "id";
262
+ const where = { [idField]: { in: ids.map((id) => coerceIdParam(entity, config, id)) } };
238
263
  try {
239
- return await prismaModel.deleteMany({ where: { id: { in: ids } } });
264
+ return await prismaModel.deleteMany({ where });
240
265
  } catch (error) {
241
266
  log.error(`Error deleting many ${entity}:`, error);
242
267
  throw error;
@@ -22,6 +22,16 @@ export type {
22
22
  export {
23
23
  createCrudCollectionHandlers,
24
24
  createCrudItemHandlers,
25
+ createCrudExportHandler,
26
+ createCrudImportHandler,
25
27
  } from './crud-route-handlers'
26
- export type { CrudHandlerDeps } from './crud-route-handlers'
28
+ export type {
29
+ CrudHandlerDeps,
30
+ CrudExportHandlerDeps,
31
+ CrudImportHandlerDeps,
32
+ } from './crud-route-handlers'
33
+ export { readImportRows, coerceBooleanCell } from './lib/import-request'
27
34
  export { compileFilterTree, conditionForOperator, type FilterTreeLeaf, type FilterTreeNode } from "./lib/filter-tree";
35
+ export { CrudRequestError } from "./lib/errors";
36
+ export { coerceFieldValue } from "./lib/coerce";
37
+ export { getEntityEndpoints } from "./lib/entity-endpoints";
@@ -72,12 +72,13 @@ export const designRules: GuardrailRule[] = [
72
72
 
73
73
  {
74
74
  id: "design/list-table-uses-kit",
75
- title: "bảng mới trong app/ dùng DataTableWrapper, không tự dựng <Table>",
75
+ title:
76
+ "bảng mới trong app/ dùng DataTable (@goerp/core/ui — import { DataTable }), không tự dựng <Table>",
76
77
  why:
77
78
  "Bảng tự dựng lại thiếu một trong: header dính, phân trang, trạng thái " +
78
79
  "rỗng, hàng bấm được, footer tổng. Mỗi bảng thiếu một thứ khác nhau, và " +
79
80
  "người dùng học lại thao tác ở mỗi trang.",
80
- fix: "Dùng `DataTableWrapper` + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
81
+ fix: "Dùng `DataTable` (@goerp/core/ui — `import { DataTable }`) + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
81
82
  run(ctx) {
82
83
  const allowed = new Set(ctx.options.allowlists["design/list-table-uses-kit"] ?? []);
83
84
  const handRolled = ctx.files
@@ -108,6 +108,22 @@
108
108
  --radius-md: calc(var(--radius) - 2px);
109
109
  --radius-sm: calc(var(--radius) - 4px);
110
110
 
111
+ /* Màn trạng thái (404/403/400/lỗi): hình và chữ nổi lên MỘT lần lúc mở.
112
+ Dùng kèm `motion-safe:` — người bật "giảm chuyển động" thấy trang tĩnh. */
113
+ --animate-status-in: status-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
114
+
115
+ @keyframes status-in {
116
+ from {
117
+ opacity: 0;
118
+ transform: translateY(6px);
119
+ }
120
+
121
+ to {
122
+ opacity: 1;
123
+ transform: none;
124
+ }
125
+ }
126
+
111
127
  --animate-accordion-down: accordion-down 0.2s ease-out;
112
128
  --animate-accordion-up: accordion-up 0.2s ease-out;
113
129
  --animate-collapsible-down: collapsible-down 0.2s ease-out;
@@ -302,7 +302,28 @@ export interface EntityConfig {
302
302
  */
303
303
  permissionResource?: string;
304
304
  apiEndpoint: string;
305
+ /**
306
+ * Endpoint import/export. Không khai thì suy từ apiEndpoint theo CÙNG MỘT
307
+ * quy tắc: `${base}/import|/export` (giữ nguyên query params nếu có) —
308
+ * xem getEntityEndpoints ở crud/lib/entity-endpoints.
309
+ */
310
+ endpoints?: {
311
+ import?: string;
312
+ export?: string;
313
+ };
314
+ /**
315
+ * Tên model Prisma (camelCase, vd "jobTitle"). Không khai thì engine suy
316
+ * theo convention từ entity key (bỏ "s" cuối + camelCase kebab) — khai ở
317
+ * đây thay cho MODEL_MAP riêng phía app.
318
+ */
319
+ modelName?: string;
305
320
  idField: string;
321
+ /**
322
+ * Kiểu khóa chính. "string" (default): create thiếu id sẽ tự sinh UUID.
323
+ * "int": KHÔNG BAO GIỜ tự sinh (DB autoincrement), id trên URL được ép
324
+ * Number — sai định dạng trả 400.
325
+ */
326
+ idKind?: "string" | "int";
306
327
  displayField: string;
307
328
  fields: FieldConfig[];
308
329
  filters?: FilterConfig[];
@@ -345,19 +366,32 @@ export interface FormModeConfig {
345
366
  sheetSide?: "top" | "right" | "bottom" | "left";
346
367
  }
347
368
 
369
+ /** Các action built-in có icon + hành vi mặc định trong CrudPage/CrudRowActions. */
370
+ export type BuiltinRowActionKind =
371
+ | "copy"
372
+ | "duplicate"
373
+ | "view"
374
+ | "archive"
375
+ | "custom"
376
+ | "approve"
377
+ | "reject"
378
+ | "set-default"
379
+ | "view-history";
380
+
348
381
  export interface RowAction {
349
382
  label: string;
350
383
  icon?: string;
351
- action:
352
- | "copy"
353
- | "duplicate"
354
- | "view"
355
- | "archive"
356
- | "custom"
357
- | "approve"
358
- | "reject"
359
- | "set-default"
360
- | "view-history";
384
+ /**
385
+ * Built-in giữ hành vi/icon cũ; chuỗi tự do = action tự khai — đi kèm
386
+ * `urlTemplate` (dispatcher HTTP của CrudPage) hoặc handler app-side.
387
+ * `(string & {})` để vẫn gợi ý được literal built-in.
388
+ */
389
+ action: BuiltinRowActionKind | (string & {});
390
+ /**
391
+ * handler/visibleWhen/transformData KHÔNG sống sót qua serializeConfig
392
+ * (RSC → client) — chỉ dùng khi config đi thẳng vào CrudPage phía client.
393
+ * Cần action serialize an toàn thì dùng bộ urlTemplate/method bên dưới.
394
+ */
361
395
  handler?: (
362
396
  rowId: string,
363
397
  rowData: Record<string, unknown>,
@@ -372,6 +406,24 @@ export interface RowAction {
372
406
  | "link";
373
407
  excludeFields?: string[];
374
408
  transformData?: (rowData: Record<string, unknown>) => Record<string, unknown>;
409
+
410
+ // ── Biến thể HTTP thuần data (serialize qua RSC được) ──
411
+ /** URL gọi khi bấm — "{id}" được thay bằng id dòng, vd "/api/employees/{id}/reset-password". */
412
+ urlTemplate?: string;
413
+ /** Method cho urlTemplate. Default: POST. */
414
+ method?: "POST" | "PATCH" | "DELETE";
415
+ /** Có khai thì window.confirm trước khi gọi. */
416
+ confirmMessage?: string;
417
+ /** Toast khi thành công. Không khai thì dùng message "updated" chung. */
418
+ successMessage?: string;
419
+ /**
420
+ * Gate hiển thị + thực thi theo action code trong quyền. Nhận cả 8 action
421
+ * CRUD chuẩn ("update", "approve"…) lẫn CUSTOM ACTION khai trong registry
422
+ * ("reset-password", "resync"…) — EntityCrudPage tự gom các permissionAction
423
+ * custom trong rowActions và hỏi checkPermission cho từng cái. Fail-closed:
424
+ * action không có trong map quyền thì nút bị chặn.
425
+ */
426
+ permissionAction?: string;
375
427
  }
376
428
 
377
429
  // ============================================================================
@@ -402,6 +454,13 @@ export interface FieldConfig {
402
454
  name: string;
403
455
  label: string;
404
456
  type: FieldType;
457
+ /**
458
+ * Kiểu GIÁ TRỊ thô của field khi kiểu cột không suy được từ `type` — cột FK
459
+ * Int autoincrement hiện qua select/multiselect/relation: form và URL chở
460
+ * chuỗi "3", khai `valueType: "int"` để coercion tập trung (coerceFieldValue)
461
+ * ép Number trước khi vào Prisma. Default: "string" (giữ nguyên).
462
+ */
463
+ valueType?: "string" | "int";
405
464
  required?: boolean;
406
465
  defaultValue?: unknown;
407
466
  placeholder?: string;
@@ -658,6 +717,12 @@ export interface CrudPermissions {
658
717
  import?: boolean;
659
718
  approve?: boolean;
660
719
  reject?: boolean;
720
+ /**
721
+ * Custom action từ registry (vd "reset-password") — getCrudPermissions nhận
722
+ * danh sách extraActions và điền vào đây; RowAction.permissionAction tra
723
+ * thẳng map này, thiếu key = false (fail-closed).
724
+ */
725
+ [action: string]: boolean | undefined;
661
726
  }
662
727
 
663
728
  export interface CrudFeatures {