@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke render chuông + hộp thư: mount thật (jsdom) với fetch giả — bắt lỗi
|
|
3
|
+
* import/hook sai trước khi app tiêu thụ, vì UI này chạy trong MainLayout của
|
|
4
|
+
* MỌI trang (vỡ là vỡ toàn app).
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
8
|
+
|
|
9
|
+
vi.mock("next/navigation", () => ({
|
|
10
|
+
useParams: () => ({ lang: "vi" }),
|
|
11
|
+
useRouter: () => ({ push: vi.fn() }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { NotificationBell } from "../ui/notification-bell"
|
|
15
|
+
import { NotificationsInbox } from "../ui/notifications-inbox"
|
|
16
|
+
|
|
17
|
+
const ITEM = {
|
|
18
|
+
id: "n1",
|
|
19
|
+
type: "info",
|
|
20
|
+
category: "system",
|
|
21
|
+
title: "Đơn hàng mới",
|
|
22
|
+
content: "Đơn DH001 vừa được tạo",
|
|
23
|
+
url: "/sales-orders/1",
|
|
24
|
+
iconName: null,
|
|
25
|
+
isRead: false,
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockFetch() {
|
|
30
|
+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
31
|
+
const url = String(input)
|
|
32
|
+
const body = url.includes("unread-count")
|
|
33
|
+
? { count: 3 }
|
|
34
|
+
: { items: [ITEM], nextCursor: null, total: 1, unreadCount: 3 }
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
})
|
|
39
|
+
}) as unknown as typeof fetch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("UI thông báo", () => {
|
|
43
|
+
beforeEach(() => mockFetch())
|
|
44
|
+
|
|
45
|
+
it("chuông render badge số chưa đọc", async () => {
|
|
46
|
+
render(<NotificationBell />)
|
|
47
|
+
expect(screen.getByLabelText("Thông báo")).toBeDefined()
|
|
48
|
+
await waitFor(() => expect(screen.getByRole("status").textContent).toBe("3"))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("mở chuông: có tin + settingsSlot của app (vd toggle web push)", async () => {
|
|
52
|
+
render(<NotificationBell settingsSlot={<span>Bật thông báo đẩy</span>} />)
|
|
53
|
+
fireEvent.click(screen.getByLabelText("Thông báo"))
|
|
54
|
+
await waitFor(() => expect(screen.getByText("Đơn hàng mới")).toBeDefined())
|
|
55
|
+
expect(screen.getByText("Bật thông báo đẩy")).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("hộp thư render danh sách từ API", async () => {
|
|
59
|
+
render(<NotificationsInbox />)
|
|
60
|
+
await waitFor(() => expect(screen.getByText("Đơn hàng mới")).toBeDefined())
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
// Thông báo in-app — engine thật (thay bản mock InMemoryStorage cũ, không app
|
|
2
|
+
// nào dùng). Bảng ship qua `goerp-features sync` (feature notifications).
|
|
3
|
+
// App wiring mẫu: vinhhoa src/server/services/notification-service.ts
|
|
4
|
+
// (configureNotificationService + afterNotify=web-push).
|
|
1
5
|
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
NotificationServiceOptions,
|
|
14
|
-
} from "./types";
|
|
6
|
+
configureNotificationService,
|
|
7
|
+
getUnreadCount,
|
|
8
|
+
listNotifications,
|
|
9
|
+
markAllRead,
|
|
10
|
+
markRead,
|
|
11
|
+
notify,
|
|
12
|
+
type ListParams,
|
|
13
|
+
type NotificationDb,
|
|
14
|
+
type NotificationType,
|
|
15
|
+
type NotifyInput,
|
|
16
|
+
} from "./notification-service"
|
|
@@ -1,120 +1,294 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
CreateNotificationInput,
|
|
3
|
-
Notification,
|
|
4
|
-
NotificationServiceOptions,
|
|
5
|
-
NotificationStorage,
|
|
6
|
-
} from "./types";
|
|
7
|
-
import { InMemoryStorage } from "./storage/in-memory";
|
|
8
|
-
import { createLogger } from "../infrastructure/logger";
|
|
9
|
-
import { eventBus } from "../infrastructure/event-bus";
|
|
10
|
-
|
|
11
|
-
const logger = createLogger("NotificationService");
|
|
12
|
-
|
|
13
1
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* import { notificationService } from '@goerp/core/notification';
|
|
19
|
-
*
|
|
20
|
-
* // Send notification
|
|
21
|
-
* await notificationService.send({
|
|
22
|
-
* userId: 'manager-123',
|
|
23
|
-
* title: 'Purchase Order cần duyệt',
|
|
24
|
-
* content: 'PO-001 chờ phê duyệt',
|
|
25
|
-
* type: 'approval',
|
|
26
|
-
* link: '/purchase-orders/PO-001'
|
|
27
|
-
* });
|
|
2
|
+
* Thông báo in-app cho NHÂN VIÊN — engine dùng chung mọi app goerp (bảng
|
|
3
|
+
* `notifications` + `push_subscriptions` ship qua `goerp-features sync`,
|
|
4
|
+
* feature notifications). Thay bản mock InMemoryStorage cũ (đã xóa — không
|
|
5
|
+
* app nào dùng); đây là bản tôi luyện từ vinhhoa.
|
|
28
6
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
7
|
+
* Triết lý: mọi hàm ghi (`notify`) là BEST-EFFORT và KHÔNG BAO GIỜ throw —
|
|
8
|
+
* thông báo lỗi không được phép chặn luồng nghiệp vụ (đơn/duyệt/thanh toán
|
|
9
|
+
* vẫn phải chạy). Fan-out ra nhiều người nhận ở tầng này (mỗi người 1 row).
|
|
31
10
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
11
|
+
* App cắm qua `configureNotificationService({ db, afterNotify? })` (cùng
|
|
12
|
+
* khuôn configureTaskRunner): db là Prisma client có các model
|
|
13
|
+
* notification / rolePermission / userRole / userBranch / user (schema RBAC
|
|
14
|
+
* chung của goerp); `afterNotify` là seam fan-out thêm kênh (web push, email…)
|
|
15
|
+
* — chạy fire-and-forget sau khi ghi DB.
|
|
35
16
|
*/
|
|
36
|
-
class NotificationServiceImpl {
|
|
37
|
-
private storage: NotificationStorage;
|
|
38
17
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
18
|
+
export type NotificationType =
|
|
19
|
+
| "info"
|
|
20
|
+
| "success"
|
|
21
|
+
| "warning"
|
|
22
|
+
| "error"
|
|
23
|
+
| "approval"
|
|
42
24
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
25
|
+
export interface NotifyInput {
|
|
26
|
+
/** Gửi trực tiếp tới các user id (bỏ qua giá trị falsy). */
|
|
27
|
+
userIds?: Array<string | null | undefined>
|
|
28
|
+
/** Gửi tới mọi user mang một trong các vai trò này. */
|
|
29
|
+
roleCodes?: string[]
|
|
30
|
+
/**
|
|
31
|
+
* Gửi tới mọi user mang các vai trò này BẤT KỂ chi nhánh — cho vai trò tập
|
|
32
|
+
* trung tại trụ sở (vd ACCOUNTANT) cần biết sự kiện của mọi CN.
|
|
33
|
+
*/
|
|
34
|
+
roleCodesAllBranches?: string[]
|
|
35
|
+
/** Gửi tới mọi user CÓ QUYỀN resource:action (vd payment-request:approve_l2). */
|
|
36
|
+
permission?: { resourceCode: string; actionCode: string }
|
|
37
|
+
/**
|
|
38
|
+
* Giới hạn người nhận suy ra từ `roleCodes`/`permission` về đúng chi nhánh.
|
|
39
|
+
* KHÔNG áp cho `userIds` (đích chỉ định tường minh) và `roleCodesAllBranches`.
|
|
40
|
+
*/
|
|
41
|
+
branchId?: string | null
|
|
42
|
+
/**
|
|
43
|
+
* Cách áp `branchId` cho nhóm suy-ra-từ-vai-trò:
|
|
44
|
+
* - "member" (mặc định): user ĐƯỢC GÁN chi nhánh đó.
|
|
45
|
+
* - "default": chi nhánh MẶC ĐỊNH của user (UserBranch.isDefault — nơi làm
|
|
46
|
+
* việc chính) — chỉ báo đúng người PHỤ TRÁCH chi nhánh của chứng từ.
|
|
47
|
+
*/
|
|
48
|
+
branchScope?: "member" | "default"
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
await this.storage.save(notification);
|
|
64
|
-
|
|
65
|
-
logger.debug("Notification sent", {
|
|
66
|
-
id: notification.id,
|
|
67
|
-
userId: notification.userId,
|
|
68
|
-
type: notification.type,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// Emit event for real-time updates
|
|
72
|
-
eventBus.emit("notification.sent", { notification });
|
|
73
|
-
|
|
74
|
-
return notification;
|
|
75
|
-
}
|
|
50
|
+
type?: NotificationType
|
|
51
|
+
category?: string
|
|
52
|
+
title: string
|
|
53
|
+
content: string
|
|
54
|
+
url?: string
|
|
55
|
+
iconName?: string
|
|
56
|
+
resourceType?: string
|
|
57
|
+
resourceId?: string
|
|
58
|
+
/** Actor gây ra sự kiện (null = hệ thống). */
|
|
59
|
+
createdBy?: string | null
|
|
60
|
+
/** Đừng tự gửi thông báo cho chính người vừa thao tác. */
|
|
61
|
+
excludeUserId?: string | null
|
|
62
|
+
meta?: unknown
|
|
63
|
+
}
|
|
76
64
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Delegate Prisma tối thiểu — structural, args `any` CÓ CHỦ ĐÍCH (client
|
|
67
|
+
* Prisma sinh ra hẹp hơn structural type — bài học SettingsDb/TaskDb).
|
|
68
|
+
*/
|
|
69
|
+
export interface NotificationDb {
|
|
70
|
+
notification: {
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
+
createMany(args: any): Promise<{ count: number }>
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
findMany(args: any): Promise<any[]>
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
count(args: any): Promise<number>
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
updateMany(args: any): Promise<{ count: number }>
|
|
79
|
+
}
|
|
80
|
+
rolePermission: {
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
findMany(args: any): Promise<Array<{ roleCode: string }>>
|
|
83
|
+
}
|
|
84
|
+
userRole: {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
findMany(args: any): Promise<Array<{ userId: string }>>
|
|
87
|
+
}
|
|
88
|
+
userBranch: {
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
findMany(args: any): Promise<Array<{ userId: string }>>
|
|
80
91
|
}
|
|
92
|
+
user: {
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
+
findMany(args: any): Promise<Array<{ id: string }>>
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface NotificationConfig {
|
|
99
|
+
db: NotificationDb
|
|
100
|
+
/**
|
|
101
|
+
* Fan-out kênh phụ (web push, email…) SAU khi ghi DB — fire-and-forget,
|
|
102
|
+
* lỗi tự nuốt (engine đã catch + log).
|
|
103
|
+
*/
|
|
104
|
+
afterNotify?: (recipients: string[], input: NotifyInput) => Promise<unknown>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let config: NotificationConfig | null = null
|
|
81
108
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
export function configureNotificationService(next: NotificationConfig): void {
|
|
110
|
+
config = next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function requireConfig(): NotificationConfig {
|
|
114
|
+
if (!config) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"[notification] chưa configureNotificationService({ db, afterNotify? }) — gọi 1 lần lúc khởi tạo app.",
|
|
117
|
+
)
|
|
85
118
|
}
|
|
119
|
+
return config
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Suy ra danh sách userId người nhận (distinct, chỉ user đang hoạt động).
|
|
124
|
+
* Gộp: userIds tường minh ∪ (role/permission [∩ chi nhánh nếu có]).
|
|
125
|
+
*/
|
|
126
|
+
async function resolveRecipients(input: NotifyInput): Promise<string[]> {
|
|
127
|
+
const { db } = requireConfig()
|
|
128
|
+
const explicit = (input.userIds ?? []).filter(
|
|
129
|
+
(id): id is string => typeof id === "string" && id.length > 0,
|
|
130
|
+
)
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
132
|
+
// Gom các roleCode: từ input.roleCodes + từ permission (resource:action).
|
|
133
|
+
const roleCodes = new Set<string>(input.roleCodes ?? [])
|
|
134
|
+
if (input.permission) {
|
|
135
|
+
const rp = await db.rolePermission.findMany({
|
|
136
|
+
where: {
|
|
137
|
+
resourceCode: input.permission.resourceCode,
|
|
138
|
+
actionCode: input.permission.actionCode,
|
|
139
|
+
},
|
|
140
|
+
select: { roleCode: true },
|
|
141
|
+
})
|
|
142
|
+
rp.forEach((r) => roleCodes.add(r.roleCode))
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
145
|
+
let roleDerived: string[] = []
|
|
146
|
+
if (roleCodes.size > 0) {
|
|
147
|
+
const userRoles = await db.userRole.findMany({
|
|
148
|
+
where: { roleCode: { in: [...roleCodes] } },
|
|
149
|
+
select: { userId: true },
|
|
150
|
+
})
|
|
151
|
+
roleDerived = userRoles.map((u) => u.userId)
|
|
152
|
+
|
|
153
|
+
// Lọc theo chi nhánh nếu yêu cầu (chỉ áp cho nhóm suy-ra-từ-vai-trò).
|
|
154
|
+
if (input.branchId && roleDerived.length > 0) {
|
|
155
|
+
const inBranch = await db.userBranch.findMany({
|
|
156
|
+
where: {
|
|
157
|
+
branchId: input.branchId,
|
|
158
|
+
userId: { in: roleDerived },
|
|
159
|
+
...(input.branchScope === "default" ? { isDefault: true } : {}),
|
|
160
|
+
},
|
|
161
|
+
select: { userId: true },
|
|
162
|
+
})
|
|
163
|
+
const allowed = new Set(inBranch.map((b) => b.userId))
|
|
164
|
+
roleDerived = roleDerived.filter((id) => allowed.has(id))
|
|
165
|
+
}
|
|
96
166
|
}
|
|
97
167
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
168
|
+
// Vai trò toàn cục (trụ sở): nhận bất kể chi nhánh, không qua lọc branchId.
|
|
169
|
+
let globalRoleDerived: string[] = []
|
|
170
|
+
if (input.roleCodesAllBranches?.length) {
|
|
171
|
+
const rows = await db.userRole.findMany({
|
|
172
|
+
where: { roleCode: { in: input.roleCodesAllBranches } },
|
|
173
|
+
select: { userId: true },
|
|
174
|
+
})
|
|
175
|
+
globalRoleDerived = rows.map((u) => u.userId)
|
|
102
176
|
}
|
|
103
177
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
178
|
+
const candidates = [
|
|
179
|
+
...new Set([...explicit, ...roleDerived, ...globalRoleDerived]),
|
|
180
|
+
]
|
|
181
|
+
if (candidates.length === 0) return []
|
|
182
|
+
|
|
183
|
+
// Chỉ giữ user còn tồn tại & đang hoạt động (loại id rác / user bị khoá).
|
|
184
|
+
const valid = await db.user.findMany({
|
|
185
|
+
where: { id: { in: candidates }, isActive: true },
|
|
186
|
+
select: { id: true },
|
|
187
|
+
})
|
|
188
|
+
let ids = valid.map((u) => u.id)
|
|
189
|
+
if (input.excludeUserId) ids = ids.filter((id) => id !== input.excludeUserId)
|
|
190
|
+
return ids
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Ghi thông báo cho (nhiều) người nhận. Trả về số row đã tạo. KHÔNG throw.
|
|
195
|
+
*/
|
|
196
|
+
export async function notify(input: NotifyInput): Promise<number> {
|
|
197
|
+
try {
|
|
198
|
+
const { db, afterNotify } = requireConfig()
|
|
199
|
+
const recipients = await resolveRecipients(input)
|
|
200
|
+
if (recipients.length === 0) return 0
|
|
201
|
+
|
|
202
|
+
const now = new Date()
|
|
203
|
+
await db.notification.createMany({
|
|
204
|
+
data: recipients.map((userId) => ({
|
|
205
|
+
userId,
|
|
206
|
+
type: input.type ?? "info",
|
|
207
|
+
category: input.category ?? null,
|
|
208
|
+
title: input.title,
|
|
209
|
+
content: input.content,
|
|
210
|
+
url: input.url ?? null,
|
|
211
|
+
iconName: input.iconName ?? null,
|
|
212
|
+
resourceType: input.resourceType ?? null,
|
|
213
|
+
resourceId: input.resourceId ?? null,
|
|
214
|
+
createdBy: input.createdBy ?? null,
|
|
215
|
+
meta: input.meta ?? undefined,
|
|
216
|
+
createdAt: now,
|
|
217
|
+
})),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Kênh phụ (web push, email…) — fire-and-forget qua seam.
|
|
221
|
+
if (afterNotify) {
|
|
222
|
+
void afterNotify(recipients, input).catch((e) =>
|
|
223
|
+
console.error("[notification] afterNotify lỗi:", e),
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return recipients.length
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error("[notification] notify lỗi:", e)
|
|
230
|
+
return 0
|
|
108
231
|
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function getUnreadCount(userId: string): Promise<number> {
|
|
235
|
+
const { db } = requireConfig()
|
|
236
|
+
return db.notification.count({ where: { userId, isRead: false } })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface ListParams {
|
|
240
|
+
cursor?: string
|
|
241
|
+
take?: number
|
|
242
|
+
filter?: "all" | "unread"
|
|
243
|
+
}
|
|
109
244
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Danh sách thông báo của CHÍNH user (cursor pagination theo createdAt desc).
|
|
247
|
+
* `nextCursor` = id của row cuối trang (null nếu hết).
|
|
248
|
+
*/
|
|
249
|
+
export async function listNotifications(userId: string, params: ListParams) {
|
|
250
|
+
const { db } = requireConfig()
|
|
251
|
+
const take = Math.min(Math.max(params.take ?? 20, 1), 50)
|
|
252
|
+
const where = {
|
|
253
|
+
userId,
|
|
254
|
+
...(params.filter === "unread" ? { isRead: false } : {}),
|
|
113
255
|
}
|
|
256
|
+
|
|
257
|
+
const rows = await db.notification.findMany({
|
|
258
|
+
where,
|
|
259
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
260
|
+
take: take + 1, // lấy dư 1 để biết còn trang sau
|
|
261
|
+
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const hasMore = rows.length > take
|
|
265
|
+
const items = hasMore ? rows.slice(0, take) : rows
|
|
266
|
+
const nextCursor = hasMore ? (items[items.length - 1]?.id ?? null) : null
|
|
267
|
+
|
|
268
|
+
const [total, unreadCount] = await Promise.all([
|
|
269
|
+
db.notification.count({ where }),
|
|
270
|
+
db.notification.count({ where: { userId, isRead: false } }),
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
return { items, nextCursor, total, unreadCount }
|
|
114
274
|
}
|
|
115
275
|
|
|
116
|
-
|
|
117
|
-
export
|
|
276
|
+
/** Đánh dấu đã đọc một số tin (scope theo user để không đọc hộ người khác). */
|
|
277
|
+
export async function markRead(userId: string, ids: string[]): Promise<number> {
|
|
278
|
+
const { db } = requireConfig()
|
|
279
|
+
if (!ids.length) return 0
|
|
280
|
+
const res = await db.notification.updateMany({
|
|
281
|
+
where: { userId, id: { in: ids }, isRead: false },
|
|
282
|
+
data: { isRead: true, readAt: new Date() },
|
|
283
|
+
})
|
|
284
|
+
return res.count
|
|
285
|
+
}
|
|
118
286
|
|
|
119
|
-
|
|
120
|
-
|
|
287
|
+
export async function markAllRead(userId: string): Promise<number> {
|
|
288
|
+
const { db } = requireConfig()
|
|
289
|
+
const res = await db.notification.updateMany({
|
|
290
|
+
where: { userId, isRead: false },
|
|
291
|
+
data: { isRead: true, readAt: new Date() },
|
|
292
|
+
})
|
|
293
|
+
return res.count
|
|
294
|
+
}
|