@goplusvn/core 0.1.52 → 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.
- package/features/README.md +9 -0
- package/features/notifications/README.md +14 -0
- package/features/notifications/migrations/0001_init.sql +59 -0
- package/features/notifications/schema.prisma +46 -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/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/notification/__tests__/notification-service.test.ts +192 -0
- package/src/notification/__tests__/notification-ui.test.tsx +62 -0
- package/src/notification/index.ts +15 -13
- package/src/notification/notification-service.ts +270 -96
- package/src/notification/ui/index.ts +2 -0
- package/src/notification/ui/notification-bell.tsx +244 -0
- package/src/notification/ui/notifications-inbox.tsx +193 -0
- package/src/system/pages/__tests__/system-jobs-page.test.tsx +92 -0
- package/src/system/pages/system-jobs-page.tsx +571 -0
- package/src/notification/storage/in-memory.ts +0 -56
- package/src/notification/storage/index.ts +0 -1
- package/src/notification/types.ts +0 -51
package/features/README.md
CHANGED
|
@@ -31,6 +31,15 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
|
|
|
31
31
|
- `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
|
|
32
32
|
(export/import chạy nền). Runtime: `@goerp/core/tasks`
|
|
33
33
|
(`configureTaskRunner`) + UI `@goerp/core/tasks/ui`.
|
|
34
|
+
- `notifications` — bảng `notifications` + `push_subscriptions` cho thông báo
|
|
35
|
+
in-app NHÂN VIÊN. Runtime: `@goerp/core/notification`
|
|
36
|
+
(`configureNotificationService`, seam `afterNotify` để app cắm web push/email)
|
|
37
|
+
và UI `@goerp/core/notification/ui` (chuông + hộp thư). YÊU CẦU model `User`
|
|
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).
|
|
34
43
|
- `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
|
|
35
44
|
(`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
|
|
36
45
|
admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Feature: notifications
|
|
2
|
+
|
|
3
|
+
Bảng `notifications` + `push_subscriptions` cho engine
|
|
4
|
+
`@goerp/core/notification` (`configureNotificationService`).
|
|
5
|
+
|
|
6
|
+
**Yêu cầu phía app (Prisma bắt buộc 2 chiều relation):** model `User` phải khai
|
|
7
|
+
|
|
8
|
+
```prisma
|
|
9
|
+
notifications Notification[]
|
|
10
|
+
pushSubscriptions PushSubscription[]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
App template (F2) có sẵn; app cũ thêm 2 dòng trên vào model User trước khi
|
|
14
|
+
`goerp-features sync`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- goerp feature: notifications — bước 0001 (idempotent). 2 bảng: thông báo
|
|
2
|
+
-- in-app (fan-out mỗi người 1 row) + web push subscription theo thiết bị.
|
|
3
|
+
-- YÊU CẦU: bảng "users" tồn tại (schema auth/RBAC chung của goerp) và model
|
|
4
|
+
-- User của app khai back-relation `notifications Notification[]` +
|
|
5
|
+
-- `pushSubscriptions PushSubscription[]` (xem features/notifications/README).
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "notifications" (
|
|
7
|
+
"id" TEXT NOT NULL,
|
|
8
|
+
"user_id" TEXT NOT NULL,
|
|
9
|
+
"type" TEXT NOT NULL DEFAULT 'info',
|
|
10
|
+
"category" TEXT,
|
|
11
|
+
"title" TEXT NOT NULL,
|
|
12
|
+
"content" TEXT NOT NULL,
|
|
13
|
+
"url" TEXT,
|
|
14
|
+
"icon_name" TEXT,
|
|
15
|
+
"resource_type" TEXT,
|
|
16
|
+
"resource_id" TEXT,
|
|
17
|
+
"is_read" BOOLEAN NOT NULL DEFAULT false,
|
|
18
|
+
"read_at" TIMESTAMP(3),
|
|
19
|
+
"created_by" TEXT,
|
|
20
|
+
"meta" JSONB,
|
|
21
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
|
|
23
|
+
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_is_read_idx" ON "notifications"("user_id", "is_read");
|
|
27
|
+
CREATE INDEX IF NOT EXISTS "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at");
|
|
28
|
+
CREATE INDEX IF NOT EXISTS "notifications_resource_type_resource_id_idx" ON "notifications"("resource_type", "resource_id");
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
|
31
|
+
"id" TEXT NOT NULL,
|
|
32
|
+
"user_id" TEXT NOT NULL,
|
|
33
|
+
"endpoint" TEXT NOT NULL,
|
|
34
|
+
"p256dh" TEXT NOT NULL,
|
|
35
|
+
"auth" TEXT NOT NULL,
|
|
36
|
+
"user_agent" TEXT,
|
|
37
|
+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
39
|
+
|
|
40
|
+
CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint");
|
|
44
|
+
CREATE INDEX IF NOT EXISTS "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id");
|
|
45
|
+
|
|
46
|
+
-- FK: PG không có ADD CONSTRAINT IF NOT EXISTS → DO-block kiểm tra pg_constraint.
|
|
47
|
+
DO $$ BEGIN
|
|
48
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'notifications_user_id_fkey') THEN
|
|
49
|
+
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey"
|
|
50
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
51
|
+
END IF;
|
|
52
|
+
END $$;
|
|
53
|
+
|
|
54
|
+
DO $$ BEGIN
|
|
55
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'push_subscriptions_user_id_fkey') THEN
|
|
56
|
+
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_fkey"
|
|
57
|
+
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
58
|
+
END IF;
|
|
59
|
+
END $$;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/// Thông báo in-app cho NHÂN VIÊN (khác ZaloNotification / TriAnhZnsMessage — đó
|
|
2
|
+
/// là ZNS gửi KHÁCH). Mỗi row là 1 tin gửi tới 1 user (fan-out ở tầng service,
|
|
3
|
+
/// không broadcast 1-row-nhiều-người). Best-effort: emit không được chặn nghiệp vụ.
|
|
4
|
+
model Notification {
|
|
5
|
+
id String @id @default(cuid()) @map("id")
|
|
6
|
+
userId String @map("user_id") // người NHẬN
|
|
7
|
+
type String @default("info") @map("type") // info | success | warning | error | approval
|
|
8
|
+
category String? @map("category") // sales-order | payment | payment-request | misa | purchase-order | system
|
|
9
|
+
title String @map("title")
|
|
10
|
+
content String @map("content") @db.Text
|
|
11
|
+
url String? @map("url") // deep-link tới đối tượng
|
|
12
|
+
iconName String? @map("icon_name") // lucide icon cho dropdown
|
|
13
|
+
resourceType String? @map("resource_type") // "SalesOrder" | "PaymentRequest" | "PurchaseOrder"…
|
|
14
|
+
resourceId String? @map("resource_id")
|
|
15
|
+
isRead Boolean @default(false) @map("is_read")
|
|
16
|
+
readAt DateTime? @map("read_at")
|
|
17
|
+
createdBy String? @map("created_by") // actor gây ra sự kiện (null = hệ thống)
|
|
18
|
+
meta Json? @map("meta")
|
|
19
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
20
|
+
|
|
21
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
22
|
+
|
|
23
|
+
@@index([userId, isRead])
|
|
24
|
+
@@index([userId, createdAt])
|
|
25
|
+
@@index([resourceType, resourceId])
|
|
26
|
+
@@map("notifications")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Web Push subscription của 1 thiết bị (PWA iOS/Android/desktop). 1 user nhiều
|
|
30
|
+
/// row = nhiều thiết bị. `endpoint` (URL APNs/FCM) là khoá dedup tự nhiên; push
|
|
31
|
+
/// service trả 404/410 → XÓA row (chuẩn giao thức), không dùng cờ active.
|
|
32
|
+
model PushSubscription {
|
|
33
|
+
id String @id @default(cuid()) @map("id")
|
|
34
|
+
userId String @map("user_id")
|
|
35
|
+
endpoint String @unique @map("endpoint")
|
|
36
|
+
p256dh String @map("p256dh") // khoá mã hoá payload (từ PushSubscription.toJSON)
|
|
37
|
+
auth String @map("auth")
|
|
38
|
+
userAgent String? @map("user_agent") // nhận diện thiết bị khi user quản lý danh sách
|
|
39
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
40
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
41
|
+
|
|
42
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
43
|
+
|
|
44
|
+
@@index([userId])
|
|
45
|
+
@@map("push_subscriptions")
|
|
46
|
+
}
|
|
@@ -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.
|
|
4
|
+
"version": "0.1.54",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"./assets/*": "./src/assets/*",
|
|
44
44
|
"./styles/*": "./src/styles/*",
|
|
45
45
|
"./auth/api-handler": "./src/auth/api-handler.ts",
|
|
46
|
+
"./notification": "./src/notification/index.ts",
|
|
47
|
+
"./notification/ui": "./src/notification/ui/index.ts",
|
|
46
48
|
"./tasks": "./src/tasks/index.ts",
|
|
47
49
|
"./tasks/ui": "./src/tasks/ui/task-list-client.tsx",
|
|
48
50
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
@@ -78,6 +80,8 @@
|
|
|
78
80
|
"./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
|
|
79
81
|
"./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
|
|
80
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",
|
|
81
85
|
"./rbac/role-service": "./src/rbac/role-service.ts",
|
|
82
86
|
"./rbac/resource-service": "./src/rbac/resource-service.ts",
|
|
83
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
|
+
});
|