@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.
- package/features/README.md +11 -1
- package/features/audit-logs/README.md +18 -0
- package/features/audit-logs/migrations/0001_init.sql +33 -0
- package/features/audit-logs/schema.prisma +20 -0
- package/features/system-jobs/README.md +40 -0
- package/features/system-jobs/migrations/0001_init.sql +47 -0
- package/features/system-jobs/schema.prisma +42 -0
- package/package.json +5 -1
- package/src/audit/__tests__/audit-context.test.ts +174 -0
- package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
- package/src/audit/audit-actor.ts +42 -0
- package/src/audit/audit-context.ts +47 -0
- package/src/audit/entity-audit.ts +71 -0
- package/src/audit/index.ts +29 -10
- package/src/audit/prisma-audit-extension.ts +572 -0
- package/src/cron/__tests__/db-cron-manager.test.ts +316 -0
- package/src/cron/db-cron-manager.ts +459 -0
- package/src/cron/index.ts +24 -0
- package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
- package/src/system/pages/__tests__/system-jobs-page.test.tsx +92 -0
- package/src/system/pages/system-audit-page.tsx +532 -0
- package/src/system/pages/system-jobs-page.tsx +571 -0
- package/src/ui/management/audit-log-page.tsx +12 -207
- package/src/audit/audit-manager.ts +0 -139
- package/src/audit/memory-audit-logger.ts +0 -86
- package/src/audit/types.ts +0 -50
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { withAuditContext } from "../audit-context";
|
|
4
|
+
import { createAuditExtension } from "../prisma-audit-extension";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extension audit chạy sâu trong Prisma, nơi rất khó quan sát lúc có sự cố —
|
|
8
|
+
* nên test ở đây bám vào ĐÚNG những gì có thể làm hỏng dữ liệu thật: bỏ sót
|
|
9
|
+
* bảng cần audit, ghi đè nhầm actor, và (quan trọng nhất) audit làm vỡ nghiệp
|
|
10
|
+
* vụ khi chính nó lỗi.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Namespace Prisma giả — `defineExtension` chỉ trả lại object để soi. */
|
|
14
|
+
const prismaNamespace = { defineExtension: (ext: any) => ext };
|
|
15
|
+
|
|
16
|
+
function makeRawClient() {
|
|
17
|
+
return {
|
|
18
|
+
auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) },
|
|
19
|
+
customer: { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn() },
|
|
20
|
+
salesOrder: { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn() },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Ghi audit là fire-and-forget — nhường 1 vòng event loop rồi mới soi. */
|
|
25
|
+
const settle = () => new Promise((r) => setTimeout(r, 10));
|
|
26
|
+
|
|
27
|
+
describe("createAuditExtension", () => {
|
|
28
|
+
let rawClient: ReturnType<typeof makeRawClient>;
|
|
29
|
+
let handler: any;
|
|
30
|
+
|
|
31
|
+
const build = (extra?: Record<string, unknown>) => {
|
|
32
|
+
const ext = createAuditExtension({
|
|
33
|
+
prisma: prismaNamespace,
|
|
34
|
+
rawClient: rawClient as any,
|
|
35
|
+
...extra,
|
|
36
|
+
});
|
|
37
|
+
return ext.query.$allModels.$allOperations;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
rawClient = makeRawClient();
|
|
43
|
+
handler = build();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("dựng đúng hình dạng extension Prisma", () => {
|
|
47
|
+
const ext = createAuditExtension({
|
|
48
|
+
prisma: prismaNamespace,
|
|
49
|
+
rawClient: rawClient as any,
|
|
50
|
+
});
|
|
51
|
+
expect(ext.name).toBe("audit-log");
|
|
52
|
+
expect(ext.query.$allModels.$allOperations).toBeInstanceOf(Function);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("bỏ qua thao tác đọc (findMany)", async () => {
|
|
56
|
+
const query = vi.fn().mockResolvedValue([{ id: "1" }]);
|
|
57
|
+
const result = await handler({
|
|
58
|
+
model: "Customer",
|
|
59
|
+
operation: "findMany",
|
|
60
|
+
args: {},
|
|
61
|
+
query,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual([{ id: "1" }]);
|
|
65
|
+
expect(rawClient.auditLog.create).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("KHÔNG audit chính bảng AuditLog (chống đệ quy vô tận)", async () => {
|
|
69
|
+
const query = vi.fn().mockResolvedValue({ id: "audit-1" });
|
|
70
|
+
await handler({
|
|
71
|
+
model: "AuditLog",
|
|
72
|
+
operation: "create",
|
|
73
|
+
args: { data: {} },
|
|
74
|
+
query,
|
|
75
|
+
});
|
|
76
|
+
await settle();
|
|
77
|
+
|
|
78
|
+
expect(query).toHaveBeenCalled();
|
|
79
|
+
expect(rawClient.auditLog.create).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(["Session", "Notification", "BackgroundTask", "ErrorLog"])(
|
|
83
|
+
"bỏ qua bảng hạ tầng %s theo mặc định",
|
|
84
|
+
async (model) => {
|
|
85
|
+
const query = vi.fn().mockResolvedValue({ id: "x" });
|
|
86
|
+
await handler({
|
|
87
|
+
model,
|
|
88
|
+
operation: "create",
|
|
89
|
+
args: { data: {} },
|
|
90
|
+
query,
|
|
91
|
+
});
|
|
92
|
+
await settle();
|
|
93
|
+
|
|
94
|
+
expect(rawClient.auditLog.create).not.toHaveBeenCalled();
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("app khai thêm bảng ồn của mình qua skipModels", async () => {
|
|
99
|
+
const h = build({ skipModels: ["TriAnhZnsMessage"] });
|
|
100
|
+
const query = vi.fn().mockResolvedValue({ id: "zns-1" });
|
|
101
|
+
|
|
102
|
+
await h({
|
|
103
|
+
model: "TriAnhZnsMessage",
|
|
104
|
+
operation: "create",
|
|
105
|
+
args: { data: {} },
|
|
106
|
+
query,
|
|
107
|
+
});
|
|
108
|
+
await settle();
|
|
109
|
+
|
|
110
|
+
expect(rawClient.auditLog.create).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
// Bảng nghiệp vụ vẫn audit bình thường.
|
|
113
|
+
await h({
|
|
114
|
+
model: "Customer",
|
|
115
|
+
operation: "create",
|
|
116
|
+
args: { data: { name: "A" } },
|
|
117
|
+
query: vi.fn().mockResolvedValue({ id: "c1", name: "A" }),
|
|
118
|
+
});
|
|
119
|
+
await settle();
|
|
120
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("audit create kèm id bản ghi vừa tạo", async () => {
|
|
124
|
+
const query = vi
|
|
125
|
+
.fn()
|
|
126
|
+
.mockResolvedValue({ id: "cust-1", name: "Nguyễn Văn A" });
|
|
127
|
+
|
|
128
|
+
await handler({
|
|
129
|
+
model: "Customer",
|
|
130
|
+
operation: "create",
|
|
131
|
+
args: { data: { name: "Nguyễn Văn A" } },
|
|
132
|
+
query,
|
|
133
|
+
});
|
|
134
|
+
await settle();
|
|
135
|
+
|
|
136
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
137
|
+
data: expect.objectContaining({
|
|
138
|
+
action: "create",
|
|
139
|
+
resource: "customer",
|
|
140
|
+
resourceId: "cust-1",
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("audit update chộp được dữ liệu CŨ trước khi ghi đè", async () => {
|
|
146
|
+
rawClient.customer.findUnique.mockResolvedValue({
|
|
147
|
+
id: "cust-1",
|
|
148
|
+
name: "Tên cũ",
|
|
149
|
+
});
|
|
150
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-1" });
|
|
151
|
+
|
|
152
|
+
await handler({
|
|
153
|
+
model: "Customer",
|
|
154
|
+
operation: "update",
|
|
155
|
+
args: { where: { id: "cust-1" }, data: { name: "Tên mới" } },
|
|
156
|
+
query,
|
|
157
|
+
});
|
|
158
|
+
await settle();
|
|
159
|
+
|
|
160
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
161
|
+
data: expect.objectContaining({
|
|
162
|
+
action: "update",
|
|
163
|
+
resourceId: "cust-1",
|
|
164
|
+
oldData: expect.objectContaining({ name: "Tên cũ" }),
|
|
165
|
+
newData: expect.objectContaining({ name: "Tên mới" }),
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("xoá-theo-điều-kiện (không có id) vẫn để lại dấu vết", async () => {
|
|
171
|
+
rawClient.customer.findFirst.mockResolvedValue({
|
|
172
|
+
id: "cust-9",
|
|
173
|
+
code: "KH009",
|
|
174
|
+
});
|
|
175
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-9" });
|
|
176
|
+
|
|
177
|
+
await handler({
|
|
178
|
+
model: "Customer",
|
|
179
|
+
operation: "delete",
|
|
180
|
+
args: { where: { code: "KH009" } },
|
|
181
|
+
query,
|
|
182
|
+
});
|
|
183
|
+
await settle();
|
|
184
|
+
|
|
185
|
+
expect(rawClient.customer.findFirst).toHaveBeenCalledWith({
|
|
186
|
+
where: { code: "KH009" },
|
|
187
|
+
});
|
|
188
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
189
|
+
data: expect.objectContaining({
|
|
190
|
+
action: "delete",
|
|
191
|
+
resourceId: "cust-9",
|
|
192
|
+
oldData: expect.objectContaining({ code: "KH009" }),
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("PascalCase → kebab-case khớp mã resource RBAC", async () => {
|
|
198
|
+
const query = vi.fn().mockResolvedValue({ id: "so-1" });
|
|
199
|
+
await handler({
|
|
200
|
+
model: "SalesOrder",
|
|
201
|
+
operation: "create",
|
|
202
|
+
args: { data: {} },
|
|
203
|
+
query,
|
|
204
|
+
});
|
|
205
|
+
await settle();
|
|
206
|
+
|
|
207
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
208
|
+
data: expect.objectContaining({ resource: "sales-order" }),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("lấy actor từ withAuditContext", async () => {
|
|
213
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-1", name: "Test" });
|
|
214
|
+
|
|
215
|
+
await withAuditContext({ userId: "user-1", userName: "Admin" }, () =>
|
|
216
|
+
handler({
|
|
217
|
+
model: "Customer",
|
|
218
|
+
operation: "create",
|
|
219
|
+
args: { data: { name: "Test" } },
|
|
220
|
+
query,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
await settle();
|
|
224
|
+
|
|
225
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
226
|
+
data: expect.objectContaining({
|
|
227
|
+
userId: "user-1",
|
|
228
|
+
description: expect.stringContaining("[Admin] Tạo mới customer"),
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("ngoài context thì rơi về resolveSessionActor (cứu route quên bọc)", async () => {
|
|
234
|
+
const resolveSessionActor = vi
|
|
235
|
+
.fn()
|
|
236
|
+
.mockResolvedValue({ userId: "u-2", userName: "Kế toán" });
|
|
237
|
+
const h = build({ resolveSessionActor });
|
|
238
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-2" });
|
|
239
|
+
|
|
240
|
+
await h({
|
|
241
|
+
model: "Customer",
|
|
242
|
+
operation: "create",
|
|
243
|
+
args: { data: {} },
|
|
244
|
+
query,
|
|
245
|
+
});
|
|
246
|
+
await settle();
|
|
247
|
+
|
|
248
|
+
expect(resolveSessionActor).toHaveBeenCalled();
|
|
249
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledWith({
|
|
250
|
+
data: expect.objectContaining({ userId: "u-2" }),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("có context rồi thì KHÔNG tra session nữa", async () => {
|
|
255
|
+
const resolveSessionActor = vi.fn();
|
|
256
|
+
const h = build({ resolveSessionActor });
|
|
257
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-3" });
|
|
258
|
+
|
|
259
|
+
await withAuditContext({ userId: "u-1", userName: "Admin" }, () =>
|
|
260
|
+
h({ model: "Customer", operation: "create", args: { data: {} }, query }),
|
|
261
|
+
);
|
|
262
|
+
await settle();
|
|
263
|
+
|
|
264
|
+
expect(resolveSessionActor).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("audit ghi hỏng KHÔNG làm vỡ nghiệp vụ", async () => {
|
|
268
|
+
rawClient.auditLog.create.mockRejectedValue(new Error("DB connect fail"));
|
|
269
|
+
const onError = vi.fn();
|
|
270
|
+
const h = build({ onError });
|
|
271
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-1" });
|
|
272
|
+
|
|
273
|
+
const result = await h({
|
|
274
|
+
model: "Customer",
|
|
275
|
+
operation: "create",
|
|
276
|
+
args: { data: { name: "Test" } },
|
|
277
|
+
query,
|
|
278
|
+
});
|
|
279
|
+
await settle();
|
|
280
|
+
|
|
281
|
+
expect(result).toEqual({ id: "cust-1" });
|
|
282
|
+
expect(onError).toHaveBeenCalledWith(
|
|
283
|
+
"[AuditExtension] Failed to write audit log:",
|
|
284
|
+
expect.objectContaining({ model: "Customer" }),
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("đọc snapshot cũ hỏng KHÔNG chặn lệnh update", async () => {
|
|
289
|
+
rawClient.customer.findUnique.mockRejectedValue(new Error("Query failed"));
|
|
290
|
+
const query = vi.fn().mockResolvedValue({ id: "cust-1" });
|
|
291
|
+
|
|
292
|
+
const result = await handler({
|
|
293
|
+
model: "Customer",
|
|
294
|
+
operation: "update",
|
|
295
|
+
args: { where: { id: "cust-1" }, data: { name: "New" } },
|
|
296
|
+
query,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(result).toEqual({ id: "cust-1" });
|
|
300
|
+
expect(query).toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("đổi Date/Decimal/BigInt sang dạng nhét được vào cột JSON", async () => {
|
|
304
|
+
const query = vi.fn().mockResolvedValue({
|
|
305
|
+
id: "cust-1",
|
|
306
|
+
createdAt: new Date("2026-03-18T10:00:00Z"),
|
|
307
|
+
amount: { toNumber: () => 1500 },
|
|
308
|
+
big: 42n,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await handler({
|
|
312
|
+
model: "Customer",
|
|
313
|
+
operation: "create",
|
|
314
|
+
args: { data: {} },
|
|
315
|
+
query,
|
|
316
|
+
});
|
|
317
|
+
await settle();
|
|
318
|
+
|
|
319
|
+
const newData = rawClient.auditLog.create.mock.calls[0]?.[0]?.data?.newData;
|
|
320
|
+
expect(newData.createdAt).toBe("2026-03-18T10:00:00.000Z");
|
|
321
|
+
expect(newData.amount).toBe(1500);
|
|
322
|
+
expect(newData.big).toBe(42);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("bỏ nhánh quan hệ lồng (connect/create) khỏi diff", async () => {
|
|
326
|
+
const query = vi.fn().mockResolvedValue({ id: "so-1" });
|
|
327
|
+
await handler({
|
|
328
|
+
model: "SalesOrder",
|
|
329
|
+
operation: "create",
|
|
330
|
+
args: { data: {} },
|
|
331
|
+
query,
|
|
332
|
+
});
|
|
333
|
+
await settle();
|
|
334
|
+
|
|
335
|
+
// newData lấy từ result nên không có nhánh quan hệ; kiểm ở update thì rõ hơn
|
|
336
|
+
rawClient.salesOrder.findUnique.mockResolvedValue({ id: "so-1" });
|
|
337
|
+
await handler({
|
|
338
|
+
model: "SalesOrder",
|
|
339
|
+
operation: "update",
|
|
340
|
+
args: {
|
|
341
|
+
where: { id: "so-1" },
|
|
342
|
+
data: { status: "done", customer: { connect: { id: "c1" } } },
|
|
343
|
+
},
|
|
344
|
+
query: vi.fn().mockResolvedValue({ id: "so-1" }),
|
|
345
|
+
});
|
|
346
|
+
await settle();
|
|
347
|
+
|
|
348
|
+
const last = rawClient.auditLog.create.mock.calls.at(-1)?.[0];
|
|
349
|
+
expect(last.data.newData).toEqual({ status: "done" });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("createMany dưới trần: mỗi dòng một hàng audit", async () => {
|
|
353
|
+
const query = vi.fn().mockResolvedValue({ count: 3 });
|
|
354
|
+
|
|
355
|
+
await handler({
|
|
356
|
+
model: "Customer",
|
|
357
|
+
operation: "createMany",
|
|
358
|
+
args: { data: [{ name: "A" }, { name: "B" }, { name: "C" }] },
|
|
359
|
+
query,
|
|
360
|
+
});
|
|
361
|
+
await settle();
|
|
362
|
+
|
|
363
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledTimes(3);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("bulk vượt trần: gom MỘT dòng tóm tắt thay vì N dòng", async () => {
|
|
367
|
+
const h = build({ maxBulkEntries: 2 });
|
|
368
|
+
const query = vi.fn().mockResolvedValue({ count: 5 });
|
|
369
|
+
|
|
370
|
+
await h({
|
|
371
|
+
model: "Customer",
|
|
372
|
+
operation: "createMany",
|
|
373
|
+
args: { data: [1, 2, 3, 4, 5].map((i) => ({ name: `KH${i}` })) },
|
|
374
|
+
query,
|
|
375
|
+
});
|
|
376
|
+
await settle();
|
|
377
|
+
|
|
378
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledTimes(1);
|
|
379
|
+
const payload = rawClient.auditLog.create.mock.calls[0]?.[0]?.data;
|
|
380
|
+
expect(payload.newData).toMatchObject({ bulk: true, count: 5 });
|
|
381
|
+
expect(payload.description).toContain("hàng loạt");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("updateMany chộp từng dòng cũ để biết đổi những gì", async () => {
|
|
385
|
+
rawClient.customer.findMany.mockResolvedValue([
|
|
386
|
+
{ id: "c1", status: "active" },
|
|
387
|
+
{ id: "c2", status: "active" },
|
|
388
|
+
]);
|
|
389
|
+
const query = vi.fn().mockResolvedValue({ count: 2 });
|
|
390
|
+
|
|
391
|
+
await handler({
|
|
392
|
+
model: "Customer",
|
|
393
|
+
operation: "updateMany",
|
|
394
|
+
args: { where: { status: "active" }, data: { status: "locked" } },
|
|
395
|
+
query,
|
|
396
|
+
});
|
|
397
|
+
await settle();
|
|
398
|
+
|
|
399
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledTimes(2);
|
|
400
|
+
const first = rawClient.auditLog.create.mock.calls[0]?.[0]?.data;
|
|
401
|
+
expect(first).toMatchObject({
|
|
402
|
+
action: "update",
|
|
403
|
+
resourceId: "c1",
|
|
404
|
+
oldData: { id: "c1", status: "active" },
|
|
405
|
+
newData: { status: "locked" },
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("updateMany không khớp dòng nào vẫn ghi 1 dòng tóm tắt", async () => {
|
|
410
|
+
rawClient.customer.findMany.mockResolvedValue([]);
|
|
411
|
+
const query = vi.fn().mockResolvedValue({ count: 0 });
|
|
412
|
+
|
|
413
|
+
await handler({
|
|
414
|
+
model: "Customer",
|
|
415
|
+
operation: "updateMany",
|
|
416
|
+
args: { where: { status: "ghost" }, data: { status: "x" } },
|
|
417
|
+
query,
|
|
418
|
+
});
|
|
419
|
+
await settle();
|
|
420
|
+
|
|
421
|
+
expect(rawClient.auditLog.create).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(
|
|
423
|
+
rawClient.auditLog.create.mock.calls[0]?.[0]?.data?.newData,
|
|
424
|
+
).toMatchObject({ bulk: true, count: 0 });
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xác định ACTOR cho audit từ session — điểm mấu chốt là phiên MẠO DANH
|
|
3
|
+
* (plugin admin của Better Auth): hàng audit phải quy về ADMIN THẬT
|
|
4
|
+
* (`userId = impersonatedBy`), `userName` ghi rõ "Admin (mạo danh Nhân viên)".
|
|
5
|
+
* Không xử lý thì nhật ký gán thao tác của admin cho nhân viên bị mạo danh —
|
|
6
|
+
* mất giá trị đối chiếu khi tra soát.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Phần session tối thiểu mà audit cần — app truyền session của mình vào. */
|
|
10
|
+
export interface AuditSessionLike {
|
|
11
|
+
user: { id?: string | null; name?: string | null; email?: string | null };
|
|
12
|
+
impersonatedBy?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param resolveName cách lấy tên admin từ id (caller tự quyết — tra DB hoặc
|
|
17
|
+
* bỏ qua). Lỗi lookup KHÔNG được làm vỡ audit.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveAuditActor(
|
|
20
|
+
session: AuditSessionLike,
|
|
21
|
+
resolveName?: (userId: string) => Promise<string | null>,
|
|
22
|
+
): Promise<{ userId?: string; userName?: string }> {
|
|
23
|
+
const targetName = session.user.name || session.user.email || "?";
|
|
24
|
+
|
|
25
|
+
if (!session.impersonatedBy) {
|
|
26
|
+
return {
|
|
27
|
+
userId: session.user.id || undefined,
|
|
28
|
+
userName: targetName === "?" ? undefined : targetName,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let adminName: string | null = null;
|
|
33
|
+
try {
|
|
34
|
+
adminName = (await resolveName?.(session.impersonatedBy)) ?? null;
|
|
35
|
+
} catch {
|
|
36
|
+
// audit không được vỡ vì lookup tên
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
userId: session.impersonatedBy,
|
|
40
|
+
userName: `${adminName ?? "Admin"} (mạo danh ${targetName})`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ngữ cảnh AUDIT theo request — mang userId/userName xuống tận Prisma extension
|
|
5
|
+
* mà không phải truyền tay qua từng lớp service.
|
|
6
|
+
*
|
|
7
|
+
* Vì sao AsyncLocalStorage: extension audit chạy bên trong Prisma, cách chỗ có
|
|
8
|
+
* session vài lớp gọi. Nếu bắt mỗi service nhận thêm tham số actor thì hoặc là
|
|
9
|
+
* sót (ghi user_id NULL), hoặc phải sửa hàng trăm chữ ký hàm.
|
|
10
|
+
*
|
|
11
|
+
* Dùng:
|
|
12
|
+
* route: return withAuditContext({ userId, userName, ip }, () => handler())
|
|
13
|
+
* extension: const ctx = getAuditContext()
|
|
14
|
+
*
|
|
15
|
+
* BẪY — `fn` phải THỰC SỰ chạy trong ngữ cảnh, không được chỉ trả về một
|
|
16
|
+
* PrismaPromise. Lệnh Prisma là promise LƯỜI: chỉ chạy khi có ai `await`. Viết
|
|
17
|
+
*
|
|
18
|
+
* withAuditContext(ctx, () => db.x.update(...)) // ❌ ghi user_id NULL
|
|
19
|
+
*
|
|
20
|
+
* thì `run()` đã thoát ngữ cảnh trước khi query khởi động → extension không
|
|
21
|
+
* thấy actor. Phải để `await` nằm BÊN TRONG:
|
|
22
|
+
*
|
|
23
|
+
* withAuditContext(ctx, async () => { await db.x.update(...) }) // ✅
|
|
24
|
+
*
|
|
25
|
+
* (Bọc cả một handler `async` thì an toàn sẵn — nó chạy ngay khi được gọi.)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface AuditContext {
|
|
29
|
+
userId?: string;
|
|
30
|
+
userName?: string;
|
|
31
|
+
ip?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const auditStore = new AsyncLocalStorage<AuditContext>();
|
|
35
|
+
|
|
36
|
+
/** Chạy `fn` trong ngữ cảnh audit — mọi lệnh Prisma bên trong thấy được actor. */
|
|
37
|
+
export function withAuditContext<T>(
|
|
38
|
+
context: AuditContext,
|
|
39
|
+
fn: () => T | Promise<T>,
|
|
40
|
+
): T | Promise<T> {
|
|
41
|
+
return auditStore.run(context, fn);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ngữ cảnh hiện tại, `undefined` nếu đang ở ngoài `withAuditContext`. */
|
|
45
|
+
export function getAuditContext(): AuditContext | undefined {
|
|
46
|
+
return auditStore.getStore();
|
|
47
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghi MỘT hàng audit mang NGỮ NGHĨA nghiệp vụ ("Xác nhận thanh toán", "Nhận
|
|
3
|
+
* hàng") — khác với `createAuditExtension` vốn chỉ chộp create/update/delete
|
|
4
|
+
* thô trên từng model. Timeline lịch sử của phiếu/đơn đọc chính những hàng này.
|
|
5
|
+
*
|
|
6
|
+
* Hai điều đắt giá, đừng bỏ:
|
|
7
|
+
* - `client`: truyền TransactionClient của lệnh nghiệp vụ vào thì hàng audit
|
|
8
|
+
* nằm chung transaction, rollback cùng nhau. Không truyền thì audit ghi độc
|
|
9
|
+
* lập — nghiệp vụ fail vẫn còn lại dòng "đã xảy ra" ma.
|
|
10
|
+
* - KHÔNG BAO GIỜ throw: audit hỏng không được chặn nghiệp vụ.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Delegate tối thiểu của bảng audit (`db.auditLog` hoặc `tx.auditLog`). */
|
|
14
|
+
export interface AuditLogWriter {
|
|
15
|
+
auditLog: { create(args: any): Promise<any> };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LogEntityActionParams {
|
|
19
|
+
/** Loại đối tượng, kebab-case: "sales-order", "purchase-order"… */
|
|
20
|
+
resource: string;
|
|
21
|
+
resourceId: string;
|
|
22
|
+
/** Mã hành động nghiệp vụ do app định nghĩa ("confirm-payment"…). */
|
|
23
|
+
action: string;
|
|
24
|
+
description: string;
|
|
25
|
+
userId?: string | null;
|
|
26
|
+
userName?: string | null;
|
|
27
|
+
oldData?: Record<string, any>;
|
|
28
|
+
newData?: Record<string, any>;
|
|
29
|
+
/** Số chứng từ để tra cứu nhanh — gộp vào newData.orderNumber. */
|
|
30
|
+
referenceNumber?: string | null;
|
|
31
|
+
/** Client ghi audit; truyền `tx` để hàng audit rollback cùng nghiệp vụ. */
|
|
32
|
+
client: AuditLogWriter;
|
|
33
|
+
/** Nơi báo lỗi ghi audit. Mặc định console.error. */
|
|
34
|
+
onError?: (error: unknown) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function logEntityAction({
|
|
38
|
+
resource,
|
|
39
|
+
resourceId,
|
|
40
|
+
action,
|
|
41
|
+
description,
|
|
42
|
+
userId,
|
|
43
|
+
userName,
|
|
44
|
+
oldData,
|
|
45
|
+
newData,
|
|
46
|
+
referenceNumber,
|
|
47
|
+
client,
|
|
48
|
+
onError,
|
|
49
|
+
}: LogEntityActionParams): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await client.auditLog.create({
|
|
52
|
+
data: {
|
|
53
|
+
action,
|
|
54
|
+
resource,
|
|
55
|
+
resourceId,
|
|
56
|
+
userId: userId || undefined,
|
|
57
|
+
description: userName ? `[${userName}] ${description}` : description,
|
|
58
|
+
oldData: oldData || undefined,
|
|
59
|
+
newData: newData
|
|
60
|
+
? { ...newData, orderNumber: referenceNumber || undefined }
|
|
61
|
+
: referenceNumber
|
|
62
|
+
? { orderNumber: referenceNumber }
|
|
63
|
+
: undefined,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Non-blocking — audit hỏng không được làm vỡ nghiệp vụ.
|
|
68
|
+
if (onError) onError(error);
|
|
69
|
+
else console.error("[Audit] Failed to log action:", error);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/audit/index.ts
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit dùng chung mọi app goerp — hai tầng bổ trợ nhau:
|
|
3
|
+
*
|
|
4
|
+
* 1. `createAuditExtension` — chộp TỰ ĐỘNG mọi create/update/delete ở tầng
|
|
5
|
+
* Prisma. Không sót, nhưng chỉ biết "ai đổi trường nào".
|
|
6
|
+
* 2. `logEntityAction` — ghi tay bản ghi mang NGỮ NGHĨA ("Xác nhận thanh
|
|
7
|
+
* toán"). Đây là thứ timeline lịch sử phiếu/đơn hiển thị.
|
|
8
|
+
*
|
|
9
|
+
* Actor chảy xuống cả hai qua `withAuditContext` (AsyncLocalStorage).
|
|
10
|
+
* Bảng `audit_logs` ship qua `goerp-features sync` (feature `audit-logs`).
|
|
11
|
+
*/
|
|
1
12
|
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "./audit-
|
|
6
|
-
export type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "./
|
|
13
|
+
getAuditContext,
|
|
14
|
+
withAuditContext,
|
|
15
|
+
type AuditContext,
|
|
16
|
+
} from "./audit-context";
|
|
17
|
+
export { resolveAuditActor, type AuditSessionLike } from "./audit-actor";
|
|
18
|
+
export {
|
|
19
|
+
logEntityAction,
|
|
20
|
+
type AuditLogWriter,
|
|
21
|
+
type LogEntityActionParams,
|
|
22
|
+
} from "./entity-audit";
|
|
23
|
+
export {
|
|
24
|
+
buildDescription,
|
|
25
|
+
createAuditExtension,
|
|
26
|
+
DEFAULT_SKIP_MODELS,
|
|
27
|
+
type AuditRawClient,
|
|
28
|
+
type CreateAuditExtensionOptions,
|
|
29
|
+
type PrismaNamespaceLike,
|
|
30
|
+
} from "./prisma-audit-extension";
|