Commit c99fd307 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/notifications-sse-client' into 'develop'

feat(notifications): replace 30s short-polling with SSE stream and realtime toast alerts

See merge request !36
parents a8fa6aeb f1652ab8
...@@ -31,7 +31,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -31,7 +31,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Quản lý Saving Goal**: Trang `/saving-goals` và `/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution. - **Quản lý Saving Goal**: Trang `/saving-goals` và `/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo. - **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo.
- Các bảng/thẻ tổng hợp trên `/reports` liên kết tới module tương ứng: số dư và từng ví tới Ví, thu/chi và dòng tiền tới Giao dịch, phân bổ chi tiêu tới Danh mục, ngân sách tới chi tiết ngân sách, mục tiêu tới danh sách mục tiêu. Chỉ bật trạng thái tương tác khi người dùng có quyền đọc route đích; route đích nhận marker `fromReports` để nút quay lại trở về đúng trang báo cáo. Thẻ Tổng thu/Tổng chi mở danh sách giao dịch với bộ lọc loại `INCOME`/`EXPENSE` tương ứng và tự mở bảng lọc để người dùng nhận biết trạng thái đang áp dụng. - Các bảng/thẻ tổng hợp trên `/reports` liên kết tới module tương ứng: số dư và từng ví tới Ví, thu/chi và dòng tiền tới Giao dịch, phân bổ chi tiêu tới Danh mục, ngân sách tới chi tiết ngân sách, mục tiêu tới danh sách mục tiêu. Chỉ bật trạng thái tương tác khi người dùng có quyền đọc route đích; route đích nhận marker `fromReports` để nút quay lại trở về đúng trang báo cáo. Thẻ Tổng thu/Tổng chi mở danh sách giao dịch với bộ lọc loại `INCOME`/`EXPENSE` tương ứng và tự mở bảng lọc để người dùng nhận biết trạng thái đang áp dụng.
- **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập; danh sách/count/reminder polling mỗi 30 giây khi đang hoạt động, refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung. - **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập. Cơ chế short polling 30 giây đã được thay thế hoàn toàn bằng Server-Sent Events (SSE) qua hook `useNotificationSSE` (kết nối tới `/notifications/stream` với token/cookie) kết hợp `NotificationRealtimeListener` trong `Layout`; khi có thông báo mới hoặc unread count thay đổi, server tự động đẩy event tới client để cập nhật badge và hiển thị popup tức thì; refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung.
- **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua - **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua
TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence. TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence.
Trên trang chủ, đây là một mục thuộc **Danh mục Nghiệp vụ**, đặt cạnh giao dịch thường; không xếp trong Trên trang chủ, đây là một mục thuộc **Danh mục Nghiệp vụ**, đặt cạnh giao dịch thường; không xếp trong
......
...@@ -17,6 +17,7 @@ import { applyTheme, useThemeStore } from "@/stores/theme-store"; ...@@ -17,6 +17,7 @@ import { applyTheme, useThemeStore } from "@/stores/theme-store";
import { I18nProvider } from "@/i18n"; import { I18nProvider } from "@/i18n";
import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher";
import { NotificationShortcut } from "@/components/shared/NotificationShortcut"; import { NotificationShortcut } from "@/components/shared/NotificationShortcut";
import { NotificationRealtimeListener } from "@/components/shared/NotificationRealtimeListener";
import HomePage from "@/pages/index"; import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide"; import StyleGuidePage from "@/pages/style-guide";
...@@ -103,6 +104,7 @@ export const Layout = () => { ...@@ -103,6 +104,7 @@ export const Layout = () => {
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<AuthInitializer> <AuthInitializer>
<NotificationRealtimeListener />
<AnimationRoutes> <AnimationRoutes>
{/* Public Auth Routes */} {/* Public Auth Routes */}
<Route path="/login" element={<LoginPage />}></Route> <Route path="/login" element={<LoginPage />}></Route>
......
import React, { useCallback } from "react";
import { useSnackbar } from "zmp-ui";
import { useNotificationSSE } from "@/hooks/use-notification-sse";
import { NotificationItem } from "@/types/notification";
export const NotificationRealtimeListener: React.FC = () => {
const { openSnackbar } = useSnackbar();
const handleNotification = useCallback(
(notification: NotificationItem) => {
openSnackbar({
type: notification.priority === "CRITICAL" ? "error" : "info",
text: `${notification.title}: ${notification.message}`,
duration: 4000,
});
},
[openSnackbar],
);
useNotificationSSE({
onNotification: handleNotification,
});
return null;
};
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { API_BASE_URL } from "@/lib/api-client";
import { useAuthStore } from "@/stores/auth-store";
import { notificationKeys } from "@/hooks/use-notifications";
import { NotificationItem } from "@/types/notification";
export interface UseNotificationSseOptions {
onNotification?: (notification: NotificationItem) => void;
}
export function useNotificationSSE(options?: UseNotificationSseOptions) {
const queryClient = useQueryClient();
const isInitialized = useAuthStore((state) => state.isInitialized);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const accessToken = useAuthStore((state) => state.accessToken);
const [isConnected, setIsConnected] = useState(false);
const onNotificationRef = useRef(options?.onNotification);
useEffect(() => {
onNotificationRef.current = options?.onNotification;
}, [options?.onNotification]);
useEffect(() => {
if (!isInitialized || !isAuthenticated) {
setIsConnected(false);
return;
}
const token = accessToken || localStorage.getItem("accessToken");
const streamUrl = new URL(`${API_BASE_URL}/notifications/stream`);
if (token) {
streamUrl.searchParams.set("token", token);
}
let eventSource: EventSource | null = null;
try {
eventSource = new EventSource(streamUrl.toString(), {
withCredentials: true,
});
eventSource.addEventListener("open", () => {
setIsConnected(true);
});
eventSource.addEventListener("connected", () => {
setIsConnected(true);
});
eventSource.addEventListener("unread_count", (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data);
if (typeof payload?.count === "number") {
queryClient.setQueryData(notificationKeys.unread(), {
success: true,
data: { count: payload.count },
});
}
} catch (error) {
console.error("[SSE] Failed to parse unread_count data:", error);
}
});
eventSource.addEventListener("notification", (event: MessageEvent) => {
try {
const notification = JSON.parse(event.data) as NotificationItem;
// Invalidate inbox list cache so user sees new notification immediately
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
// Invoke optional notification handler (e.g. snackbar)
if (onNotificationRef.current) {
onNotificationRef.current(notification);
}
} catch (error) {
console.error("[SSE] Failed to parse notification data:", error);
}
});
eventSource.onerror = () => {
setIsConnected(false);
// If the connection was rejected (e.g. token expired or unauthorized)
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
eventSource.close();
}
};
} catch (error) {
console.error("[SSE] Failed to establish EventSource connection:", error);
setIsConnected(false);
}
return () => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
setIsConnected(false);
};
}, [isInitialized, isAuthenticated, accessToken, queryClient]);
return { isConnected };
}
...@@ -16,9 +16,9 @@ export function useNotifications(query: NotificationQuery, enabled = true) { ...@@ -16,9 +16,9 @@ export function useNotifications(query: NotificationQuery, enabled = true) {
queryFn: () => notificationService.getNotifications(query), queryFn: () => notificationService.getNotifications(query),
enabled, enabled,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 15_000, staleTime: 30_000,
refetchInterval: enabled ? 30_000 : false, refetchInterval: false,
refetchOnMount: "always", refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
} }
...@@ -28,8 +28,8 @@ export function useUnreadNotificationCount(enabled = true) { ...@@ -28,8 +28,8 @@ export function useUnreadNotificationCount(enabled = true) {
queryKey: notificationKeys.unread(), queryKey: notificationKeys.unread(),
queryFn: notificationService.getUnreadCount, queryFn: notificationService.getUnreadCount,
enabled, enabled,
staleTime: 10_000, staleTime: 60_000,
refetchInterval: enabled ? 30_000 : false, refetchInterval: false,
refetchOnMount: "always", refetchOnMount: "always",
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
......
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
const apiBaseUrl = import.meta.env.VITE_API_URL || "http://localhost:7777/api/v1"; export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:7777/api/v1";
const apiBaseUrl = API_BASE_URL;
// Create an Axios instance // Create an Axios instance
export const apiClient = axios.create({ export const apiClient = axios.create({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment