diff --git a/src/api/index.ts b/src/api/index.ts index 7a1a2512..2e4b73ec 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,7 @@ import { authServiceRemote, followerServiceRemote, groupServiceRemote, + notificationServiceRemote, userServiceRemote, } from './service'; @@ -9,12 +10,14 @@ const provideAPIService = () => { const userService = userServiceRemote(); const authService = authServiceRemote(); const followerService = followerServiceRemote(); + const notificationService = notificationServiceRemote(); const groupService = groupServiceRemote(); return { userService, authService, followerService, + notificationService, groupService, }; }; diff --git a/src/api/service/index.ts b/src/api/service/index.ts index 58ae555f..e6ff7a26 100644 --- a/src/api/service/index.ts +++ b/src/api/service/index.ts @@ -1,4 +1,5 @@ export * from './auth-service'; export * from './follower-service'; export * from './group-service'; +export * from './notification-service'; export * from './user-service'; diff --git a/src/api/service/notification-service/index.ts b/src/api/service/notification-service/index.ts new file mode 100644 index 00000000..86dda9bc --- /dev/null +++ b/src/api/service/notification-service/index.ts @@ -0,0 +1,26 @@ +import { apiV1 } from '@/api/core'; +import { GetNotificationListQueryParams, NotificationList } from '@/types/service/notification'; + +export const notificationServiceRemote = () => ({ + updateRead: async (notificationId: number) => { + await apiV1.post(`/notifications/${notificationId}/read`); + }, + + updateReadAll: async () => { + await apiV1.post(`/notifications/read-all`); + }, + + getList: async (queryParams: GetNotificationListQueryParams) => { + return await apiV1.get(`/notifications`, { + params: { ...queryParams }, + }); + }, + + getUnreadCount: async () => { + try { + return await apiV1.get(`/notifications/unread-count`); + } catch { + return 0; + } + }, +}); diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts deleted file mode 100644 index dc72d83b..00000000 --- a/src/app/api/notifications/stream/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest } from 'next/server'; - -import { notificationMockItems } from '@/mock/service/notification/notification-mock'; - -export const GET = async (req: NextRequest) => { - const stream = new ReadableStream({ - start(controller) { - let index = 0; - - const intervalId = setInterval(() => { - if (index < notificationMockItems.length) { - const data = JSON.stringify(notificationMockItems[index]); - // \n\n\ : SSE 메시지 종료를 의미하는 구분자(두줄 바꿈) - controller.enqueue(`data: ${data}\n\n`); - index++; - } else { - clearInterval(intervalId); - controller.close(); - } - }, 0); - - req.signal.addEventListener('abort', () => { - clearInterval(intervalId); - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }); -}; diff --git a/src/app/notification/page.tsx b/src/app/notification/page.tsx index 82d51d25..200cfe78 100644 --- a/src/app/notification/page.tsx +++ b/src/app/notification/page.tsx @@ -1,16 +1,25 @@ 'use client'; import { NotificationCard } from '@/components/pages/notification'; -import { useNotifications } from '@/hooks/use-notifications'; +import { useGetNotificationsInfinite } from '@/hooks/use-notification/use-notification-get-list'; export default function NotificationPage() { - const messages = useNotifications(); + const { data: notificationList, fetchNextPage } = useGetNotificationsInfinite({ size: 1 }); + + if (!notificationList) return; return (
- {messages.map((data, idx) => ( - +
+

v

+

모두 읽음 처리

+
+ {notificationList.map((item, idx) => ( + ))} +
); } diff --git a/src/components/pages/notification/notification-card/index.tsx b/src/components/pages/notification/notification-card/index.tsx index 4f26bcd8..ca12e7f9 100644 --- a/src/components/pages/notification/notification-card/index.tsx +++ b/src/components/pages/notification/notification-card/index.tsx @@ -1,23 +1,41 @@ -import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Icon } from '@/components/icon'; +import { useUpdateNotificationRead } from '@/hooks/use-notification/use-notification-update-read'; import { formatTimeAgo } from '@/lib/formatDateTime'; -import { Notification, NotificationType } from '@/types/service/notification'; +import { cn } from '@/lib/utils'; +import { NotificationItem, NotificationType } from '@/types/service/notification'; interface Props { - data: Notification; + item: NotificationItem; } -export const NotificationCard = ({ data }: Props) => { - const NotificationIcon = IconMap[data.type]; - const title = getTitle(data); - const description = getDescription(data); - const route = getRoute(data); - const routeCaption = getRouteCaption(data); - const timeAgo = getTimeAgo(data); +export const NotificationCard = ({ item }: Props) => { + const router = useRouter(); + const { mutateAsync } = useUpdateNotificationRead(); + + const NotificationIcon = IconMap[item.type]; + const title = getTitle(item); + const description = getDescription(item); + const timeAgo = getTimeAgo(item); + + const handleNotificationClick = () => { + try { + mutateAsync(item.id); + + router.push(`${item.redirectUrl}`); + } catch {} + }; + return ( -
-
+
+
{NotificationIcon}
@@ -26,85 +44,50 @@ export const NotificationCard = ({ data }: Props) => { {timeAgo}

{description}

- {route && ( - - {routeCaption} - - )}
); }; const IconMap: Record = { - follow: , - 'group-create': , - 'group-delete': , - 'group-join': , - 'group-leave': , + FOLLOW: , + CREATE: , + CANCLE: , + ENTER: , + EXIT: , }; -const getTitle = (data: Notification) => { +const getTitle = (data: NotificationItem) => { switch (data.type) { - case 'follow': + case 'FOLLOW': return `새 팔로워`; - case 'group-create': + case 'CREATE': return `모임 생성`; - case 'group-delete': + case 'CANCLE': return `모임 취소`; - case 'group-join': + case 'ENTER': return `모임 현황`; - case 'group-leave': + case 'EXIT': return `모임 현황`; } }; -const getDescription = (data: Notification) => { - switch (data.type) { - case 'follow': - return `${data.user.nickName} 님이 팔로우 했습니다.`; - case 'group-create': - return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 생성했습니다.`; - case 'group-delete': - return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 취소했습니다.`; - case 'group-join': - return `${data.user.nickName} 님이 "${data.group?.title}" 모임에 참가했습니다.`; - case 'group-leave': - return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 떠났습니다.`; - } -}; - -const getRoute = (data: Notification) => { - switch (data.type) { - case 'follow': - return `/profile/${data.user.userId}`; - case 'group-create': - return `/profile/${data.user.userId}`; - case 'group-delete': - return ``; - case 'group-join': - return `/meetup/${data.group?.id}`; - case 'group-leave': - return ``; - } -}; - -const getRouteCaption = (data: Notification) => { +const getDescription = (data: NotificationItem) => { switch (data.type) { - case 'follow': - return `프로필 바로가기`; - case 'group-create': - return `프로필 바로가기`; - case 'group-delete': - return ``; - case 'group-join': - return `모임 바로가기`; - case 'group-leave': - return ``; + case 'FOLLOW': + return `${data.actorNickname} 님이 팔로우 했습니다.`; + case 'CREATE': + return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 생성했습니다.`; + case 'CANCLE': + return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 취소했습니다.`; + case 'ENTER': + return `${data.actorNickname} 님이 "${data.actorNickname}" 모임에 참가했습니다.`; + case 'EXIT': + return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 떠났습니다.`; } }; -const getTimeAgo = (data: Notification) => { +const getTimeAgo = (data: NotificationItem) => { const { createdAt } = data; return formatTimeAgo(createdAt); }; diff --git a/src/hooks/use-notification/index.ts b/src/hooks/use-notification/index.ts new file mode 100644 index 00000000..5e826e13 --- /dev/null +++ b/src/hooks/use-notification/index.ts @@ -0,0 +1,4 @@ +export { useGetNotificationsInfinite } from './use-notification-get-list'; +export { useGetNotificationUnreadCount } from './use-notification-get-unread-count'; +export { useUpdateNotificationRead } from './use-notification-update-read'; +export { useUpdateNotificationReadAll } from './use-notification-update-read-all'; diff --git a/src/hooks/use-notification/use-notification-get-list/index.ts b/src/hooks/use-notification/use-notification-get-list/index.ts new file mode 100644 index 00000000..f6e57b5d --- /dev/null +++ b/src/hooks/use-notification/use-notification-get-list/index.ts @@ -0,0 +1,15 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { notificationKeys } from '@/lib/query-key/query-key-notification'; +import { GetNotificationListQueryParams } from '@/types/service/notification'; + +export const useGetNotificationsInfinite = (params?: GetNotificationListQueryParams) => { + return useInfiniteQuery({ + queryKey: notificationKeys.list(params), + queryFn: ({ pageParam }) => API.notificationService.getList({ ...params, cursor: pageParam }), + initialPageParam: params?.cursor, + getNextPageParam: (lastPage) => lastPage.nextCursor, + select: (data) => data.pages.flatMap((page) => page.notifications) || [], + }); +}; diff --git a/src/hooks/use-notification/use-notification-get-unread-count/index.ts b/src/hooks/use-notification/use-notification-get-unread-count/index.ts new file mode 100644 index 00000000..48e0c7b6 --- /dev/null +++ b/src/hooks/use-notification/use-notification-get-unread-count/index.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { notificationKeys } from '@/lib/query-key/query-key-notification'; + +export const useGetNotificationUnreadCount = () => { + const isAuthenticated = typeof window !== 'undefined' && document.cookie.includes('accessToken'); + + return useQuery({ + queryKey: notificationKeys.unReadCount(), + queryFn: () => API.notificationService.getUnreadCount(), + retry: false, // 재시도 안 함 + enabled: isAuthenticated, + }); +}; diff --git a/src/hooks/use-notification/use-notification-update-read-all/index.ts b/src/hooks/use-notification/use-notification-update-read-all/index.ts new file mode 100644 index 00000000..0cf630c8 --- /dev/null +++ b/src/hooks/use-notification/use-notification-update-read-all/index.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { notificationKeys } from '@/lib/query-key/query-key-notification'; + +export const useUpdateNotificationReadAll = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => API.notificationService.updateReadAll(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.list() }); + queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() }); + }, + }); +}; diff --git a/src/hooks/use-notification/use-notification-update-read/index.ts b/src/hooks/use-notification/use-notification-update-read/index.ts new file mode 100644 index 00000000..eae56d7f --- /dev/null +++ b/src/hooks/use-notification/use-notification-update-read/index.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { notificationKeys } from '@/lib/query-key/query-key-notification'; + +export const useUpdateNotificationRead = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (notificationId: number) => API.notificationService.updateRead(notificationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.list() }); + queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() }); + }, + }); +}; diff --git a/src/hooks/use-notifications/index.ts b/src/hooks/use-notifications/index.ts deleted file mode 100644 index 649cbe0f..00000000 --- a/src/hooks/use-notifications/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; - -import { Notification } from '@/types/service/notification'; - -export const useNotifications = () => { - const [messages, setMessages] = useState([]); - - useEffect(() => { - const eventSource = new EventSource('/api/notifications/stream'); - - eventSource.onmessage = (event) => { - const data: Notification = JSON.parse(event.data); - setMessages((prev) => [...prev, data]); - }; - - eventSource.onerror = (err) => { - console.error('SSE 에러', err); - eventSource.close(); - }; - - return () => { - eventSource.close(); - }; - }, []); - - return messages; -}; diff --git a/src/lib/query-key/query-key-notification/index.ts b/src/lib/query-key/query-key-notification/index.ts new file mode 100644 index 00000000..1bb11c88 --- /dev/null +++ b/src/lib/query-key/query-key-notification/index.ts @@ -0,0 +1,11 @@ +import { GetNotificationListQueryParams } from '@/types/service/notification'; + +export const notificationKeys = { + all: ['notifications'] as const, + list: (params?: GetNotificationListQueryParams) => [ + 'notifications', + 'list', + ...(params ? [params] : []), + ], + unReadCount: () => [...notificationKeys.all, 'unread-count'], +}; diff --git a/src/mock/service/notification/notification-mock.ts b/src/mock/service/notification/notification-mock.ts deleted file mode 100644 index c0df29f5..00000000 --- a/src/mock/service/notification/notification-mock.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Notification } from '@/types/service/notification'; - -import { groupMockItem } from '../group/group-mock'; -import { mockUserItems } from '../user/user-mock'; - -export const notificationMockItems: Notification[] = [ - { - type: 'follow', - user: mockUserItems[1], - group: groupMockItem[0], - createdAt: '2025-12-08T17:00:00+09:00', - }, - { - type: 'group-create', - user: mockUserItems[1], - group: groupMockItem[0], - createdAt: '2025-12-08T17:00:00+09:00', - }, - { - type: 'group-delete', - user: mockUserItems[1], - group: groupMockItem[0], - createdAt: '2025-12-08T21:00:00+09:00', - }, - { - type: 'group-join', - user: mockUserItems[2], - group: groupMockItem[0], - createdAt: '2025-12-08T21:00:00+09:00', - }, - { - type: 'group-leave', - user: mockUserItems[2], - group: groupMockItem[0], - createdAt: '2025-12-08T21:00:00+09:00', - }, -]; diff --git a/src/types/service/notification.ts b/src/types/service/notification.ts index b5cb1d97..c76a2fc9 100644 --- a/src/types/service/notification.ts +++ b/src/types/service/notification.ts @@ -1,16 +1,25 @@ -import { Group } from './group'; -import { User } from './user'; +export type NotificationType = 'FOLLOW' | 'ENTER' | 'EXIT' | 'CREATE' | 'CANCLE'; -export type NotificationType = - | 'follow' - | 'group-join' - | 'group-leave' - | 'group-create' - | 'group-delete'; - -export interface Notification { +export interface NotificationItem { + id: number; + receiverId: number; + actorId: number; + actorNickname: string; + actorProfileImage: string; type: NotificationType; - user: User; - group?: Group; + readAt: string | null; + relatedId: number; + relatedType: NotificationType; + redirectUrl: string; createdAt: string; } + +export interface NotificationList { + notifications: NotificationItem[]; + nextCursor: number | null; +} + +export interface GetNotificationListQueryParams { + cursor?: number; + size?: number; +}