@goplusvn/core 0.1.53 → 0.1.55

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.
@@ -0,0 +1,572 @@
1
+ import { getAuditContext } from "./audit-context";
2
+ import type { AuditContext } from "./audit-context";
3
+
4
+ /**
5
+ * Prisma extension ghi TỰ ĐỘNG mọi create/update/delete vào bảng `audit_logs`
6
+ * (feature `audit-logs`). Engine dùng chung mọi app goerp.
7
+ *
8
+ * Nguyên tắc bất di:
9
+ * - Blacklist chứ không whitelist: mặc định audit HẾT, chỉ chừa bảng kỹ thuật.
10
+ * App mới quên khai model là vẫn có nhật ký — an toàn hơn quên bật.
11
+ * - Ghi bằng `rawClient` (client CHƯA gắn extension) → không đệ quy vô tận.
12
+ * - Non-blocking tuyệt đối: mọi lỗi audit đều nuốt, nghiệp vụ chạy tiếp.
13
+ * - Bulk có TRẦN: một `updateMany` của job đồng bộ đụng hàng trăm dòng không
14
+ * được đẻ hàng trăm hàng audit — quá `maxBulkEntries` thì gom 1 dòng tóm tắt.
15
+ *
16
+ * App cắm qua `createAuditExtension({ prisma, rawClient, ... })` — `prisma` là
17
+ * NAMESPACE Prisma của client app tự generate (core không phụ thuộc @prisma/client
18
+ * nào cụ thể, mỗi app một client riêng).
19
+ */
20
+
21
+ /** Namespace Prisma của app — chỉ cần `defineExtension`. */
22
+ export interface PrismaNamespaceLike {
23
+ defineExtension(extension: any): any;
24
+ }
25
+
26
+ /** Client gốc (chưa gắn extension) dùng để ĐỌC snapshot + GHI audit. */
27
+ export interface AuditRawClient {
28
+ auditLog: { create(args: any): Promise<any> };
29
+ [model: string]: any;
30
+ }
31
+
32
+ export interface CreateAuditExtensionOptions {
33
+ prisma: PrismaNamespaceLike;
34
+ rawClient: AuditRawClient;
35
+ /**
36
+ * Khôi phục actor khi lệnh ghi nằm NGOÀI `withAuditContext` (route chưa bọc
37
+ * apiHandler, server action…). Trả undefined nếu không có phiên. App cắm hàm
38
+ * đọc session của mình — core không biết auth stack nào.
39
+ */
40
+ resolveSessionActor?: () => Promise<AuditContext | undefined>;
41
+ /** Model bỏ qua NGOÀI danh sách mặc định (bảng ồn đặc thù của app). */
42
+ skipModels?: Iterable<string>;
43
+ /** Thay hẳn danh sách mặc định (hiếm dùng — cân nhắc `skipModels` trước). */
44
+ skipModelsOverride?: Iterable<string>;
45
+ /** Trần số hàng audit sinh ra từ một lệnh bulk. Mặc định 100. */
46
+ maxBulkEntries?: number;
47
+ onError?: (message: string, error: unknown) => void;
48
+ }
49
+
50
+ /**
51
+ * Bảng KHÔNG audit theo mặc định: nhật ký của chính nó (chống đệ quy), bảng
52
+ * auth đổi liên tục, và bảng hạ tầng do core sở hữu (task nền/thông báo/cron)
53
+ * — audit chúng chỉ tạo nhiễu che mất thao tác nghiệp vụ thật.
54
+ */
55
+ export const DEFAULT_SKIP_MODELS: readonly string[] = [
56
+ // Audit của chính nó — chống đệ quy vô tận.
57
+ "AuditLog",
58
+ // Auth/session: đổi mỗi request, không phải dấu vết nghiệp vụ.
59
+ "Account",
60
+ "Session",
61
+ "RefreshToken",
62
+ "VerificationToken",
63
+ "Verification",
64
+ // Bảng log sẵn có — audit thêm là ghi hai lần.
65
+ "SystemLog",
66
+ "SystemJob",
67
+ "JobExecutionLog",
68
+ "ErrorLog",
69
+ // Hạ tầng core: hàng đợi/tiến độ, ghi rất dày.
70
+ "BackgroundTask",
71
+ "Notification",
72
+ "PushSubscription",
73
+ ];
74
+
75
+ const AUDITED_OPERATIONS = new Set([
76
+ "create",
77
+ "update",
78
+ "delete",
79
+ "createMany",
80
+ "updateMany",
81
+ "deleteMany",
82
+ ]);
83
+
84
+ const BULK_OPERATIONS = new Set(["createMany", "updateMany", "deleteMany"]);
85
+
86
+ function toAuditAction(operation: string): string {
87
+ if (operation.startsWith("create")) return "create";
88
+ if (operation.startsWith("update")) return "update";
89
+ if (operation.startsWith("delete")) return "delete";
90
+ return operation;
91
+ }
92
+
93
+ /** "SalesOrder" → "sales-order" (khớp mã resource của RBAC). */
94
+ function toResourceName(modelName: string): string {
95
+ return modelName
96
+ .replace(/([A-Z])/g, "-$1")
97
+ .toLowerCase()
98
+ .replace(/^-/, "");
99
+ }
100
+
101
+ /** Lấy id từ where khi có — đường nhanh; không có thì fallback findFirst(where). */
102
+ function extractId(where: Record<string, any> | undefined): string | null {
103
+ if (!where?.id) return null;
104
+ if (typeof where.id === "string") return where.id;
105
+ if (typeof where.id === "object" && where.id?.equals)
106
+ return String(where.id.equals);
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Chuẩn hoá dữ liệu để nhét vào cột JSON: bỏ undefined, đổi Date/BigInt/Decimal,
112
+ * và LOẠI các nhánh quan hệ lồng (`create`/`connect`/…) — chúng không phải giá
113
+ * trị trường, ghi vào chỉ làm diff rối.
114
+ */
115
+ function sanitizeData(data: unknown): Record<string, unknown> | null {
116
+ if (!data || typeof data !== "object") return null;
117
+
118
+ const result: Record<string, unknown> = {};
119
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
120
+ if (value === undefined) continue;
121
+ if (
122
+ typeof value === "object" &&
123
+ value !== null &&
124
+ !Array.isArray(value) &&
125
+ !(value instanceof Date)
126
+ ) {
127
+ const keys = Object.keys(value);
128
+ if (
129
+ keys.some((k) =>
130
+ [
131
+ "create",
132
+ "connect",
133
+ "connectOrCreate",
134
+ "set",
135
+ "disconnect",
136
+ ].includes(k),
137
+ )
138
+ ) {
139
+ continue;
140
+ }
141
+ }
142
+ if (value instanceof Date) {
143
+ result[key] = value.toISOString();
144
+ } else if (typeof value === "bigint") {
145
+ result[key] = Number(value);
146
+ } else if (
147
+ value !== null &&
148
+ typeof value === "object" &&
149
+ "toNumber" in (value as any)
150
+ ) {
151
+ result[key] = (value as any).toNumber();
152
+ } else {
153
+ result[key] = value;
154
+ }
155
+ }
156
+
157
+ return Object.keys(result).length > 0 ? result : null;
158
+ }
159
+
160
+ function modelDelegate(rawClient: AuditRawClient, model: string): any {
161
+ return rawClient[model.charAt(0).toLowerCase() + model.slice(1)];
162
+ }
163
+
164
+ interface AuditWritePayload {
165
+ action: string;
166
+ resource: string;
167
+ resourceId?: string | undefined;
168
+ actor: AuditContext | undefined;
169
+ description: string;
170
+ oldData: Record<string, unknown> | null;
171
+ newData: Record<string, unknown> | null;
172
+ model: string;
173
+ operation: string;
174
+ }
175
+
176
+ export function createAuditExtension(options: CreateAuditExtensionOptions) {
177
+ const {
178
+ prisma,
179
+ rawClient,
180
+ resolveSessionActor,
181
+ maxBulkEntries = 100,
182
+ onError,
183
+ } = options;
184
+
185
+ const skipModels = new Set<string>(
186
+ options.skipModelsOverride ?? DEFAULT_SKIP_MODELS,
187
+ );
188
+ for (const extra of options.skipModels ?? []) skipModels.add(extra);
189
+
190
+ const reportError = (message: string, error: unknown): void => {
191
+ if (onError) onError(message, error);
192
+ else console.error(message, error);
193
+ };
194
+
195
+ /** Ghi audit fire-and-forget — không await, không throw. */
196
+ const fireAuditWrite = (p: AuditWritePayload): void => {
197
+ rawClient.auditLog
198
+ .create({
199
+ data: {
200
+ action: p.action,
201
+ resource: p.resource,
202
+ resourceId: p.resourceId,
203
+ userId: p.actor?.userId || undefined,
204
+ description: p.description,
205
+ oldData: p.oldData ?? undefined,
206
+ newData: p.newData ?? undefined,
207
+ ipAddress: p.actor?.ip || undefined,
208
+ },
209
+ })
210
+ .catch((err: unknown) => {
211
+ reportError("[AuditExtension] Failed to write audit log:", {
212
+ model: p.model,
213
+ operation: p.operation,
214
+ resourceId: p.resourceId,
215
+ error:
216
+ err instanceof Error
217
+ ? { message: err.message, code: (err as any).code }
218
+ : err,
219
+ });
220
+ });
221
+ };
222
+
223
+ /** ALS trước, phiên đăng nhập sau — cứu actor cho route chưa bọc withAudit. */
224
+ const resolveActor = async (): Promise<AuditContext | undefined> => {
225
+ const ctx = getAuditContext();
226
+ if (ctx?.userId) return ctx;
227
+ const fromSession = await resolveSessionActor?.();
228
+ // Giữ lại ip đã có trong ctx.
229
+ return { ...(ctx ?? {}), ...(fromSession ?? {}) };
230
+ };
231
+
232
+ return prisma.defineExtension({
233
+ name: "audit-log",
234
+ query: {
235
+ $allModels: {
236
+ async $allOperations({ model, operation, args, query }: any) {
237
+ if (!AUDITED_OPERATIONS.has(operation)) return query(args);
238
+ if (!model || skipModels.has(model)) return query(args);
239
+
240
+ const action = toAuditAction(operation);
241
+ const resource = toResourceName(model);
242
+ const where = (args as any)?.where;
243
+
244
+ // ── Chộp dữ liệu CŨ trước khi update/delete ──
245
+ // Đường nhanh là where.id. Không có id thì tra bằng chính where, để
246
+ // xoá-theo-code hay sửa-theo-email vẫn để lại dấu vết.
247
+ let oldData: Record<string, unknown> | null = null;
248
+ let oldRows: Array<Record<string, unknown>> | null = null;
249
+ let resourceId: string | null = null;
250
+
251
+ const isMutationByWhere = action === "update" || action === "delete";
252
+
253
+ if (isMutationByWhere && where) {
254
+ const delegate = modelDelegate(rawClient, model);
255
+
256
+ if (BULK_OPERATIONS.has(operation)) {
257
+ // `take: max + 1` để phát hiện tràn mà không phải SELECT khổng lồ.
258
+ try {
259
+ if (delegate?.findMany) {
260
+ const rows = await delegate.findMany({
261
+ where,
262
+ take: maxBulkEntries + 1,
263
+ });
264
+ if (Array.isArray(rows) && rows.length > 0) {
265
+ oldRows = rows
266
+ .map((r: any) => sanitizeData(r))
267
+ .filter(Boolean) as Array<Record<string, unknown>>;
268
+ }
269
+ }
270
+ } catch {
271
+ /* nuốt — audit không được làm vỡ nghiệp vụ */
272
+ }
273
+ } else {
274
+ resourceId = extractId(where);
275
+ try {
276
+ let existing: any = null;
277
+ if (resourceId && delegate?.findUnique) {
278
+ existing = await delegate.findUnique({
279
+ where: { id: resourceId },
280
+ });
281
+ } else if (delegate?.findFirst) {
282
+ existing = await delegate.findFirst({ where });
283
+ if (existing?.id) resourceId = existing.id;
284
+ }
285
+ if (existing) oldData = sanitizeData(existing);
286
+ } catch {
287
+ /* nuốt */
288
+ }
289
+ }
290
+ }
291
+
292
+ // ── Chạy lệnh gốc ──
293
+ const result = await query(args);
294
+
295
+ // ── Dựng & ghi audit ──
296
+ try {
297
+ const actor = await resolveActor();
298
+
299
+ if (BULK_OPERATIONS.has(operation)) {
300
+ const userName = actor?.userName || "Hệ thống";
301
+
302
+ if (operation === "createMany") {
303
+ const payload = (args as any)?.data;
304
+ const rows = Array.isArray(payload) ? payload : [payload];
305
+ const affected =
306
+ typeof (result as any)?.count === "number"
307
+ ? (result as any).count
308
+ : rows.length;
309
+
310
+ if (rows.length > maxBulkEntries) {
311
+ fireAuditWrite({
312
+ action: "create",
313
+ resource,
314
+ resourceId: undefined,
315
+ actor,
316
+ description: `[${userName}] Tạo hàng loạt ${resource} (${affected} bản ghi)`,
317
+ oldData: null,
318
+ newData: {
319
+ bulk: true,
320
+ count: affected,
321
+ sample: rows
322
+ .slice(0, 5)
323
+ .map((r) => sanitizeData(r))
324
+ .filter(Boolean),
325
+ },
326
+ model,
327
+ operation,
328
+ });
329
+ return result;
330
+ }
331
+
332
+ for (const row of rows) {
333
+ const sanitized = sanitizeData(row);
334
+ if (!sanitized) continue;
335
+ fireAuditWrite({
336
+ action: "create",
337
+ resource,
338
+ resourceId: undefined,
339
+ actor,
340
+ description: buildDescription(
341
+ "create",
342
+ resource,
343
+ userName,
344
+ null,
345
+ sanitized,
346
+ null,
347
+ ),
348
+ oldData: null,
349
+ newData: sanitized,
350
+ model,
351
+ operation,
352
+ });
353
+ }
354
+ return result;
355
+ }
356
+
357
+ // updateMany / deleteMany
358
+ const rows = oldRows ?? [];
359
+ const newPayload =
360
+ operation === "updateMany"
361
+ ? sanitizeData((args as any)?.data)
362
+ : null;
363
+ const affected =
364
+ typeof (result as any)?.count === "number"
365
+ ? (result as any).count
366
+ : rows.length;
367
+
368
+ // Tràn trần: gom MỘT dòng tóm tắt thay vì N dòng.
369
+ if (rows.length > maxBulkEntries) {
370
+ fireAuditWrite({
371
+ action,
372
+ resource,
373
+ resourceId: undefined,
374
+ actor,
375
+ description: `[${userName}] ${action === "delete" ? "Xóa" : "Cập nhật"} hàng loạt ${resource} (${affected} bản ghi)`,
376
+ oldData: null,
377
+ newData: {
378
+ bulk: true,
379
+ count: affected,
380
+ where: sanitizeData(where),
381
+ data: action === "delete" ? null : newPayload,
382
+ sampleIds: rows
383
+ .slice(0, 20)
384
+ .map((r: any) => r?.id)
385
+ .filter(Boolean),
386
+ },
387
+ model,
388
+ operation,
389
+ });
390
+ return result;
391
+ }
392
+
393
+ for (const row of rows) {
394
+ const rowId =
395
+ typeof row?.id === "string" ? (row.id as string) : null;
396
+ fireAuditWrite({
397
+ action,
398
+ resource,
399
+ resourceId: rowId ?? undefined,
400
+ actor,
401
+ description: buildDescription(
402
+ action,
403
+ resource,
404
+ userName,
405
+ row,
406
+ action === "delete" ? null : newPayload,
407
+ rowId,
408
+ ),
409
+ oldData: row,
410
+ newData: action === "delete" ? null : newPayload,
411
+ model,
412
+ operation,
413
+ });
414
+ }
415
+ if (rows.length === 0) {
416
+ fireAuditWrite({
417
+ action,
418
+ resource,
419
+ resourceId: undefined,
420
+ actor,
421
+ description: `[${userName}] ${action === "delete" ? "Xóa" : "Cập nhật"} hàng loạt ${resource}${affected ? ` (${affected} bản ghi)` : ""}`,
422
+ oldData: null,
423
+ newData: {
424
+ bulk: true,
425
+ count: affected,
426
+ where: sanitizeData(where),
427
+ data: action === "delete" ? null : newPayload,
428
+ },
429
+ model,
430
+ operation,
431
+ });
432
+ }
433
+ return result;
434
+ }
435
+
436
+ // Đường một dòng
437
+ let newData: Record<string, unknown> | null = null;
438
+ if (action === "create" && result) {
439
+ resourceId = (result as any)?.id || null;
440
+ newData = sanitizeData(result);
441
+ } else if (action === "update") {
442
+ newData = sanitizeData((args as any)?.data);
443
+ }
444
+
445
+ const userName = actor?.userName || "Hệ thống";
446
+ const description = buildDescription(
447
+ action,
448
+ resource,
449
+ userName,
450
+ oldData,
451
+ newData,
452
+ resourceId,
453
+ );
454
+
455
+ fireAuditWrite({
456
+ action,
457
+ resource,
458
+ resourceId: resourceId ?? undefined,
459
+ actor,
460
+ description,
461
+ oldData,
462
+ newData,
463
+ model,
464
+ operation,
465
+ });
466
+ } catch (auditError) {
467
+ reportError(
468
+ "[AuditExtension] Error preparing audit log:",
469
+ auditError,
470
+ );
471
+ }
472
+
473
+ return result;
474
+ },
475
+ },
476
+ },
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Mô tả đọc-được-bằng-mắt cho hàng audit. Với update thì kèm tối đa 3 trường
482
+ * đổi, để tra nhật ký khỏi phải mở panel chi tiết từng dòng.
483
+ */
484
+ export function buildDescription(
485
+ action: string,
486
+ resource: string,
487
+ userName: string,
488
+ oldData?: Record<string, unknown> | null,
489
+ newData?: Record<string, unknown> | null,
490
+ resourceId?: string | null,
491
+ ): string {
492
+ const actionLabels: Record<string, string> = {
493
+ create: "Tạo mới",
494
+ update: "Cập nhật",
495
+ delete: "Xóa",
496
+ };
497
+ const actionLabel = actionLabels[action] || action;
498
+
499
+ const skipKeys = new Set([
500
+ "id",
501
+ "createdAt",
502
+ "updatedAt",
503
+ "updatedBy",
504
+ "createdBy",
505
+ "userAgent",
506
+ "ipAddress",
507
+ "password",
508
+ "token",
509
+ "refreshToken",
510
+ ]);
511
+
512
+ let changeSummary = "";
513
+ if (action === "update" && oldData && newData) {
514
+ const allKeys = Object.keys(newData).filter((k) => !skipKeys.has(k));
515
+ const changedPairs: string[] = [];
516
+
517
+ for (const key of allKeys) {
518
+ const ov = oldData[key],
519
+ nv = newData[key];
520
+ if (JSON.stringify(ov) === JSON.stringify(nv)) continue;
521
+ if (typeof ov === "object" && typeof nv === "object") continue;
522
+ const ovStr = ov !== undefined ? String(ov ?? "—") : undefined;
523
+ const nvStr = nv !== undefined ? String(nv ?? "—") : undefined;
524
+ if (ovStr !== undefined && nvStr !== undefined) {
525
+ changedPairs.push(`${key}: ${ovStr}→${nvStr}`);
526
+ } else if (nvStr !== undefined) {
527
+ changedPairs.push(`${key}: ${nvStr}`);
528
+ }
529
+ if (changedPairs.length >= 3) break;
530
+ }
531
+
532
+ if (changedPairs.length > 0) {
533
+ changeSummary = ` — ${changedPairs.join(", ")}`;
534
+ }
535
+ } else if (action === "create" && newData) {
536
+ changeSummary = identifierSuffix(newData, [
537
+ "code",
538
+ "name",
539
+ "number",
540
+ "email",
541
+ "status",
542
+ "title",
543
+ ]);
544
+ } else if (action === "delete" && oldData) {
545
+ changeSummary = identifierSuffix(oldData, [
546
+ "code",
547
+ "name",
548
+ "number",
549
+ "email",
550
+ "title",
551
+ ]);
552
+ }
553
+
554
+ const idSuffix = resourceId ? ` #${resourceId.slice(-8)}` : "";
555
+ return `[${userName}] ${actionLabel} ${resource}${idSuffix}${changeSummary}`;
556
+ }
557
+
558
+ /** " (VH001 · Nguyễn Văn A)" — tối đa 2 trường nhận dạng đầu tiên tìm thấy. */
559
+ function identifierSuffix(
560
+ data: Record<string, unknown>,
561
+ keys: string[],
562
+ ): string {
563
+ const identifiers: string[] = [];
564
+ for (const key of keys) {
565
+ const val = data[key];
566
+ if (val && typeof val !== "object") {
567
+ identifiers.push(String(val));
568
+ if (identifiers.length >= 2) break;
569
+ }
570
+ }
571
+ return identifiers.length > 0 ? ` (${identifiers.join(" · ")})` : "";
572
+ }