@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.
@@ -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
- notificationService,
3
- NotificationServiceImpl,
4
- } from "./notification-service";
5
- export { InMemoryStorage } from "./storage";
6
- export type {
7
- Notification,
8
- NotificationType,
9
- NotificationChannel,
10
- NotificationStatus,
11
- CreateNotificationInput,
12
- NotificationStorage,
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
- * NotificationService - sends and manages user notifications
15
- *
16
- * @example
17
- * ```typescript
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
- * // Get unread notifications
30
- * const unread = await notificationService.getUnread('manager-123');
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
- * // Mark as read
33
- * await notificationService.markAsRead('notification-id');
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
- constructor(options: NotificationServiceOptions = {}) {
40
- this.storage = options.storage || new InMemoryStorage();
41
- }
18
+ export type NotificationType =
19
+ | "info"
20
+ | "success"
21
+ | "warning"
22
+ | "error"
23
+ | "approval"
42
24
 
43
- /** Set custom storage implementation */
44
- setStorage(storage: NotificationStorage): void {
45
- this.storage = storage;
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
- /** Send a notification */
49
- async send(input: CreateNotificationInput): Promise<Notification> {
50
- const notification: Notification = {
51
- id: crypto.randomUUID(),
52
- userId: input.userId,
53
- title: input.title,
54
- content: input.content,
55
- type: input.type || "info",
56
- channel: input.channel || "in-app",
57
- status: "sent",
58
- link: input.link,
59
- metadata: input.metadata,
60
- createdAt: new Date(),
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
- /** Get all notifications for a user */
78
- async getAll(userId: string): Promise<Notification[]> {
79
- return this.storage.findByUserId(userId);
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
- /** Get unread notifications for a user */
83
- async getUnread(userId: string): Promise<Notification[]> {
84
- return this.storage.findByUserId(userId, { unreadOnly: true });
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
- /** Get unread count for a user */
88
- async getUnreadCount(userId: string): Promise<number> {
89
- const unread = await this.getUnread(userId);
90
- return unread.length;
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
- /** Get a notification by ID */
94
- async getById(id: string): Promise<Notification | null> {
95
- return this.storage.findById(id);
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
- /** Mark a notification as read */
99
- async markAsRead(id: string): Promise<void> {
100
- await this.storage.markAsRead(id);
101
- eventBus.emit("notification.read", { id });
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
- /** Mark all notifications as read for a user */
105
- async markAllAsRead(userId: string): Promise<void> {
106
- await this.storage.markAllAsRead(userId);
107
- eventBus.emit("notification.allRead", { userId });
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
- /** Delete a notification */
111
- async delete(id: string): Promise<void> {
112
- await this.storage.delete(id);
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
- // Singleton instance
117
- export const notificationService = new NotificationServiceImpl();
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
- // Export class for testing
120
- export { NotificationServiceImpl };
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
+ }
@@ -0,0 +1,2 @@
1
+ export { NotificationBell } from "./notification-bell"
2
+ export { NotificationsInbox } from "./notifications-inbox"