@goplusvn/core 0.1.54 → 0.1.56

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.
Files changed (39) hide show
  1. package/PLATFORM.md +44 -0
  2. package/features/README.md +6 -0
  3. package/features/audit-logs/README.md +18 -0
  4. package/features/audit-logs/migrations/0001_init.sql +33 -0
  5. package/features/audit-logs/schema.prisma +20 -0
  6. package/package.json +5 -1
  7. package/src/audit/__tests__/audit-context.test.ts +174 -0
  8. package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
  9. package/src/audit/audit-actor.ts +42 -0
  10. package/src/audit/audit-context.ts +47 -0
  11. package/src/audit/entity-audit.ts +71 -0
  12. package/src/audit/index.ts +29 -10
  13. package/src/audit/prisma-audit-extension.ts +572 -0
  14. package/src/crud/components/crud-import-dialog.tsx +23 -421
  15. package/src/crud/components/crud-page.tsx +7 -12
  16. package/src/import/__tests__/import-dialog.test.tsx +141 -0
  17. package/src/import/__tests__/import-engine.test.ts +235 -0
  18. package/src/import/import-dialog.tsx +570 -0
  19. package/src/import/import-engine.ts +357 -0
  20. package/src/import/index.ts +39 -0
  21. package/src/import/types.ts +73 -0
  22. package/src/import/use-import.ts +156 -0
  23. package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
  24. package/src/system/pages/system-audit-page.tsx +532 -0
  25. package/src/ui/filters/__tests__/advanced-filter-builder.test.tsx +194 -0
  26. package/src/ui/filters/advanced-filter-builder.tsx +380 -0
  27. package/src/ui/filters/index.ts +7 -0
  28. package/src/ui/index.tsx +1 -0
  29. package/src/ui/management/audit-log-page.tsx +12 -207
  30. package/src/audit/audit-manager.ts +0 -139
  31. package/src/audit/memory-audit-logger.ts +0 -86
  32. package/src/audit/types.ts +0 -50
  33. package/src/crud/crud-filters/checkbox-filter.tsx +0 -87
  34. package/src/crud/crud-filters/datetime-filter.tsx +0 -82
  35. package/src/crud/crud-filters/filter-builder.tsx +0 -64
  36. package/src/crud/crud-filters/index.tsx +0 -78
  37. package/src/crud/crud-filters/radio-filter.tsx +0 -79
  38. package/src/crud/crud-filters/select-filter.tsx +0 -148
  39. package/src/crud/crud-filters/text-filter.tsx +0 -81
package/PLATFORM.md CHANGED
@@ -117,6 +117,50 @@ import { toCsv } from "@goerp/core/export/to-csv" // server CSV routes
117
117
  return new Response(toCsv(rows, columns), { headers: { ... } })
118
118
  ```
119
119
 
120
+ ## Import
121
+
122
+ One dialog for both server contracts — a workbook harness (`templateApiUrl`) or
123
+ an EntityConfig-driven CRUD import (`config`). A `202` reply means the server
124
+ queued a background task; the dialog closes instead of reporting "0 rows".
125
+
126
+ ```tsx
127
+ import { ImportDialog } from "@goerp/core/import"
128
+
129
+ <ImportDialog
130
+ apiUrl={`/api/purchase-orders/import`}
131
+ templateApiUrl={`/api/purchase-orders/import/template`}
132
+ tasksPath={`/${lang}/tasks`} // where the 202 toast links to
133
+ canImport={can("purchase-orders", "create")}
134
+ />
135
+ ```
136
+
137
+ The parsing half is pure and server-side — domain import services keep the DB
138
+ work and call these:
139
+
140
+ ```ts
141
+ import { parseExcelBuffer, validateHeaders, normalizeRow } from "@goerp/core/import"
142
+ ```
143
+
144
+ `parseFlexibleDate` refuses impossible dates (`31/06`) instead of rolling them
145
+ into the next month, and decodes Excel serials in UTC — local-time arithmetic is
146
+ off by a day in zones with pre-1911 LMT offsets (Asia/Ho_Chi_Minh among them).
147
+
148
+ Note: `@goerp/core/import` types carry a `Workbook` prefix
149
+ (`WorkbookImportResult`, …) so they don't collide with the flat CRUD
150
+ `ImportResult`/`ImportOptions` in `@goerp/core/types`.
151
+
152
+ ## Advanced filters
153
+
154
+ The client half of the CRUD filter-tree DSL — nested AND/OR groups that
155
+ serialize to the same `FilterTreeNode` the server query builder consumes.
156
+
157
+ ```tsx
158
+ import { AdvancedFilterBuilder } from "@goerp/core/ui/filters/advanced-filter-builder"
159
+
160
+ <AdvancedFilterBuilder fields={FIELDS} value={tree} onChange={setTree} />
161
+ // tzOffset defaults to "+07:00": a date leaf means that zone's whole day
162
+ ```
163
+
120
164
  ## Print
121
165
 
122
166
  Core owns *how* to print; the app supplies *what* via `renderContent`.
@@ -40,6 +40,12 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
40
40
  thái DB. Runtime: `@goerp/core/cron` (`configureCronManager`) + trang admin
41
41
  `system/pages/system-jobs-page` (app cung cấp API, mẫu vinhhoa
42
42
  /api/admin/system/jobs).
43
+ - `audit-logs` — bảng `audit_logs` cho nhật ký thay đổi dữ liệu. Runtime:
44
+ `@goerp/core/audit` (`createAuditExtension` chộp tự động create/update/delete
45
+ ở tầng Prisma, `logEntityAction` ghi hành động nghiệp vụ, `withAuditContext`
46
+ đưa actor xuống) + trang admin `system/pages/system-audit-page`. YÊU CẦU
47
+ back-relation `auditLogs AuditLog[]` trên model `User` — xem
48
+ `features/audit-logs/README.md`.
43
49
  - `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
44
50
  (`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
45
51
  admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
@@ -0,0 +1,18 @@
1
+ # Feature: audit-logs
2
+
3
+ Bảng `audit_logs` cho `@goerp/core/audit`: extension Prisma
4
+ (`createAuditExtension`) chộp tự động create/update/delete, `logEntityAction`
5
+ ghi hành động nghiệp vụ, trang admin `SystemAuditPage` đọc.
6
+
7
+ **Yêu cầu phía app (Prisma bắt buộc 2 chiều relation):** model `User` phải khai
8
+
9
+ ```prisma
10
+ auditLogs AuditLog[]
11
+ ```
12
+
13
+ **Bảng người dùng phải tên `users`** — SQL neo FK `audit_logs.user_id → users.id`.
14
+
15
+ **Trước khi bật extension, xem lại `skipModels`:** mặc định core đã bỏ qua bảng
16
+ hạ tầng (audit/auth/log/task/notification). App còn bảng ghi dày kiểu hàng đợi
17
+ tin nhắn thì khai thêm, không thì nhật ký nghiệp vụ bị rác nhấn chìm — đo ở
18
+ vinhhoa: 67% số hàng audit 7 ngày là từ 1 bảng hàng đợi ZNS.
@@ -0,0 +1,33 @@
1
+ -- goerp feature: audit-logs — bước 0001 (idempotent). Bảng `audit_logs` cho
2
+ -- @goerp/core/audit: extension Prisma chộp create/update/delete tự động +
3
+ -- logEntityAction ghi hành động nghiệp vụ, và trang admin SystemAuditPage đọc.
4
+ -- Tên index/constraint giữ đúng bản Prisma sinh ra để app đã có bảng (vinhhoa)
5
+ -- chạy lại là no-op, không đẻ index trùng.
6
+ CREATE TABLE IF NOT EXISTS "audit_logs" (
7
+ "id" TEXT NOT NULL,
8
+ "user_id" TEXT,
9
+ "action" TEXT NOT NULL,
10
+ "resource" TEXT NOT NULL,
11
+ "resource_id" TEXT,
12
+ "old_data" JSONB,
13
+ "new_data" JSONB,
14
+ "ip_address" TEXT,
15
+ "user_agent" TEXT,
16
+ "description" TEXT,
17
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+
19
+ CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS "audit_logs_user_id_idx" ON "audit_logs"("user_id");
23
+ CREATE INDEX IF NOT EXISTS "audit_logs_action_idx" ON "audit_logs"("action");
24
+ CREATE INDEX IF NOT EXISTS "audit_logs_resource_idx" ON "audit_logs"("resource");
25
+ CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx" ON "audit_logs"("created_at");
26
+
27
+ -- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
28
+ DO $$ BEGIN
29
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'audit_logs_user_id_fkey') THEN
30
+ ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey"
31
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
32
+ END IF;
33
+ END $$;
@@ -0,0 +1,20 @@
1
+ model AuditLog {
2
+ id String @id @default(cuid()) @map("id")
3
+ userId String? @map("user_id")
4
+ action String @map("action")
5
+ resource String @map("resource")
6
+ resourceId String? @map("resource_id")
7
+ oldData Json? @map("old_data")
8
+ newData Json? @map("new_data")
9
+ ipAddress String? @map("ip_address")
10
+ userAgent String? @map("user_agent")
11
+ description String? @map("description")
12
+ createdAt DateTime @default(now()) @map("created_at")
13
+ user User? @relation(fields: [userId], references: [id])
14
+
15
+ @@index([userId])
16
+ @@index([action])
17
+ @@index([resource])
18
+ @@index([createdAt])
19
+ @@map("audit_logs")
20
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.54",
4
+ "version": "0.1.56",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -47,6 +47,7 @@
47
47
  "./notification/ui": "./src/notification/ui/index.ts",
48
48
  "./tasks": "./src/tasks/index.ts",
49
49
  "./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
50
+ "./import": "./src/import/index.ts",
50
51
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
51
52
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
52
53
  "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
@@ -74,6 +75,7 @@
74
75
  "./ui/primitives/sidebar": "./src/ui/primitives/sidebar.tsx",
75
76
  "./ui/primitives/select": "./src/ui/primitives/select.tsx",
76
77
  "./ui/forms/multi-select": "./src/ui/forms/multi-select.tsx",
78
+ "./ui/filters/advanced-filter-builder": "./src/ui/filters/advanced-filter-builder.tsx",
77
79
  "./ui/data-display/collapsible": "./src/ui/data-display/collapsible.tsx",
78
80
  "./system/services/settings-service": "./src/system/services/settings-service.ts",
79
81
  "./system/services/system-category-service": "./src/system/services/system-category-service.ts",
@@ -81,7 +83,9 @@
81
83
  "./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
82
84
  "./system/pages/error-logs-page": "./src/system/pages/error-logs-page.tsx",
83
85
  "./system/pages/system-jobs-page": "./src/system/pages/system-jobs-page.tsx",
86
+ "./system/pages/system-audit-page": "./src/system/pages/system-audit-page.tsx",
84
87
  "./cron": "./src/cron/index.ts",
88
+ "./audit": "./src/audit/index.ts",
85
89
  "./rbac/role-service": "./src/rbac/role-service.ts",
86
90
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
87
91
  "./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { getAuditContext, withAuditContext } from "../audit-context";
4
+ import { resolveAuditActor } from "../audit-actor";
5
+ import { logEntityAction } from "../entity-audit";
6
+
7
+ describe("withAuditContext / getAuditContext", () => {
8
+ it("ngoài context trả undefined (extension tự biết là không có actor)", () => {
9
+ expect(getAuditContext()).toBeUndefined();
10
+ });
11
+
12
+ it("giữ context xuyên qua await", async () => {
13
+ await withAuditContext(
14
+ { userId: "user-1", userName: "Nguyễn Văn A" },
15
+ async () => {
16
+ await Promise.resolve();
17
+ expect(getAuditContext()).toEqual({
18
+ userId: "user-1",
19
+ userName: "Nguyễn Văn A",
20
+ });
21
+ },
22
+ );
23
+ });
24
+
25
+ it("lồng nhau: thoát context trong thì context ngoài trở lại", async () => {
26
+ await withAuditContext({ userId: "outer" }, async () => {
27
+ expect(getAuditContext()?.userId).toBe("outer");
28
+ await withAuditContext({ userId: "inner" }, async () => {
29
+ expect(getAuditContext()?.userId).toBe("inner");
30
+ });
31
+ expect(getAuditContext()?.userId).toBe("outer");
32
+ });
33
+ });
34
+
35
+ it("trả về giá trị của fn và không nuốt lỗi", async () => {
36
+ await expect(
37
+ withAuditContext({ userId: "u" }, async () => 42),
38
+ ).resolves.toBe(42);
39
+ await expect(
40
+ withAuditContext({ userId: "u" }, async () => {
41
+ throw new Error("test error");
42
+ }),
43
+ ).rejects.toThrow("test error");
44
+ });
45
+ });
46
+
47
+ describe("resolveAuditActor", () => {
48
+ it("phiên thường: lấy id + tên người dùng", async () => {
49
+ await expect(
50
+ resolveAuditActor({
51
+ user: { id: "u1", name: "Kế toán", email: "kt@x.vn" },
52
+ }),
53
+ ).resolves.toEqual({ userId: "u1", userName: "Kế toán" });
54
+ });
55
+
56
+ it("không có tên thì rơi về email", async () => {
57
+ await expect(
58
+ resolveAuditActor({ user: { id: "u1", name: null, email: "kt@x.vn" } }),
59
+ ).resolves.toEqual({ userId: "u1", userName: "kt@x.vn" });
60
+ });
61
+
62
+ it("mạo danh: audit quy về ADMIN THẬT, không phải người bị mạo danh", async () => {
63
+ const resolveName = vi.fn().mockResolvedValue("Quản trị");
64
+
65
+ await expect(
66
+ resolveAuditActor(
67
+ { user: { id: "u-nv", name: "Nhân viên" }, impersonatedBy: "u-admin" },
68
+ resolveName,
69
+ ),
70
+ ).resolves.toEqual({
71
+ userId: "u-admin",
72
+ userName: "Quản trị (mạo danh Nhân viên)",
73
+ });
74
+ expect(resolveName).toHaveBeenCalledWith("u-admin");
75
+ });
76
+
77
+ it("tra tên admin hỏng vẫn ghi được actor", async () => {
78
+ await expect(
79
+ resolveAuditActor(
80
+ { user: { id: "u-nv", name: "Nhân viên" }, impersonatedBy: "u-admin" },
81
+ async () => {
82
+ throw new Error("DB down");
83
+ },
84
+ ),
85
+ ).resolves.toEqual({
86
+ userId: "u-admin",
87
+ userName: "Admin (mạo danh Nhân viên)",
88
+ });
89
+ });
90
+ });
91
+
92
+ describe("logEntityAction", () => {
93
+ const makeClient = () => ({
94
+ auditLog: { create: vi.fn().mockResolvedValue({ id: "a1" }) },
95
+ });
96
+
97
+ it("ghi qua ĐÚNG client được truyền (tx → rollback cùng nghiệp vụ)", async () => {
98
+ const tx = makeClient();
99
+ await logEntityAction({
100
+ resource: "sales-order",
101
+ resourceId: "so-1",
102
+ action: "confirm-payment",
103
+ description: "Xác nhận thanh toán",
104
+ userId: "u1",
105
+ userName: "Kế toán",
106
+ referenceNumber: "DH001",
107
+ newData: { amount: 500000 },
108
+ client: tx,
109
+ });
110
+
111
+ expect(tx.auditLog.create).toHaveBeenCalledWith({
112
+ data: {
113
+ action: "confirm-payment",
114
+ resource: "sales-order",
115
+ resourceId: "so-1",
116
+ userId: "u1",
117
+ description: "[Kế toán] Xác nhận thanh toán",
118
+ oldData: undefined,
119
+ newData: { amount: 500000, orderNumber: "DH001" },
120
+ },
121
+ });
122
+ });
123
+
124
+ it("không có userName thì mô tả giữ nguyên, không có tiền tố []", async () => {
125
+ const client = makeClient();
126
+ await logEntityAction({
127
+ resource: "sales-order",
128
+ resourceId: "so-1",
129
+ action: "cancel",
130
+ description: "Huỷ đơn",
131
+ client,
132
+ });
133
+
134
+ expect(client.auditLog.create.mock.calls[0][0].data.description).toBe(
135
+ "Huỷ đơn",
136
+ );
137
+ });
138
+
139
+ it("chỉ có số chứng từ vẫn nhét được vào newData để tra cứu", async () => {
140
+ const client = makeClient();
141
+ await logEntityAction({
142
+ resource: "purchase-order",
143
+ resourceId: "po-1",
144
+ action: "receive",
145
+ description: "Nhận hàng",
146
+ referenceNumber: "PN001",
147
+ client,
148
+ });
149
+
150
+ expect(client.auditLog.create.mock.calls[0][0].data.newData).toEqual({
151
+ orderNumber: "PN001",
152
+ });
153
+ });
154
+
155
+ it("audit hỏng KHÔNG throw ra nghiệp vụ", async () => {
156
+ const client = {
157
+ auditLog: { create: vi.fn().mockRejectedValue(new Error("DB fail")) },
158
+ };
159
+ const onError = vi.fn();
160
+
161
+ await expect(
162
+ logEntityAction({
163
+ resource: "sales-order",
164
+ resourceId: "so-1",
165
+ action: "cancel",
166
+ description: "Huỷ đơn",
167
+ client,
168
+ onError,
169
+ }),
170
+ ).resolves.toBeUndefined();
171
+
172
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
173
+ });
174
+ });