@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.
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/branding/__tests__/identity.test.ts +101 -0
- package/src/branding/__tests__/logo-route.test.ts +143 -0
- package/src/branding/identity.ts +74 -0
- package/src/branding/index.ts +36 -0
- package/src/branding/logo-route.ts +249 -0
- package/src/branding/pwa.tsx +146 -0
- package/src/branding/types.ts +45 -0
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/styles/base.css +16 -0
- package/src/types/index.ts +75 -10
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +80 -87
- package/src/ui/errors/index.ts +6 -1
- package/src/ui/errors/not-found-view.tsx +89 -16
- package/src/ui/errors/status-page.tsx +132 -0
- package/src/ui/layout/logo.tsx +5 -0
- package/src/ui/pages/not-found.tsx +11 -27
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/next.config.mjs +8 -0
- package/templates/starter-app/package.json +1 -0
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
- package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
- package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +3 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +1 -2
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +7 -5
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +38 -11
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +4 -4
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +5 -5
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +3 -1
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +13 -5
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +3 -2
- package/templates/starter-app/src/app/[lang]/layout.tsx +27 -2
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +5 -5
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +1 -1
- package/templates/starter-app/src/app/api/security/sessions/route.ts +2 -2
- package/templates/starter-app/src/app/api/system-categories/route.ts +39 -14
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +32 -14
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +20 -12
- package/templates/starter-app/src/app/api/users/route.ts +11 -9
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/layout.tsx +42 -1
- package/templates/starter-app/src/app/manifest.ts +23 -19
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +1 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +2 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +15 -2
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +1 -1
- package/templates/starter-app/src/configs/tenant.ts +6 -1
- package/templates/starter-app/src/data/dictionary.ts +2 -1
- package/templates/starter-app/src/data/navigations.ts +12 -2
- package/templates/starter-app/src/lib/action-guard.ts +3 -4
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -12
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +13 -5
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +13 -2
- package/templates/starter-app/src/providers/theme-provider.tsx +4 -3
- package/templates/starter-app/src/proxy.ts +3 -1
- package/templates/starter-app/src/server/services/company-service.ts +4 -2
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +2 -2
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +8 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// @vitest-environment node
|
|
3
|
+
// Route handler thuần Request/Response — PHẢI chạy env node: jsdom ghi đè
|
|
4
|
+
// global File làm chính parser multipart của undici (req.formData) assert chết.
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
createCrudCollectionHandlers,
|
|
8
|
+
createCrudExportHandler,
|
|
9
|
+
createCrudImportHandler,
|
|
10
|
+
createCrudItemHandlers,
|
|
11
|
+
} from "./crud-route-handlers";
|
|
12
|
+
import { CrudRequestError } from "./lib/errors";
|
|
13
|
+
import type { ServerCrudService } from "./server-service";
|
|
14
|
+
import type { EntityConfig } from "../types";
|
|
15
|
+
|
|
16
|
+
// Admin session → getCrudPermissionsFromSession trả full quyền, test không
|
|
17
|
+
// phụ thuộc permission map.
|
|
18
|
+
const session = { user: { id: "u1", roles: ["SUPER_ADMIN"] } };
|
|
19
|
+
|
|
20
|
+
const config: EntityConfig = {
|
|
21
|
+
name: "job-title",
|
|
22
|
+
label: "Chức danh",
|
|
23
|
+
pluralLabel: "Chức danh",
|
|
24
|
+
apiEndpoint: "/api/crud/job-titles",
|
|
25
|
+
idField: "id",
|
|
26
|
+
displayField: "name",
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: "id", label: "ID", type: "text" },
|
|
29
|
+
{ name: "name", label: "Tên", type: "text", filterable: true },
|
|
30
|
+
{ name: "level", label: "Cấp", type: "number", filterable: true },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const makeService = (): ServerCrudService => ({
|
|
35
|
+
list: vi.fn(async () => ({ data: [], total: 0, page: 1, pageSize: 10 })),
|
|
36
|
+
getById: vi.fn(async () => ({ id: "1" })),
|
|
37
|
+
create: vi.fn(async (_e, d) => d),
|
|
38
|
+
update: vi.fn(async () => ({})),
|
|
39
|
+
delete: vi.fn(async () => ({})),
|
|
40
|
+
deleteMany: vi.fn(async () => ({})),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const ctx = (entity: string, id?: string) => ({
|
|
44
|
+
params: Promise.resolve(id === undefined ? { entity } : { entity, id }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("createCrudCollectionHandlers GET", () => {
|
|
48
|
+
let service: ServerCrudService;
|
|
49
|
+
let GET: ReturnType<typeof createCrudCollectionHandlers>["GET"];
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
service = makeService();
|
|
53
|
+
({ GET } = createCrudCollectionHandlers({
|
|
54
|
+
getSession: () => session,
|
|
55
|
+
getEntityConfig: () => config,
|
|
56
|
+
service,
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("filters không phải JSON → 400 (không rơi xuống 500)", async () => {
|
|
61
|
+
const req = new Request(
|
|
62
|
+
"http://t/api/crud/job-titles?filters=%7Bnot-json",
|
|
63
|
+
);
|
|
64
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
65
|
+
expect(res.status).toBe(400);
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
expect(body.error).toContain("filters");
|
|
68
|
+
expect(service.list).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("filterTree không phải JSON → 400", async () => {
|
|
72
|
+
const req = new Request(
|
|
73
|
+
"http://t/api/crud/job-titles?filterTree=%5Bbroken",
|
|
74
|
+
);
|
|
75
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
76
|
+
expect(res.status).toBe(400);
|
|
77
|
+
const body = await res.json();
|
|
78
|
+
expect(body.error).toContain("filterTree");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("giá trị filter được ép kiểu theo field config (number)", async () => {
|
|
82
|
+
const filters = JSON.stringify([
|
|
83
|
+
{ name: "level", operator: "eq", value: "5" },
|
|
84
|
+
]);
|
|
85
|
+
const req = new Request(
|
|
86
|
+
`http://t/api/crud/job-titles?filters=${encodeURIComponent(filters)}`,
|
|
87
|
+
);
|
|
88
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
89
|
+
expect(res.status).toBe(200);
|
|
90
|
+
const params = (service.list as any).mock.calls[0][2];
|
|
91
|
+
expect(params.filters).toEqual([
|
|
92
|
+
{ name: "level", operator: "eq", value: 5 },
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("filterTree hợp lệ được parse và truyền xuống service.list", async () => {
|
|
97
|
+
const tree = JSON.stringify({
|
|
98
|
+
$and: [{ field: "name", operator: "contains", value: "a" }],
|
|
99
|
+
});
|
|
100
|
+
const req = new Request(
|
|
101
|
+
`http://t/api/crud/job-titles?filterTree=${encodeURIComponent(tree)}`,
|
|
102
|
+
);
|
|
103
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
104
|
+
expect(res.status).toBe(200);
|
|
105
|
+
const params = (service.list as any).mock.calls[0][2];
|
|
106
|
+
expect(params.filterTree).toEqual({
|
|
107
|
+
$and: [{ field: "name", operator: "contains", value: "a" }],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("createCrudItemHandlers", () => {
|
|
113
|
+
it("service ném CrudRequestError → map ra status của lỗi (400)", async () => {
|
|
114
|
+
const service = makeService();
|
|
115
|
+
(service.delete as any).mockRejectedValue(
|
|
116
|
+
new CrudRequestError('Id không hợp lệ cho "job-titles": "abc" (idKind=int cần số nguyên)'),
|
|
117
|
+
);
|
|
118
|
+
const { DELETE } = createCrudItemHandlers({
|
|
119
|
+
getSession: () => session,
|
|
120
|
+
getEntityConfig: () => config,
|
|
121
|
+
service,
|
|
122
|
+
});
|
|
123
|
+
const req = new Request("http://t/api/crud/job-titles/abc", {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
});
|
|
126
|
+
const res = await DELETE(req, ctx("job-titles", "abc") as any);
|
|
127
|
+
expect(res.status).toBe(400);
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
expect(body.error).toContain("Id không hợp lệ");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("DELETE truyền config xuống service (idField/idKind/modelName)", async () => {
|
|
133
|
+
const service = makeService();
|
|
134
|
+
const { DELETE } = createCrudItemHandlers({
|
|
135
|
+
getSession: () => session,
|
|
136
|
+
getEntityConfig: () => config,
|
|
137
|
+
service,
|
|
138
|
+
});
|
|
139
|
+
const req = new Request("http://t/api/crud/job-titles/1", {
|
|
140
|
+
method: "DELETE",
|
|
141
|
+
});
|
|
142
|
+
const res = await DELETE(req, ctx("job-titles", "1") as any);
|
|
143
|
+
expect(res.status).toBe(204);
|
|
144
|
+
expect(service.delete).toHaveBeenCalledWith("job-titles", "1", config);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("createCrudExportHandler", () => {
|
|
149
|
+
const staffNoExport = {
|
|
150
|
+
user: { id: "u2", permissionMap: { "job-title": ["view"] } },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
it("thiếu quyền export → 403", async () => {
|
|
154
|
+
const { GET } = createCrudExportHandler({
|
|
155
|
+
getSession: () => staffNoExport,
|
|
156
|
+
getEntityConfig: () => config,
|
|
157
|
+
service: makeService(),
|
|
158
|
+
});
|
|
159
|
+
const req = new Request("http://t/api/crud/job-titles/export");
|
|
160
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
161
|
+
expect(res.status).toBe(403);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("format lạ → 400", async () => {
|
|
165
|
+
const { GET } = createCrudExportHandler({
|
|
166
|
+
getSession: () => session,
|
|
167
|
+
getEntityConfig: () => config,
|
|
168
|
+
service: makeService(),
|
|
169
|
+
});
|
|
170
|
+
const req = new Request("http://t/api/crud/job-titles/export?format=pdf");
|
|
171
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("json: kéo đủ các trang qua service.list, tôn trọng filter/sort", async () => {
|
|
176
|
+
const service = makeService();
|
|
177
|
+
// Trang 1 đầy (2 dòng = pageSize), trang 2 vơi → dừng.
|
|
178
|
+
(service.list as any)
|
|
179
|
+
.mockResolvedValueOnce({ data: [{ id: "1" }, { id: "2" }], total: 3, page: 1, pageSize: 2 })
|
|
180
|
+
.mockResolvedValueOnce({ data: [{ id: "3" }], total: 3, page: 2, pageSize: 2 });
|
|
181
|
+
const { GET } = createCrudExportHandler({
|
|
182
|
+
getSession: () => session,
|
|
183
|
+
getEntityConfig: () => config,
|
|
184
|
+
service,
|
|
185
|
+
pageSize: 2,
|
|
186
|
+
});
|
|
187
|
+
const filters = encodeURIComponent(
|
|
188
|
+
JSON.stringify([{ name: "level", operator: "eq", value: "3" }]),
|
|
189
|
+
);
|
|
190
|
+
const req = new Request(
|
|
191
|
+
`http://t/api/crud/job-titles/export?format=json&sortField=name&sortDirection=desc&filters=${filters}`,
|
|
192
|
+
);
|
|
193
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
expect(res.headers.get("content-disposition")).toContain(".json");
|
|
196
|
+
const rows = JSON.parse(await res.text());
|
|
197
|
+
expect(rows.map((r: any) => r.id)).toEqual(["1", "2", "3"]);
|
|
198
|
+
// Filter chuỗi "3" đã ép number theo field type trước khi vào engine.
|
|
199
|
+
expect((service.list as any).mock.calls[0][2].filters[0].value).toBe(3);
|
|
200
|
+
expect((service.list as any).mock.calls[0][2].sort).toEqual({
|
|
201
|
+
field: "name",
|
|
202
|
+
direction: "desc",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("csv: có BOM cho Excel + đúng content-type", async () => {
|
|
207
|
+
const service = makeService();
|
|
208
|
+
(service.list as any).mockResolvedValueOnce({
|
|
209
|
+
data: [{ name: "Kỹ sư" }],
|
|
210
|
+
total: 1, page: 1, pageSize: 500,
|
|
211
|
+
});
|
|
212
|
+
const { GET } = createCrudExportHandler({
|
|
213
|
+
getSession: () => session,
|
|
214
|
+
getEntityConfig: () => config,
|
|
215
|
+
service,
|
|
216
|
+
});
|
|
217
|
+
const req = new Request("http://t/api/crud/job-titles/export?format=csv");
|
|
218
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
219
|
+
// Response.text() strip BOM khi decode — soi bytes thô để chắc BOM đã gửi.
|
|
220
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
221
|
+
expect([bytes[0], bytes[1], bytes[2]]).toEqual([0xef, 0xbb, 0xbf]);
|
|
222
|
+
const text = new TextDecoder().decode(bytes);
|
|
223
|
+
expect(text).toContain("Kỹ sư");
|
|
224
|
+
expect(res.headers.get("content-type")).toContain("text/csv");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("maxRows cắt bớt dòng thừa", async () => {
|
|
228
|
+
const service = makeService();
|
|
229
|
+
(service.list as any).mockResolvedValue({
|
|
230
|
+
data: [{ id: "a" }, { id: "b" }],
|
|
231
|
+
total: 100, page: 1, pageSize: 2,
|
|
232
|
+
});
|
|
233
|
+
const { GET } = createCrudExportHandler({
|
|
234
|
+
getSession: () => session,
|
|
235
|
+
getEntityConfig: () => config,
|
|
236
|
+
service,
|
|
237
|
+
pageSize: 2,
|
|
238
|
+
maxRows: 3,
|
|
239
|
+
});
|
|
240
|
+
const req = new Request("http://t/api/crud/job-titles/export?format=json");
|
|
241
|
+
const res = await GET(req, ctx("job-titles") as any);
|
|
242
|
+
const rows = JSON.parse(await res.text());
|
|
243
|
+
expect(rows).toHaveLength(3);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("createCrudImportHandler", () => {
|
|
248
|
+
it("thiếu quyền import → 403", async () => {
|
|
249
|
+
const { POST } = createCrudImportHandler({
|
|
250
|
+
getSession: () => ({ user: { id: "u2", permissionMap: { "job-title": ["view"] } } }),
|
|
251
|
+
getEntityConfig: () => config,
|
|
252
|
+
service: makeService(),
|
|
253
|
+
});
|
|
254
|
+
const req = new Request("http://t/api/crud/job-titles/import", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "content-type": "application/json" },
|
|
257
|
+
body: "[]",
|
|
258
|
+
});
|
|
259
|
+
const res = await POST(req, ctx("job-titles") as any);
|
|
260
|
+
expect(res.status).toBe(403);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("JSON array: mỗi dòng qua service.create, header label dịch về tên cột", async () => {
|
|
264
|
+
const service = makeService();
|
|
265
|
+
const { POST } = createCrudImportHandler({
|
|
266
|
+
getSession: () => session,
|
|
267
|
+
getEntityConfig: () => config,
|
|
268
|
+
service,
|
|
269
|
+
});
|
|
270
|
+
const req = new Request("http://t/api/crud/job-titles/import", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify([{ "Tên": "Kỹ sư", "Cấp": "2" }]),
|
|
274
|
+
});
|
|
275
|
+
const res = await POST(req, ctx("job-titles") as any);
|
|
276
|
+
expect(res.status).toBe(200);
|
|
277
|
+
const body = await res.json();
|
|
278
|
+
expect(body).toMatchObject({ success: true, imported: 1, failed: 0 });
|
|
279
|
+
expect((service.create as any).mock.calls[0][1]).toEqual({
|
|
280
|
+
name: "Kỹ sư",
|
|
281
|
+
level: "2",
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("dòng lỗi không chặn dòng sau — gom vào errors", async () => {
|
|
286
|
+
const service = makeService();
|
|
287
|
+
(service.create as any)
|
|
288
|
+
.mockRejectedValueOnce(new Error("Tên đã tồn tại"))
|
|
289
|
+
.mockResolvedValueOnce({});
|
|
290
|
+
const { POST } = createCrudImportHandler({
|
|
291
|
+
getSession: () => session,
|
|
292
|
+
getEntityConfig: () => config,
|
|
293
|
+
service,
|
|
294
|
+
});
|
|
295
|
+
const req = new Request("http://t/api/crud/job-titles/import", {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "content-type": "application/json" },
|
|
298
|
+
body: JSON.stringify([{ name: "A" }, { name: "B" }]),
|
|
299
|
+
});
|
|
300
|
+
const res = await POST(req, ctx("job-titles") as any);
|
|
301
|
+
const body = await res.json();
|
|
302
|
+
expect(body.success).toBe(false);
|
|
303
|
+
expect(body.imported).toBe(1);
|
|
304
|
+
expect(body.failed).toBe(1);
|
|
305
|
+
expect(body.errors[0]).toMatchObject({ row: 1, message: "Tên đã tồn tại" });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("multipart {file, format} từ ImportDialog: parse tại server", async () => {
|
|
309
|
+
const service = makeService();
|
|
310
|
+
const { POST } = createCrudImportHandler({
|
|
311
|
+
getSession: () => session,
|
|
312
|
+
getEntityConfig: () => config,
|
|
313
|
+
service,
|
|
314
|
+
});
|
|
315
|
+
// Dựng multipart thô — File/FormData của môi trường test (jsdom) khác
|
|
316
|
+
// realm với undici Request, đưa thẳng vào body sẽ hỏng serialize.
|
|
317
|
+
const boundary = "----vitest-boundary";
|
|
318
|
+
const body =
|
|
319
|
+
`--${boundary}\r\n` +
|
|
320
|
+
`Content-Disposition: form-data; name="file"; filename="rows.json"\r\n` +
|
|
321
|
+
`Content-Type: application/json\r\n\r\n` +
|
|
322
|
+
JSON.stringify([{ name: "Từ file" }]) +
|
|
323
|
+
`\r\n--${boundary}\r\n` +
|
|
324
|
+
`Content-Disposition: form-data; name="format"\r\n\r\n` +
|
|
325
|
+
`json\r\n--${boundary}--\r\n`;
|
|
326
|
+
const req = new Request("http://t/api/crud/job-titles/import", {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
329
|
+
body,
|
|
330
|
+
});
|
|
331
|
+
const res = await POST(req, ctx("job-titles") as any);
|
|
332
|
+
expect(res.status).toBe(200);
|
|
333
|
+
const result = await res.json();
|
|
334
|
+
expect(result.imported).toBe(1);
|
|
335
|
+
expect((service.create as any).mock.calls[0][1]).toEqual({ name: "Từ file" });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("body không phải array → 400", async () => {
|
|
339
|
+
const { POST } = createCrudImportHandler({
|
|
340
|
+
getSession: () => session,
|
|
341
|
+
getEntityConfig: () => config,
|
|
342
|
+
service: makeService(),
|
|
343
|
+
});
|
|
344
|
+
const req = new Request("http://t/api/crud/job-titles/import", {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "content-type": "application/json" },
|
|
347
|
+
body: JSON.stringify({ not: "array" }),
|
|
348
|
+
});
|
|
349
|
+
const res = await POST(req, ctx("job-titles") as any);
|
|
350
|
+
expect(res.status).toBe(400);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
// // src/app/api/crud/[entity]/[id]/route.ts
|
|
14
14
|
// export const { GET, PUT, PATCH, DELETE } = createCrudItemHandlers({ getSession, getEntityConfig, service: crudService });
|
|
15
15
|
|
|
16
|
-
import type { EntityConfig } from "../types";
|
|
16
|
+
import type { ActiveFilter, EntityConfig, ImportResult } from "../types";
|
|
17
|
+
import { toCsv } from "../export/to-csv";
|
|
18
|
+
import { coerceFieldValue } from "./lib/coerce";
|
|
19
|
+
import { CrudRequestError } from "./lib/errors";
|
|
20
|
+
import { exportToJSON, exportToXLSX } from "./lib/import-export-service";
|
|
21
|
+
import { readImportRows } from "./lib/import-request";
|
|
17
22
|
import { getCrudPermissions } from "./lib/permissions";
|
|
18
23
|
import type { ServerCrudService } from "./server-service";
|
|
19
24
|
|
|
@@ -42,13 +47,46 @@ function makeServiceResolver(
|
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
const json = (data: unknown, status = 200) =>
|
|
45
|
-
new Response(JSON.stringify(data), {
|
|
50
|
+
new Response(JSON.stringify(data), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { "content-type": "application/json" },
|
|
53
|
+
});
|
|
46
54
|
|
|
47
55
|
const unauthorized = () => json({ error: "Unauthorized" }, 401);
|
|
48
56
|
const forbidden = () => json({ error: "Forbidden" }, 403);
|
|
49
57
|
const unknownEntity = () => json({ error: "Unknown entity" }, 404);
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
// JSON hỏng trên query param là lỗi CLIENT → CrudRequestError để catch chung
|
|
60
|
+
// trả 400 (trước đây JSON.parse ném thẳng → 500 khó hiểu).
|
|
61
|
+
function parseJsonParam(raw: string | null, name: string): unknown {
|
|
62
|
+
if (!raw) return undefined;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new CrudRequestError(`Query param "${name}" không phải JSON hợp lệ`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Ép giá trị filter theo kiểu field trong config — URL chỉ chở chuỗi, cột
|
|
71
|
+
// Int/Boolean/DateTime cần giá trị đúng kiểu trước khi vào Prisma.
|
|
72
|
+
function coerceFilters(
|
|
73
|
+
config: EntityConfig,
|
|
74
|
+
filters: unknown,
|
|
75
|
+
): ActiveFilter[] | undefined {
|
|
76
|
+
if (!Array.isArray(filters)) return undefined;
|
|
77
|
+
return filters.map((filter) => {
|
|
78
|
+
const f = filter as ActiveFilter;
|
|
79
|
+
const field = config.fields.find((fd) => fd.name === f?.name);
|
|
80
|
+
if (!field) return f; // field lạ/dotted path: query-builder tự allowlist
|
|
81
|
+
return { ...f, value: coerceFieldValue(field, f.value) };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function resolvePerms(
|
|
86
|
+
session: any,
|
|
87
|
+
config: EntityConfig,
|
|
88
|
+
entity: string,
|
|
89
|
+
) {
|
|
52
90
|
return getCrudPermissions(session, config.permissionResource ?? entity);
|
|
53
91
|
}
|
|
54
92
|
|
|
@@ -56,10 +94,16 @@ async function resolvePerms(session: any, config: EntityConfig, entity: string)
|
|
|
56
94
|
export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
57
95
|
const { getSession, getEntityConfig, onError } = deps;
|
|
58
96
|
const resolveService = makeServiceResolver(deps.service);
|
|
59
|
-
const fail = (e: unknown, req: Request) =>
|
|
60
|
-
|
|
97
|
+
const fail = (e: unknown, req: Request) => {
|
|
98
|
+
if (e instanceof CrudRequestError)
|
|
99
|
+
return json({ error: e.message }, e.status);
|
|
100
|
+
return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
101
|
+
};
|
|
61
102
|
|
|
62
|
-
async function GET(
|
|
103
|
+
async function GET(
|
|
104
|
+
req: Request,
|
|
105
|
+
ctx: { params: Promise<{ entity: string }> },
|
|
106
|
+
) {
|
|
63
107
|
const { entity } = await ctx.params;
|
|
64
108
|
try {
|
|
65
109
|
const session = await getSession();
|
|
@@ -75,9 +119,16 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
75
119
|
pageSize: parseInt(sp.get("pageSize") || "10", 10),
|
|
76
120
|
search: sp.get("search") || undefined,
|
|
77
121
|
sort: sp.get("sortField")
|
|
78
|
-
? {
|
|
122
|
+
? {
|
|
123
|
+
field: sp.get("sortField")!,
|
|
124
|
+
direction: (sp.get("sortDirection") as "asc" | "desc") || "asc",
|
|
125
|
+
}
|
|
79
126
|
: undefined,
|
|
80
|
-
filters:
|
|
127
|
+
filters: coerceFilters(
|
|
128
|
+
config,
|
|
129
|
+
parseJsonParam(sp.get("filters"), "filters"),
|
|
130
|
+
),
|
|
131
|
+
filterTree: parseJsonParam(sp.get("filterTree"), "filterTree"),
|
|
81
132
|
};
|
|
82
133
|
const service = await resolveService(entity);
|
|
83
134
|
const data = await service.list(entity, config, params as any);
|
|
@@ -87,7 +138,10 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
87
138
|
}
|
|
88
139
|
}
|
|
89
140
|
|
|
90
|
-
async function POST(
|
|
141
|
+
async function POST(
|
|
142
|
+
req: Request,
|
|
143
|
+
ctx: { params: Promise<{ entity: string }> },
|
|
144
|
+
) {
|
|
91
145
|
const { entity } = await ctx.params;
|
|
92
146
|
try {
|
|
93
147
|
const session = await getSession();
|
|
@@ -101,7 +155,8 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
101
155
|
const service = await resolveService(entity);
|
|
102
156
|
if (Array.isArray(body)) {
|
|
103
157
|
const results = [];
|
|
104
|
-
for (const item of body)
|
|
158
|
+
for (const item of body)
|
|
159
|
+
results.push(await service.create(entity, item, config));
|
|
105
160
|
return json(results);
|
|
106
161
|
}
|
|
107
162
|
return json(await service.create(entity, body, config));
|
|
@@ -117,8 +172,11 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
117
172
|
export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
118
173
|
const { getSession, getEntityConfig, onError } = deps;
|
|
119
174
|
const resolveService = makeServiceResolver(deps.service);
|
|
120
|
-
const fail = (e: unknown, req: Request) =>
|
|
121
|
-
|
|
175
|
+
const fail = (e: unknown, req: Request) => {
|
|
176
|
+
if (e instanceof CrudRequestError)
|
|
177
|
+
return json({ error: e.message }, e.status);
|
|
178
|
+
return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
179
|
+
};
|
|
122
180
|
|
|
123
181
|
type Ctx = { params: Promise<{ entity: string; id: string }> };
|
|
124
182
|
|
|
@@ -168,7 +226,8 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
168
226
|
const perms = await resolvePerms(session, config, entity);
|
|
169
227
|
if (!perms.delete) return forbidden();
|
|
170
228
|
const service = await resolveService(entity);
|
|
171
|
-
|
|
229
|
+
// Truyền config để service dùng đúng idField/idKind/modelName.
|
|
230
|
+
await service.delete(entity, id, config);
|
|
172
231
|
return new Response(null, { status: 204 });
|
|
173
232
|
} catch (e) {
|
|
174
233
|
return fail(e, req);
|
|
@@ -177,3 +236,191 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
177
236
|
|
|
178
237
|
return { GET, PUT, PATCH: PUT, DELETE };
|
|
179
238
|
}
|
|
239
|
+
|
|
240
|
+
// ── Export/Import generic — `/api/crud/[entity]/export|/import` ─────────────
|
|
241
|
+
// Nút Export/ImportDialog của CrudPage mặc định trỏ về đúng 2 URL này
|
|
242
|
+
// (getEntityEndpoints: `${apiEndpoint}/export|/import`) — không có handler thì
|
|
243
|
+
// bật feature export/import là 404 ngay cả trong template.
|
|
244
|
+
|
|
245
|
+
export interface CrudExportHandlerDeps extends CrudHandlerDeps {
|
|
246
|
+
/** Số dòng mỗi lần gọi service.list khi kéo trang. Default 500. */
|
|
247
|
+
pageSize?: number;
|
|
248
|
+
/** Backstop chống kéo cả bảng khổng lồ trong một request. Default 10 000. */
|
|
249
|
+
maxRows?: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// GET `/api/crud/[entity]/export?format=xlsx|csv|json` — tôn trọng
|
|
253
|
+
// search/sort/filters/filterTree đang áp trên bảng (client gửi kèm query).
|
|
254
|
+
export function createCrudExportHandler(deps: CrudExportHandlerDeps) {
|
|
255
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
256
|
+
const pageSize = deps.pageSize ?? 500;
|
|
257
|
+
const maxRows = deps.maxRows ?? 10_000;
|
|
258
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
259
|
+
const fail = (e: unknown, req: Request) => {
|
|
260
|
+
if (e instanceof CrudRequestError)
|
|
261
|
+
return json({ error: e.message }, e.status);
|
|
262
|
+
return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
async function GET(
|
|
266
|
+
req: Request,
|
|
267
|
+
ctx: { params: Promise<{ entity: string }> },
|
|
268
|
+
) {
|
|
269
|
+
const { entity } = await ctx.params;
|
|
270
|
+
try {
|
|
271
|
+
const session = await getSession();
|
|
272
|
+
if (!session) return unauthorized();
|
|
273
|
+
const config = getEntityConfig(entity);
|
|
274
|
+
if (!config) return unknownEntity();
|
|
275
|
+
const perms = await resolvePerms(session, config, entity);
|
|
276
|
+
if (!perms.export) return forbidden();
|
|
277
|
+
|
|
278
|
+
const sp = new URL(req.url).searchParams;
|
|
279
|
+
const format = sp.get("format") || "xlsx";
|
|
280
|
+
if (format !== "xlsx" && format !== "csv" && format !== "json") {
|
|
281
|
+
throw new CrudRequestError(
|
|
282
|
+
`Format "${format}" không hỗ trợ (xlsx|csv|json)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const search = sp.get("search") || undefined;
|
|
287
|
+
const sort = sp.get("sortField")
|
|
288
|
+
? {
|
|
289
|
+
field: sp.get("sortField")!,
|
|
290
|
+
direction: (sp.get("sortDirection") as "asc" | "desc") || "asc",
|
|
291
|
+
}
|
|
292
|
+
: undefined;
|
|
293
|
+
const filters = coerceFilters(
|
|
294
|
+
config,
|
|
295
|
+
parseJsonParam(sp.get("filters"), "filters"),
|
|
296
|
+
);
|
|
297
|
+
const filterTree = parseJsonParam(sp.get("filterTree"), "filterTree");
|
|
298
|
+
|
|
299
|
+
const service = await resolveService(entity);
|
|
300
|
+
const rows: Record<string, unknown>[] = [];
|
|
301
|
+
let page = 1;
|
|
302
|
+
for (;;) {
|
|
303
|
+
const result = await service.list(entity, config, {
|
|
304
|
+
page,
|
|
305
|
+
pageSize,
|
|
306
|
+
search,
|
|
307
|
+
sort,
|
|
308
|
+
filters,
|
|
309
|
+
filterTree,
|
|
310
|
+
} as any);
|
|
311
|
+
rows.push(...(result.data as Record<string, unknown>[]));
|
|
312
|
+
if (result.data.length < pageSize || rows.length >= maxRows) break;
|
|
313
|
+
page += 1;
|
|
314
|
+
}
|
|
315
|
+
if (rows.length > maxRows) rows.length = maxRows;
|
|
316
|
+
|
|
317
|
+
// Tên file lấy từ entity slug (ASCII) — config.name có thể chứa tiếng
|
|
318
|
+
// Việt, đưa thẳng vào Content-Disposition là hỏng header.
|
|
319
|
+
const filename = `${entity}-export-${new Date().toISOString().slice(0, 10)}`;
|
|
320
|
+
|
|
321
|
+
if (format === "json") {
|
|
322
|
+
return new Response(exportToJSON(rows), {
|
|
323
|
+
headers: {
|
|
324
|
+
"content-type": "application/json",
|
|
325
|
+
"content-disposition": `attachment; filename="${filename}.json"`,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (format === "csv") {
|
|
330
|
+
// BOM để Excel mở đúng UTF-8 tiếng Việt.
|
|
331
|
+
return new Response("\uFEFF" + toCsv(rows), {
|
|
332
|
+
headers: {
|
|
333
|
+
"content-type": "text/csv; charset=utf-8",
|
|
334
|
+
"content-disposition": `attachment; filename="${filename}.csv"`,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return new Response(await exportToXLSX(rows), {
|
|
339
|
+
headers: {
|
|
340
|
+
"content-type":
|
|
341
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
342
|
+
"content-disposition": `attachment; filename="${filename}.xlsx"`,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return fail(e, req);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { GET };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface CrudImportHandlerDeps extends CrudHandlerDeps {
|
|
354
|
+
/**
|
|
355
|
+
* Header legacy → tên cột theo entity (file mẫu tiếng Việt của hệ cũ…).
|
|
356
|
+
* Label trong EntityConfig và tên cột máy luôn được nhận sẵn, không cần khai.
|
|
357
|
+
*/
|
|
358
|
+
legacyHeaders?: (entity: string) => Record<string, string> | undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// POST `/api/crud/[entity]/import` — nhận multipart {file, format} từ
|
|
362
|
+
// ImportDialog của core LẪN JSON array (client tự parse). Mỗi dòng đi qua
|
|
363
|
+
// service.create (coercion + validate + audit của engine áp y như tạo tay);
|
|
364
|
+
// dòng lỗi không chặn các dòng sau, gom vào ImportResult.errors.
|
|
365
|
+
export function createCrudImportHandler(deps: CrudImportHandlerDeps) {
|
|
366
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
367
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
368
|
+
const fail = (e: unknown, req: Request) => {
|
|
369
|
+
if (e instanceof CrudRequestError)
|
|
370
|
+
return json({ error: e.message }, e.status);
|
|
371
|
+
return onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
async function POST(
|
|
375
|
+
req: Request,
|
|
376
|
+
ctx: { params: Promise<{ entity: string }> },
|
|
377
|
+
) {
|
|
378
|
+
const { entity } = await ctx.params;
|
|
379
|
+
try {
|
|
380
|
+
const session = await getSession();
|
|
381
|
+
if (!session) return unauthorized();
|
|
382
|
+
const config = getEntityConfig(entity);
|
|
383
|
+
if (!config) return unknownEntity();
|
|
384
|
+
const perms = await resolvePerms(session, config, entity);
|
|
385
|
+
if (!perms.import) return forbidden();
|
|
386
|
+
|
|
387
|
+
const rows = await readImportRows(
|
|
388
|
+
req,
|
|
389
|
+
config,
|
|
390
|
+
deps.legacyHeaders?.(entity),
|
|
391
|
+
);
|
|
392
|
+
if (!rows) {
|
|
393
|
+
throw new CrudRequestError(
|
|
394
|
+
"Body import không hợp lệ — cần file (multipart) hoặc JSON array",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const service = await resolveService(entity);
|
|
399
|
+
const result: ImportResult = {
|
|
400
|
+
success: true,
|
|
401
|
+
imported: 0,
|
|
402
|
+
failed: 0,
|
|
403
|
+
errors: [],
|
|
404
|
+
};
|
|
405
|
+
for (const [index, row] of rows.entries()) {
|
|
406
|
+
try {
|
|
407
|
+
await service.create(entity, row, config);
|
|
408
|
+
result.imported += 1;
|
|
409
|
+
} catch (e) {
|
|
410
|
+
result.failed += 1;
|
|
411
|
+
result.errors.push({
|
|
412
|
+
row: index + 1,
|
|
413
|
+
field: "",
|
|
414
|
+
message: e instanceof Error ? e.message : String(e),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
result.success = result.failed === 0;
|
|
419
|
+
return json(result);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
return fail(e, req);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { POST };
|
|
426
|
+
}
|