@goplusvn/core 0.1.53 → 0.1.54

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.
@@ -34,8 +34,12 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
34
34
  - `notifications` — bảng `notifications` + `push_subscriptions` cho thông báo
35
35
  in-app NHÂN VIÊN. Runtime: `@goerp/core/notification`
36
36
  (`configureNotificationService`, seam `afterNotify` để app cắm web push/email)
37
- + UI `@goerp/core/notification/ui` (chuông + hộp thư). YÊU CẦU model `User`
37
+ UI `@goerp/core/notification/ui` (chuông + hộp thư). YÊU CẦU model `User`
38
38
  của app khai back-relation — xem `features/notifications/README.md`.
39
+ - `system-jobs` — bảng `system_jobs` + `job_execution_logs` cho cron có trạng
40
+ thái DB. Runtime: `@goerp/core/cron` (`configureCronManager`) + trang admin
41
+ `system/pages/system-jobs-page` (app cung cấp API, mẫu vinhhoa
42
+ /api/admin/system/jobs).
39
43
  - `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
40
44
  (`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
41
45
  admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
@@ -0,0 +1,40 @@
1
+ # Feature: system-jobs
2
+
3
+ Bảng `system_jobs` + `job_execution_logs` cho engine `@goerp/core/cron`
4
+ (`configureCronManager`) và trang admin
5
+ `@goerp/core/system/pages/system-jobs-page`.
6
+
7
+ Không phụ thuộc model nào của app (không FK sang `users`) — sync xong là dùng
8
+ được ngay.
9
+
10
+ ## Cắm vào app
11
+
12
+ ```ts
13
+ // src/lib/cron.ts (hoặc nơi khởi tạo)
14
+ import { configureCronManager, cronManager } from "@goerp/core/cron"
15
+ import { db } from "@/lib/prisma"
16
+
17
+ configureCronManager({ db })
18
+ export { cronManager }
19
+
20
+ // instrumentation.ts — đăng ký job
21
+ cronManager.addJob({ name: "sync-gold-price", cronTime: "5m", onTick: syncGoldPrice })
22
+ ```
23
+
24
+ Trong job, ghi diễn biến để trang admin xem lại được:
25
+
26
+ ```ts
27
+ import { getJobExecutionContext } from "@goerp/core/cron"
28
+ getJobExecutionContext()?.log("Đã đồng bộ", `${n} bản ghi`)
29
+ ```
30
+
31
+ ## API app phải cung cấp (trang admin gọi)
32
+
33
+ - `GET <apiUrl>` → `{ data: SystemJob[] }` (gộp `cronManager.getJobsInfo()`
34
+ với row DB, thêm cờ `inMemory`)
35
+ - `POST <apiUrl>` `{ name, action: "toggle" | "run" | "remove" }`
36
+ - `GET <apiUrl>/<name>/history?skip&take` → `{ data, meta.total }`
37
+
38
+ Mẫu chuẩn: vinhhoa `src/app/api/admin/system/jobs/`. Quyền do app gác
39
+ (`apiHandler(..., { resource, action })`) — Permission Registry của app là
40
+ nguồn sự thật.
@@ -0,0 +1,47 @@
1
+ -- goerp feature: system-jobs — bước 0001 (idempotent). 2 bảng cho engine cron
2
+ -- có trạng thái DB (@goerp/core/cron, configureCronManager) + trang admin
3
+ -- SystemJobsPage: cấu hình job (bật/tắt, lịch) và lịch sử từng lần chạy.
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 "system_jobs" (
7
+ "id" TEXT NOT NULL,
8
+ "name" TEXT NOT NULL,
9
+ "cron_time" TEXT NOT NULL,
10
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
11
+ "last_run" TIMESTAMP(3),
12
+ "next_run" TIMESTAMP(3),
13
+ "status" TEXT NOT NULL DEFAULT 'idle',
14
+ "error" TEXT,
15
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+ "updated_at" TIMESTAMP(3) NOT NULL,
17
+ "metadata" JSONB,
18
+
19
+ CONSTRAINT "system_jobs_pkey" PRIMARY KEY ("id")
20
+ );
21
+
22
+ CREATE UNIQUE INDEX IF NOT EXISTS "system_jobs_name_key" ON "system_jobs"("name");
23
+
24
+ CREATE TABLE IF NOT EXISTS "job_execution_logs" (
25
+ "id" TEXT NOT NULL,
26
+ "job_name" TEXT NOT NULL,
27
+ "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
28
+ "finished_at" TIMESTAMP(3),
29
+ "duration_ms" INTEGER,
30
+ "status" TEXT NOT NULL DEFAULT 'running',
31
+ "error" TEXT,
32
+ "actions" JSONB,
33
+ "summary" TEXT,
34
+
35
+ CONSTRAINT "job_execution_logs_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS "job_execution_logs_job_name_idx" ON "job_execution_logs"("job_name");
39
+ CREATE INDEX IF NOT EXISTS "job_execution_logs_started_at_idx" ON "job_execution_logs"("started_at");
40
+
41
+ -- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
42
+ DO $$ BEGIN
43
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_execution_logs_job_name_fkey') THEN
44
+ ALTER TABLE "job_execution_logs" ADD CONSTRAINT "job_execution_logs_job_name_fkey"
45
+ FOREIGN KEY ("job_name") REFERENCES "system_jobs"("name") ON DELETE CASCADE ON UPDATE CASCADE;
46
+ END IF;
47
+ END $$;
@@ -0,0 +1,42 @@
1
+ /// Job định kỳ có TRẠNG THÁI TRONG DB (engine `@goerp/core/cron`). DB là nguồn
2
+ /// sự thật của cờ enabled + lịch chạy: admin tắt job thì deploy mới không tự
3
+ /// bật lại, và restart tiến trình không mất cấu hình. `name` trùng tên đăng ký
4
+ /// trong code (`cronManager.addJob({ name })`) nên là khoá tự nhiên.
5
+ model SystemJob {
6
+ id String @id @default(cuid()) @map("id")
7
+ name String @unique @map("name")
8
+ cronTime String @map("cron_time") // "5m" | "1h" | "0 * * * *"
9
+ enabled Boolean @default(true) @map("enabled")
10
+ lastRun DateTime? @map("last_run")
11
+ nextRun DateTime? @map("next_run")
12
+ status String @default("idle") @map("status") // idle | running | failed
13
+ error String? @map("error") // lỗi lần chạy gần nhất (xoá khi chạy lại được)
14
+ createdAt DateTime @default(now()) @map("created_at")
15
+ updatedAt DateTime @updatedAt @map("updated_at")
16
+ metadata Json? @map("metadata")
17
+ executionLogs JobExecutionLog[]
18
+
19
+ @@map("system_jobs")
20
+ }
21
+
22
+ /// Một LẦN chạy của job: mở row lúc bắt đầu (status=running) và đóng lúc kết
23
+ /// thúc. `actions` là nhật ký diễn biến do chính job ghi qua
24
+ /// `getJobExecutionContext().log()` — thứ giúp admin biết job "chạy rồi" đã
25
+ /// làm được gì, không chỉ thành công/thất bại.
26
+ model JobExecutionLog {
27
+ id String @id @default(cuid()) @map("id")
28
+ jobName String @map("job_name")
29
+ startedAt DateTime @default(now()) @map("started_at")
30
+ finishedAt DateTime? @map("finished_at")
31
+ durationMs Int? @map("duration_ms")
32
+ status String @default("running") @map("status") // running | success | failed
33
+ error String? @map("error")
34
+ actions Json? @map("actions") // [{ time, action, details? }]
35
+ summary String? @map("summary")
36
+
37
+ job SystemJob @relation(fields: [jobName], references: [name], onDelete: Cascade)
38
+
39
+ @@index([jobName])
40
+ @@index([startedAt])
41
+ @@map("job_execution_logs")
42
+ }
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.53",
4
+ "version": "0.1.54",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -80,6 +80,8 @@
80
80
  "./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
81
81
  "./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
82
82
  "./system/pages/error-logs-page": "./src/system/pages/error-logs-page.tsx",
83
+ "./system/pages/system-jobs-page": "./src/system/pages/system-jobs-page.tsx",
84
+ "./cron": "./src/cron/index.ts",
83
85
  "./rbac/role-service": "./src/rbac/role-service.ts",
84
86
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
85
87
  "./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
@@ -0,0 +1,316 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ configureCronManager,
5
+ DbCronJobManager,
6
+ getJobExecutionContext,
7
+ } from "../db-cron-manager";
8
+ import type { CronDb } from "../db-cron-manager";
9
+
10
+ interface JobRow {
11
+ name: string;
12
+ cronTime: string;
13
+ enabled: boolean;
14
+ status?: string;
15
+ lastRun?: Date | null;
16
+ nextRun?: Date | null;
17
+ error?: string | null;
18
+ }
19
+
20
+ interface LogRow {
21
+ id: string;
22
+ jobName: string;
23
+ status: string;
24
+ actions?: unknown;
25
+ durationMs?: number | null;
26
+ error?: string | null;
27
+ summary?: string | null;
28
+ }
29
+
30
+ /** Fake Prisma tối thiểu — đủ để quan sát side-effect DB của manager. */
31
+ function makeDb(seed: JobRow[] = []) {
32
+ const jobs = new Map(seed.map((j) => [j.name, { ...j }]));
33
+ const logs: LogRow[] = [];
34
+ let seq = 0;
35
+
36
+ const db = {
37
+ systemJob: {
38
+ count: vi.fn(async () => jobs.size),
39
+ findUnique: vi.fn(
40
+ async ({ where }: { where: { name: string } }) =>
41
+ jobs.get(where.name) ?? null,
42
+ ),
43
+ create: vi.fn(async ({ data }: { data: JobRow }) => {
44
+ jobs.set(data.name, { ...data });
45
+ return jobs.get(data.name);
46
+ }),
47
+ update: vi.fn(
48
+ async ({
49
+ where,
50
+ data,
51
+ }: {
52
+ where: { name: string };
53
+ data: Partial<JobRow>;
54
+ }) => {
55
+ const row = jobs.get(where.name);
56
+ if (!row) throw new Error(`no job ${where.name}`);
57
+ Object.assign(row, data);
58
+ return row;
59
+ },
60
+ ),
61
+ },
62
+ jobExecutionLog: {
63
+ create: vi.fn(async ({ data }: { data: Omit<LogRow, "id"> }) => {
64
+ const row = { id: `log${++seq}`, ...data };
65
+ logs.push(row);
66
+ return row;
67
+ }),
68
+ update: vi.fn(
69
+ async ({
70
+ where,
71
+ data,
72
+ }: {
73
+ where: { id: string };
74
+ data: Partial<LogRow>;
75
+ }) => {
76
+ const row = logs.find((l) => l.id === where.id);
77
+ if (!row) throw new Error(`no log ${where.id}`);
78
+ Object.assign(row, data);
79
+ return row;
80
+ },
81
+ ),
82
+ },
83
+ };
84
+
85
+ return { db: db as unknown as CronDb, jobs, logs };
86
+ }
87
+
88
+ const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
89
+
90
+ /** Chờ các promise nền (syncJobWithDb chạy fire-and-forget trong addJob). */
91
+ const flush = () => new Promise((r) => setTimeout(r, 0));
92
+
93
+ describe("DbCronJobManager — đăng ký job", () => {
94
+ let manager: DbCronJobManager;
95
+
96
+ beforeEach(() => {
97
+ manager = new DbCronJobManager();
98
+ });
99
+ afterEach(() => manager.stopAll());
100
+
101
+ it("chưa có row DB → tạo mới và chạy theo cờ start", async () => {
102
+ const { db, jobs } = makeDb();
103
+ configureCronManager({ db, logger: silentLogger });
104
+
105
+ manager.addJob({
106
+ name: "sync",
107
+ cronTime: "5m",
108
+ onTick: vi.fn(),
109
+ start: true,
110
+ });
111
+ await flush();
112
+
113
+ expect(jobs.get("sync")).toMatchObject({ cronTime: "5m", enabled: true });
114
+ expect(manager.getJob("sync")?.isRunning()).toBe(true);
115
+ });
116
+
117
+ it("DB enabled=false THẮNG start:true — admin tắt thì deploy mới không tự bật", async () => {
118
+ const { db } = makeDb([{ name: "sync", cronTime: "5m", enabled: false }]);
119
+ configureCronManager({ db, logger: silentLogger });
120
+
121
+ manager.addJob({
122
+ name: "sync",
123
+ cronTime: "5m",
124
+ onTick: vi.fn(),
125
+ start: true,
126
+ });
127
+ await flush();
128
+
129
+ expect(manager.getJob("sync")?.isRunning()).toBe(false);
130
+ });
131
+
132
+ it("lịch trong CODE đổi → ghi ngược lại DB", async () => {
133
+ const { db, jobs } = makeDb([
134
+ { name: "sync", cronTime: "1h", enabled: false },
135
+ ]);
136
+ configureCronManager({ db, logger: silentLogger });
137
+
138
+ manager.addJob({ name: "sync", cronTime: "15m", onTick: vi.fn() });
139
+ await flush();
140
+
141
+ expect(jobs.get("sync")?.cronTime).toBe("15m");
142
+ });
143
+
144
+ it("app chưa sync feature (thiếu model) → memory-only, KHÔNG nổ", async () => {
145
+ configureCronManager({ db: {} as CronDb, logger: silentLogger });
146
+
147
+ manager.addJob({
148
+ name: "sync",
149
+ cronTime: "5m",
150
+ onTick: vi.fn(),
151
+ start: true,
152
+ });
153
+ await manager.init();
154
+ await flush();
155
+
156
+ expect(manager.getJob("sync")?.isRunning()).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe("DbCronJobManager — lịch sử thực thi", () => {
161
+ let manager: DbCronJobManager;
162
+
163
+ beforeEach(() => {
164
+ manager = new DbCronJobManager();
165
+ });
166
+ afterEach(() => manager.stopAll());
167
+
168
+ it("chạy thành công: log running → success kèm actions của job", async () => {
169
+ const { db, jobs, logs } = makeDb([
170
+ { name: "sync", cronTime: "5m", enabled: true },
171
+ ]);
172
+ configureCronManager({ db, logger: silentLogger });
173
+
174
+ manager.addJob({
175
+ name: "sync",
176
+ cronTime: "5m",
177
+ onTick: async () => {
178
+ getJobExecutionContext()?.log("Đã tải giá", "12 mã");
179
+ },
180
+ });
181
+ await flush();
182
+ await manager.runJobNow("sync");
183
+ await flush();
184
+
185
+ expect(logs).toHaveLength(1);
186
+ expect(logs[0]).toMatchObject({ jobName: "sync", status: "success" });
187
+ expect(logs[0].actions).toEqual([
188
+ expect.objectContaining({ action: "Đã tải giá", details: "12 mã" }),
189
+ ]);
190
+ expect(jobs.get("sync")).toMatchObject({ status: "idle", error: null });
191
+ });
192
+
193
+ it("chạy lỗi: log failed + job.status failed + giữ nguyên message", async () => {
194
+ const { db, jobs, logs } = makeDb([
195
+ { name: "sync", cronTime: "5m", enabled: true },
196
+ ]);
197
+ configureCronManager({ db, logger: silentLogger });
198
+
199
+ manager.addJob({
200
+ name: "sync",
201
+ cronTime: "5m",
202
+ onTick: async () => {
203
+ throw new Error("upstream 503");
204
+ },
205
+ });
206
+ await flush();
207
+ await manager.runJobNow("sync"); // fire-and-forget: lỗi được nuốt ở đây
208
+ await flush();
209
+
210
+ expect(logs[0]).toMatchObject({ status: "failed" });
211
+ expect(String(logs[0].error)).toContain("upstream 503");
212
+ expect(jobs.get("sync")?.status).toBe("failed");
213
+ expect(String(jobs.get("sync")?.error)).toContain("upstream 503");
214
+ });
215
+
216
+ it("ngữ cảnh được dọn sau khi chạy — không rò sang lần sau", async () => {
217
+ const { db } = makeDb([{ name: "sync", cronTime: "5m", enabled: true }]);
218
+ configureCronManager({ db, logger: silentLogger });
219
+
220
+ manager.addJob({ name: "sync", cronTime: "5m", onTick: vi.fn() });
221
+ await flush();
222
+ await manager.runJobNow("sync");
223
+ await flush();
224
+
225
+ expect(getJobExecutionContext()).toBeNull();
226
+ });
227
+
228
+ it("đổi lịch xong VẪN ghi lịch sử (bản cũ mất log sau updateJob)", async () => {
229
+ const { db, jobs, logs } = makeDb([
230
+ { name: "sync", cronTime: "5m", enabled: true },
231
+ ]);
232
+ configureCronManager({ db, logger: silentLogger });
233
+
234
+ const onTick = vi.fn();
235
+ manager.addJob({ name: "sync", cronTime: "5m", onTick });
236
+ await flush();
237
+
238
+ expect(await manager.updateJob("sync", "30m", false)).toBe(true);
239
+ expect(jobs.get("sync")?.cronTime).toBe("30m");
240
+ expect(manager.getJob("sync")?.isRunning()).toBe(false);
241
+
242
+ await manager.runJobNow("sync");
243
+ await flush();
244
+
245
+ expect(onTick).toHaveBeenCalledTimes(1);
246
+ expect(logs).toHaveLength(1);
247
+ expect(logs[0].status).toBe("success");
248
+ });
249
+
250
+ it("updateJob job lạ → false, không đụng DB", async () => {
251
+ const { db } = makeDb();
252
+ configureCronManager({ db, logger: silentLogger });
253
+ expect(await manager.updateJob("khong-co", "5m")).toBe(false);
254
+ });
255
+ });
256
+
257
+ describe("DbCronJobManager — điều khiển", () => {
258
+ let manager: DbCronJobManager;
259
+
260
+ beforeEach(() => {
261
+ manager = new DbCronJobManager();
262
+ });
263
+ afterEach(() => manager.stopAll());
264
+
265
+ it("toggleJob ghi cờ enabled xuống DB", async () => {
266
+ const { db, jobs } = makeDb([
267
+ { name: "sync", cronTime: "5m", enabled: true },
268
+ ]);
269
+ configureCronManager({ db, logger: silentLogger });
270
+
271
+ manager.addJob({ name: "sync", cronTime: "5m", onTick: vi.fn() });
272
+ await flush();
273
+ expect(manager.getJob("sync")?.isRunning()).toBe(true);
274
+
275
+ await manager.toggleJob("sync");
276
+ expect(manager.getJob("sync")?.isRunning()).toBe(false);
277
+ expect(jobs.get("sync")?.enabled).toBe(false);
278
+ });
279
+
280
+ it("runJobNow/toggleJob với job chưa đăng ký → false", async () => {
281
+ const { db } = makeDb();
282
+ configureCronManager({ db, logger: silentLogger });
283
+ expect(await manager.runJobNow("khong-co")).toBe(false);
284
+ expect(await manager.toggleJob("khong-co")).toBe(false);
285
+ });
286
+
287
+ it("removeJob dừng timer và quên callback", async () => {
288
+ const { db } = makeDb([{ name: "sync", cronTime: "5m", enabled: true }]);
289
+ configureCronManager({ db, logger: silentLogger });
290
+
291
+ manager.addJob({ name: "sync", cronTime: "5m", onTick: vi.fn() });
292
+ await flush();
293
+ manager.removeJob("sync");
294
+
295
+ expect(manager.getJob("sync")).toBeUndefined();
296
+ expect(manager.getAllJobs()).toHaveLength(0);
297
+ expect(await manager.runJobNow("sync")).toBe(false);
298
+ });
299
+
300
+ it("getJobsInfo trả trạng thái runtime cho trang admin", async () => {
301
+ const { db } = makeDb([{ name: "sync", cronTime: "5m", enabled: true }]);
302
+ configureCronManager({ db, logger: silentLogger });
303
+
304
+ manager.addJob({ name: "sync", cronTime: "5m", onTick: vi.fn() });
305
+ await flush();
306
+
307
+ const info = manager.getJobsInfo();
308
+ expect(info).toHaveLength(1);
309
+ expect(info[0]).toMatchObject({
310
+ name: "sync",
311
+ cronTime: "5m",
312
+ isRunning: true,
313
+ });
314
+ expect(info[0].nextDate).toBeInstanceOf(Date);
315
+ });
316
+ });