diff --git a/public/sw.js b/public/sw.js index eda53e11..ac7984e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -24,7 +24,7 @@ self.addEventListener("push", (event) => { includeUncontrolled: true, }); clients.forEach((client) => { - client.postMessage({ type: "PUSH_RECEIVED" }); + client.postMessage({ type: "PUSH_RECEIVED", payload }); }); })(), ); diff --git a/src/hooks/customQuery.ts b/src/hooks/customQuery.ts index 13df81a5..558fb81f 100644 --- a/src/hooks/customQuery.ts +++ b/src/hooks/customQuery.ts @@ -92,10 +92,13 @@ export function useCoreMutation< onError: (error, vars, ctx) => { if (optimisticUpdate && ctx?.prevData !== undefined) { - queryClient.setQueryData( - optimisticUpdate.key, - ctx.prevData as TCache, - ); + queryClient.setQueryData(optimisticUpdate.key, ctx?.prevData); + } + // invalidateKeys가 존재하면 해당 키들을 무효화하여 데이터를 다시 가져오도록 함 + if (invalidateKeys?.length) { + invalidateKeys.forEach((key) => { + void queryClient.invalidateQueries({ queryKey: key }); + }); } userOnError?.(error, vars, ctx); }, diff --git a/src/hooks/notification/useNotificationRead.ts b/src/hooks/notification/useNotificationRead.ts index c516d1f2..d1cfdfc1 100644 --- a/src/hooks/notification/useNotificationRead.ts +++ b/src/hooks/notification/useNotificationRead.ts @@ -3,6 +3,12 @@ import { toast } from "sonner"; import type { IApiErrorResponse } from "@/types/common/common"; import type { TNotificationEmptyData } from "@/types/setting/notification"; +import { + markAllNotificationRead, + markNotificationRead, + type TNotificationHistoryCache, +} from "@/utils/notification/historyCache"; + import { useCoreMutation } from "@/hooks/customQuery"; import { @@ -15,7 +21,13 @@ import useWorkspaceStore from "@/store/useWorkspaceStore"; export function useReadNotification() { const orgId = useWorkspaceStore((s) => s.selectedOrgId); - return useCoreMutation( + return useCoreMutation< + TNotificationEmptyData, + number, + IApiErrorResponse, + { prevData?: unknown }, + TNotificationHistoryCache + >( (useNotificationId: number) => { if (orgId == null) { return Promise.reject(new Error("워크스페이스를 선택해주세요")); @@ -23,6 +35,20 @@ export function useReadNotification() { return readNotificationHistory(orgId, useNotificationId); }, { + // 작동예시 + // mutate(5) 호출 + // 아직 서버 응답 전 updateFn이 캐시 id 5번을 isRead: true로 변경 -> 바로 뱃지 -1 + // 동시에 PATCH 요청 + // 실패하면 4번칸의 prevData로 캐시 복구 -> 뱃지 +1 (즉,원상복구) + // 성공하면 history 다시 GET해서 서버랑 맞춤 + optimisticUpdate: + orgId != null + ? { + key: QUERY_KEYS.notification.history(orgId), + updateFn: (old, userNotificationId) => + markNotificationRead(old, userNotificationId), + } + : undefined, invalidateKeys: orgId != null ? [QUERY_KEYS.notification.history(orgId)] : [], userOnError: (error) => { @@ -38,7 +64,13 @@ export function useReadNotification() { export function useAllReadNotifications() { const orgId = useWorkspaceStore((s) => s.selectedOrgId); - return useCoreMutation( + return useCoreMutation< + TNotificationEmptyData, + void, + IApiErrorResponse, + { prevData?: unknown }, + TNotificationHistoryCache + >( () => { if (orgId == null) { return Promise.reject(new Error("워크스페이스를 선택해주세요")); @@ -46,6 +78,13 @@ export function useAllReadNotifications() { return readAllNotificationHistory(orgId); }, { + optimisticUpdate: + orgId != null + ? { + key: QUERY_KEYS.notification.history(orgId), + updateFn: (old) => markAllNotificationRead(old), + } + : undefined, invalidateKeys: orgId != null ? [QUERY_KEYS.notification.history(orgId)] : [], userOnError: (error) => { diff --git a/src/hooks/notification/usePushNotificationRuntime.ts b/src/hooks/notification/usePushNotificationRuntime.ts index 72b0d6e6..36758c5e 100644 --- a/src/hooks/notification/usePushNotificationRuntime.ts +++ b/src/hooks/notification/usePushNotificationRuntime.ts @@ -1,6 +1,13 @@ import { useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import type { TNotificationType } from "@/types/notification/notification"; +import type { IPushReceivedMessage } from "@/types/notification/push"; + +import { + prependNotification, + type TNotificationHistoryCache, +} from "@/utils/notification/historyCache"; import { registerPushServiceWorker, syncPushSubscription, @@ -8,8 +15,25 @@ import { import { useMyNotificationSettings } from "@/hooks/setting/useMyNotificationSettings"; +import { QUERY_KEYS } from "@/lib/queryKeys"; import useWorkspaceStore from "@/store/useWorkspaceStore"; +const NOTIFICATION_TYPES: TNotificationType[] = [ + "BOT_CLICKS", + "CLICKS_INCREASE", + "REPORT", +]; + +function toNotificationType(value: unknown): TNotificationType { + if ( + typeof value === "string" && + NOTIFICATION_TYPES.includes(value as TNotificationType) + ) { + return value as TNotificationType; + } + return "REPORT"; +} + export function usePushNotificationRuntime() { const orgId = useWorkspaceStore((s) => s.selectedOrgId); const { data: settings } = useMyNotificationSettings(); @@ -23,10 +47,26 @@ export function usePushNotificationRuntime() { // 이 브라우저 객체에 serviceWorker라는 기능이 없으면 그만둠 / 있으면 이 브라우저는 SW 지원 if (!("serviceWorker" in navigator)) return; - const onMessage = (event: MessageEvent) => { + const onMessage = (event: MessageEvent) => { if (event.data?.type !== "PUSH_RECEIVED") return; + if (orgId == null) return; + + const payload = event.data.payload; + if (payload?.orgId != null && payload.orgId !== orgId) return; + + const historyKey = QUERY_KEYS.notification.history(orgId); + queryClient.setQueryData(historyKey, (old) => + prependNotification(old, { + userNotificationId: payload?.userNotificationId ?? -Date.now(), + title: payload?.title ?? "알림", + message: payload?.message ?? payload?.body ?? "", + createdAt: new Date().toISOString(), + type: toNotificationType(payload?.type), + isRead: false, + }), + ); void queryClient.invalidateQueries({ - queryKey: ["notification", "history"], + queryKey: historyKey, }); }; @@ -35,7 +75,7 @@ export function usePushNotificationRuntime() { return () => { navigator.serviceWorker.removeEventListener("message", onMessage); }; - }, [queryClient]); + }, [orgId, queryClient]); useEffect(() => { if (orgId == null) return; diff --git a/src/types/notification/push.ts b/src/types/notification/push.ts index 62d6b4dd..b75bfd36 100644 --- a/src/types/notification/push.ts +++ b/src/types/notification/push.ts @@ -1,3 +1,5 @@ +import type { TNotificationType } from "@/types/notification/notification"; + export interface IVapidPublicKeyData { publicKey: string; } @@ -21,3 +23,20 @@ export interface IPushSubscriptionRequest { export interface IDeletePushSubscriptionReqest { endpoint: string; } + +// payload를 받았을때, 앱도 어떤 거 왔는지 알게 한 뒤, 앱에서 처리할 수 있도록 하는 인터페이스. +// 기존에는 payload 왔다만 알고, 앱에서는 처리할 수 없어서 실시간 알림+1을 처리할 수 없었음. +// 그래서 payload를 받았을때, 브라우저도 어떤 알림이 왔는지 알 수 있도록 처리. +export interface IPushReceivedPayload { + title?: string; + body?: string; + message?: string; + type?: TNotificationType; + userNotificationId?: number; + orgId?: number; +} + +export interface IPushReceivedMessage { + type: "PUSH_RECEIVED"; + payload?: IPushReceivedPayload; +} diff --git a/src/utils/notification/historyCache.ts b/src/utils/notification/historyCache.ts new file mode 100644 index 00000000..a006fc20 --- /dev/null +++ b/src/utils/notification/historyCache.ts @@ -0,0 +1,92 @@ +import type { InfiniteData } from "@tanstack/react-query"; + +import type { + INotificationHistoryData, + INotificationHistoryItem, +} from "@/types/notification/notification"; + +export type TNotificationHistoryCache = InfiniteData< + INotificationHistoryData, + string | null +>; + +export function emptyHistoryCache(): TNotificationHistoryCache { + return { pages: [], pageParams: [] }; +} + +//알림 단건 읽음 처리 +export function markNotificationRead( + cache: TNotificationHistoryCache | undefined, + userNotificationId: number, +): TNotificationHistoryCache { + if (!cache) return emptyHistoryCache(); + + return { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + notifications: page.notifications.map((item) => + item.userNotificationId === userNotificationId + ? { ...item, isRead: true } + : item, + ), + })), + }; +} + +//모두 읽음 처리 +export function markAllNotificationRead( + cache: TNotificationHistoryCache | undefined, +): TNotificationHistoryCache { + if (!cache) return emptyHistoryCache(); + + return { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + notifications: page.notifications.map((item) => ({ + ...item, + isRead: true, + })), + })), + }; +} + +//새알림 맨 위로 붙이기 +export function prependNotification( + cache: TNotificationHistoryCache | undefined, + item: INotificationHistoryItem, +): TNotificationHistoryCache { + // 캐시가 없거나 페이지 0장이면 새로운 페이지 만들어서 알림 추가 + if (!cache || cache.pages.length === 0) { + return { + pages: [{ hasNext: false, nextCursor: null, notifications: [item] }], + pageParams: [null], + }; + } + + // 이미 페이지가 있으면 페이지 맨위에 알림 추가 + const alreadyExists = cache.pages.some((page) => + page.notifications.some( + (n) => n.userNotificationId === item.userNotificationId, + ), + ); + // 이미 있는 알림이면 캐시 그대로 반환 + if (alreadyExists) return cache; + + //firstPage = 1페이지 (최신알람들 있는곳) + //restPages = 2페이지부터 나머지 다 + const [firstPage, ...restPages] = cache.pages; + const [firstParam, ...restParams] = cache.pageParams; + + return { + pages: [ + { + ...firstPage, + notifications: [item, ...firstPage.notifications], //[새알림, ...예전1페이지알림들] + }, + ...restPages, + ], + pageParams: [firstParam, ...restParams], + }; +}