From 4d24dee1ec4243bcde4ad0ac35903430ec075edb Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Fri, 8 May 2026 14:57:37 +0200 Subject: [PATCH 1/6] test(notification): Initial setup for notifications ui with popover after bell click --- frontend/src/components/layout/Navbar.tsx | 13 +-- frontend/src/components/ui/popover.tsx | 87 ++++++++++++++++ .../components/NotificationBell.tsx | 99 +++++++++++++++++++ .../notification/notification.hooks.ts | 32 ++++++ .../notification/notification.keys.ts | 4 + .../notification/notification.types.ts | 39 ++++++++ 6 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/features/notification/components/NotificationBell.tsx create mode 100644 frontend/src/features/notification/notification.hooks.ts create mode 100644 frontend/src/features/notification/notification.keys.ts create mode 100644 frontend/src/features/notification/notification.types.ts diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index 78b432b6..fff6a48d 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { NAV_ITEMS } from '@/routes/navigation'; +import {NotificationBell} from "@/features/notification/components/NotificationBell.tsx"; export const Navbar = () => { const { user } = useAuth(); @@ -31,9 +32,6 @@ export const Navbar = () => { const isActive = (path: string) => location.pathname.startsWith(path); - // TODO: powiadomienia - const unreadNotifications = 3; - const initials = `${user?.firstName?.charAt(0) || ''}${user?.lastName?.charAt(0) || ''}`.toUpperCase(); const visibleNavItems = NAV_ITEMS.filter((item) => { @@ -80,14 +78,7 @@ export const Navbar = () => {
- + diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..0ad11df7 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx new file mode 100644 index 00000000..a3131015 --- /dev/null +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -0,0 +1,99 @@ +import { useNavigate } from 'react-router-dom'; +import { Bell, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { useNotifications } from '../notification.hooks'; +import { getNotificationUrl } from '../notification.types'; +import { cn } from '@/lib/utils'; + +export const NotificationBell = () => { + const navigate = useNavigate(); + const { data: notifications = [], isLoading } = useNotifications(); + + const unreadCount = notifications.filter(n => !n.isRead).length; + + const handleNotificationClick = (type: any, referenceId: string, id: string) => { + // 1. TODO: Strzał do backendu, żeby oznaczyć jako przeczytane (mutacja) + console.log('Oznaczam jako przeczytane:', id); + + const url = getNotificationUrl(type, referenceId); + navigate(url); + }; + + return ( + + + + + + +

+ Powiadomienia + {unreadCount > 0 && ( + + )} +
+ +
+ {isLoading ? ( +
Ładowanie...
+ ) : notifications.length === 0 ? ( +
Brak nowych powiadomień
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+ +
+ +
+ + + ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts new file mode 100644 index 00000000..b9589826 --- /dev/null +++ b/frontend/src/features/notification/notification.hooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import type { NotificationResponse } from './notification.types'; +import {NOTIFICATION_KEYS} from "@/features/notification/notification.keys.ts"; + +const MOCK_NOTIFICATIONS: NotificationResponse[] = [ + { + id: '1', + type: 'ASSIGNMENT_REQUESTED', + message: 'Jan Kowalski prosi o przypisanie do projektu "Apollo".', + isRead: false, + referenceId: 'proj-123', + createdAt: new Date().toISOString(), + }, + { + id: '2', + type: 'QUALIFICATION_ACCEPTED', + message: 'Twoja kwalifikacja "React" została zaakceptowana.', + isRead: true, + referenceId: 'qual-456', + createdAt: new Date(Date.now() - 86400000).toISOString(), + } +]; + +export const useNotifications = () => { + return useQuery({ + queryKey: NOTIFICATION_KEYS.feed(), + queryFn: async () => { + await new Promise(resolve => setTimeout(resolve, 500)); + return MOCK_NOTIFICATIONS; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.keys.ts b/frontend/src/features/notification/notification.keys.ts new file mode 100644 index 00000000..173b996c --- /dev/null +++ b/frontend/src/features/notification/notification.keys.ts @@ -0,0 +1,4 @@ +export const NOTIFICATION_KEYS = { + all: ['notifications'] as const, + feed: () => [...NOTIFICATION_KEYS.all, 'feed'] as const, +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.types.ts b/frontend/src/features/notification/notification.types.ts new file mode 100644 index 00000000..eb83aa1e --- /dev/null +++ b/frontend/src/features/notification/notification.types.ts @@ -0,0 +1,39 @@ +import { PATHS } from '@/routes/paths'; + +export type NotificationType = + | 'ASSIGNMENT_REQUESTED' + | 'ASSIGNMENT_ACCEPTED' + | 'ASSIGNMENT_REJECTED' + | 'QUALIFICATION_REQUESTED' + | 'QUALIFICATION_ACCEPTED' + | 'QUALIFICATION_REJECTED' + | 'SYSTEM_NEW_EMPLOYEE'; + +export interface NotificationResponse { + id: string; + type: NotificationType; + message: string; + isRead: boolean; + referenceId: string; + createdAt: string; +} + +// TODO: Do zmiany gdy już beda znane +export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { + switch (type) { + case 'ASSIGNMENT_REQUESTED': + return `/requests`; + case 'ASSIGNMENT_ACCEPTED': + case 'ASSIGNMENT_REJECTED': + return `project/${referenceId}` + case 'QUALIFICATION_REQUESTED': + return `/requests`; + case 'QUALIFICATION_ACCEPTED': + case 'QUALIFICATION_REJECTED': + return `/qualifications/${referenceId}`; + case 'SYSTEM_NEW_EMPLOYEE': + return `/profile`; + default: + return PATHS.ROOT; + } +}; \ No newline at end of file From 6f8547dcfed368849d05d9d15011276336732647 Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Mon, 11 May 2026 16:10:58 +0200 Subject: [PATCH 2/6] feat(notification): Create hooks, services to manage fetching data. Enhance NotificationBell. Create logic to connect with backend using SSE --- frontend/package-lock.json | 47 ++++--- frontend/package.json | 1 + frontend/src/api/client.ts | 8 +- frontend/src/api/endpoints.ts | 6 + frontend/src/api/index.ts | 8 +- frontend/src/components/layout/MainLayout.tsx | 3 + frontend/src/components/ui/switch.tsx | 31 +++++ .../components/NotificationBell.tsx | 97 +++++++++---- .../notification/notification.hooks.ts | 127 ++++++++++++++---- .../notification/notification.keys.ts | 4 - .../notification/notification.service.ts | 21 +++ 11 files changed, 276 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/ui/switch.tsx delete mode 100644 frontend/src/features/notification/notification.keys.ts create mode 100644 frontend/src/features/notification/notification.service.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e47ddf73..3c9158b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", @@ -1078,6 +1079,12 @@ "@tanstack/react-query": ">= 4.0.0" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -4176,12 +4183,12 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -5428,12 +5435,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5543,9 +5550,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6065,9 +6072,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6192,9 +6199,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -7496,9 +7503,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", diff --git a/frontend/package.json b/frontend/package.json index 3a26e2d7..49ed953d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@fontsource-variable/geist": "^5.2.8", "@hookform/resolvers": "^5.2.2", "@lukemorales/query-key-factory": "^1.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.99.0", "@tanstack/react-query-devtools": "^5.99.0", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 57a68d0c..d923fa78 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,11 +4,15 @@ import {ENDPOINTS} from "./endpoints.ts"; let currentAccessToken: string | null = null; export const setAccessToken = (token: string | null) => { - currentAccessToken = token; + currentAccessToken = token; } +export const getAccessToken = () => currentAccessToken; + +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; + const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 45268bf6..4deaf6c6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -46,5 +46,11 @@ export const ENDPOINTS = { ASSIGNMENT_DETAIL: (id: string) => `/approvals/assignments/${id}/details`, ACCEPT_ASSIGNMENT: (id: string) => `/approvals/assignments/${id}/accept`, REJECT_ASSIGNMENT: (id: string) => `/approvals/assignments/${id}/reject` + }, + NOTIFICATIONS: { + BASE: '/notifications', + STREAM: '/notifications/stream', + MARK_READ: (id: string) => `/notifications/${id}/mark-as-read`, + MARK_ALL_READ: '/notifications/mark-all-as-read', } } as const; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index af50dd08..f77bf48d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,4 +5,10 @@ export const employeeAssignmentsKeys = createQueryKeys('employee-assignments', { list: null }) -export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys) \ No newline at end of file +export const notificationsKeys = createQueryKeys('notifications', { + feed: (unreadOnly: boolean, page: number) => [{ unreadOnly, page }], + + infinite: (unreadOnly: boolean) => [{ unreadOnly, type: 'infinite' }], +}); + +export const queryKeys = mergeQueryKeys(employeeAssignmentsKeys, notificationsKeys) \ No newline at end of file diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 77ed4600..2887ad13 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -1,7 +1,10 @@ import { Outlet } from 'react-router-dom'; import { Navbar } from './Navbar'; +import { useNotificationStream } from '@/features/notification/notification.hooks'; export const MainLayout = () => { + useNotificationStream(); + return (
diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 00000000..877dbc82 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx index a3131015..736ee3cd 100644 --- a/frontend/src/features/notification/components/NotificationBell.tsx +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -1,26 +1,45 @@ +// features/notification/components/NotificationBell.tsx +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bell, Check } from 'lucide-react'; +import { Bell, Check, Loader2, Filter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; -import { useNotifications } from '../notification.hooks'; +import { + useNotifications, + useMarkNotificationAsRead, + useMarkAllNotificationsAsRead +} from '../notification.hooks'; import { getNotificationUrl } from '../notification.types'; +import type { NotificationType } from '../notification.types'; import { cn } from '@/lib/utils'; +import { PATHS } from '@/routes/paths'; export const NotificationBell = () => { const navigate = useNavigate(); - const { data: notifications = [], isLoading } = useNotifications(); + const [unreadOnly, setUnreadOnly] = useState(true); + + const { data, isLoading } = useNotifications(unreadOnly); - const unreadCount = notifications.filter(n => !n.isRead).length; + const { mutate: markAsRead } = useMarkNotificationAsRead(); + const { mutate: markAllAsRead, isPending: isMarkingAll } = useMarkAllNotificationsAsRead(); - const handleNotificationClick = (type: any, referenceId: string, id: string) => { - // 1. TODO: Strzał do backendu, żeby oznaczyć jako przeczytane (mutacja) - console.log('Oznaczam jako przeczytane:', id); + const notifications = data?.items || []; + const unreadCount = unreadOnly + ? (data?.totalCount || 0) + : notifications.filter(n => !n.isRead).length; + + const handleNotificationClick = (type: NotificationType, referenceId: string, id: string, isRead: boolean) => { + if (!isRead) { + markAsRead(id); + } const url = getNotificationUrl(type, referenceId); navigate(url); }; @@ -32,38 +51,65 @@ export const NotificationBell = () => { {unreadCount > 0 && ( - {unreadCount} + {unreadCount > 99 ? '99+' : unreadCount} )} -
- Powiadomienia - {unreadCount > 0 && ( - - )} +
+
+ Powiadomienia + {unreadCount > 0 && ( + + )} +
+ +
+
+ + +
+ +
-
+
{isLoading ? ( -
Ładowanie...
+
+ +
) : notifications.length === 0 ? ( -
Brak nowych powiadomień
+
+ Brak {unreadOnly ? 'nieprzeczytanych' : 'nowych'} powiadomień +
) : ( notifications.map((notification) => (
diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts index b9589826..6fcbe06b 100644 --- a/frontend/src/features/notification/notification.hooks.ts +++ b/frontend/src/features/notification/notification.hooks.ts @@ -1,32 +1,105 @@ -import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import {useQuery, useMutation, useQueryClient, useInfiniteQuery} from '@tanstack/react-query'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { notificationService } from './notification.service'; +import { queryKeys } from '@/api'; +import { ENDPOINTS } from '@/api/endpoints'; import type { NotificationResponse } from './notification.types'; -import {NOTIFICATION_KEYS} from "@/features/notification/notification.keys.ts"; - -const MOCK_NOTIFICATIONS: NotificationResponse[] = [ - { - id: '1', - type: 'ASSIGNMENT_REQUESTED', - message: 'Jan Kowalski prosi o przypisanie do projektu "Apollo".', - isRead: false, - referenceId: 'proj-123', - createdAt: new Date().toISOString(), - }, - { - id: '2', - type: 'QUALIFICATION_ACCEPTED', - message: 'Twoja kwalifikacja "React" została zaakceptowana.', - isRead: true, - referenceId: 'qual-456', - createdAt: new Date(Date.now() - 86400000).toISOString(), - } -]; - -export const useNotifications = () => { +import type { PagedResponse } from '@/api/api.types'; +import {API_BASE_URL, getAccessToken} from "@/api/client.ts"; + +export const useNotifications = (unreadOnly: boolean = true) => { return useQuery({ - queryKey: NOTIFICATION_KEYS.feed(), - queryFn: async () => { - await new Promise(resolve => setTimeout(resolve, 500)); - return MOCK_NOTIFICATIONS; + queryKey: queryKeys.notifications.feed(unreadOnly, 0).queryKey, + queryFn: () => notificationService.getNotifications(unreadOnly, 0, 10), + }); +}; + +export const useInfiniteNotifications = (unreadOnly: boolean = true) => { + return useInfiniteQuery({ + queryKey: queryKeys.notifications.infinite(unreadOnly).queryKey, + queryFn: ({ pageParam = 0 }) => notificationService.getNotifications(unreadOnly, pageParam, 20), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if (lastPage.pageNumber < lastPage.totalPages - 1) { + return lastPage.pageNumber + 1; + } + return undefined; + }, + }); +}; + +export const useMarkNotificationAsRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: notificationService.markAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications._def }); + }, + }); +}; + +export const useMarkAllNotificationsAsRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: notificationService.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications._def }); }, }); +}; + +export const useNotificationStream = () => { + const queryClient = useQueryClient(); + + useEffect(() => { + const token = getAccessToken(); + + if (!token) return; + + const controller = new AbortController(); + + const connectStream = async () => { + const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; + + await fetchEventSource(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'text/event-stream, application/json', + }, + signal: controller.signal, + onmessage(ev) { + if (ev.event === 'NOTIFICATION') { + const newNotification: NotificationResponse = JSON.parse(ev.data); + + const mainFeedKey = queryKeys.notifications.feed(true, 0).queryKey; + + queryClient.setQueryData>( + mainFeedKey, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + items: [newNotification, ...oldData.items], + totalCount: oldData.totalCount + 1, + }; + } + ); + } + }, + onerror(err) { + console.error('Błąd połączenia SSE:', err); + } + }); + }; + + connectStream(); + + return () => { + controller.abort(); + }; + }, [queryClient, getAccessToken()]); }; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.keys.ts b/frontend/src/features/notification/notification.keys.ts deleted file mode 100644 index 173b996c..00000000 --- a/frontend/src/features/notification/notification.keys.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const NOTIFICATION_KEYS = { - all: ['notifications'] as const, - feed: () => [...NOTIFICATION_KEYS.all, 'feed'] as const, -}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.service.ts b/frontend/src/features/notification/notification.service.ts new file mode 100644 index 00000000..77ee4359 --- /dev/null +++ b/frontend/src/features/notification/notification.service.ts @@ -0,0 +1,21 @@ +import api from '@/api/client'; +import { ENDPOINTS } from '@/api/endpoints'; +import type { NotificationResponse } from './notification.types'; +import type { PagedResponse } from '@/api/api.types'; + +export const notificationService = { + getNotifications: async (unreadOnly: boolean = true, page: number = 0, size: number = 20): Promise> => { + const { data } = await api.get>(ENDPOINTS.NOTIFICATIONS.BASE, { + params: { unreadOnly, page, size } + }); + return data; + }, + + markAsRead: async (id: string): Promise => { + await api.patch(ENDPOINTS.NOTIFICATIONS.MARK_READ(id)); + }, + + markAllAsRead: async (): Promise => { + await api.patch(ENDPOINTS.NOTIFICATIONS.MARK_ALL_READ); + } +}; \ No newline at end of file From a310a5d1b087e0c3a8fbde6d0161b7ee115d7828 Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Mon, 11 May 2026 20:07:28 +0200 Subject: [PATCH 3/6] feat(notification): Create new endpoint to get unread notifications count to enhance UX. Make notifications fetched only after clicking the bell. --- .../notification/NotificationController.java | 7 ++ .../notification/NotificationRepository.java | 2 + .../notification/NotificationService.java | 5 + frontend/src/api/endpoints.ts | 1 + frontend/src/api/index.ts | 2 +- .../components/NotificationBell.tsx | 99 ++++++++++--------- .../notification/notification.hooks.ts | 82 ++++++++------- .../notification/notification.service.ts | 9 +- .../notification/notification.types.ts | 4 + 9 files changed, 126 insertions(+), 85 deletions(-) diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java index b96f8ae4..7e075e9e 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/controller/notification/NotificationController.java @@ -15,6 +15,7 @@ import pl.edu.agh.project_manager.security.UserPrincipal; import pl.edu.agh.project_manager.service.notification.NotificationService; +import java.util.Map; import java.util.UUID; @RestController @@ -31,6 +32,12 @@ public ResponseEntity streamNotifications(@AuthenticationPrincipal U return ResponseEntity.ok(emitter); } + @GetMapping("/unread-count") + public ResponseEntity> getUnreadCount(@AuthenticationPrincipal UserPrincipal userPrincipal) { + int count = notificationService.getUnreadCount(userPrincipal.userId()); + return ResponseEntity.ok(Map.of("count", count)); + } + @GetMapping public ResponseEntity> getUserNotifications( @RequestParam(defaultValue = "true") boolean unreadOnly, diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java index e3cf3ed8..71b13e97 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/repository/notification/NotificationRepository.java @@ -20,6 +20,8 @@ public interface NotificationRepository extends JpaRepository findAllByRecipientIdAndIsReadFalseOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + Integer countAllByRecipientIdAndIsReadFalse(UUID recipientId); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Notification n SET n.isRead = true WHERE n.recipient.id = :userId AND n.isRead = false") void markAllAsReadByUserId(@Param("userId") UUID userId); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java index 6217a84d..344f19b5 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java @@ -51,6 +51,11 @@ private void create(User recipient, NotificationType type, String message, UUID notificationSender.send(response, recipient.getId()); } + @Transactional(readOnly = true) + public Integer getUnreadCount(UUID userId) { + return notificationRepository.countAllByRecipientIdAndIsReadFalse(userId); + } + @Transactional(readOnly = true) public PagedResponse getNotificationsForUser(UUID userId, int page, int size, boolean unreadOnly) { Pageable pageable = PageRequest.of(page, size); diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 4deaf6c6..2af161fb 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -50,6 +50,7 @@ export const ENDPOINTS = { NOTIFICATIONS: { BASE: '/notifications', STREAM: '/notifications/stream', + UNREAD_COUNT: 'notifications/unread-count', MARK_READ: (id: string) => `/notifications/${id}/mark-as-read`, MARK_ALL_READ: '/notifications/mark-all-as-read', } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f77bf48d..4b5d5ada 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,7 +7,7 @@ export const employeeAssignmentsKeys = createQueryKeys('employee-assignments', { export const notificationsKeys = createQueryKeys('notifications', { feed: (unreadOnly: boolean, page: number) => [{ unreadOnly, page }], - + unreadCount: () => ['count'], infinite: (unreadOnly: boolean) => [{ unreadOnly, type: 'infinite' }], }); diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx index 736ee3cd..9eeb7278 100644 --- a/frontend/src/features/notification/components/NotificationBell.tsx +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -1,7 +1,6 @@ -// features/notification/components/NotificationBell.tsx import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bell, Check, Loader2, Filter } from 'lucide-react'; +import { Bell, Check, CheckCircle2, Loader2, Filter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -13,39 +12,41 @@ import { } from '@/components/ui/popover'; import { useNotifications, + useUnreadCount, useMarkNotificationAsRead, useMarkAllNotificationsAsRead } from '../notification.hooks'; import { getNotificationUrl } from '../notification.types'; import type { NotificationType } from '../notification.types'; import { cn } from '@/lib/utils'; -import { PATHS } from '@/routes/paths'; export const NotificationBell = () => { const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); const [unreadOnly, setUnreadOnly] = useState(true); - const { data, isLoading } = useNotifications(unreadOnly); + const { data: unreadCount = 0 } = useUnreadCount(); + + const { data, isLoading } = useNotifications(unreadOnly, isOpen); const { mutate: markAsRead } = useMarkNotificationAsRead(); const { mutate: markAllAsRead, isPending: isMarkingAll } = useMarkAllNotificationsAsRead(); const notifications = data?.items || []; - const unreadCount = unreadOnly - ? (data?.totalCount || 0) - : notifications.filter(n => !n.isRead).length; - const handleNotificationClick = (type: NotificationType, referenceId: string, id: string, isRead: boolean) => { - if (!isRead) { - markAsRead(id); - } - const url = getNotificationUrl(type, referenceId); - navigate(url); + if (!isRead) markAsRead(id); + setIsOpen(false); + navigate(getNotificationUrl(type, referenceId)); + }; + + const handleMarkAsReadOnly = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + markAsRead(id); }; return ( - +
) : ( notifications.map((notification) => ( - + {notification.message} +

+ + + {!notification.isRead && ( + + )} +
)) )}
-
diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts index 6fcbe06b..af201114 100644 --- a/frontend/src/features/notification/notification.hooks.ts +++ b/frontend/src/features/notification/notification.hooks.ts @@ -1,31 +1,27 @@ import { useEffect } from 'react'; -import {useQuery, useMutation, useQueryClient, useInfiniteQuery} from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { notificationService } from './notification.service'; import { queryKeys } from '@/api'; import { ENDPOINTS } from '@/api/endpoints'; +import { getAccessToken, API_BASE_URL } from '@/api/client'; import type { NotificationResponse } from './notification.types'; import type { PagedResponse } from '@/api/api.types'; -import {API_BASE_URL, getAccessToken} from "@/api/client.ts"; -export const useNotifications = (unreadOnly: boolean = true) => { +export const useUnreadCount = () => { return useQuery({ - queryKey: queryKeys.notifications.feed(unreadOnly, 0).queryKey, - queryFn: () => notificationService.getNotifications(unreadOnly, 0, 10), + queryKey: queryKeys.notifications.unreadCount().queryKey, + queryFn: notificationService.getUnreadCount, + staleTime: Infinity, }); }; -export const useInfiniteNotifications = (unreadOnly: boolean = true) => { - return useInfiniteQuery({ - queryKey: queryKeys.notifications.infinite(unreadOnly).queryKey, - queryFn: ({ pageParam = 0 }) => notificationService.getNotifications(unreadOnly, pageParam, 20), - initialPageParam: 0, - getNextPageParam: (lastPage) => { - if (lastPage.pageNumber < lastPage.totalPages - 1) { - return lastPage.pageNumber + 1; - } - return undefined; - }, +export const useNotifications = (unreadOnly: boolean, isOpen: boolean) => { + return useQuery({ + queryKey: queryKeys.notifications.feed(unreadOnly, 0).queryKey, + queryFn: () => notificationService.getNotifications(unreadOnly, 0, 10), + enabled: isOpen, + staleTime: Infinity, }); }; @@ -34,20 +30,41 @@ export const useMarkNotificationAsRead = () => { return useMutation({ mutationFn: notificationService.markAsRead, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.notifications._def }); + onMutate: async (notificationId) => { + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => Math.max(0, (old || 0) - 1)); + + const allFeedKey = queryKeys.notifications.feed(false, 0).queryKey; + queryClient.setQueryData>(allFeedKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map(n => n.id === notificationId ? { ...n, isRead: true } : n) + }; + }); + + const unreadFeedKey = queryKeys.notifications.feed(true, 0).queryKey; + queryClient.setQueryData>(unreadFeedKey, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.filter(n => n.id !== notificationId), + totalCount: Math.max(0, old.totalCount - 1) + }; + }); }, }); }; export const useMarkAllNotificationsAsRead = () => { const queryClient = useQueryClient(); - return useMutation({ mutationFn: notificationService.markAllAsRead, + onMutate: async () => { + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, 0); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.notifications._def }); - }, + } }); }; @@ -56,14 +73,11 @@ export const useNotificationStream = () => { useEffect(() => { const token = getAccessToken(); - if (!token) return; const controller = new AbortController(); - const connectStream = async () => { const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; - await fetchEventSource(url, { method: 'GET', headers: { @@ -75,31 +89,25 @@ export const useNotificationStream = () => { if (ev.event === 'NOTIFICATION') { const newNotification: NotificationResponse = JSON.parse(ev.data); - const mainFeedKey = queryKeys.notifications.feed(true, 0).queryKey; + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => (old || 0) + 1); - queryClient.setQueryData>( - mainFeedKey, - (oldData) => { + [true, false].forEach(unreadOnly => { + const feedKey = queryKeys.notifications.feed(unreadOnly, 0).queryKey; + queryClient.setQueryData>(feedKey, (oldData) => { if (!oldData) return oldData; return { ...oldData, - items: [newNotification, ...oldData.items], + items: [newNotification, ...oldData.items].slice(0, 10), totalCount: oldData.totalCount + 1, }; - } - ); + }); + }); } - }, - onerror(err) { - console.error('Błąd połączenia SSE:', err); } }); }; connectStream(); - - return () => { - controller.abort(); - }; + return () => controller.abort(); }, [queryClient, getAccessToken()]); }; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.service.ts b/frontend/src/features/notification/notification.service.ts index 77ee4359..f88faa8c 100644 --- a/frontend/src/features/notification/notification.service.ts +++ b/frontend/src/features/notification/notification.service.ts @@ -1,6 +1,6 @@ import api from '@/api/client'; import { ENDPOINTS } from '@/api/endpoints'; -import type { NotificationResponse } from './notification.types'; +import type {NotificationResponse, UnreadCountResponse} from './notification.types'; import type { PagedResponse } from '@/api/api.types'; export const notificationService = { @@ -17,5 +17,10 @@ export const notificationService = { markAllAsRead: async (): Promise => { await api.patch(ENDPOINTS.NOTIFICATIONS.MARK_ALL_READ); - } + }, + + getUnreadCount: async (): Promise => { + const { data } = await api.get(ENDPOINTS.NOTIFICATIONS.UNREAD_COUNT); + return data.count; + }, }; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.types.ts b/frontend/src/features/notification/notification.types.ts index eb83aa1e..50bce06c 100644 --- a/frontend/src/features/notification/notification.types.ts +++ b/frontend/src/features/notification/notification.types.ts @@ -18,6 +18,10 @@ export interface NotificationResponse { createdAt: string; } +export interface UnreadCountResponse { + count: number; +} + // TODO: Do zmiany gdy już beda znane export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { switch (type) { From c46da76c579a6f376e141a2d8635fdce7a0a499b Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Tue, 12 May 2026 00:27:30 +0200 Subject: [PATCH 4/6] feat(notification): refactor of notification page --- frontend/src/components/layout/Navbar.tsx | 2 - .../components/NotificationBell.tsx | 2 +- .../components/NotificationFeed.tsx | 123 ++++++++++++++++++ .../components/NotificationListItem.tsx | 77 +++++++++++ .../notification/notification.hooks.ts | 16 ++- .../notification/notification.types.ts | 24 +--- .../notification/notification.utils.ts | 22 ++++ frontend/src/pages/NotificationPage.tsx | 9 ++ frontend/src/routes/AppRoutes.tsx | 2 + frontend/src/routes/paths.ts | 3 +- 10 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 frontend/src/features/notification/components/NotificationFeed.tsx create mode 100644 frontend/src/features/notification/components/NotificationListItem.tsx create mode 100644 frontend/src/features/notification/notification.utils.ts create mode 100644 frontend/src/pages/NotificationPage.tsx diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index fff6a48d..b953585b 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -3,7 +3,6 @@ import { useAuth } from '@/providers/AuthContext'; import { useAuthActions } from '@/features/auth/auth.hooks'; import { PATHS } from '@/routes/paths'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, @@ -17,7 +16,6 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LogOut, Briefcase, - Bell, UserCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx index 9eeb7278..772b7cca 100644 --- a/frontend/src/features/notification/components/NotificationBell.tsx +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -16,7 +16,7 @@ import { useMarkNotificationAsRead, useMarkAllNotificationsAsRead } from '../notification.hooks'; -import { getNotificationUrl } from '../notification.types'; +import { getNotificationUrl } from '../notification.utils'; import type { NotificationType } from '../notification.types'; import { cn } from '@/lib/utils'; diff --git a/frontend/src/features/notification/components/NotificationFeed.tsx b/frontend/src/features/notification/components/NotificationFeed.tsx new file mode 100644 index 00000000..04058880 --- /dev/null +++ b/frontend/src/features/notification/components/NotificationFeed.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Bell, Check, Loader2, Filter } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + useInfiniteNotifications, + useMarkAllNotificationsAsRead, + useMarkNotificationAsRead +} from '../notification.hooks'; +import { getNotificationUrl } from '../notification.utils'; +import type { NotificationResponse } from '../notification.types'; +import { NotificationListItem } from './NotificationListItem'; + +export const NotificationFeed = () => { + const navigate = useNavigate(); + const [unreadOnly, setUnreadOnly] = useState(false); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteNotifications(unreadOnly); + + const { mutate: markAllAsRead, isPending: isMarkingAll } = useMarkAllNotificationsAsRead(); + const { mutate: markAsRead } = useMarkNotificationAsRead(); + + const notifications = data?.pages.flatMap(page => page.items) || []; + const totalNotifications = data?.pages[0]?.totalCount || 0; + + const handleNotificationClick = (notification: NotificationResponse) => { + if (!notification.isRead) { + markAsRead(notification.id); + } + navigate(getNotificationUrl(notification.type, notification.referenceId)); + }; + + return ( +
+
+
+

+ + Twoje powiadomienia +

+

+ Zarządzaj swoimi alertami i prośbami systemowymi. +

+
+ +
+
+ + + +
+
+ +
+
+ +
+ {isLoading ? ( +
+ +

Ładowanie powiadomień...

+
+ ) : notifications.length === 0 ? ( +
+ +

Brak powiadomień

+

Nie masz żadnych {unreadOnly ? 'nieprzeczytanych' : 'nowych'} wiadomości.

+
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} + + {hasNextPage && ( +
+ +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/components/NotificationListItem.tsx b/frontend/src/features/notification/components/NotificationListItem.tsx new file mode 100644 index 00000000..59b9f9da --- /dev/null +++ b/frontend/src/features/notification/components/NotificationListItem.tsx @@ -0,0 +1,77 @@ +import { CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { NotificationResponse } from '../notification.types'; + +interface NotificationListItemProps { + notification: NotificationResponse; + onClick: (notification: NotificationResponse) => void; + onMarkAsRead: (id: string) => void; +} + +export const NotificationListItem = ({ + notification, + onClick, + onMarkAsRead + }: NotificationListItemProps) => { + + const handleMarkAsReadOnly = (e: React.MouseEvent) => { + e.stopPropagation(); + onMarkAsRead(notification.id); + }; + + return ( +
+ + + {!notification.isRead && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts index af201114..12ea8c6e 100644 --- a/frontend/src/features/notification/notification.hooks.ts +++ b/frontend/src/features/notification/notification.hooks.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import {useQuery, useMutation, useQueryClient, useInfiniteQuery} from '@tanstack/react-query'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { notificationService } from './notification.service'; import { queryKeys } from '@/api'; @@ -68,6 +68,20 @@ export const useMarkAllNotificationsAsRead = () => { }); }; +export const useInfiniteNotifications = (unreadOnly: boolean) => { + return useInfiniteQuery({ + queryKey: queryKeys.notifications.infinite(unreadOnly).queryKey, + queryFn: ({ pageParam = 0 }) => notificationService.getNotifications(unreadOnly, pageParam, 20), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if (lastPage.pageNumber < lastPage.totalPages - 1) { + return lastPage.pageNumber + 1; + } + return undefined; + }, + }); +}; + export const useNotificationStream = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/features/notification/notification.types.ts b/frontend/src/features/notification/notification.types.ts index 50bce06c..9f8dd03f 100644 --- a/frontend/src/features/notification/notification.types.ts +++ b/frontend/src/features/notification/notification.types.ts @@ -1,5 +1,3 @@ -import { PATHS } from '@/routes/paths'; - export type NotificationType = | 'ASSIGNMENT_REQUESTED' | 'ASSIGNMENT_ACCEPTED' @@ -20,24 +18,4 @@ export interface NotificationResponse { export interface UnreadCountResponse { count: number; -} - -// TODO: Do zmiany gdy już beda znane -export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { - switch (type) { - case 'ASSIGNMENT_REQUESTED': - return `/requests`; - case 'ASSIGNMENT_ACCEPTED': - case 'ASSIGNMENT_REJECTED': - return `project/${referenceId}` - case 'QUALIFICATION_REQUESTED': - return `/requests`; - case 'QUALIFICATION_ACCEPTED': - case 'QUALIFICATION_REJECTED': - return `/qualifications/${referenceId}`; - case 'SYSTEM_NEW_EMPLOYEE': - return `/profile`; - default: - return PATHS.ROOT; - } -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/features/notification/notification.utils.ts b/frontend/src/features/notification/notification.utils.ts new file mode 100644 index 00000000..f534bfc2 --- /dev/null +++ b/frontend/src/features/notification/notification.utils.ts @@ -0,0 +1,22 @@ +import type {NotificationType} from "@/features/notification/notification.types.ts"; +import {PATHS} from "@/routes/paths.ts"; + +// TODO: Do zmiany gdy już beda znane +export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { + switch (type) { + case 'ASSIGNMENT_REQUESTED': + return `/requests`; + case 'ASSIGNMENT_ACCEPTED': + case 'ASSIGNMENT_REJECTED': + return `project/${referenceId}` + case 'QUALIFICATION_REQUESTED': + return `/requests`; + case 'QUALIFICATION_ACCEPTED': + case 'QUALIFICATION_REJECTED': + return `/qualifications/${referenceId}`; + case 'SYSTEM_NEW_EMPLOYEE': + return `/profile`; + default: + return PATHS.ROOT; + } +}; \ No newline at end of file diff --git a/frontend/src/pages/NotificationPage.tsx b/frontend/src/pages/NotificationPage.tsx new file mode 100644 index 00000000..686c023f --- /dev/null +++ b/frontend/src/pages/NotificationPage.tsx @@ -0,0 +1,9 @@ +import { NotificationFeed } from '@/features/notification/components/NotificationFeed.tsx'; + +export const NotificationPage = () => { + return ( +
+ +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index ecbef29d..effbb324 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -7,6 +7,7 @@ import { CreateProjectPage } from '@/pages/CreateProjectPage'; import { ProjectDetailsPage } from '@/pages/ProjectDetailsPage.tsx'; import { AdminUsersPage } from '../pages/AdminUsersPage'; import { AdminUserDetailsPage } from '../pages/AdminUserDetailsPage'; +import { NotificationPage } from "@/pages/NotificationPage.tsx"; import { ROUTE_PARAMS } from './paths'; import { UserRole } from '@/features/auth/auth.types'; import { MainLayout } from '@/components/layout/MainLayout'; @@ -32,6 +33,7 @@ export const AppRoutes = () => { } /> } /> } /> + } /> {/* LINEAR MANAGER ROUTES */} }> diff --git a/frontend/src/routes/paths.ts b/frontend/src/routes/paths.ts index 3120bca5..c0096897 100644 --- a/frontend/src/routes/paths.ts +++ b/frontend/src/routes/paths.ts @@ -17,5 +17,6 @@ export const PATHS = { ADMIN_USERS: '/admin/users', ADMIN_USER_DETAILS: (userId: string) => `/admin/users/${userId}`, CREATE_PROJECT: `/create-project`, - PROJECT: (id: string) => `project/${id}` + PROJECT: (id: string) => `project/${id}`, + NOTIFICATION: `notifications` } as const; \ No newline at end of file From 71a73d8f2685eaec55244203be80596b236939f8 Mon Sep 17 00:00:00 2001 From: mtrznadel24 Date: Wed, 13 May 2026 00:56:30 +0200 Subject: [PATCH 5/6] feat(notification): fix user UI in notificationBell and notificationBellItem Component. And add sending events in correct places on backend. --- .../QualificationManagementService.java | 42 ++++++++- .../service/auth/AuthService.java | 15 +++ .../project/ProjectAssignmentService.java | 10 +- .../service/user/QualificationService.java | 22 +++-- .../service/user/UserInvitationService.java | 8 -- .../src/main/resources/application.properties | 2 + frontend/index.html | 2 +- frontend/public/favicon.svg | 11 +++ frontend/src/api/client.ts | 32 ++++--- .../components/NotificationBell.tsx | 50 ++-------- .../components/NotificationBellItem.tsx | 92 +++++++++++++++++++ .../notification/notification.hooks.ts | 67 ++++++++++++-- .../notification/notification.utils.ts | 21 +++-- .../src/pages/EmployeeAssignmentsPage.tsx | 27 ++++-- .../src/pages/QualificationRequestsPage.tsx | 26 ++++-- frontend/src/routes/paths.ts | 4 +- 16 files changed, 321 insertions(+), 110 deletions(-) create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/features/notification/components/NotificationBellItem.tsx diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java index 1442b2ab..42937b76 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.approval; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.controller.dto.approvals.QualificationDetailsResponse; @@ -11,13 +12,16 @@ import pl.edu.agh.project_manager.domain.entity.user.Skill; import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.QualificationStatus; +import pl.edu.agh.project_manager.domain.event.NotificationEvent; +import pl.edu.agh.project_manager.domain.event.QualificationAcceptedEvent; +import pl.edu.agh.project_manager.domain.event.QualificationRejectedEvent; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.user.QualificationRepository; import pl.edu.agh.project_manager.repository.user.UserRepository; +import pl.edu.agh.project_manager.service.notification.NotificationSender; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Service @@ -25,6 +29,7 @@ public class QualificationManagementService { private final QualificationRepository qualificationRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public List getRecordsForManager(UUID managerId) { @@ -62,16 +67,45 @@ public void updateRequests(UUID managerId, List requ throw new ApplicationException(ApiErrorCode.QUALIFICATION_OWNER_NOT_SUBORDINATE); } + Map> acceptedSkillsByUser = new HashMap<>(); + Map> rejectedSkillsByUser = new HashMap<>(); + for (Qualification qualification : allowedQualifications) { validateWaitingStatus(qualification); + User owner = qualification.getUser(); var action = activeRequestsMap.get(qualification.getId()).action(); + String skillName = qualification.getSkill().getName(); switch (action) { - case QualificationUpdateAction.ACCEPT -> qualification.accept(); - case QualificationUpdateAction.REJECT -> qualification.reject(); + case ACCEPT -> { + qualification.accept(); + acceptedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } + case REJECT -> { + qualification.reject(); + rejectedSkillsByUser.computeIfAbsent(owner, k -> new ArrayList<>()).add(skillName); + } } } + + acceptedSkillsByUser.forEach((user, skills) -> { + String skillsJoined = String.join(", ", skills); + eventPublisher.publishEvent(new QualificationAcceptedEvent( + user.getId(), + user, + "Zatwierdzono Twoje kwalifikacje: " + skillsJoined + )); + }); + + rejectedSkillsByUser.forEach((user, skills) -> { + String skillsJoined = String.join(", ", skills); + eventPublisher.publishEvent(new QualificationRejectedEvent( + user.getId(), + user, + "Odrzucono wnioski o kwalifikacje: " + skillsJoined + )); + }); } @Transactional(readOnly = true) diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java index 36672239..b66fd6a4 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java @@ -1,6 +1,7 @@ package pl.edu.agh.project_manager.service.auth; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -11,7 +12,9 @@ import org.springframework.transaction.annotation.Transactional; import pl.edu.agh.project_manager.domain.entity.user.ActivationToken; import pl.edu.agh.project_manager.domain.entity.user.User; +import pl.edu.agh.project_manager.domain.enums.UserRole; import pl.edu.agh.project_manager.domain.enums.UserStatus; +import pl.edu.agh.project_manager.domain.event.SystemNewEmployeeEvent; import pl.edu.agh.project_manager.domain.exception.ApiErrorCode; import pl.edu.agh.project_manager.domain.exception.ApplicationException; import pl.edu.agh.project_manager.repository.user.ActivationTokenRepository; @@ -34,6 +37,7 @@ public class AuthService { private final JwtService jwtService; private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; + private final ApplicationEventPublisher eventPublisher; @Transactional public TokenPair register(RegisterCommand command) { @@ -56,6 +60,17 @@ public TokenPair register(RegisterCommand command) { tokenRepository.delete(activationToken); + if (user.getUserRole() == UserRole.COMMON && user.getSupervisor() != null) { + String message = String.format("Nowy pracownik %s aktywował swoje konto i dołączył do Twojego zespołu.", + user.getFullName()); + + eventPublisher.publishEvent(new SystemNewEmployeeEvent( + user.getId(), + user.getSupervisor(), + message + )); + } + UserDetails userDetails = new UserPrincipal( user.getId(), user.getEmail(), diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java index 18e1e038..2d723ddf 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java @@ -123,10 +123,16 @@ public void acceptAssignment(UUID assignmentId) { } eventPublisher.publishEvent(new AssignmentAcceptedEvent( - assignment.getId(), + project.getId(), project.getProjectManager(), "Zaakceptowano przypisanie pracownika " + user.getFullName() + " do projektu " + project.getTitle() )); + + eventPublisher.publishEvent(new AssignmentAcceptedEvent( + project.getId(), + user, + "Zostałeś dodany do projektu: " + project.getTitle() + )); } @Transactional @@ -138,7 +144,7 @@ public void rejectAssignment(UUID assignmentId) { assignment.setStatus(AssignmentStatus.REJECTED); eventPublisher.publishEvent(new AssignmentRejectedEvent( - assignment.getId(), + assignment.getProject().getId(), assignment.getProject().getProjectManager(), "Odrzucono przypisanie pracownika " + assignment.getUser().getName() + " do projektu " + assignment.getProject().getTitle() )); diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java index db8dc756..bb2b70a3 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -58,6 +59,18 @@ public List addQualificationsToUser(UUID userId, List q.getSkill().getName()) + .collect(Collectors.joining(", ")); + + eventPublisher.publishEvent(new QualificationRequestedEvent( + user.getId(), + user.getSupervisor(), + user.getFullName() + " zgłasza nowe umiejętności: " + skillsJoined + )); + } + return savedQualifications.stream() .map(q -> new QualificationResponse(q.getId(), q.getSkill().getName(), q.getStatus())) .toList(); @@ -72,15 +85,6 @@ private Qualification saveQualification(User user, Skill skill) { user.addQualification(qualification); Qualification saved = qualificationRepository.save(qualification); - // FIXME: na ten moment przy jednym zgłoszeniu 5 skilli przychodziłoby 5 powiadomień, raczej tak nie chcemy, ale na razie nie wiem jak będzie wyglądało to na froncie, więc zostawiam zakomentowane -// if (user.getSupervisor() != null) { -// eventPublisher.publishEvent(new QualificationRequestedEvent( -// saved.getId(), -// user.getSupervisor(), -// user.getFullName() + " zgłasza umiejętność: " + skill.getName() -// )); -// } - return saved; } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java index 3c9d794b..264dfd1a 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/UserInvitationService.java @@ -43,14 +43,6 @@ private void processInvitation(String email, UserRole role, UUID supervisorId) { var newUser = createInvitedUser(email, role, supervisor); userRepository.save(newUser); - if (supervisor != null) { - eventPublisher.publishEvent(new SystemNewEmployeeEvent( - newUser.getId(), - supervisor, - "Do twojego zespołu zaproszono nowego pracownika: " + email - )); - } - var activationToken = createTokenForUser(newUser); sendInvitationEmail(email, activationToken); diff --git a/backend/project-manager/src/main/resources/application.properties b/backend/project-manager/src/main/resources/application.properties index baacaeb9..7a4af714 100644 --- a/backend/project-manager/src/main/resources/application.properties +++ b/backend/project-manager/src/main/resources/application.properties @@ -5,6 +5,8 @@ springdoc.swagger-ui.path=/swagger springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true application.security.swagger.enabled=true +logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=FATAL +logging.level.org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]=FATAL # Jwt diff --git a/frontend/index.html b/frontend/index.html index 0fca6f04..4f5da590 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + IO Project Manager
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..15944c14 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d923fa78..d433c69c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -19,6 +19,22 @@ const api = axios.create({ withCredentials: true, }); +export const refreshAccessToken = async () => { + try { + const refreshResponse = await axios.post( + `${API_BASE_URL}${ENDPOINTS.AUTH.REFRESH}`, + {}, + { withCredentials: true } + ); + const newAccessToken = refreshResponse.data.accessToken; + setAccessToken(newAccessToken); + return newAccessToken; + } catch (error) { + setAccessToken(null); + window.dispatchEvent(new Event('auth-logout')); + throw error; + } +}; api.interceptors.request.use( (config) => { @@ -46,24 +62,10 @@ api.interceptors.response.use( originalRequest._retry = true; try { - const refreshResponse = await axios.post( - `${api.defaults.baseURL}${ENDPOINTS.AUTH.REFRESH}`, - {}, - { withCredentials: true } - ); - - const newAccessToken = refreshResponse.data.accessToken; - setAccessToken(newAccessToken) - + const newAccessToken = await refreshAccessToken(); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); - } catch (refreshError) { - setAccessToken(null); - - window.dispatchEvent(new Event('auth-logout')); - return Promise.reject(refreshError); } } diff --git a/frontend/src/features/notification/components/NotificationBell.tsx b/frontend/src/features/notification/components/NotificationBell.tsx index 772b7cca..6faa868f 100644 --- a/frontend/src/features/notification/components/NotificationBell.tsx +++ b/frontend/src/features/notification/components/NotificationBell.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bell, Check, CheckCircle2, Loader2, Filter } from 'lucide-react'; +import { Bell, Check, Loader2, Filter } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -18,7 +18,7 @@ import { } from '../notification.hooks'; import { getNotificationUrl } from '../notification.utils'; import type { NotificationType } from '../notification.types'; -import { cn } from '@/lib/utils'; +import {NotificationBellItem} from "@/features/notification/components/NotificationBellItem.tsx"; export const NotificationBell = () => { const navigate = useNavigate(); @@ -102,48 +102,12 @@ export const NotificationBell = () => {
) : ( notifications.map((notification) => ( -
- - - {!notification.isRead && ( - - )} -
+ notification={notification} + onClick={handleNotificationClick} + onMarkAsRead={handleMarkAsReadOnly} + /> )) )}
diff --git a/frontend/src/features/notification/components/NotificationBellItem.tsx b/frontend/src/features/notification/components/NotificationBellItem.tsx new file mode 100644 index 00000000..80191638 --- /dev/null +++ b/frontend/src/features/notification/components/NotificationBellItem.tsx @@ -0,0 +1,92 @@ +import type {MouseEvent} from "react"; +import {CheckCircle2, ChevronDown, ChevronUp} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { NotificationResponse } from '../notification.types'; +import {useState} from "react"; + +interface NotificationBellItemProps { + notification: NotificationResponse; + onClick: (type: NotificationResponse['type'], referenceId: string, id: string, isRead: boolean) => void; + onMarkAsRead: (e: MouseEvent, id: string) => void; +} + +export const NotificationBellItem = ({ notification, onClick, onMarkAsRead }: NotificationBellItemProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = (e: MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + return ( +
onClick(notification.type, notification.referenceId, notification.id, notification.isRead)} + > + + + {!notification.isRead && ( +
+ +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts index 12ea8c6e..4b3c94b2 100644 --- a/frontend/src/features/notification/notification.hooks.ts +++ b/frontend/src/features/notification/notification.hooks.ts @@ -4,7 +4,7 @@ import { fetchEventSource } from '@microsoft/fetch-event-source'; import { notificationService } from './notification.service'; import { queryKeys } from '@/api'; import { ENDPOINTS } from '@/api/endpoints'; -import { getAccessToken, API_BASE_URL } from '@/api/client'; +import {getAccessToken, API_BASE_URL, refreshAccessToken} from '@/api/client'; import type { NotificationResponse } from './notification.types'; import type { PagedResponse } from '@/api/api.types'; @@ -86,12 +86,19 @@ export const useNotificationStream = () => { const queryClient = useQueryClient(); useEffect(() => { - const token = getAccessToken(); - if (!token) return; + let controller = new AbortController(); - const controller = new AbortController(); const connectStream = async () => { + const token = getAccessToken(); + + if (!token) { + console.log('⚠️ [SSE] Brak tokena - przerywam próbę połączenia.'); + return; + } + + console.log('🔗 [SSE] Próba połączenia ze strumieniem...'); const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; + await fetchEventSource(url, { method: 'GET', headers: { @@ -99,16 +106,58 @@ export const useNotificationStream = () => { 'Accept': 'text/event-stream, application/json', }, signal: controller.signal, + + async onopen(response) { + console.log(`📡 [SSE] Połączono. Status: ${response.status}`); + + if (response.ok) { + console.log('🔄 [SSE] Odświeżam stan powiadomień po pomyślnym połączeniu (na wypadek uśpienia przeglądarki)...'); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount().queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(true, 0).queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(false, 0).queryKey }); + } + + if (response.status === 401) { + try { + console.log('🔄 [SSE] Token wygasł, próbuję odświeżyć...'); + await refreshAccessToken(); + + controller.abort(); + controller = new AbortController(); + setTimeout(connectStream, 100); + return; + } catch (err) { + console.error('❌ [SSE] Nie udało się odświeżyć tokena.'); + throw new Error('Refresh failed - stop retrying'); + } + } + + if (response.status >= 400 && response.status !== 401) { + console.error(`❌ [SSE] Błąd serwera. Status: ${response.status}`); + throw new Error('Server Error - Stop retrying'); + } + }, + onmessage(ev) { if (ev.event === 'NOTIFICATION') { + console.log('📬 [SSE] Otrzymano nowe powiadomienie!', ev.data); const newNotification: NotificationResponse = JSON.parse(ev.data); + // 1. Zwiększamy licznik (to zadziała zawsze) queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => (old || 0) + 1); + // 2. Dodajemy do list (jeśli są pobrane w cache) [true, false].forEach(unreadOnly => { const feedKey = queryKeys.notifications.feed(unreadOnly, 0).queryKey; queryClient.setQueryData>(feedKey, (oldData) => { - if (!oldData) return oldData; + + // POPRAWKA: Jeżeli cache jest pusty, wymuszamy odświeżenie danych w tle + if (!oldData) { + console.log('🔄 [SSE] Cache był pusty - oznaczam zapytanie jako nieważne (invalidate).'); + queryClient.invalidateQueries({ queryKey: feedKey }); + return oldData; + } + return { ...oldData, items: [newNotification, ...oldData.items].slice(0, 10), @@ -117,11 +166,17 @@ export const useNotificationStream = () => { }); }); } + }, + + onerror(err) { + console.error('❌ [SSE] Błąd strumienia:', err); + throw err; // Zatrzymuje nieskończoną pętlę odnawiania w fetch-event-source } }); }; connectStream(); + return () => controller.abort(); - }, [queryClient, getAccessToken()]); + }, [queryClient]); }; \ No newline at end of file diff --git a/frontend/src/features/notification/notification.utils.ts b/frontend/src/features/notification/notification.utils.ts index f534bfc2..4306e76d 100644 --- a/frontend/src/features/notification/notification.utils.ts +++ b/frontend/src/features/notification/notification.utils.ts @@ -1,21 +1,26 @@ -import type {NotificationType} from "@/features/notification/notification.types.ts"; -import {PATHS} from "@/routes/paths.ts"; +import type { NotificationType } from "@/features/notification/notification.types.ts"; +import {PATHS, QUERY_PARAMS} from "@/routes/paths.ts"; -// TODO: Do zmiany gdy już beda znane export const getNotificationUrl = (type: NotificationType, referenceId: string): string => { switch (type) { case 'ASSIGNMENT_REQUESTED': - return `/requests`; + return `${PATHS.PROJECT_REQUESTS}?${QUERY_PARAMS.REQUEST_ID}=${referenceId}`; + case 'ASSIGNMENT_ACCEPTED': + return PATHS.PROJECT(referenceId); case 'ASSIGNMENT_REJECTED': - return `project/${referenceId}` + return PATHS.PROJECT(referenceId); + case 'QUALIFICATION_REQUESTED': - return `/requests`; + return `${PATHS.QUALIFICATION_REQUESTS}?${QUERY_PARAMS.USER_ID}=${referenceId}`; + case 'QUALIFICATION_ACCEPTED': case 'QUALIFICATION_REJECTED': - return `/qualifications/${referenceId}`; + return PATHS.PROFILE; + case 'SYSTEM_NEW_EMPLOYEE': - return `/profile`; + return PATHS.PROFILE; // TODO: Powinno przenosic na profil pracownika, ale nie ma obecnie takiej mozliwosci + default: return PATHS.ROOT; } diff --git a/frontend/src/pages/EmployeeAssignmentsPage.tsx b/frontend/src/pages/EmployeeAssignmentsPage.tsx index c2b4736f..7f5d8dad 100644 --- a/frontend/src/pages/EmployeeAssignmentsPage.tsx +++ b/frontend/src/pages/EmployeeAssignmentsPage.tsx @@ -1,29 +1,42 @@ import { useEmployeeAssignments, useEmployeeAssignmentsActions } from "@/features/employee-assignments/employee-assignments.hooks"; import { getColumns } from "@/features/employee-assignments/employee-assignments.columns"; -import { useState, useCallback, useMemo } from "react"; +import { useCallback, useMemo} from "react"; import type { EmployeeAssignment } from "@/features/employee-assignments/employee-assignments.types"; import { EmployeeAssignmentDetails } from "@/features/employee-assignments/components/EmployeeAssignmentDetails"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { DataTable } from "@/components/ui/data-table"; import { TablePageShell } from "@/components/layout/TablePageShell"; +import { useSearchParams } from "react-router-dom"; export const EmployeeAssignmentsPage = () => { const { employeeAssignments, areAssignmentsLoading } = useEmployeeAssignments(); const { rejectRequest } = useEmployeeAssignmentsActions(); + const [searchParams, setSearchParams] = useSearchParams(); + const requestIdFromUrl = searchParams.get('requestId'); - const [selectedAssignment, setSelectedAssignment] = useState(null); + const selectedAssignment = useMemo(() => { + if (!requestIdFromUrl || !employeeAssignments) return null; + return employeeAssignments.find(a => a.id === requestIdFromUrl) || null; + }, [requestIdFromUrl, employeeAssignments]); const handleReject = useCallback((assignment: EmployeeAssignment) => { rejectRequest(assignment.id); }, [rejectRequest]); + const handleOpenModal = useCallback((assignment: EmployeeAssignment) => { + setSearchParams({ requestId: assignment.id }); + }, [setSearchParams]); + + const handleCloseModal = useCallback(() => { + searchParams.delete('requestId'); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams]); + const columns = useMemo( - () => getColumns({ onVerify: setSelectedAssignment, onReject: handleReject }), - [handleReject] + () => getColumns({ onVerify: handleOpenModal, onReject: handleReject }), + [handleOpenModal, handleReject] ); - const handleCloseModal = () => setSelectedAssignment(null); - return ( { > - !open && setSelectedAssignment(null)}> + !open && handleCloseModal()}> Szczegóły weryfikacji diff --git a/frontend/src/pages/QualificationRequestsPage.tsx b/frontend/src/pages/QualificationRequestsPage.tsx index 702c2e6f..87d65b0b 100644 --- a/frontend/src/pages/QualificationRequestsPage.tsx +++ b/frontend/src/pages/QualificationRequestsPage.tsx @@ -5,17 +5,31 @@ import { QualificationRequestDetails } from "@/features/qualifications/component import { getColumns } from "@/features/qualifications/qualifications.columns"; import { useWaitingQualificationsSummaryQuery } from "@/features/qualifications/qualifications.hooks"; import type { QualificationRequestResponse } from "@/features/qualifications/qualifications.types"; -import { useMemo, useState } from "react"; +import {useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; export const QualificationRequestsPage = () => { const { waitingQualificationsSummary, isLoading } = useWaitingQualificationsSummaryQuery(); - const [selectedRequest, setselectedRequest] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const userIdFromUrl = searchParams.get('userId'); - const columns = useMemo(() => getColumns({ - onVerify: (user) => setselectedRequest(user) - }), []); + const selectedRequest = useMemo(() => { + if (!userIdFromUrl || !waitingQualificationsSummary) return null; + return waitingQualificationsSummary.find(req => req.userId === userIdFromUrl) || null; + }, [userIdFromUrl, waitingQualificationsSummary]); + + const handleOpenModal = useCallback((user: QualificationRequestResponse) => { + setSearchParams({ userId: user.userId }); + }, [setSearchParams]); - const handleCloseModal = () => setselectedRequest(null); + const handleCloseModal = useCallback(() => { + searchParams.delete('userId'); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams]); + + const columns = useMemo(() => getColumns({ + onVerify: handleOpenModal + }), [handleOpenModal]); return ( Date: Sat, 16 May 2026 14:18:33 +0200 Subject: [PATCH 6/6] feat(notification): decouple sse connection logic form notification hooks. Extract the business logic of formatting string from services to Event records with new method buildMessage. And fixes from review. --- .../domain/event/AssignmentAcceptedEvent.java | 13 +- .../domain/event/AssignmentRejectedEvent.java | 8 +- .../event/AssignmentRequestedEvent.java | 8 +- .../domain/event/NotificationEvent.java | 2 +- .../event/QualificationAcceptedEvent.java | 8 +- .../event/QualificationRejectedEvent.java | 8 +- .../event/QualificationRequestedEvent.java | 9 +- .../domain/event/SystemNewEmployeeEvent.java | 13 +- .../QualificationManagementService.java | 6 +- .../service/auth/AuthService.java | 5 +- .../notification/NotificationService.java | 2 +- .../project/ProjectAssignmentService.java | 12 +- .../service/user/QualificationService.java | 7 +- .../notification/NotificationServiceTest.java | 17 ++- frontend/src/api/sseClient.ts | 70 +++++++++ .../components/NotificationBellItem.tsx | 21 +-- .../notification/notification.hooks.ts | 140 ++++++------------ 17 files changed, 215 insertions(+), 134 deletions(-) create mode 100644 frontend/src/api/sseClient.ts diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java index 741850df..480413b0 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentAcceptedEvent.java @@ -7,8 +7,9 @@ public record AssignmentAcceptedEvent( UUID assignmentId, - User recipient, String - message + User recipient, + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,12 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_ACCEPTED; } + + @Override + public String buildMessage() { + if (employeeFullName != null) { + return "Zaakceptowano przypisanie pracownika " + employeeFullName + " do projektu " + projectName; + } + return "Zostałeś dodany do projektu: " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java index 97eccd2b..100e31fc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRejectedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRejectedEvent( UUID assignmentId, User recipient, - String message + String employeeName, + String projectName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono przypisanie pracownika " + employeeName + " do projektu " + projectName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java index 0ecfd997..2bda749c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/AssignmentRequestedEvent.java @@ -8,7 +8,8 @@ public record AssignmentRequestedEvent( UUID assignmentId, User recipient, - String message + String projectName, + String employeeFullName ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.ASSIGNMENT_REQUESTED; } + + @Override + public String buildMessage() { + return "Kierownik projektu " + projectName + " prosi o alokację pracownika " + employeeFullName; + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java index 958ef54d..48bbd453 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/NotificationEvent.java @@ -7,7 +7,7 @@ public interface NotificationEvent { User recipient(); - String message(); UUID referenceId(); NotificationType type(); + String buildMessage(); } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java index ddb54a78..8088eb13 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationAcceptedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationAcceptedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_ACCEPTED; } + + @Override + public String buildMessage() { + return "Zatwierdzono Twoje kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java index 5b5ee14e..da0df94f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRejectedEvent.java @@ -3,12 +3,13 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRejectedEvent( UUID qualificationId, User recipient, - String message + List skills ) implements NotificationEvent { @Override @@ -20,4 +21,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REJECTED; } + + @Override + public String buildMessage() { + return "Odrzucono wnioski o kwalifikacje: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java index e0530085..ba585196 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/QualificationRequestedEvent.java @@ -3,12 +3,14 @@ import pl.edu.agh.project_manager.domain.entity.user.User; import pl.edu.agh.project_manager.domain.enums.NotificationType; +import java.util.List; import java.util.UUID; public record QualificationRequestedEvent( UUID qualificationId, User recipient, - String message + String employeeFullName, + List skills ) implements NotificationEvent { @Override @@ -20,4 +22,9 @@ public UUID referenceId() { public NotificationType type() { return NotificationType.QUALIFICATION_REQUESTED; } + + @Override + public String buildMessage() { + return employeeFullName + " zgłasza nowe umiejętności: " + String.join(", ", skills); + } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java index 3bc8f816..add2cc0c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/domain/event/SystemNewEmployeeEvent.java @@ -8,16 +8,17 @@ public record SystemNewEmployeeEvent( UUID employeeId, User recipient, - String message + String employeeFullName ) implements NotificationEvent { @Override - public UUID referenceId() { - return employeeId; - } + public UUID referenceId() { return employeeId; } + + @Override + public NotificationType type() { return NotificationType.SYSTEM_NEW_EMPLOYEE; } @Override - public NotificationType type() { - return NotificationType.SYSTEM_NEW_EMPLOYEE; + public String buildMessage() { + return String.format("Nowy pracownik %s aktywował swoje konto i dołączył do Twojego zespołu.", employeeFullName); } } \ No newline at end of file diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java index 42937b76..edc9cf9f 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/approval/QualificationManagementService.java @@ -90,20 +90,18 @@ public void updateRequests(UUID managerId, List requ } acceptedSkillsByUser.forEach((user, skills) -> { - String skillsJoined = String.join(", ", skills); eventPublisher.publishEvent(new QualificationAcceptedEvent( user.getId(), user, - "Zatwierdzono Twoje kwalifikacje: " + skillsJoined + skills )); }); rejectedSkillsByUser.forEach((user, skills) -> { - String skillsJoined = String.join(", ", skills); eventPublisher.publishEvent(new QualificationRejectedEvent( user.getId(), user, - "Odrzucono wnioski o kwalifikacje: " + skillsJoined + skills )); }); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java index b66fd6a4..4f7f94dc 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/auth/AuthService.java @@ -61,13 +61,10 @@ public TokenPair register(RegisterCommand command) { tokenRepository.delete(activationToken); if (user.getUserRole() == UserRole.COMMON && user.getSupervisor() != null) { - String message = String.format("Nowy pracownik %s aktywował swoje konto i dołączył do Twojego zespołu.", - user.getFullName()); - eventPublisher.publishEvent(new SystemNewEmployeeEvent( user.getId(), user.getSupervisor(), - message + user.getFullName() )); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java index 344f19b5..7fe58050 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/notification/NotificationService.java @@ -34,7 +34,7 @@ public class NotificationService { @TransactionalEventListener @Transactional(propagation = Propagation.REQUIRES_NEW) public void onNotificationEvent(NotificationEvent event) { - create(event.recipient(), event.type(), event.message(), event.referenceId()); + create(event.recipient(), event.type(), event.buildMessage(), event.referenceId()); } private void create(User recipient, NotificationType type, String message, UUID referenceId) { diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java index a1b1a2ec..1ae6a2e8 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/project/ProjectAssignmentService.java @@ -54,7 +54,8 @@ public AssignmentResponse createAssignment(UUID projectId, AssignmentCommand com eventPublisher.publishEvent(new AssignmentRequestedEvent( savedAssignment.getId(), user.getSupervisor(), - "Kierownik projektu " + project.getTitle() + " prosi o alokację pracownika " + user.getFullName() + project.getTitle(), + user.getFullName() )); } @@ -125,13 +126,15 @@ public void acceptAssignment(UUID assignmentId) { eventPublisher.publishEvent(new AssignmentAcceptedEvent( project.getId(), project.getProjectManager(), - "Zaakceptowano przypisanie pracownika " + user.getFullName() + " do projektu " + project.getTitle() + project.getTitle(), + user.getFullName() )); eventPublisher.publishEvent(new AssignmentAcceptedEvent( project.getId(), user, - "Zostałeś dodany do projektu: " + project.getTitle() + project.getTitle(), + null )); } @@ -146,7 +149,8 @@ public void rejectAssignment(UUID assignmentId) { eventPublisher.publishEvent(new AssignmentRejectedEvent( assignment.getProject().getId(), assignment.getProject().getProjectManager(), - "Odrzucono przypisanie pracownika " + assignment.getUser().getName() + " do projektu " + assignment.getProject().getTitle() + assignment.getUser().getName(), + assignment.getProject().getTitle() )); } diff --git a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java index bb2b70a3..75ed424c 100644 --- a/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java +++ b/backend/project-manager/src/main/java/pl/edu/agh/project_manager/service/user/QualificationService.java @@ -60,14 +60,15 @@ public List addQualificationsToUser(UUID userId, List skillsList = savedQualifications.stream() .map(q -> q.getSkill().getName()) - .collect(Collectors.joining(", ")); + .toList(); eventPublisher.publishEvent(new QualificationRequestedEvent( user.getId(), user.getSupervisor(), - user.getFullName() + " zgłasza nowe umiejętności: " + skillsJoined + user.getFullName(), + skillsList )); } diff --git a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java index a325062e..a4f9b707 100644 --- a/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java +++ b/backend/project-manager/src/test/java/pl/edu/agh/project_manager/service/notification/NotificationServiceTest.java @@ -46,9 +46,16 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { User recipient = new User(); recipient.setId(UUID.randomUUID()); UUID assignmentId = UUID.randomUUID(); - String message = "Prośba o przypisanie"; - AssignmentRequestedEvent event = new AssignmentRequestedEvent(assignmentId, recipient, message); + String projectName = "Projekt Apollo"; + String employeeName = "Jan Kowalski"; + + AssignmentRequestedEvent event = new AssignmentRequestedEvent( + assignmentId, + recipient, + projectName, + employeeName + ); ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(Notification.class); @@ -56,11 +63,12 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { .id(UUID.randomUUID()) .recipient(recipient) .type(NotificationType.ASSIGNMENT_REQUESTED) - .message(message) + .message(event.buildMessage()) .referenceId(assignmentId) .isRead(false) .createdAt(LocalDateTime.now()) .build(); + when(notificationRepository.save(any(Notification.class))).thenReturn(savedMock); // When @@ -69,8 +77,11 @@ void shouldCreateSaveAndSendNotificationWhenNotificationEventIsPublished() { // Then verify(notificationRepository).save(notificationCaptor.capture()); Notification capturedNotification = notificationCaptor.getValue(); + assertEquals(NotificationType.ASSIGNMENT_REQUESTED, capturedNotification.getType()); + assertEquals("Kierownik projektu Projekt Apollo prosi o alokację pracownika Jan Kowalski", capturedNotification.getMessage()); + verify(notificationSender).send(any(NotificationResponse.class), eq(recipient.getId())); } diff --git a/frontend/src/api/sseClient.ts b/frontend/src/api/sseClient.ts new file mode 100644 index 00000000..d2d34a69 --- /dev/null +++ b/frontend/src/api/sseClient.ts @@ -0,0 +1,70 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { getAccessToken, refreshAccessToken } from './client'; + +interface SSEConnectionOptions { + url: string; + onOpen?: () => void; + onMessage?: (event: { event: string; data: string }) => void; + onError?: (error: unknown) => void; +} + +export const connectSSE = ({ url, onOpen, onMessage, onError }: SSEConnectionOptions) => { + let controller = new AbortController(); + + const connect = async () => { + const token = getAccessToken(); + if (!token) return; + + try { + await fetchEventSource(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'text/event-stream, application/json', + }, + signal: controller.signal, + + async onopen(response) { + if (response.ok && onOpen) { + onOpen(); + } + + if (response.status === 401) { + try { + await refreshAccessToken(); + controller.abort(); + controller = new AbortController(); + setTimeout(connect, 100); + return; + } catch (err) { + throw new Error('Refresh failed - stop retrying'); + } + } + + if (response.status >= 400 && response.status !== 401) { + throw new Error('Server Error - Stop retrying'); + } + }, + + onmessage(ev) { + if (onMessage) { + onMessage(ev); + } + }, + + onerror(err) { + if (onError) onError(err); + throw err; + } + }); + } catch (error) { + console.error('SSE Connection failed', error); + } + }; + + connect(); + + return () => { + controller.abort(); + }; +}; \ No newline at end of file diff --git a/frontend/src/features/notification/components/NotificationBellItem.tsx b/frontend/src/features/notification/components/NotificationBellItem.tsx index 80191638..b352b4cb 100644 --- a/frontend/src/features/notification/components/NotificationBellItem.tsx +++ b/frontend/src/features/notification/components/NotificationBellItem.tsx @@ -1,9 +1,9 @@ -import type {MouseEvent} from "react"; -import {CheckCircle2, ChevronDown, ChevronUp} from 'lucide-react'; +import React, { memo, useState } from "react"; +import type { MouseEvent } from "react"; +import { CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { NotificationResponse } from '../notification.types'; -import {useState} from "react"; interface NotificationBellItemProps { notification: NotificationResponse; @@ -11,7 +11,11 @@ interface NotificationBellItemProps { onMarkAsRead: (e: MouseEvent, id: string) => void; } -export const NotificationBellItem = ({ notification, onClick, onMarkAsRead }: NotificationBellItemProps) => { +export const NotificationBellItem: React.FC = memo(({ + notification, + onClick, + onMarkAsRead + }) => { const [isExpanded, setIsExpanded] = useState(false); const toggleExpand = (e: MouseEvent) => { @@ -27,10 +31,7 @@ export const NotificationBellItem = ({ notification, onClick, onMarkAsRead }: No )} onClick={() => onClick(notification.type, notification.referenceId, notification.id, notification.isRead)} > - +
{!notification.isRead && (
@@ -89,4 +90,4 @@ export const NotificationBellItem = ({ notification, onClick, onMarkAsRead }: No )}
); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/frontend/src/features/notification/notification.hooks.ts b/frontend/src/features/notification/notification.hooks.ts index 4b3c94b2..75027a3c 100644 --- a/frontend/src/features/notification/notification.hooks.ts +++ b/frontend/src/features/notification/notification.hooks.ts @@ -1,10 +1,11 @@ import { useEffect } from 'react'; import {useQuery, useMutation, useQueryClient, useInfiniteQuery} from '@tanstack/react-query'; -import { fetchEventSource } from '@microsoft/fetch-event-source'; +import type { InfiniteData } from '@tanstack/react-query'; +import { connectSSE } from '@/api/sseClient'; import { notificationService } from './notification.service'; import { queryKeys } from '@/api'; import { ENDPOINTS } from '@/api/endpoints'; -import {getAccessToken, API_BASE_URL, refreshAccessToken} from '@/api/client'; +import { API_BASE_URL } from '@/api/client'; import type { NotificationResponse } from './notification.types'; import type { PagedResponse } from '@/api/api.types'; @@ -51,6 +52,20 @@ export const useMarkNotificationAsRead = () => { totalCount: Math.max(0, old.totalCount - 1) }; }); + + const infiniteAllKey = queryKeys.notifications.infinite(false).queryKey; + queryClient.setQueryData>>(infiniteAllKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map(page => ({ + ...page, + items: page.items.map(n => n.id === notificationId ? { ...n, isRead: true } : n) + })) + }; + }); + + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.infinite(true).queryKey }); }, }); }; @@ -86,97 +101,40 @@ export const useNotificationStream = () => { const queryClient = useQueryClient(); useEffect(() => { - let controller = new AbortController(); - - const connectStream = async () => { - const token = getAccessToken(); - - if (!token) { - console.log('⚠️ [SSE] Brak tokena - przerywam próbę połączenia.'); - return; - } - - console.log('🔗 [SSE] Próba połączenia ze strumieniem...'); - const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; - - await fetchEventSource(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Accept': 'text/event-stream, application/json', - }, - signal: controller.signal, - - async onopen(response) { - console.log(`📡 [SSE] Połączono. Status: ${response.status}`); - - if (response.ok) { - console.log('🔄 [SSE] Odświeżam stan powiadomień po pomyślnym połączeniu (na wypadek uśpienia przeglądarki)...'); - queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount().queryKey }); - queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(true, 0).queryKey }); - queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(false, 0).queryKey }); - } - - if (response.status === 401) { - try { - console.log('🔄 [SSE] Token wygasł, próbuję odświeżyć...'); - await refreshAccessToken(); - - controller.abort(); - controller = new AbortController(); - setTimeout(connectStream, 100); - return; - } catch (err) { - console.error('❌ [SSE] Nie udało się odświeżyć tokena.'); - throw new Error('Refresh failed - stop retrying'); - } - } - - if (response.status >= 400 && response.status !== 401) { - console.error(`❌ [SSE] Błąd serwera. Status: ${response.status}`); - throw new Error('Server Error - Stop retrying'); - } - }, - - onmessage(ev) { - if (ev.event === 'NOTIFICATION') { - console.log('📬 [SSE] Otrzymano nowe powiadomienie!', ev.data); - const newNotification: NotificationResponse = JSON.parse(ev.data); - - // 1. Zwiększamy licznik (to zadziała zawsze) - queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => (old || 0) + 1); - - // 2. Dodajemy do list (jeśli są pobrane w cache) - [true, false].forEach(unreadOnly => { - const feedKey = queryKeys.notifications.feed(unreadOnly, 0).queryKey; - queryClient.setQueryData>(feedKey, (oldData) => { - - // POPRAWKA: Jeżeli cache jest pusty, wymuszamy odświeżenie danych w tle - if (!oldData) { - console.log('🔄 [SSE] Cache był pusty - oznaczam zapytanie jako nieważne (invalidate).'); - queryClient.invalidateQueries({ queryKey: feedKey }); - return oldData; - } - - return { - ...oldData, - items: [newNotification, ...oldData.items].slice(0, 10), - totalCount: oldData.totalCount + 1, - }; - }); + const url = `${API_BASE_URL}${ENDPOINTS.NOTIFICATIONS.STREAM}`; + + const disconnect = connectSSE({ + url, + onOpen: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount().queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(true, 0).queryKey }); + queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed(false, 0).queryKey }); + }, + onMessage: (ev) => { + if (ev.event === 'NOTIFICATION') { + const newNotification: NotificationResponse = JSON.parse(ev.data); + + queryClient.setQueryData(queryKeys.notifications.unreadCount().queryKey, (old) => (old || 0) + 1); + + [true, false].forEach(unreadOnly => { + const feedKey = queryKeys.notifications.feed(unreadOnly, 0).queryKey; + queryClient.setQueryData>(feedKey, (oldData) => { + if (!oldData) { + queryClient.invalidateQueries({ queryKey: feedKey }); + return oldData; + } + + return { + ...oldData, + items: [newNotification, ...oldData.items].slice(0, 10), + totalCount: oldData.totalCount + 1, + }; }); - } - }, - - onerror(err) { - console.error('❌ [SSE] Błąd strumienia:', err); - throw err; // Zatrzymuje nieskończoną pętlę odnawiania w fetch-event-source + }); } - }); - }; - - connectStream(); + } + }); - return () => controller.abort(); + return () => disconnect(); }, [queryClient]); }; \ No newline at end of file