@goplusvn/core 0.1.60 → 0.1.61

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.60",
4
+ "version": "0.1.61",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -0,0 +1,105 @@
1
+ import type { EntityConfig } from "../../types";
2
+ import type { ServerCrudLogger } from "../server-service";
3
+
4
+ export interface PrepareMutationDataOptions {
5
+ data: any;
6
+ config?: EntityConfig;
7
+ mode: "create" | "update";
8
+ systemFields: string[];
9
+ relationFieldSkip?: string[];
10
+ logger: ServerCrudLogger;
11
+ touchUpdatedAtOnCreate?: boolean;
12
+ }
13
+
14
+ export function prepareMutationData(options: PrepareMutationDataOptions) {
15
+ const { data, config, mode, systemFields, relationFieldSkip, logger, touchUpdatedAtOnCreate } = options;
16
+
17
+ let filtered = data;
18
+
19
+ if (config) {
20
+ // 1. Filter valid fields
21
+ const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
22
+ for (const f of systemFields) valid.add(f);
23
+ filtered = {};
24
+ for (const [k, v] of Object.entries(data)) {
25
+ if (valid.has(k)) {
26
+ filtered[k] = v;
27
+ } else {
28
+ logger.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
29
+ }
30
+ }
31
+
32
+ // 2. Cast field values (boolean, number)
33
+ 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
+ }
61
+ }
62
+ }
63
+
64
+ // 3. System defaults for create
65
+ if (mode === "create") {
66
+ if (!filtered.id) {
67
+ filtered.id = crypto.randomUUID();
68
+ }
69
+ if (touchUpdatedAtOnCreate && !filtered.updatedAt) {
70
+ filtered.updatedAt = new Date();
71
+ }
72
+ }
73
+
74
+ // 4. Transform relation fields (*Id -> connect/disconnect)
75
+ const skip = new Set([
76
+ "id",
77
+ "createdBy",
78
+ "updatedBy",
79
+ "citizenId",
80
+ "targetId",
81
+ ...(relationFieldSkip ?? []),
82
+ ]);
83
+
84
+ const prismaData = { ...filtered };
85
+ for (const key of Object.keys(prismaData)) {
86
+ if (skip.has(key)) continue;
87
+
88
+ if (key.endsWith("Id") && key.length > 2) {
89
+ const rel = key.slice(0, -2);
90
+ const value = prismaData[key];
91
+
92
+ if (value && typeof value === "string" && value.trim() !== "") {
93
+ prismaData[rel] = { connect: { id: value } };
94
+ delete prismaData[key];
95
+ } else if (value === null || value === undefined || value === "") {
96
+ if (mode === "update") {
97
+ prismaData[rel] = { disconnect: true };
98
+ }
99
+ delete prismaData[key];
100
+ }
101
+ }
102
+ }
103
+
104
+ return prismaData;
105
+ }
@@ -0,0 +1,119 @@
1
+ import { compileFilterTree } from "./filter-tree";
2
+ import type { CrudQueryParams, EntityConfig } from "../../types";
3
+
4
+ export interface BuildListQueryOptions {
5
+ entity: string;
6
+ config: EntityConfig;
7
+ params: CrudQueryParams;
8
+ scopeWhere?: Record<string, unknown>;
9
+ onDisallowedFilter?: (field: string) => void;
10
+ }
11
+
12
+ export function buildListQuery({
13
+ entity,
14
+ config,
15
+ params,
16
+ scopeWhere,
17
+ onDisallowedFilter,
18
+ }: BuildListQueryOptions) {
19
+ const { page = 1, pageSize = 10, search, sort, filters, filterTree } = params;
20
+ const MAX_PAGE_SIZE = 200;
21
+
22
+ const safePage = Math.max(1, Number(page) || 1);
23
+ const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
24
+ const skip = (safePage - 1) * safePageSize;
25
+ const take = safePageSize;
26
+
27
+ const allowedFields = new Set<string>([
28
+ ...config.fields.map((f) => f.name),
29
+ "id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
30
+ ]);
31
+ const allowedRelations = new Set<string>(config.include || []);
32
+ const isAllowed = (name: string) => {
33
+ if (!name) return false;
34
+ if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
35
+ return allowedFields.has(name);
36
+ };
37
+
38
+ const where: any = { ...(scopeWhere ?? {}) };
39
+ if (search && search.trim()) {
40
+ const term = search.trim();
41
+ const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
42
+ if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
43
+ }
44
+
45
+ if (filters && filters.length) {
46
+ for (const filter of filters) {
47
+ const { name, value, operator } = filter as any;
48
+ if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
49
+ if (!isAllowed(name)) {
50
+ if (onDisallowedFilter) onDisallowedFilter(name);
51
+ continue;
52
+ }
53
+ let target = where;
54
+ let key = name;
55
+ if (name.includes(".")) {
56
+ const parts = name.split(".");
57
+ key = parts.pop()!;
58
+ for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
59
+ }
60
+ const op = operator as string;
61
+ if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
62
+ else if (op === "in") target[key] = { in: value };
63
+ else if (op === "notIn") target[key] = { notIn: value };
64
+ else if (op === "eq") target[key] = value;
65
+ else if (op === "ne") target[key] = { not: value };
66
+ else if (op === "gt") target[key] = { gt: value };
67
+ else if (op === "gte") target[key] = { gte: value };
68
+ else if (op === "lt") target[key] = { lt: value };
69
+ else if (op === "lte") target[key] = { lte: value };
70
+ else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
71
+ else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
72
+ else if (op === "isNull") target[key] = null;
73
+ else if (op === "isNotNull") target[key] = { not: null };
74
+ else target[key] = value;
75
+ }
76
+ }
77
+
78
+ if (filterTree) {
79
+ const compiled = compileFilterTree(filterTree, {
80
+ isAllowed,
81
+ onDisallowed: (field) => {
82
+ if (onDisallowedFilter) onDisallowedFilter(field);
83
+ },
84
+ });
85
+ if (compiled) {
86
+ where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
87
+ }
88
+ }
89
+
90
+ const orderBy: any = {};
91
+ const applySort = (field: string, direction: any) => {
92
+ if (field.includes(".")) {
93
+ const parts = field.split(".");
94
+ const leaf = parts.pop()!;
95
+ let t = orderBy;
96
+ for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
97
+ t[leaf] = direction;
98
+ } else orderBy[field] = direction;
99
+ };
100
+
101
+ if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
102
+ else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
103
+ else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
104
+ else orderBy[config.idField || "id"] = "desc";
105
+
106
+ const include: any = {};
107
+ if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
108
+ const includeOption = Object.keys(include).length ? { include } : {};
109
+
110
+ return {
111
+ where,
112
+ orderBy,
113
+ includeOption,
114
+ skip,
115
+ take,
116
+ safePage,
117
+ safePageSize,
118
+ };
119
+ }
@@ -15,7 +15,8 @@
15
15
  // getModelName: (e) => getModelName(e, MODEL_MAP),
16
16
  // });
17
17
 
18
- import { compileFilterTree } from "./lib/filter-tree";
18
+ import { buildListQuery } from "./lib/query-builder";
19
+ import { prepareMutationData } from "./lib/mutation-builder";
19
20
  import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
20
21
  import { serializeDecimalFields } from "../utils/serialize";
21
22
 
@@ -136,163 +137,27 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
136
137
  return m;
137
138
  };
138
139
 
139
- const filterValidFields = (data: any, config: EntityConfig) => {
140
- const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
141
- for (const f of systemFields) valid.add(f);
142
- const out: Record<string, unknown> = {};
143
- for (const [k, v] of Object.entries(data)) {
144
- if (valid.has(k)) out[k] = v;
145
- else log.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
146
- }
147
- return out;
148
- };
149
140
 
150
- const castFieldValues = (data: any, config: EntityConfig) => {
151
- const out = { ...data };
152
- for (const field of config.fields) {
153
- const value = out[field.name];
154
- if (value === undefined || value === null) continue;
155
- if (field.type === "boolean" || field.type === "switch") {
156
- let isTrue: boolean;
157
- if (typeof value === "string") {
158
- const lv = value.toLowerCase();
159
- isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
160
- } else isTrue = Boolean(value);
161
- if (field.type === "switch" && field.options && field.options.length >= 2) {
162
- out[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
163
- } else out[field.name] = isTrue;
164
- } else if (field.type === "number" || (field.type as string) === "integer") {
165
- if (typeof value === "string") {
166
- if (value.trim() === "") out[field.name] = null;
167
- else {
168
- const num = Number(value);
169
- if (!isNaN(num)) out[field.name] = num;
170
- }
171
- }
172
- }
173
- }
174
- return out;
175
- };
176
-
177
- const transformRelationFields = (data: any, mode: "create" | "update") => {
178
- const out = { ...data };
179
- // citizenId/targetId là legacy-default từ consumer đầu tiên — app mới khai
180
- // cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
181
- const skip = new Set([
182
- "id",
183
- "createdBy",
184
- "updatedBy",
185
- "citizenId",
186
- "targetId",
187
- ...(deps.relationFieldSkip ?? []),
188
- ]);
189
- for (const key of Object.keys(out)) {
190
- if (skip.has(key)) continue;
191
- if (key.endsWith("Id") && key.length > 2) {
192
- const rel = key.slice(0, -2);
193
- const value = out[key];
194
- if (value && typeof value === "string" && value.trim() !== "") {
195
- out[rel] = { connect: { id: value } };
196
- delete out[key];
197
- } else if (value === null || value === undefined || value === "") {
198
- if (mode === "update") out[rel] = { disconnect: true };
199
- delete out[key];
200
- }
201
- }
202
- }
203
- return out;
204
- };
205
141
 
206
142
  return {
207
143
  async list(entity, config, params) {
208
144
  const prismaModel = model(entity);
209
- const { page = 1, pageSize = 10, search, sort, filters } = params;
210
- const safePage = Math.max(1, Number(page) || 1);
211
- const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
212
- const skip = (safePage - 1) * safePageSize;
213
- const take = safePageSize;
214
-
215
- const allowedFields = new Set<string>([
216
- ...config.fields.map((f) => f.name),
217
- "id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
218
- ]);
219
- const allowedRelations = new Set<string>(config.include || []);
220
- const isAllowed = (name: string) => {
221
- if (!name) return false;
222
- if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
223
- return allowedFields.has(name);
224
- };
225
-
226
- const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
227
- if (search && search.trim()) {
228
- const term = search.trim();
229
- const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
230
- if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
231
- }
232
- if (filters && filters.length) {
233
- for (const filter of filters) {
234
- const { name, value, operator } = filter as any;
235
- if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
236
- if (!isAllowed(name)) {
237
- log.warn(`Ignoring filter on disallowed field "${name}" for entity "${entity}"`);
238
- continue;
239
- }
240
- let target = where;
241
- let key = name;
242
- if (name.includes(".")) {
243
- const parts = name.split(".");
244
- key = parts.pop()!;
245
- for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
246
- }
247
- const op = operator as string;
248
- if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
249
- else if (op === "in") target[key] = { in: value };
250
- else if (op === "notIn") target[key] = { notIn: value };
251
- else if (op === "eq") target[key] = value;
252
- else if (op === "ne") target[key] = { not: value };
253
- else if (op === "gt") target[key] = { gt: value };
254
- else if (op === "gte") target[key] = { gte: value };
255
- else if (op === "lt") target[key] = { lt: value };
256
- else if (op === "lte") target[key] = { lte: value };
257
- else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
258
- else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
259
- else if (op === "isNull") target[key] = null;
260
- else if (op === "isNotNull") target[key] = { not: null };
261
- else target[key] = value;
262
- }
263
- }
264
- // Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
265
- // cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
266
- // nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
267
- if (params.filterTree) {
268
- const compiled = compileFilterTree(params.filterTree, {
269
- isAllowed,
270
- onDisallowed: (field) =>
271
- log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
272
- });
273
- if (compiled) {
274
- where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
275
- }
276
- }
277
-
278
- const orderBy: any = {};
279
- const applySort = (field: string, direction: any) => {
280
- if (field.includes(".")) {
281
- const parts = field.split(".");
282
- const leaf = parts.pop()!;
283
- let t = orderBy;
284
- for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
285
- t[leaf] = direction;
286
- } else orderBy[field] = direction;
287
- };
288
- if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
289
- else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
290
- else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
291
- else orderBy[config.idField || "id"] = "desc";
292
-
293
- const include: any = {};
294
- if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
295
- const includeOption = Object.keys(include).length ? { include } : {};
145
+
146
+ const {
147
+ where,
148
+ orderBy,
149
+ includeOption,
150
+ skip,
151
+ take,
152
+ safePage,
153
+ safePageSize,
154
+ } = buildListQuery({
155
+ entity,
156
+ config,
157
+ params,
158
+ scopeWhere: deps.scopeWhere?.(entity, config) as Record<string, unknown> | undefined,
159
+ onDisallowedFilter: (field) => log.warn(`Ignoring filter on disallowed field "${field}" for entity "${entity}"`),
160
+ });
296
161
 
297
162
  try {
298
163
  const [total, data] = await Promise.all([
@@ -324,13 +189,15 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
324
189
  async create(entity, data, config, tx) {
325
190
  const prismaModel = model(entity, tx ?? prisma);
326
191
  try {
327
- let filtered = config ? filterValidFields(data, config) : data;
328
- if (config) filtered = castFieldValues(filtered, config);
329
- if (!filtered.id) filtered.id = crypto.randomUUID();
330
- if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
331
- filtered.updatedAt = new Date();
332
- }
333
- const prismaData = transformRelationFields(filtered, "create");
192
+ const prismaData = prepareMutationData({
193
+ data,
194
+ config,
195
+ mode: "create",
196
+ systemFields,
197
+ relationFieldSkip: deps.relationFieldSkip,
198
+ logger: log,
199
+ touchUpdatedAtOnCreate: deps.touchUpdatedAtOnCreate,
200
+ });
334
201
  return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
335
202
  } catch (error) {
336
203
  log.error(`Error creating ${entity}:`, error);
@@ -341,9 +208,14 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
341
208
  async update(entity, id, data, config, tx) {
342
209
  const prismaModel = model(entity, tx ?? prisma);
343
210
  try {
344
- let filtered = config ? filterValidFields(data, config) : data;
345
- if (config) filtered = castFieldValues(filtered, config);
346
- const prismaData = transformRelationFields(filtered, "update");
211
+ const prismaData = prepareMutationData({
212
+ data,
213
+ config,
214
+ mode: "update",
215
+ systemFields,
216
+ relationFieldSkip: deps.relationFieldSkip,
217
+ logger: log,
218
+ });
347
219
  return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
348
220
  } catch (error) {
349
221
  log.error(`Error updating ${entity}:`, error);