@fastify-core/base 1.0.7 → 1.0.8

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/dist/index.d.mts CHANGED
@@ -1,18 +1,173 @@
1
1
  import * as Decimal from "decimal.js";
2
- import { Pool, PoolConnection } from "mysql2/promise";
3
- import { FastifyRequest } from "fastify";
2
+ import { Pool, PoolConnection, RowDataPacket } from "mysql2/promise";
3
+ import { FastifyRequest, Session } from "fastify";
4
4
  import { SessionStore } from "@fastify/session";
5
+ //#region src/Sql.d.ts
6
+ /**
7
+ * Các kiểu và hàm dùng để xây dựng SQL từ định danh.
8
+ *
9
+ * Identifier động luôn được escape bằng backtick. Riêng SELECT và điều kiện
10
+ * JOIN được giới hạn ở column reference đơn giản, không nhận SQL thô.
11
+ */
12
+ type SqlSelectField = string | {
13
+ column: string;
14
+ alias?: string;
15
+ };
16
+ type SqlSelectInput = string | readonly SqlSelectField[];
17
+ type SqlJoinOperator = "=" | "<>" | "!=" | "<=" | ">=" | "<" | ">";
18
+ type SqlJoinCondition = string | {
19
+ left: string;
20
+ right: string;
21
+ operator?: SqlJoinOperator;
22
+ };
23
+ /**
24
+ * Escape identifier đơn theo chuẩn MySQL bằng cách nhân đôi backtick bên trong.
25
+ * Từ chối tên rỗng, chứa NUL hoặc vượt giới hạn của MySQL.
26
+ */
27
+ declare function escapeIdentifier(name: string): string;
28
+ /**
29
+ * Escape từng phần của một đường dẫn, ví dụ `users.id` thành
30
+ * `` `users`.`id` ``. Giá trị chỉ được dùng làm identifier, không phải SQL.
31
+ */
32
+ declare function escapeQualifiedName(path: string): string;
33
+ /** Escape một column reference hợp lệ dùng cho SELECT hoặc JOIN. */
34
+ declare function escapeColumnReference(reference: string): string;
35
+ /**
36
+ * Chuẩn hóa và kiểm tra tên JOIN table để giữ nguyên alias được truyền vào.
37
+ *
38
+ * - Tên đơn được escape thành backtick.
39
+ * - Tên có alias được ghi thành `` `table` `alias` ``; alias vẫn được escape.
40
+ *
41
+ * @param tableAndAlias Chuỗi `table` hoặc `table AS alias`
42
+ * @throws {Error} `invalid_join_table` khi thiếu/sai định dạng hoặc alias
43
+ */
44
+ declare function buildJoinTable(tableAndAlias: string): string;
45
+ /** Chuẩn hóa và kiểm tra chiều ORDER BY. */
46
+ declare function assertOrderDirection(direction: string): "ASC" | "DESC";
47
+ /** Chuẩn hóa loại JOIN; giá trị thiếu mặc định thành LEFT JOIN. */
48
+ declare function assertJoinType(type?: string | null): string;
49
+ /**
50
+ * Xây dựng danh sách cột của mệnh đề SELECT.
51
+ *
52
+ * Chấp nhận:
53
+ * - Chuỗi: `"id"`, `"users.id, roles.name"`.
54
+ * - Mảng chuỗi: `["id", { column: "users.name", alias: "userName" }]`.
55
+ * - `{ column, alias? }` để đặt tên cột trả về.
56
+ *
57
+ * Chỉ nhận tên cột hoặc column reference; aggregate, hàm, subquery và SQL
58
+ * thô đều bị từ chối bằng `invalid_column_reference`.
59
+ *
60
+ * @throws {Error} `invalid_select` khi danh sách rỗng
61
+ * @throws {Error} `invalid_column_reference` khi có tên cột không hợp lệ
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * buildSelectList(["id", { column: "roles.name", alias: "roleName" }]);
66
+ * // `id`, `roles`.`name` AS `roleName`
67
+ * ```
68
+ */
69
+ declare function buildSelectList(select: SqlSelectInput): string;
70
+ /**
71
+ * Xây dựng điều kiện JOIN mà không nối SQL thô.
72
+ *
73
+ * Hỗ trợ `"users.id = roles.userId"` và `{ left, right, operator? }` với
74
+ * operator thuộc `=`, `<>`, `!=`, `<=`, `>=`, `<`, `>`. Subquery, function,
75
+ * comment, dấu `;` và biểu thức nhiều phép so sánh đều bị từ chối.
76
+ *
77
+ * @throws {Error} `invalid_join_condition` khi cú pháp không hợp lệ
78
+ */
79
+ declare function buildJoinCondition(condition: SqlJoinCondition): string;
80
+ //#endregion
5
81
  //#region src/BaseModel.d.ts
6
- interface IBaseModel<T = any> {
82
+ /**
83
+ * Ràng buộc tối thiểu cho entity của model: bắt buộc có khoá chính `id`.
84
+ *
85
+ * Cố ý KHÔNG dùng `Record<string, unknown>` ở đây. Một `interface` khai báo
86
+ * kiểu thường (`interface UserItem { id: number; ... }`) không có implicit
87
+ * index signature nên sẽ không thoả `Record<string, unknown>`, dẫn đến lỗi
88
+ * `TS2344` cho đúng cách khai báo entity mà README hướng dẫn.
89
+ */
90
+ type ModelEntity = {
91
+ id: number;
92
+ };
93
+ /** Dòng dữ liệu dạng generic cho các model chưa có kiểu entity cụ thể. */
94
+ type ModelRow = Record<string, unknown> & ModelEntity;
95
+ /** Giá trị được phép truyền trực tiếp cho placeholder (`?`) của mysql2. */
96
+ type SqlParam = string | number | boolean | null | Date | Buffer | SqlParam[];
97
+ /** Giá trị "rộng" mà bộ lọc động và các hàm CRUD chấp nhận. */
98
+ type LooseValue = string | number | boolean | null | Date | Buffer | LooseValue[] | {
99
+ [key: string]: LooseValue;
100
+ };
101
+ type LooseRecord = Record<string, LooseValue>;
102
+ /**
103
+ * Thu hẹp biến `unknown` trong `catch` về kiểu `Error` để đọc được `.message`.
104
+ *
105
+ * @param error Giá trị bắt được từ `catch` (có thể là bất kỳ kiểu nào)
106
+ * @returns Chính `error` nếu đã là `Error`, ngược lại một `Error` mới chứa `String(error)`
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * try {
111
+ * JSON.parse(raw);
112
+ * } catch (error) {
113
+ * // Không cần tự kiểm tra `error instanceof Error` thủ công
114
+ * console.error(toError(error).message);
115
+ * // => "Unexpected token o in JSON at position 1"
116
+ * }
117
+ * ```
118
+ */
119
+ declare function toError(error: unknown): Error;
120
+ type FilterValue = unknown;
121
+ /**
122
+ * Điều kiện lọc của `BaseModel`.
123
+ *
124
+ * `T` chỉ dùng để **gợi ý tên field hợp lệ** khi gõ; `Record<string, FilterValue>`
125
+ * vẫn cho phép mọi khoá vì filter là ngôn ngữ động (toán tử `$in`, `$or`, tên
126
+ * cột không nằm trong entity...). Vì vậy TypeScript **không** bắt được lỗi gõ
127
+ * typo trong tên field — hãy dùng `model.isFields([...])` để kiểm tra tên cột
128
+ * lấy từ người dùng.
129
+ *
130
+ * @template T Kiểu entity của model, dùng để gợi ý
131
+ */
132
+ type Filter<T extends ModelEntity = ModelEntity> = Partial<Record<keyof T, FilterValue>> & Record<string, FilterValue>;
133
+ /**
134
+ * Filter dạng "rộng" cho code nội bộ của thư viện.
135
+ *
136
+ * `keyof T` ở {@link Filter} chưa cụ thể hoá khi T là type parameter, nên không
137
+ * dùng được cho các thao tác nội bộ như gán `isDeleted` hay dựng `{ id }`.
138
+ */
139
+ type AnyFilter = Record<string, FilterValue>;
140
+ /** Kết quả của {@link BaseModel.saveResult}. */
141
+ type SaveResult<T> = {
142
+ ok: true;
143
+ item: T;
144
+ } | {
145
+ ok: false;
146
+ errors: string[];
147
+ };
148
+ type QueryRows = RowDataPacket[];
149
+ /** Một tập hàng kết quả truy vấn. */
150
+ type QueryResult = [QueryRows, unknown];
151
+ /**
152
+ * Giá trị trả về thật của {@link BaseModel.query}, giống hệt `mysql2`: một tuple
153
+ * `[result, fields]`, nơi `result` là `OkPacket`, `ResultSetHeader` hoặc mảng hàng
154
+ * tuỳ loại câu lệnh.
155
+ *
156
+ * Phải dùng kiểu này cho chữ ký `query()`. Nếu khai báo `Promise<QueryResult>` thì
157
+ * `BaseModel` **không** gán được cho `IBaseModel`, khiến khai báo `modifieds` báo
158
+ * lỗi type ở phía người dùng.
159
+ */
160
+ type RawQueryResult = [unknown, unknown];
161
+ interface IBaseModel<T extends ModelEntity = ModelRow> {
7
162
  table: string;
8
163
  isDeleted: boolean;
9
164
  errors: string[];
10
165
  fieldName(key: string): string;
11
166
  save(item: Partial<T>, conn?: PoolConnection): Promise<T | false>;
12
- findOne(filter: Record<string, any>): Promise<T | null>;
13
- find(filter: Record<string, any>): Promise<T[]>;
14
- query(sql: string, params?: any[], conn?: PoolConnection): any;
15
- update(data: Record<string, any>, filter: Record<string, any>, conn?: PoolConnection): Promise<number>;
167
+ findOne(filter: Filter<T>): Promise<T | null>;
168
+ find(filter: Filter<T>): Promise<T[]>;
169
+ query(sql: string, params?: SqlParam[], conn?: PoolConnection): Promise<RawQueryResult>;
170
+ update(data: Partial<T> | Record<string, SqlParam | SqlParam[]>, filter: Filter<T>, conn?: PoolConnection): Promise<number>;
16
171
  }
17
172
  interface PaginationResult<T> {
18
173
  page: number;
@@ -22,191 +177,729 @@ interface PaginationResult<T> {
22
177
  items: T[];
23
178
  }
24
179
  interface JoinType {
180
+ /** Tên bảng liên kết — được escape tự động */
25
181
  table: string;
26
- on: string;
27
- type: string;
28
- fields: string[];
182
+ /**
183
+ * Điều kiện nối bảng: `"users.id = profiles.user_id"` hoặc
184
+ * `{ left: "users.id", right: "profiles.user_id", operator: "=" }`.
185
+ * Chỉ hỗ trợ phép so sánh đơn giản giữa các column reference.
186
+ */
187
+ on: SqlJoinCondition;
188
+ /** Kiểu JOIN — phải thuộc whitelist (`LEFT JOIN`, `INNER JOIN`...), sai sẽ ném `invalid_join_type` */
189
+ type?: string | null;
190
+ /** Danh sách cột cần lấy — chỉ nhận tên cột, không nhận SQL thô */
191
+ fields?: readonly string[];
29
192
  }
30
193
  interface ModifiedType {
31
194
  [field: string]: {
32
- smodel: IBaseModel<any>;
195
+ /** Model liên kết; dùng `AnyIBaseModel` để nhận được model khai báo entity bằng `interface`. */
196
+ smodel: AnyIBaseModel;
33
197
  tkey: string;
34
198
  skey: string;
35
199
  fmap: string;
36
200
  };
37
201
  }
38
202
  /**
39
- * Abstract base model class.
40
- * Provides common database operations such as
41
- * CRUD, query building, pagination and validation.
203
+ * Lớp model cơ sở (abstract).
204
+ *
205
+ * Cung cấp các thao tác database dùng chung như:
206
+ * CRUD, dựng câu truy vấn, phân trang và kiểm tra dữ liệu (validation).
42
207
  *
43
- * @template T Entity type, must contain `id`
208
+ * @template T Kiểu entity, bắt buộc phải có trường `id`
44
209
  */
45
- declare abstract class BaseModel<T extends Record<string, any> & {
46
- id: number;
47
- }> {
210
+ declare abstract class BaseModel<T extends ModelEntity = ModelRow> {
48
211
  abstract table: string;
49
212
  protected pool: Pool;
50
213
  isDeleted: boolean;
51
214
  errors: string[];
52
- vdObject: any;
215
+ validate: Record<string, string> | undefined;
53
216
  modifieds: ModifiedType | undefined;
54
217
  /**
55
- * Create model instance
56
- * @param pool MySQL connection pool
218
+ * Cache danh sách cột theo bảng, phục vụ `isField`/`isFields`.
219
+ * Xoá bằng `clearSchemaCache()`.
220
+ */
221
+ private readonly schemaCache;
222
+ /**
223
+ * Khởi tạo instance của model.
224
+ *
225
+ * @param pool Connection pool MySQL dùng để truy vấn
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * import mysql from 'mysql2/promise';
230
+ * import { UserModel } from './models/UserModel.js';
231
+ *
232
+ * const pool = mysql.createPool({ host: 'localhost', user: 'root', database: 'app_db' });
233
+ * const userModel = new UserModel(pool);
234
+ * ```
57
235
  */
58
236
  constructor(pool: Pool);
237
+ /**
238
+ * Lấy tên hiển thị của trường.
239
+ *
240
+ * Có thể ghi đè trong class con để ứng dụng tự dùng khi dựng thông báo lỗi.
241
+ * Lưu ý: `Validation` không còn gọi tới hàm này vì validator không phụ thuộc model.
242
+ *
243
+ * @param key Tên cột trong bảng
244
+ * @returns Tên hiển thị tương ứng
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * // Lớp con ghi đè để trả về nhãn tiếng Việt dùng cho thông báo lỗi
249
+ * override fieldName(key: string) {
250
+ * return { fullname: 'Họ và tên', email: 'Địa chỉ Email' }[key] ?? key;
251
+ * }
252
+ *
253
+ * userModel.fieldName('fullname'); // => "Họ và tên"
254
+ * userModel.fieldName('status'); // => "status" (không có trong map thì trả về chính key)
255
+ * ```
256
+ */
59
257
  fieldName(key: string): string;
258
+ /**
259
+ * Lấy danh sách các thông báo lỗi phát sinh trong quá trình thực thi của model.
260
+ * @returns Mảng các chuỗi thông báo lỗi
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * const ok = await userModel.save({ email: 'khong-hop-le' }); // false do DB báo lỗi
265
+ * userModel.getErrors();
266
+ * // => ["ER_BAD_NULL_ERROR: Column 'fullname' cannot be null"]
267
+ * ```
268
+ */
60
269
  getErrors(): string[];
270
+ /**
271
+ * Lấy một kết nối đơn lẻ từ connection pool để chạy transaction.
272
+ * @returns Promise giải quyết kết nối PoolConnection
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * const conn = await userModel.getConnection();
277
+ * try {
278
+ * await conn.beginTransaction();
279
+ * await userModel.update({ status: 1 }, { id: 7 }, conn);
280
+ * await conn.commit();
281
+ * } catch (err) {
282
+ * await conn.rollback();
283
+ * throw err;
284
+ * } finally {
285
+ * // Bắt buộc trả kết nối về pool
286
+ * conn.release();
287
+ * }
288
+ * ```
289
+ */
61
290
  getConnection(): Promise<PoolConnection>;
62
291
  /**
63
- * Validate data using Validation rules
292
+ * Kiểm tra dữ liệu theo bộ quy tắc của lớp `Validation`.
64
293
  *
65
- * @param item Data to validate
66
- * @param vdObject Validation rules
294
+ * `Validation` hoàn toàn thuần (không truy vấn database), nên việc kiểm tra
295
+ * trùng lặp phải tự thực hiện ở tầng nghiệp vụ trước khi gọi hàm này.
296
+ *
297
+ * @param item Dữ liệu cần kiểm tra
298
+ * @param validate Bộ quy tắc kiểm tra dạng `field -> rule1|rule2`
299
+ * @param lang Mã ngôn ngữ cho thông báo lỗi (mặc định `'vi'`)
300
+ * @throws {Error} Khi có ít nhất một rule không thỏa hoặc rule không tồn tại
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * await userModel.runValidate(
305
+ * { fullname: 'Nguyen Van A', email: 'a@example.com', age: 25 },
306
+ * { email: 'required|email', age: 'required|integer|rangeNum(18,60)' }
307
+ * );
308
+ * // Không ném lỗi => dữ liệu hợp lệ
309
+ *
310
+ * // Trả thông báo lỗi bằng tiếng Anh
311
+ * await userModel.runValidate({ email: 'sai' }, { email: 'required|email' }, 'en');
312
+ * // throw: "Email is not in the correct format"
313
+ * ```
67
314
  */
68
- validate(item: Partial<T>, vdObject?: Record<string, any>): Promise<void>;
315
+ runValidate(item: Partial<T>, validate?: Record<string, string>, lang?: string): Promise<void>;
69
316
  /**
70
- * Execute SQL query using pool or provided connection
317
+ * Thực thi câu SQL thông qua connection pool hoặc connection được truyền vào.
318
+ *
319
+ * @param sql Câu SQL, mọi giá trị động đều phải dùng placeholder `?`
320
+ * @param params Danh sách tham số bind cho các placeholder
321
+ * @param conn Connection cụ thể (thường dùng khi chạy transaction)
322
+ * @returns Kết quả trả về nguyên bản của mysql2
323
+ *
324
+ * @example
325
+ * ```ts
326
+ * const [rows] = await userModel.query(
327
+ * 'SELECT id, email FROM users WHERE roleId = ? AND status = ?',
328
+ * [2, 1]
329
+ * );
330
+ * ```
71
331
  */
72
- query(sql: string, params?: any[], conn?: PoolConnection): Promise<[import("mysql2").QueryResult, import("mysql2").FieldPacket[]]>;
332
+ query(sql: string, params?: SqlParam[], conn?: PoolConnection): Promise<[import("mysql2").QueryResult, import("mysql2").FieldPacket[]]>;
73
333
  /**
74
- * Synchronize mapped fields from related models
334
+ * Đồng bộ các trường được ánh xạ (mapped field) từ model liên kết.
335
+ *
336
+ * Dùng để tự động điền trường hiển thị từ khóa ngoại.
75
337
  *
76
- * Used to auto-fill display fields from foreign keys
338
+ * @param data Dữ liệu đích (được sửa tại chỗ)
339
+ * @returns Chính đối tượng `data` sau khi đã bổ sung các trường ánh xạ
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * // Khai báo ánh xạ: lấy `roleName` từ bảng `roles` theo `roleId` của bản ghi hiện tại
344
+ * userModel.modifieds = {
345
+ * roleName: { smodel: roleModel, tkey: 'roleId', skey: 'id', fmap: 'name' }
346
+ * };
77
347
  *
78
- * @param data Target data
348
+ * const data = await userModel.modifiedSync({ id: 1, fullname: 'Nguyen Van A', roleId: 2 });
349
+ * // data.roleName => "Admin" (tự động truy vấn roles WHERE id = 2)
350
+ * ```
79
351
  */
80
- modifiedSync<T extends Record<string, any>>(data: any): Promise<T>;
81
352
  /**
82
- * Build SQL WHERE clause from filter object
353
+ * Đồng bộ các trường hiển thị lấy từ model liên kết.
354
+ *
355
+ * Dùng để điền tên gọi từ khoá ngoại (ví dụ `roleId` → `roleName`) lấy từ bảng
356
+ * khác.
357
+ *
358
+ * Truy vấn được **gom nhóm**: các field dùng chung một `(model, khoá)` chỉ gây
359
+ * ra đúng 1 lệnh `SELECT ... WHERE khoá IN (...)` thay vì 1 lệnh cho mỗi
360
+ * field — trước đây đây là mẫu N+1 và các lệnh được `await` tuần tự.
83
361
  *
84
- * Supports advanced operators such as:
85
- * `$in`, `$nin`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$like`
362
+ * Chỉ gán giá trị khi tìm thấy bản ghi nguồn; nếu không thì field đích được
363
+ * để nguyên (giống hành vi trước đây).
86
364
  *
87
- * @param filter Query filter object
88
- * @param als Optional table alias
365
+ * @param data Bản ghi cần điền trường hiển thị (được sửa tại chỗ)
366
+ * @returns Chính `data`
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * // Khai báo ánh xạ: lấy `roleName` từ bảng `roles` theo `roleId` của bản ghi
371
+ * userModel.modifieds = {
372
+ * roleName: { smodel: roleModel, tkey: 'roleId', skey: 'id', fmap: 'name' }
373
+ * };
374
+ *
375
+ * const data = await userModel.modifiedSync({ id: 1, fullname: 'Nguyen Van A', roleId: 2 });
376
+ * // data.roleName => "Admin"
377
+ * ```
89
378
  */
90
- buildWhere(filter: Record<string, any>, als?: string): {
379
+ modifiedSync<R extends Record<string, unknown>>(data: Record<string, unknown>): Promise<R>;
380
+ /**
381
+ * Dựng mệnh đề SQL `WHERE` từ đối tượng filter.
382
+ *
383
+ * Hỗ trợ các toán tử nâng cao:
384
+ * `$in`, `$nin`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$like`, `$likeIn`
385
+ * và nhóm điều kiện `$or`, `$and`.
386
+ *
387
+ * Khi `isDeleted = true`, mọi filter sẽ được tự động gắn thêm điều kiện `isDeleted = 0`
388
+ * (soft delete), trừ khi filter đã chỉ định `isDeleted` rõ ràng.
389
+ *
390
+ * @param filter Đối tượng điều kiện lọc
391
+ * @param als Tên alias của bảng (tùy chọn), dùng khi truy vấn có `join`
392
+ * @returns Object gồm `sql` (mệnh đề WHERE) và `params` (tham số theo đúng thứ tự placeholder)
393
+ * @throws {Error} `$between requires [min, max]` khi mảng khoảng không đúng 2 phần tử
394
+ *
395
+ * @example
396
+ * ```ts
397
+ * const { sql, params } = userModel.buildWhere({
398
+ * status: 1,
399
+ * roleId: { $in: [1, 2] },
400
+ * fullname: { $like: 'Nguyen' },
401
+ * $or: [{ email: 'a@example.com' }, { email: 'b@example.com' }]
402
+ * });
403
+ * // sql => "`status` = ? AND (`roleId` IN (?,?)) AND (`fullname` LIKE ?) AND ((`email` = ?) OR (`email` = ?)) AND `isDeleted` = ?"
404
+ * // params => [1, 1, 2, '%Nguyen%', 'a@example.com', 'b@example.com', 0]
405
+ *
406
+ * // Filter rỗng khi bật soft delete vẫn lọc theo isDeleted = 0
407
+ * userModel.buildWhere({}); // { sql: "`isDeleted` = ?", params: [0] }
408
+ *
409
+ * // Truyền alias khi cần tự viết câu SQL có join:
410
+ * const where = userModel.buildWhere({ roleId: 2 }, 'u');
411
+ * // sql => "`u`.`roleId` = ? AND `u`.`isDeleted` = ?"
412
+ * ```
413
+ */
414
+ buildWhere(filter: AnyFilter, als?: string): {
91
415
  sql: string;
92
- params: any[];
416
+ params: SqlParam[];
93
417
  };
94
418
  /**
95
- * Count records matching filter
419
+ * Đếm số bản ghi khớp với điều kiện lọc.
96
420
  *
97
- * @param filter Query conditions
421
+ * @param filter Điều kiện lọc (mặc định không lọc)
422
+ * @returns Tổng số bản ghi, trả về `0` nếu không có kết quả
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * await userModel.count(); // => tổng số user
427
+ * await userModel.count({ status: 1, roleId: { $gte: 2 } }); // => số user còn hoạt động thuộc role từ 2 trở lên
428
+ * ```
98
429
  */
99
- count(filter?: Record<string, any>): Promise<number>;
430
+ count(filter?: Filter<T>): Promise<number>;
100
431
  /**
101
- * Insert a new record
432
+ * Thêm mới một bản ghi.
433
+ *
434
+ * @param data Dữ liệu cần thêm (tên cột sẽ được escape tự động)
435
+ * @param conn Kết nối DB tùy chọn (dùng trong transaction)
436
+ * @returns `insertId` của bản ghi vừa thêm
102
437
  *
103
- * @param data Insert data
104
- * @param conn Optional DB connection
438
+ * @example
439
+ * ```ts
440
+ * const newId = await userModel.insert({
441
+ * fullname: 'Nguyen Van A',
442
+ * email: 'a@example.com',
443
+ * roleId: 2,
444
+ * status: 1
445
+ * });
446
+ * // newId => 42
447
+ * ```
448
+ */
449
+ insert(data: Record<string, SqlParam>, conn?: PoolConnection): Promise<number>;
450
+ /**
451
+ * Cập nhật bản ghi theo điều kiện (so sánh bằng dấu `=`).
452
+ *
453
+ * Khi `isDeleted = true`, mệnh đề `WHERE` cũng được gắn thêm `isDeleted = 0`
454
+ * để không cập nhật nhầm lên bản ghi đã xoá mềm (trừ khi filter đã chỉ định
455
+ * rõ `isDeleted`).
456
+ *
457
+ * @param data Dữ liệu cần cập nhật
458
+ * @param filter Điều kiện lọc WHERE
459
+ * @param conn Kết nối DB tùy chọn
460
+ * @returns Số dòng bị ảnh hưởng
461
+ * @throws {Error} Khi `filter` rỗng, tránh cập nhật toàn bảng
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * const affected = await userModel.update(
466
+ * { status: 0, fullname: 'Nguyen Van B' },
467
+ * { id: 7 }
468
+ * );
469
+ * // affected => 1
470
+ *
471
+ * // Nhiều điều kiện được nối bằng AND
472
+ * await userModel.update({ status: 1 }, { roleId: 2, status: 0 });
473
+ * ```
105
474
  */
106
- insert(data: Record<string, any>, conn?: PoolConnection): Promise<number>;
475
+ update(data: Record<string, SqlParam>, filter: Filter<T>, conn?: PoolConnection): Promise<number>;
107
476
  /**
108
- * Update records by condition
477
+ * Cập nhật nâng cao với điều kiện `WHERE` động (hỗ trợ toán tử nâng cao).
478
+ *
479
+ * @param data Danh sách giá trị cần cập nhật; bỏ trống thì hàm thoát ra ngay
480
+ * @param filter Điều kiện lọc, dựng bằng `buildWhere()`
481
+ * @param conn Kết nối DB tùy chọn
482
+ * @returns Số dòng bị ảnh hưởng, `undefined` nếu `data` rỗng
483
+ * @throws {Error} `invalid_update_is_deleted` khi có lỗi phát sinh trong quá trình cập nhật
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * // Khác `update()`: điều kiện lọc dùng được toán tử nâng cao
488
+ * const affected = await userModel.updateAdv(
489
+ * { status: 0 },
490
+ * { roleId: { $in: [1, 2] }, createdAt: { $between: ['2024-01-01', '2024-12-31'] } }
491
+ * );
492
+ * // affected => số user thuộc role 1 hoặc 2 được tạo trong năm 2024
493
+ * ```
494
+ */
495
+ updateAdv(data?: Record<string, SqlParam>, filter?: Filter<T>, conn?: PoolConnection): Promise<number | undefined>;
496
+ /**
497
+ * Thêm mới hoặc cập nhật bản ghi theo khóa chính.
498
+ *
499
+ * - Nếu có `item.id` → thực hiện `UPDATE`
500
+ * - Ngược lại → thực hiện `INSERT`
501
+ *
502
+ * @param item Dữ liệu entity
503
+ * @param conn Kết nối DB tùy chọn
504
+ * @returns Đối tượng đã lưu (với `id` đã được gán khi thêm mới), hoặc `false` nếu có lỗi
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * // Không có id => INSERT, item.id được gán lại bằng insertId
509
+ * const created = await userModel.save({ fullname: 'Nguyen Van A', email: 'a@example.com' });
510
+ * // created.id => 42
511
+ *
512
+ * // Có id => UPDATE
513
+ * await userModel.save({ id: 42, status: 0 });
514
+ * ```
109
515
  */
110
- update(data: Record<string, any>, filter: Record<string, any>, conn?: PoolConnection): Promise<number>;
111
516
  /**
112
- * Advanced update with dynamic where conditions
517
+ * Lưu bản ghi và trả về kết quả có kiểu, **không** dùng state lỗi dùng chung.
518
+ *
519
+ * Model thường được đăng ký là singleton (`initModels`) nên dùng chung cho mọi
520
+ * request. Biến `errors` của instance vì thế có thể bị lẫn giữa các request
521
+ * đang chạy song song. Hàm này trả lỗi trực tiếp trong giá trị trả về nên
522
+ * không cần đọc `getErrors()` — đây là API nên dùng.
113
523
  *
114
- * @param data Update values
115
- * @param filter Query conditions
116
- * @param conn Optional DB connection
524
+ * @param item Dữ liệu cần lưu
525
+ * @param conn Kết nối DB tùy chọn
526
+ * @returns `{ ok: true, item }` khi thành công, `{ ok: false, errors }` khi lỗi
527
+ *
528
+ * @example
529
+ * ```ts
530
+ * const result = await userModel.saveResult({ fullname: 'A' });
531
+ * if (!result.ok) throw new ClientError(result.errors.join(', '), 400);
532
+ * console.log(result.item.id);
533
+ * ```
117
534
  */
118
- updateAdv(data?: Record<string, any>, filter?: Record<string, any>, conn?: PoolConnection): Promise<any>;
535
+ saveResult(item: Partial<T>, conn?: PoolConnection): Promise<SaveResult<T>>;
119
536
  /**
120
- * Insert or update record by primary key
537
+ * Lưu bản ghi, trả về `false` nếu thất bại.
121
538
  *
122
- * - If `item.id` exists → update
123
- * - Otherwise → insert
539
+ * > **Cảnh báo về trạng thái dùng chung**: hàm này ghi lỗi vào `this.errors`
540
+ * > (dùng chung cho mọi request khi model là singleton). Với code chạy đồng
541
+ * > thời, hãy dùng {@link BaseModel.saveResult} để nhận lỗi trực tiếp.
124
542
  *
125
- * @param item Entity data
126
- * @param conn Optional DB connection
543
+ * @param item Dữ liệu cần lưu
544
+ * @param conn Kết nối DB tùy chọn
545
+ * @returns Bản ghi đã lưu, hoặc `false` khi thất bại
127
546
  */
128
547
  save(item: Partial<T>, conn?: PoolConnection): Promise<T | false>;
129
548
  /**
130
- * Validate and save record
549
+ * Kiểm tra dữ liệu rồi lưu bản ghi.
550
+ *
551
+ * Trình tự thực hiện: `modifiedSync()` → `validate()` → `save()`.
131
552
  *
132
- * @throws Error when validation or save fails
553
+ * @param item Dữ liệu cần lưu
554
+ * @param validate Bộ quy tắc kiểm tra; truyền `false` để bỏ qua bước validate
555
+ * @param conn Kết nối DB tùy chọn
556
+ * @param lang Mã ngôn ngữ cho thông báo lỗi (mặc định `'vi'`)
557
+ * @returns Bản ghi đã lưu, hoặc `null` nếu bỏ qua validate và lưu thất bại
558
+ * @throws {Error} Khi kiểm tra hoặc lưu thất bại; message là các lỗi nối bằng dấu `, `
559
+ *
560
+ * @example
561
+ * ```ts
562
+ * const rules = { email: 'required|email' };
563
+ *
564
+ * try {
565
+ * const user = await userModel.vdSave(
566
+ * { fullname: 'Nguyen Van A', email: 'a@example.com' },
567
+ * rules
568
+ * );
569
+ * // user.id => 42
570
+ * } catch (error) {
571
+ * // Ví dụ: "Email không đúng định dạng"
572
+ * console.error(toError(error).message);
573
+ * }
574
+ *
575
+ * // Thông báo lỗi bằng tiếng Anh (lang là tham số cuối)
576
+ * await userModel.vdSave({ email: 'sai' }, rules, undefined, 'en');
577
+ * // throw: "Email is not in the correct format"
578
+ * ```
133
579
  */
134
- vdSave(item: any, vdObject?: any | false, conn?: PoolConnection): Promise<T | null>;
580
+ vdSave(item: Partial<T>, validate?: Record<string, string> | false, conn?: PoolConnection, lang?: string): Promise<T | null>;
135
581
  /**
136
- * Batch update multiple records using CASE WHEN
582
+ * Cập nhật hàng loạt nhiều bản ghi bằng kỹ thuật `CASE WHEN`.
583
+ *
584
+ * Mỗi cột được dựng thành `CASE ... WHEN id THEN ? ... ELSE <cột hiện tại> END`,
585
+ * nên câu lệnh chỉ chạy một lần thay vì N lần.
586
+ *
587
+ * @param rows Danh sách dữ liệu, các phần tử phải có cùng tập cột
588
+ * @param key Tên trường khóa chính dùng để định danh từng dòng
589
+ * @param conn Kết nối DB tùy chọn
590
+ * @throws {Error} `invalid_update_many` khi có lỗi phát sinh trong quá trình cập nhật
137
591
  *
138
- * @param rows Data rows
139
- * @param key Primary key field
592
+ * @example
593
+ * ```ts
594
+ * await userModel.updateMany([
595
+ * { id: 1, status: 0 },
596
+ * { id: 2, status: 1 }
597
+ * ], 'id');
598
+ * // => UPDATE `users` SET `status` = CASE `id` WHEN ? THEN ? WHEN ? THEN ? ELSE `status` END WHERE `id` IN (?,?)
599
+ * ```
140
600
  */
141
- updateMany(rows: any[], key: string, conn?: PoolConnection): Promise<void>;
601
+ updateMany(rows: Array<Record<string, SqlParam>>, key: string, conn?: PoolConnection): Promise<void>;
142
602
  /**
143
- * Bulk insert or update (UPSERT) records
603
+ * Thêm hàng loạt bản ghi, tự cập nhật nếu bản ghi đã tồn tại (UPSERT).
604
+ *
605
+ * Dùng `INSERT ... ON DUPLICATE KEY UPDATE` với mệnh đề `col = VALUES(col)`
606
+ * cho mọi cột trừ khóa chính.
607
+ *
608
+ * @param rows Danh sách dữ liệu, các phần tử phải có cùng tập cột
609
+ * @param key Tên trường khóa chính (không được đưa vào phần `UPDATE`)
610
+ * @param conn Kết nối DB tùy chọn
611
+ * @throws {Error} `invalid_update_and_create` khi có lỗi phát sinh trong quá trình ghi
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * await userModel.updateAndCreateMany([
616
+ * { id: 1, fullname: 'Nguyen Van A', status: 1 },
617
+ * { id: 2, fullname: 'Tran Thi B', status: 1 }
618
+ * ], 'id');
619
+ * // id chưa có => INSERT; id đã có => UPDATE các cột còn lại
620
+ * ```
621
+ */
622
+ updateAndCreateMany(rows: Array<Record<string, SqlParam>>, key: string, conn?: PoolConnection): Promise<void>;
623
+ /**
624
+ * Tìm danh sách bản ghi với các tùy chọn nâng cao (lọc, sắp xếp, join, chọn cột, giới hạn).
625
+ *
626
+ * @param filter Điều kiện lọc WHERE
627
+ * @param order Tùy chọn sắp xếp (ORDER BY) — key là tên cột (đã escape), value chỉ nhận `'ASC' | 'DESC'`
628
+ * @param join Cấu hình JOIN bảng; `on` chỉ nhận phép so sánh đơn giản giữa các cột
629
+ * @param select Danh sách cột cần lấy; không nhận SQL thô
630
+ * @param limit Số bản ghi tối đa cần lấy, `0` nghĩa là không giới hạn
631
+ * @returns Mảng các bản ghi tìm thấy (mảng rỗng nếu không có kết quả)
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * // Lọc + sắp xếp + giới hạn
636
+ * const users = await userModel.find(
637
+ * { status: 1, roleId: { $in: [1, 2] }, fullname: { $like: 'Nguyen' } },
638
+ * { id: 'DESC' },
639
+ * false,
640
+ * ['id', 'fullname', 'email'],
641
+ * 10
642
+ * );
144
643
  *
145
- * @param rows Data rows
146
- * @param key Primary key field
644
+ * // Kết hợp join để lấy thêm cột của bảng khác
645
+ * const usersWithRoles = await userModel.find(
646
+ * { status: 1 },
647
+ * { id: 'DESC' },
648
+ * { table: 'roles', on: { left: 'users.roleId', right: 'roles.id', operator: '=' }, fields: ['name'] },
649
+ * [{ column: 'users.id' }, { column: 'roles.name', alias: 'roleName' }]
650
+ * );
651
+ * ```
147
652
  */
148
- updateAndCreateMany(rows: any[], key: string, conn?: PoolConnection): Promise<void>;
653
+ find(filter?: Filter<T>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: SqlSelectInput | false, limit?: number): Promise<T[]>;
149
654
  /**
150
- * Find records with advanced options
655
+ * Tìm 1 bản ghi đầu tiên thỏa mãn điều kiện lọc
656
+ *
657
+ * @param filter Điều kiện lọc WHERE
658
+ * @param order Tùy chọn sắp xếp (ORDER BY); key là tên cột, value là `ASC` / `DESC`
659
+ * @param join Cấu hình JOIN bảng; `on` chỉ nhận phép so sánh đơn giản giữa các cột
660
+ * @param select Danh sách cột cần lấy; không nhận SQL thô
661
+ * @returns Bản ghi tìm thấy hoặc null nếu không tồn tại
151
662
  *
152
- * @param filter Query conditions
153
- * @param order Sort options
154
- * @param join Join configuration
155
- * @param select Custom select fields
156
- * @param limit Limit result count
663
+ * @example
664
+ * ```ts
665
+ * const user = await userModel.findOne({ email: 'a@example.com' });
666
+ * // user?.id => 42, hoặc null nếu email không tồn tại
667
+ *
668
+ * // Kèm điều kiện sắp xếp để chọn đúng 1 bản ghi khi có nhiều kết quả
669
+ * await userModel.findOne({ roleId: 2 }, { id: 'DESC' });
670
+ * ```
671
+ */
672
+ findOne(filter?: Filter<T>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: SqlSelectInput | false): Promise<T | null>;
673
+ /**
674
+ * Tìm bản ghi theo ID khóa chính
675
+ *
676
+ * @param id Khóa chính ID của bản ghi
677
+ * @param filter Điều kiện lọc bổ sung
678
+ * @returns Bản ghi tìm thấy hoặc null
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * const user = await userModel.findById(42);
683
+ * // user?.email => "a@example.com", hoặc null nếu id không tồn tại
684
+ *
685
+ * // Có thể kèm điều kiện lọc bổ sung (được nối với điều kiện id bằng AND)
686
+ * await userModel.findById(42, { status: 1 });
687
+ * ```
688
+ */
689
+ findById(id: number, filter?: Filter<T>): Promise<T | null>;
690
+ /**
691
+ * Xóa 1 bản ghi thỏa mãn điều kiện lọc (LIMIT 1)
692
+ *
693
+ * @param filter Điều kiện lọc WHERE
694
+ * @param conn Kết nối DB tùy chọn
695
+ * @returns `true` nếu xóa thành công ít nhất 1 dòng, ngược lại `false`
696
+ *
697
+ * @example
698
+ * ```ts
699
+ * // Xóa tối đa 1 bản ghi đầu tiên khớp điều kiện
700
+ * const ok = await userModel.deleteOne({ email: 'a@example.com' });
701
+ * // ok => true nếu có dòng bị xóa, ngược lại false
702
+ * ```
703
+ */
704
+ deleteOne(filter: Filter<T>, conn?: PoolConnection): Promise<boolean>;
705
+ /**
706
+ * Xóa bản ghi theo ID khóa chính
707
+ *
708
+ * @param id Khóa chính ID cần xóa
709
+ * @param conn Kết nối DB tùy chọn
710
+ * @returns `true` nếu xóa thành công, ngược lại `false`
711
+ *
712
+ * @example
713
+ * ```ts
714
+ * const ok = await userModel.deleteById(42);
715
+ * // ok => true nếu bản ghi id = 42 bị xóa, ngược lại false
716
+ * ```
157
717
  */
158
- find(filter?: Record<string, any>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: string | string[] | false, limit?: number): Promise<T[]>;
159
- findOne(filter?: Record<string, any>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: string | string[] | false): Promise<T | null>;
160
- findById(id: number, filter?: Record<string, any>): Promise<T | null>;
161
- deleteOne(filter: Record<string, any>, conn?: PoolConnection): Promise<boolean>;
162
718
  deleteById(id: number, conn?: PoolConnection): Promise<boolean>;
163
- deleteMany(filter: Record<string, any>, limit?: number, conn?: PoolConnection): Promise<boolean>;
719
+ /**
720
+ * Xóa nhiều bản ghi thỏa mãn điều kiện lọc
721
+ *
722
+ * @param filter Điều kiện lọc WHERE (bắt buộc)
723
+ * @param limit Giới hạn số dòng cần xóa (0 = không giới hạn)
724
+ * @param conn Kết nối DB tùy chọn
725
+ * @returns `true` nếu có dòng bị xóa, ngược lại `false`
726
+ * @throws {Error} `deleteMany requires a filter condition` khi `filter` rỗng.
727
+ * Bắt buộc chặn trường hợp này vì `buildWhere()` luôn trả về `1=1` khi
728
+ * không có điều kiện, và `DELETE FROM t WHERE 1=1` sẽ xóa toàn bộ bảng.
729
+ *
730
+ * @example
731
+ * ```ts
732
+ * // Xóa toàn bộ bản ghi khớp điều kiện
733
+ * const ok = await userModel.deleteMany({ roleId: 2 });
734
+ * // ok => true nếu có dòng bị xóa
735
+ *
736
+ * // Giới hạn số dòng xóa tối đa
737
+ * await userModel.deleteMany({ status: 0 }, 100);
738
+ *
739
+ * // Filter rỗng bị từ chối để tránh xóa nhầm toàn bộ bảng
740
+ * await userModel.deleteMany({}); // throw Error: deleteMany requires a filter condition
741
+ * ```
742
+ */
743
+ deleteMany(filter: Filter<T>, limit?: number, conn?: PoolConnection): Promise<boolean>;
744
+ /**
745
+ * Kiểm tra một cột cụ thể có tồn tại trong bảng hay không
746
+ *
747
+ * @param field Tên cột cần kiểm tra
748
+ * @param table Tên bảng (mặc định: bảng hiện tại của model)
749
+ * @returns `true` nếu cột tồn tại, ngược lại `false`
750
+ *
751
+ * @example
752
+ * ```ts
753
+ * await userModel.isField('email'); // => true (cột tồn tại trong bảng `users`)
754
+ * await userModel.isField('not_exist'); // => false
755
+ * await userModel.isField('name', 'roles'); // kiểm tra trên bảng khác
756
+ *
757
+ * // Thường dùng để kiểm tra cột do người dùng gửi lên trước khi đưa vào truy vấn
758
+ * if (await userModel.isField(inputField)) {
759
+ * await userModel.find({ [inputField]: inputValue });
760
+ * }
761
+ * ```
762
+ */
164
763
  isField(field: string, table?: string): Promise<boolean>;
764
+ /**
765
+ * Xác thực danh sách các cột có thuộc schema của bảng hay không.
766
+ * Thường dùng để phòng chống SQL injection qua custom fields.
767
+ *
768
+ * @param fields Danh sách tên cột cần kiểm tra
769
+ * @returns Danh sách các cột hợp lệ
770
+ * @throws {Error} `invalid_fields` nếu có cột không tồn tại trong bảng
771
+ *
772
+ * @example
773
+ * ```ts
774
+ * // Mảng rỗng => trả về mảng rỗng, không truy vấn DB
775
+ * await userModel.isFields([]); // => []
776
+ *
777
+ * await userModel.isFields(['id', 'fullname', 'email']); // => ['id', 'fullname', 'email']
778
+ * await userModel.isFields(['id', 'hack']); // throw Error: invalid_fields
779
+ *
780
+ * // Dùng để chống SQL injection khi người dùng tự chọn cột cần sắp xếp
781
+ * const orderBy = await userModel.isFields([req.query.sortBy as string]);
782
+ * await userModel.find({}, { [orderBy[0]]: 'DESC' });
783
+ * ```
784
+ */
165
785
  isFields(fields?: string[]): Promise<string[]>;
166
786
  /**
167
- * Get mapped display value from a related model.
787
+ * Đọc (và ghi nhớ) danh sách cột của một bảng.
168
788
  *
169
- * This method is commonly used to convert a foreign key value
170
- * into a human-readable field (e.g. `name`, `title`) from another table.
789
+ * Kết quả được cache theo tên bảng để tránh gọi `SHOW COLUMNS` lặp lại cho
790
+ * mỗi request — trước đây `isField`/`isFields` đều chạy một round-trip mỗi lần
791
+ * gọi (mẫu N+1).
171
792
  *
172
- * It will only query the related model when:
173
- * - `fieldVal` is provided
174
- * - `fieldVal` is different from the previous value (`itemOld`)
793
+ * Cache là thuộc tính của **instance**. Nếu schema bảng thay đổi trong lúc
794
+ * ứng dụng đang chạy, dùng `clearSchemaCache()` để nạp lại.
175
795
  *
176
- * @param field Target field name in current model
177
- * @param fieldVal New value of the field (usually a foreign key ID)
178
- * @param mdClass Related model class instance used to fetch data
179
- * @param itemOld Previous record data (used to detect value changes)
180
- * @param fieldMap Field name in related model to map from (default: `"name"`)
796
+ * @param table Tên bảng cần đọc schema
797
+ * @returns Danh sách tên cột
798
+ * @private
799
+ */
800
+ private getSchemaFields;
801
+ /**
802
+ * Xoá cache schema của model.
181
803
  *
182
- * @returns Mapped display value from related model,
183
- * or empty string if not found or unchanged
804
+ * Cần gọi khi bảng được migrate/ALTER trong lúc ứng dụng đang chạy.
184
805
  *
185
806
  * @example
186
807
  * ```ts
187
- * // Convert categoryId → categoryName
188
- * const categoryName = await productModel.getMapName(
189
- * "categoryId",
190
- * product.categoryId,
191
- * categoryModel,
192
- * oldProduct,
193
- * "name"
808
+ * await migrate();
809
+ * userModel.clearSchemaCache();
810
+ * ```
811
+ */
812
+ clearSchemaCache(): void;
813
+ /**
814
+ * Lấy giá trị hiển thị được ánh xạ từ model liên kết.
815
+ *
816
+ * Thường dùng để chuyển một giá trị khóa ngoại thành trường dễ đọc
817
+ * (ví dụ `name`, `title`) lấy từ bảng khác.
818
+ *
819
+ * Chỉ truy vấn model liên kết khi:
820
+ * - `fieldVal` có giá trị
821
+ * - `fieldVal` khác giá trị cũ (`itemOld`)
822
+ *
823
+ * @param field Tên trường đích trong model hiện tại
824
+ * @param fieldVal Giá trị mới của trường (thường là ID khóa ngoại)
825
+ * @param mdClass Instance model liên kết dùng để lấy dữ liệu
826
+ * @param itemOld Dữ liệu bản ghi cũ (dùng để phát hiện giá trị có thay đổi)
827
+ * @param fieldMap Tên trường cần lấy trong model liên kết (mặc định: `"name"`)
828
+ *
829
+ * @returns Giá trị hiển thị lấy từ model liên kết,
830
+ * hoặc chuỗi rỗng nếu không tìm thấy hoặc giá trị không đổi
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * // Chuyển roleId => roleName
835
+ * const roleName = await userModel.getMapName(
836
+ * 'roleId',
837
+ * user.roleId,
838
+ * roleModel,
839
+ * oldUser,
840
+ * 'name'
194
841
  * );
842
+ * // roleName => "Admin"
843
+ * // Nếu roleId không đổi so với oldUser hoặc không tìm thấy bản ghi => ""
195
844
  * ```
196
845
  */
197
- getMapName(field: string, fieldVal: any, mdClass: any, itemOld?: any, fieldMap?: string): Promise<any>;
846
+ getMapName(field: string, fieldVal: SqlParam, mdClass: IBaseModel | null | undefined, itemOld?: Partial<T> | null, fieldMap?: string): Promise<{}>;
198
847
  /**
199
- * Find records with pagination support
848
+ * Tìm bản ghi kèm phân trang.
849
+ *
850
+ * Chạy 2 câu truy vấn: một câu `COUNT(*)` để tính tổng số bản ghi
851
+ * và một câu `SELECT ... LIMIT ? OFFSET ?` để lấy dữ liệu trang hiện tại.
852
+ *
853
+ * @param filter Điều kiện lọc WHERE
854
+ * @param order Tùy chọn sắp xếp (ORDER BY) — key là tên cột (đã escape), value chỉ nhận `'ASC' | 'DESC'`
855
+ * @param join Cấu hình JOIN bảng; `on` chỉ nhận phép so sánh đơn giản giữa các cột
856
+ * @param select Danh sách cột cần lấy; không nhận SQL thô
857
+ * @param page Số trang, bắt đầu từ 1 (giá trị không hợp lệ sẽ được chuẩn hóa về 1)
858
+ * @param limit Số bản ghi mỗi trang (tối thiểu 1)
859
+ * @returns Object gồm `page`, `length`, `pageTotal`, `recordTotal` và `items`
860
+ *
861
+ * @example
862
+ * ```ts
863
+ * const result = await userModel.findWithPagination(
864
+ * { status: 1 },
865
+ * { id: 'DESC' },
866
+ * false, // join
867
+ * false, // select
868
+ * 2, // page: trang thứ 2
869
+ * 20 // limit: 20 bản ghi mỗi trang
870
+ * );
871
+ * // result.recordTotal => 45 (tổng số bản ghi)
872
+ * // result.pageTotal => 3 (Math.ceil(45 / 20))
873
+ * // result.items => mảng 20 bản ghi của trang 2
200
874
  *
201
- * @param filter Query conditions
202
- * @param order Sort options
203
- * @param join Join configuration
204
- * @param select Custom select fields
205
- * @param page Page number (starts from 1)
206
- * @param limit Items per page
875
+ * // Kết hợp join để lấy tên role kèm theo
876
+ * await userModel.findWithPagination(
877
+ * { status: 1 },
878
+ * { id: 'DESC' },
879
+ * { table: 'roles', on: { left: 'users.roleId', right: 'roles.id', operator: '=' } },
880
+ * [{ column: 'users.id' }, { column: 'users.fullname' }, { column: 'roles.name', alias: 'roleName' }]
881
+ * );
882
+ * ```
207
883
  */
208
- findWithPagination(filter?: Record<string, any>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: string | string[] | false, page?: number, limit?: number): Promise<PaginationResult<T>>;
884
+ findWithPagination(filter?: Filter<T>, order?: Record<string, string> | false, join?: JoinType | JoinType[] | false, select?: SqlSelectInput | false, page?: number, limit?: number): Promise<PaginationResult<T>>;
209
885
  }
886
+ /**
887
+ * Instance model dùng ở những vị trí không cần biết entity cụ thể, ví dụ container
888
+ * `Models` hay khai báo `modifieds`.
889
+ *
890
+ * Vì sao dùng `any` thay vì `ModelRow`: `BaseModel<T>` là **bất biến** (invariant)
891
+ * trong `T` — `T` xuất hiện ở cả vị trí tham số (`save(item: Partial<T>)`) lẫn kiểu
892
+ * trả về (`Promise<T | false>`) — nên `BaseModel<UserItem>` không thể gán cho
893
+ * `BaseModel<ModelRow>`. Nếu ràng buộc bằng `ModelRow` thì container sẽ không nhận
894
+ * được bất kỳ model nào khai báo entity bằng `interface`.
895
+ *
896
+ * Ở các vị trí này không có hàm nào của model được gọi, nên ràng buộc chỉ mang tính
897
+ * gợi ý; kiểu chính xác của từng model vẫn được giữ nguyên qua `InstanceType` trong
898
+ * `Models<T>`.
899
+ */
900
+ type AnyBaseModel = BaseModel<any>;
901
+ /** @see {@link AnyBaseModel} — phiên bản interface cho các vị trí tương tự. */
902
+ type AnyIBaseModel = IBaseModel<any>;
210
903
  //#endregion
211
904
  //#region src/Common.d.ts
212
905
  /**
@@ -214,19 +907,56 @@ declare abstract class BaseModel<T extends Record<string, any> & {
214
907
  * @param value Chuỗi mật khẩu gốc
215
908
  * @returns Chuỗi mật khẩu đã được hash
216
909
  */
217
- declare function makePassword(value: any): Promise<string>;
910
+ declare function makePassword(value: string | Buffer): Promise<string>;
218
911
  /**
219
912
  * 🔐 Kiểm tra mật khẩu với hash
220
913
  * @param value Mật khẩu người dùng nhập
221
914
  * @param hashedValue Mật khẩu đã hash lưu trong DB
222
915
  * @returns true nếu khớp, false nếu không
223
916
  */
224
- declare function checkPassword(value: any, hashedValue: any): Promise<boolean>;
917
+ declare function checkPassword(value: string | Buffer, hashedValue: string): Promise<boolean>;
918
+ /**
919
+ * 🛡️ Escape các ký tự đặc biệt của HTML.
920
+ *
921
+ * Đây là cách **loại bỏ rủi ro XSS triệt để** thay vì "xoá thẻ nguy hiểm": escape
922
+ * làm cho mọi thẻ HTML trở thành **văn bản hiển thị**, nên không tồn tại payload nào
923
+ * lọt qua được — kể cả `onerror`, `onload`, `javascript:`, `<svg>`, `<iframe>`...
924
+ *
925
+ * Phạm vi: escape `& < > " ' \`` — đủ cho cả ngữ cảnh văn bản lẫn thuộc tính HTML.
926
+ *
927
+ * Lưu ý: escape **mọi thứ**, kể cả thẻ vô hại. Nếu cần lưu rich-text (HTML do
928
+ * người dùng soạn bằng trình soạn thảo), hãy dùng thư viện sanitizer chuyên dụng
929
+ * (`sanitize-html`, `DOMPurify`) thay vì escape.
930
+ *
931
+ * @param input Chuỗi cần escape
932
+ * @returns Chuỗi đã escape; input không phải string trả `''`
933
+ *
934
+ * @example
935
+ * ```ts
936
+ * escapeHtml('<img src=x onerror=alert(1)>');
937
+ * // => '&lt;img src=x onerror=alert(1)&gt;' — hiển thị an toàn, không thực thi
938
+ * ```
939
+ */
940
+ declare function escapeHtml(input: string): string;
225
941
  /**
226
- * 🔢 Tạo chuỗi random
942
+ * 🎲 Tạo chuỗi ngẫu nhiên bằng CSPRNG (`node:crypto`).
943
+ *
944
+ * Dùng `crypto.randomInt()` chứ không phải `Math.random()`: `Math.random()` dùng
945
+ * thuật toán PRNG không phải sinh hoàn toàn và **không đảm bảo an toàn về mật mã**,
946
+ * tức chuỗi sinh ra có thể bị dự đoán nếu dùng cho OTP, token đặt lại mật khẩu hay
947
+ * khoá phiên.
948
+ *
949
+ * Tập ký tự sinh ra giống hệt bản cũ nên chuỗi trả về tương thích ngược.
950
+ *
227
951
  * @param length Độ dài chuỗi
228
- * @param az Có bao gồm chữ cái hay không
229
- * @returns Chuỗi random
952
+ * @param az Có bao gồm chữ cái hay không (mặc định chỉ chữ số)
953
+ * @returns Chuỗi ngẫu nhiên
954
+ *
955
+ * @example
956
+ * ```ts
957
+ * randomText(6); // '428193' — OTP
958
+ * randomText(32, true); // 32 ký tự chữ + số — CSRF token
959
+ * ```
230
960
  */
231
961
  declare function randomText(length: number, az?: boolean): string;
232
962
  /**
@@ -250,7 +980,7 @@ declare function slugify(text: string): string;
250
980
  * @param headers Header bổ sung
251
981
  * @returns JSON response
252
982
  */
253
- declare function callFetchApi(url: string, data?: any, type?: string, ctype?: string, headers?: Record<string, any>): Promise<any>;
983
+ declare function callFetchApi(url: string, data?: unknown, type?: string, ctype?: string, headers?: Record<string, string>): Promise<any>;
254
984
  /**
255
985
  * 🧠 Tạo chuỗi keyword phục vụ search
256
986
  * @param text Chuỗi đầu vào
@@ -300,7 +1030,7 @@ declare function toMySQLDateNowVN(): string;
300
1030
  * @param df Giá trị mặc định
301
1031
  * @returns Giá trị hoặc default
302
1032
  */
303
- declare function getValue(item: any, key: any, df?: any): any;
1033
+ declare function getValue<T>(item: Record<string, unknown> | T | null | undefined, key: keyof T | string, df?: unknown): unknown;
304
1034
  /**
305
1035
  * ➕ Tính tổng 1 cột trong mảng object
306
1036
  * @param items Mảng object
@@ -357,28 +1087,28 @@ declare function getTrueFlaseStatus(id: string | boolean | number, color?: boole
357
1087
  * @param arr2 Mảng thứ hai
358
1088
  * @returns Mảng chứa các phần tử chỉ xuất hiện ở một trong hai mảng
359
1089
  */
360
- declare function getDiffArr(arr1: any[], arr2: any[]): any[];
1090
+ declare function getDiffArr<T>(arr1: T[], arr2: T[]): T[];
361
1091
  /**
362
1092
  * 📦 Kiểm tra mảng arr1 có phải là tập con của arr2 hay không
363
1093
  * @param arr1 Mảng cần kiểm tra
364
1094
  * @param arr2 Mảng cha
365
1095
  * @returns true nếu mọi phần tử arr1 đều tồn tại trong arr2
366
1096
  */
367
- declare function isSubArr(arr1: any[], arr2: any[]): boolean;
1097
+ declare function isSubArr<T>(arr1: T[], arr2: T[]): boolean;
368
1098
  /**
369
1099
  * ➖ Lấy các phần tử chỉ có trong mảng thứ nhất
370
1100
  * @param arr1 Mảng gốc
371
1101
  * @param arr2 Mảng so sánh
372
1102
  * @returns Mảng các phần tử chỉ tồn tại trong arr1
373
1103
  */
374
- declare function getArrOnlyInFirst(arr1: any[], arr2: any[]): any[];
1104
+ declare function getArrOnlyInFirst<T>(arr1: T[], arr2: T[]): T[];
375
1105
  /**
376
1106
  * 📤 Lấy danh sách giá trị của một cột trong mảng object
377
1107
  * @param arr Mảng object
378
1108
  * @param field Tên field cần lấy
379
1109
  * @returns Mảng giá trị của field
380
1110
  */
381
- declare function getArrColumn(arr: any[], field: string): any[];
1111
+ declare function getArrColumn<T extends Record<string, unknown>>(arr: T[], field: keyof T | string): any[];
382
1112
  /**
383
1113
  * ➕ Tính tổng một cột trong mảng object với điều kiện tùy chọn
384
1114
  * @param arr Mảng object
@@ -386,7 +1116,7 @@ declare function getArrColumn(arr: any[], field: string): any[];
386
1116
  * @param conditions Điều kiện lọc (optional)
387
1117
  * @returns Tổng giá trị thỏa điều kiện
388
1118
  */
389
- declare function sumArrColumn<T extends Record<string, any>>(arr: T[], sumColumn: keyof T, conditions?: Partial<T>): number;
1119
+ declare function sumArrColumn<T extends Record<string, unknown>>(arr: T[], sumColumn: keyof T, conditions?: Partial<T>): number;
390
1120
  /**
391
1121
  * 🔢 Chuyển chuỗi số sang Decimal.js an toàn
392
1122
  * @param value Chuỗi hoặc số (có thể chứa ký tự khác)
@@ -399,7 +1129,7 @@ declare function decimalNumber(value: string | number): Decimal.Decimal;
399
1129
  * @param df Giá trị mặc định nếu parse lỗi
400
1130
  * @returns Object | Array | df
401
1131
  */
402
- declare function stringToJson(input: any, df?: Record<string, any>): any;
1132
+ declare function stringToJson<T = Record<string, unknown>>(input: unknown, df?: T): any;
403
1133
  /**
404
1134
  * 🌐 Lấy danh sách ngôn ngữ
405
1135
  * @param ids Danh sách id cần lọc hoặc false để lấy tất cả
@@ -411,20 +1141,92 @@ declare function getLangs(ids?: number[] | false): {
411
1141
  }[];
412
1142
  //#endregion
413
1143
  //#region src/Csrf.d.ts
1144
+ /**
1145
+ * Kiểm tra xem phương thức HTTP có phải là method làm thay đổi trạng thái (POST, PUT, PATCH, DELETE) hay không.
1146
+ *
1147
+ * @param method Tên phương thức HTTP (vd: 'GET', 'POST', 'delete')
1148
+ * @returns `true` nếu method thuộc POST, PUT, PATCH, DELETE; ngược lại `false`
1149
+ */
414
1150
  declare function isStateChangingMethod(method: string): boolean;
415
- declare function getCsrfTokenFromRequest(req: any): any;
416
- declare function ensureCsrfProtection(req: any, session: any, options?: any): {
417
- allowed: boolean;
418
- reason: null;
419
- } | {
1151
+ interface CsrfRequest {
1152
+ method?: string;
1153
+ headers?: Record<string, string | string[] | undefined>;
1154
+ body?: Record<string, unknown> | null;
1155
+ }
1156
+ interface CsrfSession {
1157
+ csrfToken?: string;
1158
+ }
1159
+ interface CsrfProtectionOptions {
1160
+ expectedToken?: string;
1161
+ }
1162
+ interface CsrfProtectionResult {
420
1163
  allowed: boolean;
421
- reason: string;
422
- };
1164
+ reason: string | null;
1165
+ }
1166
+ /**
1167
+ * Trích xuất CSRF token từ request Fastify/Express.
1168
+ * Token có thể được gửi qua header (`x-csrf-token`, `X-CSRF-Token`) hoặc body (`csrfToken`, `csrf_token`).
1169
+ *
1170
+ * @param req Đối tượng request
1171
+ * @returns Chuỗi CSRF token hoặc rỗng nếu không tìm thấy
1172
+ */
1173
+ declare function getCsrfTokenFromRequest(req: CsrfRequest | null | undefined): string;
1174
+ /**
1175
+ * Xác thực bảo vệ chống CSRF cho request.
1176
+ * - Với GET/HEAD/OPTIONS...: bỏ qua kiểm tra, luôn cho phép.
1177
+ * - Với POST/PUT/PATCH/DELETE: so sánh token trong session với token gửi từ request.
1178
+ *
1179
+ * Token được so sánh bằng `safeEqual()` (constant-time) để không bị lộ qua
1180
+ * timing attack.
1181
+ *
1182
+ * @param req Đối tượng request
1183
+ * @param session Đối tượng session chứa `csrfToken`
1184
+ * @param options Tùy chọn bổ sung, có thể truyền `expectedToken` nếu không nằm trong session
1185
+ * @returns Đối tượng `{ allowed: boolean, reason: string | null }`
1186
+ *
1187
+ * @example
1188
+ * ```ts
1189
+ * const check = ensureCsrfProtection(req, session);
1190
+ * if (!check.allowed) {
1191
+ * throw new ClientError('CSRF token không hợp lệ', 403);
1192
+ * }
1193
+ * ```
1194
+ */
1195
+ declare function ensureCsrfProtection(req: CsrfRequest | null | undefined, session: CsrfSession | null | undefined, options?: CsrfProtectionOptions): CsrfProtectionResult;
1196
+ //#endregion
1197
+ //#region src/Crypto.d.ts
1198
+ /**
1199
+ * So sánh hai chuỗi bí mật theo cách chống **timing attack**.
1200
+ *
1201
+ * `===` / `!==` dừng so sánh ngay khi gặp ký tự khác nhau, nên thời gian thực thi
1202
+ * phản ánh vị trí ký tự đầu tiên lệch → kẻ tấn công có thể dò từng ký tự secret.
1203
+ * `timingSafeEqual` so sánh toàn bộ byte trong thời gian cố định.
1204
+ *
1205
+ * Cả hai chuỗi được băm SHA-256 trước nhằm:
1206
+ * - luôn có cùng độ dài 32 byte để `timingSafeEqual` không ném lỗi khi hai chuỗi
1207
+ * khác độ dài;
1208
+ * - không làm lộ độ dài của secret qua thời gian chạy.
1209
+ *
1210
+ * @param a Chuỗi thứ nhất (ví dụ token người dùng gửi lên)
1211
+ * @param b Chuỗi thứ hai (ví dụ secret lưu trong session/env)
1212
+ * @returns `true` khi hai chuỗi giống nhau
1213
+ *
1214
+ * @example
1215
+ * ```ts
1216
+ * safeEqual(req.headers['x-api-key'] ?? '', process.env.API_KEY ?? '');
1217
+ * ```
1218
+ */
1219
+ declare function safeEqual(a: string, b: string): boolean;
423
1220
  //#endregion
424
1221
  //#region src/FastRequest.d.ts
425
1222
  /**
426
1223
  * Kiểu dữ liệu file upload tạm
427
1224
  */
1225
+ type RequestValue = string | number | boolean | null | undefined | RequestValue[] | {
1226
+ [key: string]: RequestValue;
1227
+ };
1228
+ type RequestBody = Record<string, RequestValue | unknown>;
1229
+ type FilterType = string;
428
1230
  interface FileType$1 {
429
1231
  filename: string;
430
1232
  basename: string;
@@ -449,7 +1251,7 @@ interface FileType$1 {
449
1251
  * - Import / export / upload file
450
1252
  */
451
1253
  declare class FastRequest {
452
- [x: string]: any;
1254
+ [x: string]: unknown;
453
1255
  /** Fastify request gốc */
454
1256
  private req;
455
1257
  /** Body sau khi parse */
@@ -465,13 +1267,19 @@ declare class FastRequest {
465
1267
  */
466
1268
  constructor(req: FastifyRequest);
467
1269
  /**
468
- * Start xử lý request
1270
+ * Parse dữ liệu request vào `body`/`files` để dùng cho các getter.
469
1271
  *
470
- * Công dụng:
471
- * - Parse body thường (JSON)
472
- * - Parse multipart/form-data
473
- * - Lưu file upload vào thư mục tạm
474
- * - Gom field & file theo đúng format
1272
+ * Điều kiện xử lý:
1273
+ * - `req.body` truthy được dùng nguyên trạng; nhánh multipart chỉ chạy khi
1274
+ * body falsy và `req.isMultipart()` trả `true`.
1275
+ * - Multipart field trùng tên: giá trị đầu giữ scalar, lần thứ hai trở đi
1276
+ * được gom thành mảng theo thứ tự.
1277
+ * - File chỉ được ghi khi `FileUpload.checkDefFile(...)` trả `true`; nếu ghi
1278
+ * lỗi, thông báo được log nhưng entry file vẫn được thêm với đường dẫn đích.
1279
+ * - Thư mục `uploads/tmb` được tạo nếu chưa tồn tại. Tên file dùng basename
1280
+ * đã slugify nhưng không được bảo đảm unique nếu các field trùng tên file.
1281
+ *
1282
+ * @returns `Promise<void>`
475
1283
  */
476
1284
  start: () => Promise<void>;
477
1285
  /**
@@ -488,54 +1296,226 @@ declare class FastRequest {
488
1296
  /** Check AJAX request */
489
1297
  isAjax(): boolean;
490
1298
  /**
491
- * Lấy dữ liệu POST
1299
+ * Lấy một giá trị từ body đã được `start()` parse.
1300
+ *
1301
+ * Điều kiện xử lý:
1302
+ * - `name` rỗng: trả toàn bộ `this.body` mà không filter.
1303
+ * - Field không tồn tại: trả `defaultValue`.
1304
+ * - Field là object có thuộc tính `value !== undefined`: lấy `field.value`.
1305
+ * - Các trường hợp còn lại: chuyển qua `FastRequest.filterSafeData()`.
1306
+ *
1307
+ * `defaultValue` được truyền xuống filter, nên còn được dùng khi `int`/`number`
1308
+ * không chuyển đổi được. Giá trị có sẵn kiểu `number` luôn được giữ nguyên.
1309
+ *
1310
+ * @param name Tên field; chuỗi rỗng dùng để lấy toàn bộ body
1311
+ * @param defaultValue Giá trị khi thiếu field hoặc chuyển kiểu số thất bại
1312
+ * @param type Kiểu filter, xem `FastRequest.filterSafeData`
1313
+ * @returns Toàn bộ body hoặc giá trị đã xử lý
1314
+ * @throws {Error} `invalid_max_safe_integer` hoặc `invalid_min_safe_integer`
1315
+ * khi giá trị số vượt giới hạn an toàn
1316
+ */
1317
+ getPost(name?: string, defaultValue?: unknown, type?: string): any;
1318
+ /**
1319
+ * Lấy giá trị của field đa ngôn ngữ có tên dạng `field{lang}`.
1320
+ *
1321
+ * Điều kiện xử lý:
1322
+ * - `name` rỗng hoặc không khớp đúng mẫu `field{lang}`: trả `defaultValue`.
1323
+ * - `field` trong body là chuỗi JSON: parse thành object/array trước khi đọc key `lang`.
1324
+ * - Body JSON không hợp lệ hoặc không đúng cấu trúc được xem như không có key `lang`.
1325
+ * - Giá trị tìm thấy được chuyển qua `FastRequest.filterSafeData()` với `defaultValue`.
1326
+ *
1327
+ * Với field lồng không tồn tại, kết quả phụ thuộc `type`: `int`/`number` trả
1328
+ * `defaultValue`; các loại chuỗi thông thường trả `''`, còn `raw` có thể trả `undefined`.
1329
+ *
1330
+ * @param name Tên theo mẫu `field{lang}`
1331
+ * @param defaultValue Giá trị khi tên sai hoặc dùng làm fallback của filter số
1332
+ * @param type Kiểu filter, xem `FastRequest.filterSafeData`
1333
+ * @returns Giá trị đa ngôn ngữ đã filter
1334
+ */
1335
+ getLangPost(name: string, defaultValue?: unknown, type?: string): any;
1336
+ /**
1337
+ * Lấy một route param từ `request.params`.
1338
+ *
1339
+ * Điều kiện xử lý:
1340
+ * - Không có `params`, hoặc `params[name]` là falsy (`undefined`, `null`, `''`,
1341
+ * `0`, `false`, `NaN`): trả `defaultValue`.
1342
+ * - Giá trị truthy được chuyển qua `FastRequest.filterSafeData()`.
1343
+ *
1344
+ * `defaultValue` không được truyền vào filter. Vì vậy khi `type='int'` hoặc
1345
+ * `type='number'` nhưng chuyển đổi thất bại, hàm trả `''` (fallback mặc định
1346
+ * của filter), không trả `defaultValue` đã truyền cho hàm.
1347
+ *
1348
+ * @param name Tên route param
1349
+ * @param defaultValue Giá trị khi param không tồn tại hoặc có giá trị falsy
1350
+ * @param type Kiểu filter, xem `FastRequest.filterSafeData`
1351
+ * @returns Giá trị param đã filter hoặc `defaultValue`
1352
+ * @throws {Error} Khi chuyển số vượt giới hạn `Number.MIN_SAFE_INTEGER` đến
1353
+ * `Number.MAX_SAFE_INTEGER`
1354
+ */
1355
+ getParam(name: string, defaultValue?: unknown, type?: string): any;
1356
+ /**
1357
+ * Lấy một query-string value hoặc toàn bộ query từ `request.query`.
1358
+ *
1359
+ * Điều kiện xử lý:
1360
+ * - `name` rỗng: trả toàn bộ `request.query` mà không filter.
1361
+ * - `query[name]` là `undefined`: trả `defaultValue`.
1362
+ * - Giá trị tồn tại (kể cả `null`, `''`, `0`, `false`): chuyển qua
1363
+ * `FastRequest.filterSafeData()`.
1364
+ *
1365
+ * `defaultValue` không được truyền vào filter. Vì vậy khi chuyển `int`/`number`
1366
+ * thất bại, hàm trả `''` chứ không trả `defaultValue` của hàm.
1367
+ *
1368
+ * @param name Tên query; chuỗi rỗng dùng để lấy toàn bộ query
1369
+ * @param defaultValue Giá trị khi query không có key
1370
+ * @param type Kiểu filter, xem `FastRequest.filterSafeData`
1371
+ * @returns Toàn bộ query hoặc giá trị đã filter
1372
+ * @throws {Error} Khi chuyển số vượt giới hạn an toàn
1373
+ */
1374
+ get(name?: string, defaultValue?: unknown, type?: string): any;
1375
+ /**
1376
+ * Kiểm tra key query có tồn tại hay không.
1377
+ *
1378
+ * Với một tên, tồn tại nghĩa là `query[name] !== undefined`; vì vậy `null`, chuỗi
1379
+ * rỗng, `0` và `false` vẫn được xem là có. Với mảng tên, tất cả key phải tồn tại
1380
+ * (`every`); mảng rỗng trả `true`.
1381
+ *
1382
+ * @param names Một tên query hoặc danh sách tên
1383
+ * @returns `true` khi toàn bộ tên được yêu cầu tồn tại
1384
+ */
1385
+ has(names: string | string[]): boolean;
1386
+ /**
1387
+ * Kiểm tra field body đã parse có tồn tại hay không.
492
1388
  *
493
- * @param name Tên field
494
- * @param defaultValue Giá trị mặc định
495
- * @param type Kiểu filter
1389
+ * Với một tên, tồn tại nghĩa là `body[name] !== undefined`; vì vậy `null`, chuỗi
1390
+ * rỗng, `0` và `false` vẫn được xem là có. Với mảng tên, tất cả field phải tồn
1391
+ * tại (`every`); mảng rỗng trả `true`.
496
1392
  *
497
- * @returns Giá trị đã filter
1393
+ * @param names Một tên field hoặc danh sách tên field
1394
+ * @returns `true` khi toàn bộ field được yêu cầu tồn tại
498
1395
  */
499
- getPost(name?: string, defaultValue?: any, type?: string): any;
1396
+ hasPost(names: string | string[]): boolean;
500
1397
  /**
501
- * Lấy dữ liệu POST dạng đa ngôn ngữ
1398
+ * Lấy header dưới dạng chuỗi.
502
1399
  *
503
- * Ví dụ field: title{vi}, title{en}
504
- */
505
- getLangPost(name: string, defaultValue?: any, type?: string): any;
506
- /**
507
- * Lấy param từ URL
508
- */
509
- getParam(name: string, defaultValue?: any, type?: string): any;
510
- /**
511
- * Lấy query string
1400
+ * Key được chuyển về chữ thường để phù hợp header của Node/Fastify. Giá trị được
1401
+ * ép sang chuỗi; header thiếu hoặc có giá trị falsy sau ép chuỗi sẽ trả `''`.
1402
+ *
1403
+ * @param key Tên header không phân biệt hoa thường
1404
+ * @returns Giá trị header hoặc chuỗi rỗng
512
1405
  */
513
- get(name?: string, defaultValue?: any, type?: string): any;
514
- /** Check tồn tại query */
515
- has(names: string | string[]): boolean;
516
- /** Check tồn tại post */
517
- hasPost(names: string | string[]): boolean;
518
- /** Lấy header */
519
1406
  getHeader(key: string): string;
520
1407
  /**
521
- * Filter & validate dữ liệu an toàn
1408
+ * Lọc/chuyển kiểu một giá trị đầu vào.
1409
+ *
1410
+ * Điều kiện chung:
1411
+ * - Nếu `input` đã là `number`, hàm trả nguyên giá trị đó ngay và bỏ qua mọi
1412
+ * kiểm tra/filter còn lại. Vì vậy `int` không cắt phần thập phân của số đã có,
1413
+ * và số vượt safe range cũng không bị lỗi.
1414
+ * - `defaultValue` chỉ được dùng khi chuyển `int`/`number` ra `NaN`.
1415
+ * - `type` không thuộc các nhánh chuyên biệt sẽ so sánh `typeof input === type`;
1416
+ * không khớp thì trả `''`.
1417
+ *
1418
+ * Các kiểu được hỗ trợ:
1419
+ * - `ids`: mảng ID number; loại NaN và loại trùng, giữ thứ tự xuất hiện.
1420
+ * - `stringIds`: như `ids` nhưng chuyển từng ID thành chuỗi.
1421
+ * - `strings`: mảng chuỗi; mỗi phần tử được `stripTags`, loại trùng.
1422
+ * - `dQuoteIds`: mảng ID number, mỗi ID thành chuỗi `%"id"%`.
1423
+ * - `int`: `Number(input)`, kiểm tra safe integer rồi `Math.trunc`.
1424
+ * - `number`: `Number(input)`, giữ nguyên phần thập phân, kiểm tra safe range.
1425
+ * - `decimal`: `Decimal`; mọi ký tự ngoài số và dấu `.` bị loại.
1426
+ * - `stripTags`: bỏ **mọi** thẻ rồi trim. An toàn khi xuất ra dạng text, nhưng
1427
+ * có thể mất dữ liệu (ví dụ `a < b and c > d` thành `a d`).
1428
+ * - `html`: escape `& < > " ' \`` (xem `escapeHtml`) — **không thể bypass**, nhưng
1429
+ * thẻ vô hại cũng thành text. Dùng khi lưu dữ liệu người dùng rồi render HTML.
1430
+ * - `date`: chuỗi ISO hợp lệ thành `yyyy-MM-dd`, ngày sai trả `null`.
1431
+ * - `datetime`: chuỗi ISO hợp lệ thành `yyyy-MM-dd HH:mm:ss`, ngày sai trả `null`.
1432
+ * - `raw`: JSON string được parse; JSON sai hoặc input không phải string được giữ nguyên.
1433
+ * - Ngoài ra có thể dùng tên `typeof`, ví dụ `boolean`, `object`, `string`.
1434
+ *
1435
+ * `date`, `datetime`, `html` và `stripTags` chỉ được áp dụng khi input là string.
1436
+ * Với input không phù hợp, chúng rơi xuống kiểm tra `typeof` và thường trả `''`.
522
1437
  *
523
1438
  * @param input Dữ liệu đầu vào
524
- * @param type Kiểu filter
525
- * @param defaultValue Giá trị fallback
1439
+ * @param type Kiểu filter/chuyển đổi; mặc định `stripTags`
1440
+ * @param defaultValue Fallback khi `int`/`number` không parse được; mặc định `''`
1441
+ * @returns Giá trị đã filter/chuyển kiểu
1442
+ * @throws {Error} `invalid_max_safe_integer` hoặc `invalid_min_safe_integer` khi
1443
+ * input không phải number sẵn có và giá trị chuyển đổi vượt safe range
1444
+ */
1445
+ static filterSafeData(input: unknown, type?: string, defaultValue?: unknown): any;
1446
+ /**
1447
+ * Parse raw input.
1448
+ *
1449
+ * - Input không phải string được trả nguyên trạng.
1450
+ * - String là JSON hợp lệ được parse thành kiểu tương ứng, kể cả null, boolean,
1451
+ * number, string, array hoặc object.
1452
+ * - JSON không hợp lệ được trả nguyên chuỗi gốc.
1453
+ *
1454
+ * @param input Giá trị cần parse
1455
+ * @returns Giá trị đã parse hoặc input gốc
526
1456
  */
527
- static filterSafeData(input: any, type?: string, defaultValue?: any): any;
528
- /** Parse raw JSON input */
529
1457
  private static parseRawInput;
530
- /** Filter HTML tránh XSS */
1458
+ /**
1459
+ /**
1460
+ * Escape HTML — biến mọi thẻ thành văn bản hiển thị.
1461
+ *
1462
+ * > ✅ **Không còn payload nào lọt qua.** Trước đây hàm này dùng regex để xoá
1463
+ * > riêng các thẻ `script`/`style`/`meta`, khiến các vector khác lọt nguyên vẹn
1464
+ * > (đã kiểm chứng): `<img src=x onerror=...>`, `<svg/onload=...>`,
1465
+ * > `<a href="javascript:...">`, `<iframe>`, `<body onload=...>`.
1466
+ * >
1467
+ * > Nay escape `& < > " ' \``, nên mọi thẻ đều thành text — không thể bypass.
1468
+ * > Đổi lại, **thẻ vô hại cũng bị escape**: `<p>Keep</p>` trở thành
1469
+ * > `&lt;p&gt;Keep&lt;/p&gt;`. Nếu cần lưu rich-text, hãy dùng thư viện sanitizer
1470
+ * > chuyên dụng (`sanitize-html`, `DOMPurify`).
1471
+ *
1472
+ * @param input Chuỗi cần escape
1473
+ * @returns Chuỗi đã escape; input không phải string trả `''`
1474
+ */
531
1475
  private static filterHtml;
532
- /** Filter mảng ID number */
1476
+ /**
1477
+ * Chuẩn hóa input thành mảng ID number.
1478
+ *
1479
+ * - Chuỗi JSON array được parse và dùng làm mảng.
1480
+ * - Chuỗi không phải JSON được thử `Number()`; số hợp lệ thành mảng một phần tử.
1481
+ * - Chuỗi không parse được trả `[]`.
1482
+ * - Mọi phần tử được `Number()`, bỏ `NaN`, rồi khử trùng; thứ tự đầu được giữ.
1483
+ * - Input không phải array/string trả `[]`.
1484
+ *
1485
+ * @param input Một ID, danh sách ID hoặc chuỗi JSON
1486
+ * @returns Mảng ID number duy nhất
1487
+ */
533
1488
  private static filterIds;
534
- /** Filter mảng ID string */
1489
+ /**
1490
+ * Lọc mảng ID rồi chuyển từng ID sang chuỗi.
1491
+ *
1492
+ * Chuỗi JSON không phải array hoặc input không hợp lệ trả `[]`; mảng hợp lệ đi
1493
+ * qua `filterIds()` nên vẫn loại `NaN` và trùng trước khi chuyển `String`.
1494
+ *
1495
+ * @param input Một ID, danh sách ID hoặc chuỗi JSON
1496
+ * @returns Mảng ID dạng chuỗi duy nhất
1497
+ */
535
1498
  private static filterStringIds;
536
- /** Filter mảng string */
1499
+ /**
1500
+ * Lọc và khử trùng một mảng giá trị.
1501
+ *
1502
+ * Từng phần tử được xử lý bằng `filterSafeData(..., 'stripTags')`; phần tử số được
1503
+ * giữ nguyên, chuỗi được bỏ thẻ và trim, giá trị khác thường trả `''`. Kết quả
1504
+ * được đưa vào `Set`, vì vậy so sánh trùng dùng semantics của JavaScript.
1505
+ *
1506
+ * @param input Danh sách giá trị
1507
+ * @returns Danh sách đã lọc và khử trùng
1508
+ */
537
1509
  private static filterStrings;
538
- /** Filter ID dạng LIKE "%id%" */
1510
+ /**
1511
+ * Chuyển mảng ID thành từng mẫu dạng `%"id"%`.
1512
+ *
1513
+ * Trước hết ID được ép number, loại `NaN` và trùng giống `filterIds()`; sau đó
1514
+ * mỗi ID được bọc trong hai dấu `%` và một cặp dấu nháy kép.
1515
+ *
1516
+ * @param input Danh sách ID
1517
+ * @returns Danh sách mẫu `%"id"%`
1518
+ */
539
1519
  private static filterDQuoteIds;
540
1520
  }
541
1521
  //#endregion
@@ -565,11 +1545,26 @@ declare class FileUpload {
565
1545
  constructor(files: {
566
1546
  [key: string]: FileType[];
567
1547
  }, uploadType?: string);
1548
+ /**
1549
+ * Nối `subPath` vào `baseDir` và bảo đảm kết quả vẫn nằm bên trong `baseDir`.
1550
+ *
1551
+ * Chặn **path traversal**: nếu không có bước này thì `folders = '../../etc'`
1552
+ * sẽ khiến file được ghi ra ngoài thư mục upload.
1553
+ *
1554
+ * @param baseDir Thư mục gốc (thư mục upload)
1555
+ * @param subPath Đường dẫn tương đối do ứng dụng truyền vào
1556
+ * @returns Đường dẫn tuyệt đối, chắc chắn nằm trong `baseDir`
1557
+ * @throws {Error} `invalid_upload_folder` khi `subPath` thoát ra khỏi `baseDir`
1558
+ */
1559
+ private static resolveInside;
568
1560
  /**
569
1561
  * Copy danh sách file từ src sang dist
570
1562
  * Dùng khi cần nhân bản file đã tồn tại
571
1563
  */
572
- copyFiles(files: any[]): Promise<void>;
1564
+ copyFiles(files: Array<{
1565
+ src: string;
1566
+ dist: string;
1567
+ }>): Promise<void>;
573
1568
  /**
574
1569
  * Upload 1 file theo field name
575
1570
  * @returns đường dẫn file sau upload hoặc false
@@ -585,6 +1580,11 @@ declare class FileUpload {
585
1580
  * - Tự tạo tên file theo timestamp
586
1581
  * - Tạo folder nếu chưa tồn tại
587
1582
  * - Move file từ temp sang thư mục upload
1583
+ *
1584
+ * `folders` và `file.filename` đều được kiểm tra để không thoát khỏi thư mục
1585
+ * upload (path traversal).
1586
+ *
1587
+ * @throws {Error} `invalid_upload_folder` khi `folders` hoặc tên file chứa đường dẫn thoát ra ngoài
588
1588
  */
589
1589
  upload(file: FileType, folders?: string): Promise<string | false>;
590
1590
  /**
@@ -598,6 +1598,8 @@ declare class FileUpload {
598
1598
  static removeFiles: (pathFiles: string[] | boolean, uploadType?: string) => boolean;
599
1599
  /**
600
1600
  * Xóa 1 file theo đường dẫn
1601
+ *
1602
+ * Đường dẫn phải nằm trong thư mục upload, nếu không sẽ bị từ chối.
601
1603
  */
602
1604
  static removeFile: (fileName: string | false, uploadType?: string) => boolean;
603
1605
  /**
@@ -627,28 +1629,190 @@ declare class FileUpload {
627
1629
  }
628
1630
  //#endregion
629
1631
  //#region src/JWTApp.d.ts
1632
+ interface JwtUser {
1633
+ id: number;
1634
+ fullname: string;
1635
+ }
1636
+ interface JwtRequest {
1637
+ ip?: string;
1638
+ headers: Record<string, string | string[] | undefined>;
1639
+ }
1640
+ interface JwtPayload {
1641
+ iss?: string;
1642
+ aud?: string;
1643
+ sub: number;
1644
+ name: string;
1645
+ ip?: string;
1646
+ us?: string;
1647
+ iat: number;
1648
+ exp: number;
1649
+ expired: number;
1650
+ accessToken: string;
1651
+ [key: string]: unknown;
1652
+ }
1653
+ /**
1654
+ * Lớp tiện ích xử lý JSON Web Token (JWT) cho ứng dụng.
1655
+ * Hỗ trợ tạo token, xác thực token từ Authorization header hoặc Cookie,
1656
+ * và kiểm tra khóa bảo mật hệ thống.
1657
+ */
630
1658
  declare class JWTApp {
631
- static createToken(user: any, req: any): string;
1659
+ /**
1660
+ * Tạo JWT token cho người dùng dựa trên thông tin IP, User-Agent và cấu hình env.
1661
+ * Biến môi trường sử dụng: `JWT_KEY`, `JWT_AUD`, `JWT_ISS`, `JWT_TIMEOUT` (mặc định 3600 giây).
1662
+ *
1663
+ * @param user Thông tin người dùng (yêu cầu thuộc tính `id`, `fullname`)
1664
+ * @param req Request Fastify/Express để lấy `ip` và `user-agent`
1665
+ * @returns Chuỗi JWT token đã được ký
1666
+ * @throws {Error} `missing_jwt_key` khi chưa cấu hình `JWT_KEY`
1667
+ *
1668
+ * @example
1669
+ * ```ts
1670
+ * const token = JWTApp.createToken({ id: 1, fullname: 'Admin' }, req);
1671
+ * ```
1672
+ */
1673
+ static createToken(user: JwtUser, req: JwtRequest): string;
1674
+ /**
1675
+ * Xác thực JWT token từ request (header Bearer hoặc Cookie).
1676
+ * Kiểm tra tính hợp lệ của token, thời gian hết hạn, và so khớp User-Agent
1677
+ * cùng IP của client.
1678
+ *
1679
+ * Hạn dùng được kiểm tra hai lần: `exp` (giây, do `jsonwebtoken` kiểm tra) và
1680
+ * trường tuỳ chỉnh `expired` (mili-giây) do `createToken()` ghi kèm.
1681
+ *
1682
+ * @param req FastifyRequest (có thể kèm `cookies`)
1683
+ * @param isCookie `true` nếu đọc từ cookie `accessToken`, `false` nếu đọc từ header `Authorization: Bearer <token>`
1684
+ * @returns Payload đã giải mã và xác thực kèm thuộc tính `accessToken`
1685
+ * @throws {Error | ClientError} Khi thiếu `JWT_KEY`, token bị thiếu, không hợp lệ
1686
+ * (sai chữ ký, sai user-agent, sai ip, sai user ID) hoặc đã hết hạn
1687
+ *
1688
+ * @example
1689
+ * ```ts
1690
+ * const userPayload = JWTApp.verifyToken(request);
1691
+ * ```
1692
+ */
632
1693
  static verifyToken(req: FastifyRequest & {
633
1694
  cookies?: Record<string, string | undefined>;
634
- }, isCookie?: boolean): any;
1695
+ }, isCookie?: boolean): JwtPayload;
1696
+ /**
1697
+ * Kiểm tra Secret Key được truyền vào có khớp với biến môi trường `JWT_SECRET_KEY` hay không.
1698
+ * Thường dùng cho các webhook nội bộ hoặc giao tiếp giữa các service.
1699
+ *
1700
+ * So sánh dùng `safeEqual()` (constant-time) để không bị lộ secret qua
1701
+ * timing attack.
1702
+ *
1703
+ * @param key Chuỗi secret key cần kiểm tra
1704
+ * @throws {Error} Khi biến môi trường `JWT_SECRET_KEY` chưa được thiết lập
1705
+ * @throws {ClientError} Khi khóa không khớp
1706
+ *
1707
+ * @example
1708
+ * ```ts
1709
+ * JWTApp.checkSecretKey(apiKeyHeader);
1710
+ * ```
1711
+ */
635
1712
  static checkSecretKey(key: string): void;
636
1713
  }
637
1714
  //#endregion
638
1715
  //#region src/Models.d.ts
639
- type ModelConstructor<T extends BaseModel<any> = BaseModel<any>> = new (pool: Pool) => T;
1716
+ /**
1717
+ * Kiểu constructor cho class kế thừa từ BaseModel
1718
+ *
1719
+ * Dùng `AnyBaseModel` làm ràng buộc: `BaseModel<T>` bất biến trong `T` nên ràng
1720
+ * buộc bằng `ModelRow` sẽ loại bỏ mọi model khai báo entity bằng `interface`.
1721
+ * @see {@link AnyBaseModel}
1722
+ */
1723
+ type ModelConstructor<T extends AnyBaseModel = AnyBaseModel> = new (pool: Pool) => T;
1724
+ /**
1725
+ * Kiểu map các model instance tương ứng với ModelConstructor
1726
+ */
640
1727
  type Models<T extends Record<string, ModelConstructor>> = { [K in keyof T]: InstanceType<T[K]>; };
1728
+ /**
1729
+ * Khởi tạo danh sách instance của các model từ modelMap và connection pool.
1730
+ *
1731
+ * @param modelMap Đối tượng chứa các class model constructor
1732
+ * @param pool MySQL Connection Pool
1733
+ * @returns Đối tượng chứa các instance model tương ứng
1734
+ *
1735
+ * @example
1736
+ * ```ts
1737
+ * const models = createModels({ user: UserModel, product: ProductModel }, pool);
1738
+ * ```
1739
+ */
641
1740
  declare function createModels<T extends Record<string, ModelConstructor>>(modelMap: T, pool: Pool): Models<T>;
1741
+ /**
1742
+ * Khởi tạo singleton container chứa các model toàn ứng dụng.
1743
+ * Nếu đã được khởi tạo trước đó, hàm trả về đối tượng hiện tại mà không khởi tạo lại.
1744
+ *
1745
+ * @param modelMap Đối tượng chứa các class model constructor
1746
+ * @param pool MySQL Connection Pool
1747
+ * @returns Singleton container chứa các instance model
1748
+ *
1749
+ * @example
1750
+ * ```ts
1751
+ * initModels({ user: UserModel, post: PostModel }, pool);
1752
+ * ```
1753
+ */
642
1754
  declare function initModels<T extends Record<string, ModelConstructor>>(modelMap: T, pool: Pool): Models<T>;
1755
+ /**
1756
+ * Lấy singleton container chứa các model đã khởi tạo.
1757
+ *
1758
+ * @template T Kiểu container models mong muốn
1759
+ * @returns Đối tượng các instance model
1760
+ * @throws {Error} Khi models chưa được gọi `initModels()`
1761
+ *
1762
+ * @example
1763
+ * ```ts
1764
+ * const { user, post } = getModels<AppModels>();
1765
+ * ```
1766
+ */
643
1767
  declare function getModels<T>(): T;
644
1768
  //#endregion
645
1769
  //#region src/MySQLSessionStore.d.ts
1770
+ type SessionData = Session;
1771
+ type SessionCallbackError = Error | null | undefined;
1772
+ type SessionResultCallback = (err: SessionCallbackError, session?: SessionData | null) => void;
1773
+ type SessionDoneCallback = (err?: SessionCallbackError) => void;
1774
+ interface SessionTableRow {
1775
+ data: string;
1776
+ }
1777
+ /**
1778
+ * Bộ lưu trữ Session MySQL cho @fastify/session.
1779
+ * Tự động tạo hoặc cập nhật session trong bảng `sessions` với thời gian hết hạn 14 ngày.
1780
+ */
646
1781
  declare class MySQLSessionStore implements SessionStore {
647
1782
  private pool;
648
- constructor(pool: Pool);
649
- get(sid: string, cb: (err: any, session?: any | null) => void): Promise<void>;
650
- set(sid: string, session: any, cb: (err?: any) => void): Promise<void>;
651
- destroy(sid: string, cb: (err?: any) => void): Promise<void>;
1783
+ /**
1784
+ * Số ngày session còn hiệu lực.
1785
+ *
1786
+ * Có thể chỉnh qua biến môi trường `SESSION_TTL_DAYS` (mặc định 14). Giá trị
1787
+ * không hợp lệ sẽ rơi về mặc định.
1788
+ */
1789
+ private readonly ttlDays;
1790
+ /**
1791
+ * Khởi tạo session store với MySQL connection pool.
1792
+ * @param pool MySQL Pool
1793
+ * @param ttlDays Số ngày hết hạn; bỏ trống thì đọc `SESSION_TTL_DAYS` hoặc dùng 14
1794
+ */
1795
+ constructor(pool: Pool, ttlDays?: number);
1796
+ /**
1797
+ * Lấy thông tin session theo session ID (`sid`).
1798
+ * @param sid Mã định danh session
1799
+ * @param cb Callback trả về lỗi hoặc session data (null nếu không tìm thấy)
1800
+ */
1801
+ get(sid: string, cb: SessionResultCallback): Promise<void>;
1802
+ /**
1803
+ * Lưu hoặc cập nhật session data theo session ID (`sid`).
1804
+ * Thời hạn session được gia hạn mỗi lần lưu, mặc định 14 ngày.
1805
+ * @param sid Mã định danh session
1806
+ * @param session Dữ liệu session cần lưu
1807
+ * @param cb Callback hoàn tất
1808
+ */
1809
+ set(sid: string, session: SessionData, cb: SessionDoneCallback): Promise<void>;
1810
+ /**
1811
+ * Xóa session khỏi cơ sở dữ liệu theo session ID (`sid`).
1812
+ * @param sid Mã định danh session cần hủy
1813
+ * @param cb Callback hoàn tất
1814
+ */
1815
+ destroy(sid: string, cb: SessionDoneCallback): Promise<void>;
652
1816
  }
653
1817
  //#endregion
654
1818
  //#region src/Route.d.ts
@@ -664,10 +1828,19 @@ declare class MySQLSessionStore implements SessionStore {
664
1828
  * @property method HTTP method (get | post | put | delete...)
665
1829
  * @property rateLimit Giới hạn request (optional)
666
1830
  */
1831
+ /**
1832
+ * Controller của route: một class được khởi tạo rồi gọi action theo tên.
1833
+ *
1834
+ * Không yêu cầu `Record<string, ...>` vì một class thông thường không có
1835
+ * implicit index signature, nên ràng buộc đó sẽ khiến `route.addGS(link, MyController, module)`
1836
+ * — đúng như README hướng dẫn — không compile. Việc controller có thật sự có
1837
+ * action tương ứng được kiểm tra lúc chạy ở phía đăng ký route.
1838
+ */
1839
+ type RouteController = new (...args: never[]) => object;
667
1840
  interface RouterType {
668
1841
  link: string;
669
1842
  module: string;
670
- controller: any;
1843
+ controller: RouteController;
671
1844
  action: string;
672
1845
  method?: string;
673
1846
  rateLimit?: number;
@@ -691,6 +1864,21 @@ interface RouterType {
691
1864
  * - Khai báo routing tập trung
692
1865
  * - Auto register route cho Fastify / Express
693
1866
  */
1867
+ interface AddGsOptions {
1868
+ /**
1869
+ * Định nghĩa route param cho `:code` trong `detail/:code`, `edit/:code` và
1870
+ * `delete/:code`.
1871
+ *
1872
+ * Mặc định `':code([0-9]+)'` — chỉ nhận mã dạng số. Truyền giá trị khác khi
1873
+ * mã nghiệp vụ không phải số, ví dụ:
1874
+ * - UUID: `':code([0-9a-fA-F-]{36})'`
1875
+ * - slug: `':code([a-z0-9-]+)'`
1876
+ * - chuỗi tuỳ ý: `':code'`
1877
+ *
1878
+ * @default ':code([0-9]+)'
1879
+ */
1880
+ codeParam?: string;
1881
+ }
694
1882
  declare class Route {
695
1883
  /** Danh sách route */
696
1884
  private routes;
@@ -700,7 +1888,7 @@ declare class Route {
700
1888
  * @param router RouterType
701
1889
  * @returns method (mặc định: GET)
702
1890
  */
703
- static getMethod(router: any): any;
1891
+ static getMethod(router: Pick<RouterType, 'method'>): string;
704
1892
  /**
705
1893
  * Thêm danh sách route với prefix
706
1894
  *
@@ -726,14 +1914,21 @@ declare class Route {
726
1914
  * - POST /copy
727
1915
  * - POST /import
728
1916
  * - GET /export
729
- * - GET /delete/:code
1917
+ * - POST /delete/:code
730
1918
  * - POST /delete
731
1919
  *
1920
+ * Mọi route làm thay đổi trạng thái đều dùng `POST`. Route `POST /delete/:code`
1921
+ * là `POST` (không phải `GET`) vì `GET` sẽ khiến thao tác xóa bị kích hoạt
1922
+ * bởi link, thẻ `<img>`, prefetch của trình duyệt — đồng thời `GET` không nằm
1923
+ * trong danh sách method mà `ensureCsrfProtection()` kiểm tra nên sẽ bị bỏ qua
1924
+ * CSRF.
1925
+ *
732
1926
  * @param link Base URL (vd: /admin/product)
733
1927
  * @param controller Controller xử lý
734
1928
  * @param module Tên module
1929
+ * @param options Tùy chọn, xem {@link AddGsOptions} (mặc định `:code` chỉ nhận số)
735
1930
  */
736
- addGS(link: string, controller: any, module: string): void;
1931
+ addGS(link: string, controller: RouteController, module: string, options?: AddGsOptions): void;
737
1932
  /**
738
1933
  * Lấy toàn bộ danh sách route
739
1934
  *
@@ -743,27 +1938,153 @@ declare class Route {
743
1938
  }
744
1939
  //#endregion
745
1940
  //#region src/Utils.d.ts
746
- declare function sanitizeAuditPayload(value: any, parentKey?: string): any;
747
- declare function buildAuditLogData(item: any, itemOld: any, ignoreFields?: never[]): {
748
- difference: any;
749
- item: any;
1941
+ type AuditValue = string | number | boolean | null | undefined | AuditValue[] | {
1942
+ [key: string]: AuditValue;
1943
+ };
1944
+ type SanitizedAuditValue = string | number | boolean | null | undefined | SanitizedAuditValue[] | {
1945
+ [key: string]: SanitizedAuditValue;
1946
+ };
1947
+ type AuditRecord = Record<string, unknown>;
1948
+ /**
1949
+ * Loại bỏ các trường thông tin nhạy cảm (mật khẩu, token, secret, api key...) khỏi payload để ghi audit log an toàn.
1950
+ * Hàm duyệt đệ quy qua các object và mảng lồng nhau.
1951
+ *
1952
+ * @param value Dữ liệu cần làm sạch
1953
+ * @param parentKey Tên key cha (phục vụ đệ quy)
1954
+ * @returns Dữ liệu đã được loại bỏ các field nhạy cảm
1955
+ */
1956
+ declare function sanitizeAuditPayload(value: unknown, parentKey?: string): SanitizedAuditValue;
1957
+ /**
1958
+ * Tạo dữ liệu phục vụ ghi nhận audit log giữa bản ghi mới và bản ghi cũ.
1959
+ * Tự động loại trừ các trường mặc định (`updatedAt`, `createdAt`, `__v`) cùng các trường chỉ định,
1960
+ * và khử trùng thông tin nhạy cảm khỏi log.
1961
+ *
1962
+ * @param item Bản ghi mới sau khi cập nhật
1963
+ * @param itemOld Bản ghi cũ trước khi cập nhật
1964
+ * @param ignoreFields Danh sách tên các trường cần bỏ qua không tính toán chênh lệch
1965
+ * @returns Đối tượng chứa:
1966
+ * - `difference`: Chi tiết các trường thay đổi `{ field: { old, new } }`
1967
+ * - `item`: Bản ghi mới đã khử trùng
1968
+ *
1969
+ * @example
1970
+ * ```ts
1971
+ * const audit = buildAuditLogData(newData, oldData, ['lastActive']);
1972
+ * ```
1973
+ */
1974
+ declare function buildAuditLogData(item: AuditRecord | null | undefined, itemOld: AuditRecord | null | undefined, ignoreFields?: string[]): {
1975
+ difference: SanitizedAuditValue;
1976
+ item: SanitizedAuditValue;
750
1977
  };
751
- declare function getCorsOriginPolicy(corsOrigins: any, isProduction: boolean | number): any;
1978
+ /**
1979
+ * Xác định chính sách nguồn gốc CORS (origin) dựa trên danh sách origins cấu hình và môi trường chạy.
1980
+ *
1981
+ * @param corsOrigins Danh sách allowed origins (mảng hoặc chuỗi)
1982
+ * @param isProduction Cờ xác định môi trường production (boolean hoặc 1/0)
1983
+ * @returns
1984
+ * - Nếu có cấu hình `corsOrigins`: trả về chính `corsOrigins`
1985
+ * - Nếu không có: trả về `false` (chặn) trên production, hoặc `true` (cho phép tất cả) trên dev/local
1986
+ */
1987
+ declare function getCorsOriginPolicy(corsOrigins: string | string[] | null | undefined, isProduction: boolean | number): string | boolean | string[];
752
1988
  //#endregion
753
1989
  //#region src/Validation.d.ts
1990
+ type ValidationValue = string | number | boolean | null | undefined | ValidationValue[] | {
1991
+ [key: string]: ValidationValue;
1992
+ };
1993
+ /** Dữ liệu cần validate; là object phẳng `field -> value`. */
1994
+ type ValidationItem = Record<string, ValidationValue>;
1995
+ type ValidationRules = Record<string, string>;
1996
+ interface ValidationEntry {
1997
+ field: string;
1998
+ value: ValidationValue;
1999
+ rules: string;
2000
+ fieldName: string;
2001
+ /**
2002
+ * Danh sách args dự phòng cho rule không có tham số.
2003
+ * Giữ theo cấu trúc cũ (`filed`) nên thường là `undefined` khi tạo từ `runValidate()`.
2004
+ */
2005
+ filed?: string[];
2006
+ }
2007
+ /**
2008
+ * Bộ validator dùng rule dạng chuỗi, ví dụ `required|minLen(6)`.
2009
+ *
2010
+ * Quy ước chung:
2011
+ * - `runValidate()` chuẩn hóa `null`/`undefined` của field thành chuỗi rỗng.
2012
+ * - Rule có tham số dùng `tenRule(doiSo1,doiSo2)`; các đối số luôn là chuỗi.
2013
+ * - Rule chạy theo thứ tự trong từng chuỗi; các field được validate tuần tự.
2014
+ * `skip=true` dừng ngay tại lỗi đầu và trả `false`.
2015
+ * - Nên kết hợp `required` hoặc `requiredId` với các rule regex/định dạng vì
2016
+ * nhiều rule định dạng coi giá trị rỗng là hợp lệ.
2017
+ *
2018
+ * Validator hoàn toàn thuần: không truy vấn database và không phụ thuộc model.
2019
+ * Tên hiển thị trong thông báo lỗi chính là tên field (`validate`).
2020
+ * Việc kiểm tra trùng lặp phải tự thực hiện bằng query ở tầng nghiệp vụ.
2021
+ *
2022
+ * Lưu ý: phần mô tả ngay trên từng rule mô tả chính xác điều kiện đang được
2023
+ * kiểm tra bởi implementation hiện tại, kể cả các giới hạn của biểu thức chính quy.
2024
+ */
754
2025
  declare class Validation {
755
- private md?;
756
- constructor(md?: IBaseModel<any>);
2026
+ /** Danh sách thông báo lỗi của lần validate hiện tại. */
757
2027
  private errors;
758
- runValidate(item: any, vdObject?: Record<string, any>): Promise<void>;
759
2028
  /**
760
- * Start the validation using values and rules passed in data
761
- * @param array data
762
- * @param bool skip To skip validations as soon as one of the rules fails+
763
- * @throws Error if rule method doesn't exist
764
- * @return bool
2029
+ * Validate toàn bộ field theo cấu hình rule.
2030
+ *
2031
+ * Điều kiện xử lý:
2032
+ * - Mỗi lần gọi đều xoá lỗi của lần trước, nên một instance có thể tái
2033
+ * sử dụng cho nhiều request mà không bị dính lỗi cũ.
2034
+ * - `validate` không có entry: dừng ngay và không ghi lỗi.
2035
+ * - Chỉ các own enumerable field của `validate` được validate.
2036
+ * - `item` không tồn tại vẫn được xử lý; mỗi field nullish được đổi thành `''`.
2037
+ * - Tên hiển thị lấy trực tiếp từ tên field trong `validate`; validator không
2038
+ * gọi tới model nên không có tên hiển thị tùy chỉnh.
2039
+ * - Sau khi chạy mọi rule, nếu danh sách lỗi không rỗng thì ném một `Error` chứa
2040
+ * toàn bộ thông báo nối bằng `, `.
2041
+ * - Thông báo lỗi được tra theo `lang` trong từ điển i18n (khóa `validate_<rule>`).
2042
+ * Ngôn ngữ chưa có bản dịch sẽ dùng thông báo chung `validate_default`.
2043
+ * - Tên rule phải nằm trong allowlist `RULES`; tên từ `Object.prototype`
2044
+ * (`toString`, `valueOf`, `constructor`...) bị từ chối.
2045
+ *
2046
+ * @param item Dữ liệu cần validate
2047
+ * @param validate Map `field -> rule1|rule2`; rule phải là chuỗi
2048
+ * @param lang Mã ngôn ngữ cho thông báo lỗi (mặc định `'vi'`)
2049
+ * @returns `Promise<void>`
2050
+ * @throws {Error} Khi có ít nhất một rule fail hoặc rule không tồn tại
2051
+ *
2052
+ * @example
2053
+ * ```ts
2054
+ * const rules = { email: 'required|email', age: 'required|rangeNum(18,60)' };
2055
+ *
2056
+ * await validator.runValidate({ email: 'sai', age: 10 }, rules, 'vi');
2057
+ * // throw: "Email không đúng định dạng, age phải nằm trong khoản từ 18 đến 60"
2058
+ *
2059
+ * await validator.runValidate({ email: 'sai', age: 10 }, rules, 'en');
2060
+ * // throw: "Email is not in the correct format, age must be between 18 and 60"
2061
+ * ```
2062
+ */
2063
+ runValidate(item: ValidationItem | null | undefined, validate?: ValidationRules, lang?: string): Promise<void>;
2064
+ /**
2065
+ * Chạy danh sách rule đã chuẩn hóa và ghi lỗi khi rule trả false.
2066
+ *
2067
+ * Điều kiện xử lý:
2068
+ * - `validation` được duyệt tuần tự; mỗi entry cần `rules`, `value` và
2069
+ * `fieldName` (`filed` được đọc để giữ tương thích nhưng thường là undefined).
2070
+ * - `rules` phải là chuỗi được phân tách bằng `|`; chỉ phần có tham số được
2071
+ * trim trong `getRuleArgs()`. Tên rule không có tham số không được trim trước khi tra cứu.
2072
+ * - Với rule có tham số, `args` là mảng chuỗi từ `getRuleArgs()`; với rule không
2073
+ * có tham số, `args` lấy từ `dtVal.filed` (thường `undefined` trong dữ liệu
2074
+ * do `runValidate()` tạo và được `addError()` xử lý như danh sách rỗng).
2075
+ * - `skip=true` dừng ngay tại rule fail đầu tiên; rule/field sau đó không chạy.
2076
+ * - Mọi rule đều thuần và đồng bộ; không có rule nào truy vấn database.
2077
+ * - Rule không tồn tại hoặc tên rule rỗng sẽ ném lỗi từ `this[method]()`.
2078
+ * - `passed` được trả về nhưng caller `runValidate()` kiểm tra `getErrors()` thay vì
2079
+ * dùng giá trị này.
2080
+ *
2081
+ * @param validation Danh sách field/value/rules đã chuẩn hóa
2082
+ * @param skip Dừng ngay sau rule fail đầu tiên
2083
+ * @param lang Mã ngôn ngữ cho thông báo lỗi (mặc định `'vi'`)
2084
+ * @returns `false` nếu có ít nhất một rule fail; ngược lại `true`
2085
+ * @throws {Error} Khi không gọi được rule hoặc rule async phát sinh lỗi
765
2086
  */
766
- validate(validation: any, skip?: boolean): Promise<boolean>;
2087
+ validate(validation: ValidationEntry[], skip?: boolean, lang?: string): Promise<boolean>;
767
2088
  /**
768
2089
  * Determine if a given rule has arguments, Ex: max(4)
769
2090
  *
@@ -772,27 +2093,44 @@ declare class Validation {
772
2093
  */
773
2094
  private isruleHasArgs;
774
2095
  /**
775
- * get rule name for rules that have args
2096
+ * Tách tên rule khỏi phần tham số trong ngoặc.
776
2097
  *
777
- * @param string rule
778
- * @return string
2098
+ * `minLen(6)` trả `minLen`; phần trước dấu `(` đầu tiên được giữ nguyên.
2099
+ *
2100
+ * @param rule Chuỗi rule
2101
+ * @returns Tên rule
779
2102
  */
780
2103
  private getRuleName;
781
2104
  /**
782
- * get arguments for rules that have args
2105
+ * Kiểm tra `hasRuleArgs()` rồi lấy đối số của rule.
783
2106
  *
784
- * @param string rule
785
- * @return array
2107
+ * Điều kiện:
2108
+ * - Hàm chỉ được gọi sau `isruleHasArgs(rule) === true`.
2109
+ * - Lấy phần sau dấu `(` đầu tiên; nếu có `)` ở cuối thì cắt đi rồi tách theo
2110
+ * `,`. Không trim từng đối số, không escape và không parse số.
2111
+ * - `rule()` với phần giữa rỗng trả `['']`; nếu gọi trực tiếp với rule không
2112
+ * có `(` thì `args` là `undefined` và có thể ném lỗi tại `endsWith`.
2113
+ *
2114
+ * @param rule Chuỗi rule như `minLen(6)` hoặc `rangeNum(1,10)`
2115
+ * @returns Mảng đối số nguyên bản
786
2116
  */
787
2117
  private getRuleArgs;
788
2118
  /**
789
- * Add an error
2119
+ * Thêm thông báo cho rule fail.
790
2120
  *
791
- * @param string rule
792
- * @param string placeholder for filed
793
- * @param mixed value
794
- * @param array args
2121
+ * Điều kiện:
2122
+ * - `rule` rỗng: không làm gì.
2123
+ * - Có message mặc định: thay `{placeholder}`, `{value}` và `{0}`, `{1}`...
2124
+ * bằng dữ liệu thực; value không phải string bị thay bằng chuỗi rỗng.
2125
+ * Giá trị `args` mặc định là `[]`; nếu truyền `null` thì có thể lỗi khi gọi `forEach`.
2126
+ * - Message mặc định là chuỗi rỗng: không ghi lỗi ở đây (rule phải tự xử lý).
2127
+ * - Không có message mặc định: ghi thông báo tổng quát bằng tên field.
795
2128
  *
2129
+ * @param rule Tên rule không có tham số
2130
+ * @param placeholder Tên field hiển thị
2131
+ * @param value Giá trị đã validate
2132
+ * @param args Tham số rule để điền vào message
2133
+ * @param lang Mã ngôn ngữ dùng để tra thông báo trong từ điển
796
2134
  */
797
2135
  private addError;
798
2136
  /**
@@ -815,144 +2153,370 @@ declare class Validation {
815
2153
  /** ************** Validations ************** **/
816
2154
  /** *********************************************** **/
817
2155
  /**
818
- * Is value not empty?
819
- * @param mixed value
820
- * @return bool
2156
+ * Rule `required`: yêu cầu value khác nullish và chuỗi không rỗng sau trim.
2157
+ *
2158
+ * Mọi giá trị khác nullish đều hợp lệ, kể cả `0`, `false`, `NaN`, mảng/object rỗng;
2159
+ * chỉ chuỗi có `trim() === ''` bị từ chối.
2160
+ *
2161
+ * @param value Giá trị field
2162
+ * @returns `true` khi có giá trị theo điều kiện trên
821
2163
  */
822
2164
  private required;
823
2165
  /**
824
- * Is value not empty?
825
- * @param mixed value
826
- * @return bool
2166
+ * Rule `requiredId`: yêu cầu ID khác nullish, chuỗi không rỗng sau trim và
2167
+ * không bằng số `0`.
2168
+ *
2169
+ * `false`, object, mảng và các số khác 0 vẫn hợp lệ; chuỗi `'0'` cũng hợp lệ
2170
+ * vì điều kiện chỉ chặn number `0`.
2171
+ *
2172
+ * @param value Giá trị ID
2173
+ * @returns `true` khi ID đạt điều kiện
827
2174
  */
828
2175
  private requiredId;
829
2176
  /**
830
- * min string length
831
- * @param string str
832
- * @param array args(min)
833
- * @return bool
2177
+ * Rule `minLen`: độ dài chuỗi phải lớn hơn hoặc bằng `parseInt(args[0])`.
2178
+ *
2179
+ * Giá trị được kỳ vọng là string; rule không tự kiểm tra `typeof` và không trim.
2180
+ * `parseInt` không có radix nên có thể đọc tiền tố của chuỗi tham số.
2181
+ *
2182
+ * @param str Chuỗi cần đo
2183
+ * @param args Mảng tham số, `args[0]` là độ dài tối thiểu
2184
+ * @returns `true` khi độ dài đạt yêu cầu
834
2185
  */
835
2186
  private minLen;
2187
+ /**
2188
+ * Rule `equalLen`: độ dài chuỗi phải bằng `parseInt(args[0])` chính xác.
2189
+ *
2190
+ * Không trim và không kiểm tra kiểu đầu vào; giá trị được kỳ vọng là string.
2191
+ *
2192
+ * @param str Chuỗi cần đo
2193
+ * @param args Mảng tham số, `args[0]` là độ dài yêu cầu
2194
+ * @returns `true` khi độ dài bằng yêu cầu
2195
+ */
836
2196
  private equalLen;
837
2197
  /**
838
- * max string length
2198
+ * Rule `maxLen`: độ dài chuỗi phải nhỏ hơn hoặc bằng `parseInt(args[0])`.
839
2199
  *
840
- * @param string str
841
- * @param array args(max)
2200
+ * Không trim và không kiểm tra `typeof`; giá trị được kỳ vọng là string.
842
2201
  *
843
- * @return bool
2202
+ * @param str Chuỗi cần đo
2203
+ * @param args Mảng tham số, `args[0]` là độ dài tối đa
2204
+ * @returns `true` khi độ dài không vượt quá
844
2205
  */
845
2206
  private maxLen;
846
2207
  /**
847
- * check if number between given range of numbers
2208
+ * Rule `rangeNum`: value phải nằm trong khoảng đóng từ `args[0]` đến `args[1]`.
848
2209
  *
849
- * @param int num
850
- * @param array args(min,max)
851
- * @return bool
2210
+ * Hai bound được đọc bằng `parseInt`; phép so sánh JavaScript có thể coerce
2211
+ * value. Không tự kiểm tra `typeof`, không kiểm tra số nguyên và không trim.
2212
+ *
2213
+ * @param num Giá trị cần kiểm tra
2214
+ * @param args Mảng tham số `[min, max]`
2215
+ * @returns `true` khi `min <= num <= max`
852
2216
  */
853
2217
  private rangeNum;
854
2218
  /**
855
- * check if number between given range of numbers
2219
+ * Rule `max`: value phải nhỏ hơn hoặc bằng `Number(args[0])`.
856
2220
  *
857
- * @param int num
858
- * @param array args(min,max)
859
- * @return bool
2221
+ * Bound được parse bằng `Number`, không phải `parseInt`; không tự kiểm tra kiểu
2222
+ * value. `Number('abc')` thành `NaN` nên phép so sánh thường fail.
2223
+ *
2224
+ * @param num Giá trị cần kiểm tra
2225
+ * @param args Mảng tham số, `args[0]` là ngưỡng trên
2226
+ * @returns Kết quả phép so sánh `num <= Number(args[0])`
860
2227
  */
861
2228
  private max;
2229
+ /**
2230
+ * Rule `min`: value phải lớn hơn hoặc bằng `Number(args[0])`.
2231
+ *
2232
+ * Bound được parse bằng `Number`, không phải `parseInt`; không tự kiểm tra kiểu
2233
+ * value. `Number('abc')` thành `NaN` nên phép so sánh thường fail.
2234
+ *
2235
+ * @param num Giá trị cần kiểm tra
2236
+ * @param args Mảng tham số, `args[0]` là ngưỡng dưới
2237
+ * @returns Kết quả phép so sánh `num >= Number(args[0])`
2238
+ */
862
2239
  private min;
863
2240
  /**
864
- * check if value is a valid number
2241
+ * Rule `integer`: `parseInt(value)` không phải `NaN`.
865
2242
  *
866
- * @param string|integer value
867
- * @return bool
2243
+ * Đây không phải kiểm tra số nguyên nghiêm ngặt: `parseInt('12abc')` là 12,
2244
+ * `parseInt('1.9')` là 1, và chuỗi rỗng thường fail. Giá trị falsy không được
2245
+ * guard riêng trong method này.
2246
+ *
2247
+ * @param value Giá trị cần parse
2248
+ * @returns `true` nếu `parseInt(value)` không phải `NaN`
2249
+ */
2250
+ /**
2251
+ * Rule `integer`: giá trị phải là một số nguyên hợp lệ.
2252
+ *
2253
+ * Dùng `Number()` + `Number.isInteger()` thay vì `parseInt()` vì `parseInt`
2254
+ * chỉ đọc **tiền tố** nên `'12abc'` và `'12.5'` đều bị coi là hợp lệ.
2255
+ * Chuỗi rỗng / toàn khoảng trắng bị từ chối vì `Number('')` là `0`.
2256
+ *
2257
+ * @param value Giá trị cần kiểm tra
2258
+ * @returns `true` khi value là số nguyên
868
2259
  */
869
2260
  private integer;
870
2261
  /**
871
- * check if value(s) is in a given array
2262
+ * Rule `inArray`: giá trị phải thuộc danh sách tham số của rule.
872
2263
  *
873
- * @param string|array value
874
- * @param array arr
875
- * @return bool
2264
+ * - Primitive (chuỗi/số): so sánh membership bằng `includes` trên `String(value)`.
2265
+ * - Object/array: **mọi value bên trong** phải thuộc danh sách; object rỗng pass.
2266
+ * - So sánh luôn là so sánh chuỗi nên `inArray(1,2)` nhận cả `1` và `'1'`.
2267
+ *
2268
+ * Trước đây rule dùng toán tử `in`, mà `in` trên mảng chỉ kiểm tra **index**
2269
+ * (`'a' in ['a']` là `false`) chứ không kiểm tra membership — khiến
2270
+ * `inArray(a)` với giá trị `'a'` luôn thất bại.
2271
+ *
2272
+ * @param value Giá trị hoặc object/array cần kiểm tra
2273
+ * @param arr Danh sách giá trị hợp lệ, lấy từ tham số rule `inArray(a,b,c)`
2274
+ * @returns `true` khi mọi giá trị được kiểm tra đều thuộc `arr`
876
2275
  */
877
2276
  private inArray;
2277
+ /**
2278
+ * Rule `inArrayNumber`: kiểm tra value hoặc từng phần tử object/array nằm trong
2279
+ * danh sách số.
2280
+ *
2281
+ * - `arr` được chuyển từng phần tử sang `Number` trước khi so sánh.
2282
+ * - Mảng: kiểm tra từng phần tử. Object thường: kiểm tra từng **value**.
2283
+ * - `null`/`undefined` trả `false` thay vì ném `TypeError` — trước đây
2284
+ * `typeof null === 'object'` khiến nhánh object được gọi `.every` trên `null`.
2285
+ * - Với primitive: so sánh nghiêm ngặt, nên chuỗi `'1'` không khớp số `1`.
2286
+ *
2287
+ * @param value Giá trị hoặc danh sách cần kiểm tra
2288
+ * @param arr Danh sách giá trị được phép
2289
+ * @returns `true` khi tất cả giá trị được chuyển số nằm trong danh sách
2290
+ */
878
2291
  private inArrayNumber;
879
2292
  /**
880
- * check if value is contains alphabetic characters and numbers
2293
+ * Rule `alphaNum`: chỉ cho phép chữ ASCII `[a-zA-Z0-9]`, ít nhất một ký tự.
881
2294
  *
882
- * @param mixed value
883
- * @return bool
2295
+ * Giá trị truthy được `RegExp.test()` ép sang chuỗi theo JavaScript; không có
2296
+ * guard `typeof` riêng. Chuỗi có khoảng trắng, dấu hoặc ký tự Unicode không hợp lệ.
2297
+ *
2298
+ * @param value Giá trị cần kiểm tra
2299
+ * @returns `true` khi value falsy hoặc chỉ gồm chữ/số ASCII
884
2300
  */
885
2301
  private alphaNum;
886
2302
  /**
887
- * check if value is contains alphabetic characters and numbers
2303
+ * Rule `number`: `true` khi value là số nằm trong khoảng an toàn.
888
2304
  *
889
- * @param mixed value
890
- * @return bool
2305
+ * Trước đây rule này **ném** `INVALID_SAFE_NUMBER` khi số vượt khoảng trong
2306
+ * khi 18 rule còn lại đều trả `false`. Hệ quả là `runValidate()` dừng giữa
2307
+ * chừng, các field phía sau không được kiểm tra, và lỗi trả về có dạng khác
2308
+ * hẳn so với lỗi validate thông thường. Nay mọi rule đều tuân theo cùng một
2309
+ * hợp đồng: trả boolean.
2310
+ *
2311
+ * `NaN` giờ bị từ chối (trước đây lọt qua vì mọi phép so sánh với `NaN` đều
2312
+ * cho `false`).
2313
+ *
2314
+ * @param value Giá trị cần kiểm tra
2315
+ * @returns `true` khi value là số hữu hạn trong khoảng `MIN/MAX_SAFE_INTEGER`
891
2316
  */
892
2317
  private number;
893
2318
  /**
894
- * check if value is contains alphabetic characters, numbers and spaces
2319
+ * Rule `alphaNumWithSpaces`: chỉ cho phép chữ/số ASCII và khoảng trắng ASCII.
895
2320
  *
896
- * @param mixed value
897
- * @return bool
2321
+ * Regex là `/^[a-z0-9 ]+$/i`, nên phải có ít nhất một ký tự và chỉ cho phép
2322
+ * khoảng trắng ASCII; space ở đầu/cuối vẫn được phép, còn tab, dấu và ký tự
2323
+ * Unicode không hợp lệ. Value falsy được coi là hợp lệ và trả `true`.
2324
+ *
2325
+ * @param value Giá trị cần kiểm tra
2326
+ * @returns `true` khi value rỗng/falsy hoặc chỉ gồm chữ/số/space ASCII
898
2327
  */
899
2328
  private alphaNumWithSpaces;
900
2329
  /**
901
- * check if password has at least
902
- * - one lowercase letter
903
- * - one uppercase letter
904
- * - one number
905
- * - one special(non-word) character
2330
+ * Rule `password`: yêu cầu mật khẩu 8-32 ký tự trong regex hiện tại.
2331
+ *
2332
+ * Regex bắt buộc có ít nhất một chữ số, chữ thường, chữ hoa và một ký tự đặc
2333
+ * biệt trong tập `@#!%^&*`; phần ký tự được phép là chữ/số và các ký tự này.
2334
+ * Có `$` ở cuối nên chuỗi dài hơn 32 ký tự hoặc chứa ký tự lạ đều bị từ chối;
2335
+ * value falsy được coi là hợp lệ.
2336
+ *
2337
+ * @param value Mật khẩu cần kiểm tra
2338
+ * @returns `true` khi value falsy hoặc match regex
906
2339
  */
907
2340
  private password;
908
2341
  /**
909
- * - Phone VN
910
- */
2342
+ * Rule `phoneVn`: regex số điện thoại Việt Nam hiện tại.
2343
+ *
2344
+ * Pattern là `/^[03|05|07|08|09]{2}[0-9]{8}/`: hai ký tự đầu phải thuộc tập ký
2345
+ * tự `0,3,|,5,7,8,9`, sau đó tối thiểu tám chữ số. Pattern không có `$`, nên
2346
+ * chuỗi dài hơn vẫn có thể match; falsy value được trả `true`.
2347
+ *
2348
+ /**
2349
+ * Rule `phoneVn`: số điện thoại di động Việt Nam gồm 10 chữ số.
2350
+ *
2351
+ * Theo quy hoạch số thuê bao của Bộ TT&TT, định dạng số di động là
2352
+ * `03xx xxx xxx`, `05xx xxx xxx`, `07xx xxx xxx`, `08xx xxx xxx`,
2353
+ * `09xx xxx xxx` — tức **chữ số thứ hai** phải thuộc `{3, 5, 7, 8, 9}`
2354
+ * (không có khối `01`, `02`, `04`, `06` cho di động).
2355
+ *
2356
+ * Cố ý **không** giới hạn chữ số thứ ba: quy hoạch không ràng buộc vị trí này
2357
+ * và các đầu số `090` (MobiFone), `091` (VinaPhone) đều đang được sử dụng.
2358
+ * Ép về `[2-9]` sẽ chặn nhầm khách hàng thật.
2359
+ *
2360
+ * Đầu vào được chuẩn hoá trước khi kiểm tra:
2361
+ * - Bỏ khoảng trắng và các dấu phân cách `.`, `-`, `(`, `)`.
2362
+ * - Đưa mã quốc gia `+84` / `84` về dạng nội địa có số `0` ở đầu.
2363
+ * - Bỏ tiền tố quốc tế `00`.
2364
+ * Nhờ vậy `0900 000 000`, `0900-000-000` và `+84 900 000 000` đều hợp lệ.
2365
+ *
2366
+ * Giá trị falsy (`''`, `null`, `undefined`) được coi là hợp lệ — nhất quán với
2367
+ * `email`, `alphaNum`, `password`; nên kết hợp với `required` khi bắt buộc.
2368
+ *
2369
+ * @param value Số điện thoại cần kiểm tra
2370
+ * @returns `true` khi value falsy hoặc là số di động VN hợp lệ
2371
+ */
911
2372
  private phoneVn;
912
2373
  /**
913
- * check if value is equals to another value(strings)
2374
+ * Rule `equals`: so sánh `String(value)` với `args[0]`.
914
2375
  *
915
- * @param string value
916
- * @param array args(value)
917
- * @return bool
2376
+ * Tham số rule luôn là chuỗi nên phải chuẩn hoá về chuỗi để so sánh — trước đây
2377
+ * so sánh nghiêm ngặt khiến số `1` không bằng chuỗi `'1'`, dù `equals(1)` rõ
2378
+ * ràng là muốn so với số `1`.
2379
+ *
2380
+ * @param value Giá trị cần so sánh
2381
+ * @param args Mảng tham số, giá trị đích ở `args[0]`
2382
+ * @returns `true` khi hai bên bằng nhau sau khi chuẩn hoá chuỗi
918
2383
  */
919
2384
  private equals;
920
2385
  /**
921
- * check if value is not equal to another value(strings)
2386
+ * Rule `notEqual`: phủ định của {@link Validation.equals}.
922
2387
  *
923
- * @param string value
924
- * @param array args(value)
925
- * @return bool
2388
+ * @param value Giá trị cần so sánh
2389
+ * @param args Mảng tham số, giá trị bị cấm ở `args[0]`
2390
+ * @returns `true` khi hai bên khác nhau sau khi chuẩn hoá chuỗi
926
2391
  */
927
2392
  private notEqual;
928
2393
  /**
929
- * check if value is a valid email
2394
+ * Tách chuỗi rule theo dấu `|`, **tôn trọng ký tự escape `\|`**.
930
2395
  *
931
- * @param string email
932
- * @return bool
2396
+ * `|` vừa là dấu phân cách rule vừa là toán tử alternation `(a|b)` trong
2397
+ * regex. Nếu tách thẳng thì `regex(/^(?:x|y)=\d$/)` bị cắt thành hai rule
2398
+ * và hỏng. Vì vậy `\|` được hiểu là dấu `|` literal và dấu `\` bị bỏ đi.
2399
+ *
2400
+ * @param rules Chuỗi rule, ví dụ `required|regex(/^a\|b$/)`
2401
+ * @returns Mỗi phần tử là một tên rule
933
2402
  */
934
- private email;
935
- /** *********************************************** **/
936
- /** ************ Database Validations *********** **/
937
- /** *********************************************** **/
2403
+ private static splitRules;
938
2404
  /**
939
- * check if a value of a column is unique.
2405
+ * Lấy nguyên văn pattern của rule `regex(...)`.
940
2406
  *
941
- * @param string value
942
- * @param array args(table, column)
943
- * @return bool
2407
+ * Cố ý KHÔNG dùng {@link Validation.getRuleArgs} vì hàm đó tách tham số theo
2408
+ * dấu phẩy, trong khi pattern regex rất hay chứa dấu phẩy:
2409
+ * `/a{2,3}/`, `/[a,b]/`, `/^(x,y)$/` — những pattern đó sẽ bị cắt đôi.
2410
+ *
2411
+ * @param rule Chuỗi rule dạng `regex(/pattern/flags)`
2412
+ * @returns Chuỗi pattern, hoặc `''` nếu không trích được
2413
+ */
2414
+ private getRegexPattern;
2415
+ /**
2416
+ * Rule `regex`: kiểm tra giá trị có khớp biểu thức chính quy tuỳ chỉnh hay không.
2417
+ *
2418
+ * - Nhận cả hai dạng có và không có dấu nháy bao:
2419
+ * `regex(/^[A-Z]{2}\d{6}$/)`, `regex(/^ab+c$/i)`, `regex(^[A-Z]+$)`.
2420
+ * - Dấu `|` bên trong pattern phải viết thành `\|`, vì `|` là dấu phân cách
2421
+ * rule: `regex(/^(?:x\|y)=1$/)`. Có thể tránh bằng lớp ký tự `[xy]`.
2422
+ * - Cờ `g` và `y` bị bỏ qua: `RegExp.test()` với cờ global có trạng thái
2423
+ * `lastIndex` nên kết quả sẽ phụ thuộc thứ tự gọi.
2424
+ * - **Pattern không được neo tự động**: `regex(/abc/)` cũng khớp `xxabcxx`.
2425
+ * Hãy tự viết `^...$` nếu cần khớp trọn vẹn.
2426
+ * - Giá trị falsy trả `true`, đúng như các rule khác — hãy kết hợp `required`.
2427
+ *
2428
+ * ⚠️ Pattern do ứng dụng tự viết trong chuỗi rule, không lấy từ dữ liệu người
2429
+ * dùng. Tuy vậy nên tránh biểu thức có khả năng **catastrophic backtracking**
2430
+ * (ReDoS) như `/(a+)+$/`.
2431
+ *
2432
+ * @param value Giá trị cần kiểm tra
2433
+ * @param args `args[0]` là pattern
2434
+ * @returns `true` khi value falsy hoặc khớp pattern
2435
+ *
2436
+ * @example
2437
+ * ```ts
2438
+ * const rules = { so: 'required|regex(/^0\d{9}$/)' };
2439
+ * await validator.runValidate({ so: '0900000000' }, rules); // ok
2440
+ * await validator.runValidate({ so: 'sai' }, rules); // throw lỗi
2441
+ * ```
2442
+ */
2443
+ private regex;
2444
+ /**
2445
+ * Rule `email`: kiểm tra định dạng email ASCII.
2446
+ *
2447
+ * - Local part cho phép ký tự đặc biệt hợp lệ của RFC 5322 kể cả `+`
2448
+ * (ví dụ `ten+the@gmail.com`) và chấp nhận subdomain lồng nhau.
2449
+ * - TLD là **2 chữ cái ASCII trở lên**, không giới hạn 4 ký tự — trước đây
2450
+ * giới hạn `{2,4}` nên từ chối các TLD dài hợp lệ như `.technology`.
2451
+ * - Không cho phép dấu chấm liên tiếp và không cho phép dấu chấm ở đầu/cuối
2452
+ * local part.
2453
+ * - Không hỗ trợ email quốc tế viết bằng ký tự Unicode (IDN) và không trim.
2454
+ * `FastRequest.filterSafeData(..., 'stripTags')` đã trim sẵn theo mặc
2455
+ * định nên đường thông thường không bị vấn đề khoảng trắng.
2456
+ *
2457
+ * @param email Giá trị cần kiểm tra
2458
+ * @returns `true` khi email falsy hoặc khớp định dạng
944
2459
  */
945
- private unique;
2460
+ private email;
946
2461
  /** *********************************************** **/
947
2462
  /** ************ Default Messages *********** **/
948
2463
  /** *********************************************** **/
2464
+ /**
2465
+ * Tra thông báo mặc định của một rule theo ngôn ngữ.
2466
+ *
2467
+ * Khóa trong từ điển có dạng `validate_<tênRule>` (xem `src/lang/vi.ts`, `src/lang/en.ts`).
2468
+ * Ứng dụng có thể ghi đè hoặc bổ sung ngôn ngữ mới bằng `initI18n()`.
2469
+ *
2470
+ * @param rule Tên rule, ví dụ `minLen`
2471
+ * @param lang Mã ngôn ngữ (mặc định `'vi'`)
2472
+ * @returns Chuỗi thông báo, hoặc `null` nếu rule chưa có bản dịch
2473
+ */
949
2474
  private static defaultMessages;
2475
+ /**
2476
+ * Tra bản dịch cho `key` theo ngôn ngữ, có lùi về tiếng Việt.
2477
+ *
2478
+ * `t()` trả về chính `key` khi không tìm thấy, nên hàm này dùng giá trị đó
2479
+ * làm tín hiệu "thiếu bản dịch" và thử lại với `'vi'` trước khi kết luận.
2480
+ *
2481
+ * @param key Khoá trong từ điển, ví dụ `validate_email`
2482
+ * @param lang Mã ngôn ngữ
2483
+ * @returns Chuỗi đã dịch, hoặc chính `key` nếu không có ở ngôn ngữ nào
2484
+ */
2485
+ private static translate;
950
2486
  }
951
2487
  //#endregion
952
2488
  //#region src/I18n.d.ts
953
2489
  type LanguageDictionary = Record<string, string>;
954
2490
  type I18nDictionaries = Record<string, LanguageDictionary>;
2491
+ /**
2492
+ * Khởi tạo hoặc cập nhật từ điển đa ngôn ngữ (i18n).
2493
+ * Hỗ trợ ghi đè các từ khóa mặc định (vi, en) hoặc bổ sung ngôn ngữ mới.
2494
+ *
2495
+ * @param data Đối tượng chứa các từ điển ngôn ngữ cần khởi tạo/bổ sung
2496
+ *
2497
+ * @example
2498
+ * ```ts
2499
+ * initI18n({
2500
+ * vi: { welcome: 'Xin chào' },
2501
+ * en: { welcome: 'Welcome' },
2502
+ * ja: { welcome: 'いらっしゃいませ' }
2503
+ * });
2504
+ * ```
2505
+ */
955
2506
  declare function initI18n(data: I18nDictionaries): void;
2507
+ /**
2508
+ * Dịch một khóa ngôn ngữ theo mã ngôn ngữ tương ứng.
2509
+ * Nếu không tìm thấy khóa trong từ điển, hàm trả về chính khóa đó.
2510
+ *
2511
+ * @param key Khóa cần dịch (vd: 'invalid_403')
2512
+ * @param lang Mã ngôn ngữ (mặc định: 'vi')
2513
+ * @returns Chuỗi bản dịch tương ứng hoặc chính key nếu chưa được định nghĩa
2514
+ *
2515
+ * @example
2516
+ * ```ts
2517
+ * const msg = t('invalid_403', 'vi'); // 'Truy cập không được phép'
2518
+ * ```
2519
+ */
956
2520
  declare function t(key: string, lang?: string): string;
957
2521
  //#endregion
958
2522
  //#region src/HMAC.d.ts
@@ -975,7 +2539,7 @@ declare function t(key: string, lang?: string): string;
975
2539
  * const signature = createSignature(data);
976
2540
  * ```
977
2541
  */
978
- declare function createSignature(data: Record<string, any>): string;
2542
+ declare function createSignature(data: Record<string, unknown>): string;
979
2543
  /**
980
2544
  * Creates an HMAC-SHA256 signature for the given data using the Web Crypto API.
981
2545
  *
@@ -998,13 +2562,23 @@ declare function createSignature(data: Record<string, any>): string;
998
2562
  * const signature = await createHmac(data, secret);
999
2563
  * ```
1000
2564
  */
1001
- declare function createHmac(data: Record<string, any>, secret: string): Promise<string>;
2565
+ declare function createHmac(data: Record<string, unknown>, secret: string): Promise<string>;
1002
2566
  //#endregion
1003
2567
  //#region src/Error.d.ts
2568
+ /**
2569
+ * Lỗi phía client (Bad Request hoặc lỗi tương tự)
2570
+ * Dùng để phân biệt lỗi do người dùng gửi lên với lỗi hệ thống 500
2571
+ */
1004
2572
  declare class ClientError extends Error {
1005
2573
  readonly statusCode: number;
1006
2574
  readonly code?: string | undefined;
2575
+ /**
2576
+ * Khởi tạo lỗi ClientError
2577
+ * @param message Thông điệp lỗi
2578
+ * @param statusCode Mã trạng thái HTTP (mặc định: 400)
2579
+ * @param code Mã lỗi tùy chọn phục vụ định danh lỗi (vd: invalid_user)
2580
+ */
1007
2581
  constructor(message: string, statusCode?: number, code?: string | undefined);
1008
2582
  }
1009
2583
  //#endregion
1010
- export { BaseModel, ClientError, FastRequest, FileUpload, IBaseModel, JWTApp, JoinType, ModelConstructor, Models, ModifiedType, MySQLSessionStore, PaginationResult, Route, RouterType, Validation, buildAuditLogData, callFetchApi, checkPassword, createHmac, createModels, createSignature, decimalNumber, ensureCsrfProtection, formatDate, generateKeywords, getActiveStatus, getArrColumn, getArrOnlyInFirst, getCorsOriginPolicy, getCsrfTokenFromRequest, getDaysInMonth, getDiffArr, getLangs, getModels, getMonthRange, getPublishStatus, getTrueFlaseStatus, getValue, initI18n, initModels, isStateChangingMethod, isSubArr, makePassword, parseDate, randomText, removeVietnameseTones, sanitizeAuditPayload, slugify, stringToJson, sumArrCol, sumArrColumn, t, toMySQLDateNowVN, toRoman };
2584
+ export { AddGsOptions, AnyBaseModel, AnyIBaseModel, AuditRecord, AuditValue, BaseModel, ClientError, CsrfProtectionOptions, CsrfProtectionResult, CsrfRequest, CsrfSession, FastRequest, FileUpload, Filter, FilterType, FilterValue, IBaseModel, JWTApp, JoinType, JwtPayload, JwtRequest, JwtUser, LooseRecord, LooseValue, ModelConstructor, ModelEntity, ModelRow, Models, ModifiedType, MySQLSessionStore, PaginationResult, QueryResult, QueryRows, RawQueryResult, RequestBody, RequestValue, Route, RouteController, RouterType, SanitizedAuditValue, SaveResult, SessionCallbackError, SessionData, SessionDoneCallback, SessionResultCallback, SessionTableRow, SqlJoinCondition, SqlJoinOperator, SqlParam, SqlSelectField, SqlSelectInput, Validation, ValidationEntry, ValidationItem, ValidationRules, ValidationValue, assertJoinType, assertOrderDirection, buildAuditLogData, buildJoinCondition, buildJoinTable, buildSelectList, callFetchApi, checkPassword, createHmac, createModels, createSignature, decimalNumber, ensureCsrfProtection, escapeColumnReference, escapeHtml, escapeIdentifier, escapeQualifiedName, formatDate, generateKeywords, getActiveStatus, getArrColumn, getArrOnlyInFirst, getCorsOriginPolicy, getCsrfTokenFromRequest, getDaysInMonth, getDiffArr, getLangs, getModels, getMonthRange, getPublishStatus, getTrueFlaseStatus, getValue, initI18n, initModels, isStateChangingMethod, isSubArr, makePassword, parseDate, randomText, removeVietnameseTones, safeEqual, sanitizeAuditPayload, slugify, stringToJson, sumArrCol, sumArrColumn, t, toError, toMySQLDateNowVN, toRoman };