임동욱 임동욱 09-16
feat: 알림을 누르면 읽음 처리한다
PUT /api/v1/common/ntcn/{ntcnSn}을 규약대로 붙인다. 누르는 즉시 낙관적으로 읽음 표시
(표시선·배지 감소)를 하고 이동한 뒤 router.refresh()로 서버 목록에 되맞춘다.

백엔드 컨트롤러가 아직 경로변수 ntcnSn과 readYn을 VO에 담지 않아 어느 행도 갱신되지
않고 200만 돌아온다 — 그래서 지금은 새로 고치면 안 읽음으로 돌아온다. 백엔드가
setNtcnSn(ntcnSn)·setReadYn("Y") 두 줄을 채우면 프론트 수정 없이 동작한다.

Co-Authored-By: Claude Opus 5 
@df7e5a7716c5d3d72a8ace55c88509ed5375384c
 
app/(protected)/(basic)/_actions.ts (added)
+++ app/(protected)/(basic)/_actions.ts
@@ -0,0 +1,34 @@
+'use server';
+
+import { unstable_rethrow } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { markNotificationRead } from '@/lib/data/repositories/notification-repository';
+
+export type NotificationActionResult =
+  | { status: 'success' }
+  | { status: 'error'; message: string };
+
+export async function markNotificationReadAction(
+  id: string
+): Promise<NotificationActionResult> {
+  await verifySession();
+
+  if (!id) {
+    return { status: 'error', message: '요청이 올바르지 않습니다.' };
+  }
+
+  try {
+    await markNotificationRead(id);
+    return { status: 'success' };
+  } catch (error) {
+    // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다.
+    unstable_rethrow(error);
+    return {
+      status: 'error',
+      message:
+        error instanceof Error && error.message
+          ? error.message
+          : '알림을 읽음 처리하지 못했습니다.',
+    };
+  }
+}
app/(protected)/(basic)/_components/admin-header.tsx
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
@@ -2,8 +2,10 @@
 
 import Image from 'next/image';
 import Link from 'next/link';
-import { useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { startTransition, useEffect, useOptimistic, useRef, useState } from 'react';
 import { logout } from '@/app/(protected)/_actions';
+import { markNotificationReadAction } from '@/app/(protected)/(basic)/_actions';
 import {
   countUnread,
   resolveNotificationHref,
@@ -33,15 +35,33 @@
  *  - 검색·사용자 아이콘 버튼을 뺐다 — 연결할 기능이 없어 눌러도 아무 일이 없었고, 사용자
  *    자리는 옆 프로필 pill과 겹쳤다.
  *
- * 남은 알림 버튼도 연결할 기능이 아직 없어 `onAction` 없이 둔다 — 시안의 모양을 유지하려고
- * 비활성으로 만들지는 않았다.
+ * 이슈: 알림 읽음은 낙관적으로 표시한 뒤 `router.refresh()`로 서버 값에 되맞춘다 — 백엔드
+ * PUT이 아직 행을 갱신하지 못해(notification-repository 참조) 새로 고치면 안 읽음으로 돌아온다.
  */
 export function AdminHeader({ adminName, notifications }: AdminHeaderProps) {
+  const router = useRouter();
   const [isNotificationOpen, setIsNotificationOpen] = useState(false);
   const notificationRef = useRef<HTMLDivElement>(null);
-  const unreadCount = countUnread(notifications);
+  const [optimisticNotifications, markReadOptimistically] = useOptimistic(
+    notifications,
+    (current, id: string) =>
+      current.map((item) => (item.id === id ? { ...item, isRead: true } : item))
+  );
+  const unreadCount = countUnread(optimisticNotifications);
   const [isMenuOpen, setIsMenuOpen] = useState(false);
   const profileRef = useRef<HTMLDivElement>(null);
+
+  function openNotification(item: AdminNotification) {
+    setIsNotificationOpen(false);
+    if (item.isRead) {
+      return;
+    }
+    startTransition(async () => {
+      markReadOptimistically(item.id);
+      await markNotificationReadAction(item.id);
+      router.refresh();
+    });
+  }
 
   // 바깥을 누르거나 Esc를 누르면 닫는다 — 열린 동안에만 듣는다. 프로필·알림이 같은 규칙이라
   // 한 곳에서 처리하고, 하나를 열면 다른 하나는 닫는다.
@@ -108,11 +128,11 @@
 
           {isNotificationOpen && (
             <div className={styles.notifications} role="menu">
-              {notifications.length === 0 ? (
+              {optimisticNotifications.length === 0 ? (
                 <p className={styles.notificationEmpty}>새 알림이 없습니다.</p>
               ) : (
                 <ul className={styles.notificationList}>
-                  {notifications.map((item) => {
+                  {optimisticNotifications.map((item) => {
                     const href = resolveNotificationHref(item);
                     const className = `${styles.notification} ${
                       item.isRead ? '' : styles.notificationUnread
@@ -134,7 +154,7 @@
                             href={href}
                             role="menuitem"
                             className={`${className} ${styles.notificationLink}`}
-                            onClick={() => setIsNotificationOpen(false)}
+                            onClick={() => openNotification(item)}
                           >
                             {body}
                           </Link>
lib/data/repositories/notification-repository.ts
--- lib/data/repositories/notification-repository.ts
+++ lib/data/repositories/notification-repository.ts
@@ -7,7 +7,8 @@
  * 관리자 알림 Repository.
  *
  * ```
- * GET /api/v1/common/ntcn/list   로그인한 사용자의 알림 전체(등록일시 최신순)
+ * GET /api/v1/common/ntcn/list       로그인한 사용자의 알림 전체(등록일시 최신순)
+ * PUT /api/v1/common/ntcn/{ntcnSn}   읽음 처리
  * ```
  *
  * 관리자 전용(`mngr`)이 아니라 공통 경로다 — 토큰의 주인이 누구인지로 대상이 갈리고,
@@ -16,14 +17,17 @@
  * 지금 적재되는 알림은 「학습자가 1:1문의를 등록했다」 하나다(`CmmBbsServiceImpl`이 등록 시
  * 관리자 전원에게 한 건씩 넣는다).
  *
- * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 붙이지 않았다 — 백엔드 컨트롤러가 경로변수 `ntcnSn`도
- * `readYn`도 VO에 담지 않아 어느 행도 갱신되지 않는다(보고함). 고쳐지면 여기에 함수를 더한다.
+ * ⚠️ 읽음 처리(`PUT /{ntcnSn}`)는 **아직 아무것도 바꾸지 못한다** — 백엔드 컨트롤러가
+ * 경로변수 `ntcnSn`도 `readYn`도 VO에 담지 않아 `WHERE ntcn_sn = NULL`로 어느 행도 갱신되지
+ * 않고 200만 돌아온다(보고함). 호출은 규약대로 붙여 두었으니 백엔드가 두 줄
+ * (`setNtcnSn(ntcnSn)`·`setReadYn("Y")`)을 채우면 프론트 수정 없이 동작한다.
  *
  * 페이징이 없다. 목록 SQL에 LIMIT이 없어 전체가 온다 — 알림이 쌓이면 백엔드에 상한을
  * 요청해야 한다.
  */
 
-const NOTIFICATION_LIST_PATH = '/api/v1/common/ntcn/list';
+const NOTIFICATION_PATH = '/api/v1/common/ntcn';
+const NOTIFICATION_LIST_PATH = `${NOTIFICATION_PATH}/list`;
 
 function isRecord(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === 'object';
@@ -92,3 +96,21 @@
     return [];
   }
 }
+
+export async function markNotificationRead(id: string): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(
+    `${NOTIFICATION_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'PUT',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
Add a comment
List