Skip to content

#88 Feat: 알림 API 연동#102

Merged
yiyoonseo merged 10 commits into
developfrom
feature/#88-notification_api-feat
Jul 24, 2026
Merged

#88 Feat: 알림 API 연동#102
yiyoonseo merged 10 commits into
developfrom
feature/#88-notification_api-feat

Conversation

@yiyoonseo

@yiyoonseo yiyoonseo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🔗 관련 이슈

#88

📝 개요

  • 알림 API 연동
  • 병합 충돌 해결 및 자소서 작성 페이지 오류 해결

⌨️ 작업 상세 내용

1. SSE(Server-Sent Events) 기반 실시간 알림 연동

  • 실시간 데이터 수신: 서버와의 SSE 연결을 통해 새로운 알림(분석 성공/실패, 공고 업데이트 등)을 폴링(Polling) 없이 실시간으로 수신하도록 구현했습니다.
  • 즉각적인 UI 반영: 새로운 이벤트가 들어오면 즉시 전역 상태(또는 알림 리스트 상태)를 업데이트하여 뱃지(빨간 점)와 패널 목록에 실시간으로 반영되도록 파이프라인을 연결했습니다.

2. 알림 클릭 시 라우팅 버그 수정 및 예외 처리

  • mockApplyId 추출 로직 수정: 알림 클릭 시 엉뚱한 라우팅이 발생하던 원인(targetId 혼용)을 제거하고, 오직 payload.mockApplyId만을 엄격하게 추출하여 사용하도록 mapApiToLnbItem 매핑 함수를 개선했습니다.
  • 안전한 라우팅 분기: apiType에 따라 정상적으로 결과 페이지 및 공고 페이지로 이동하도록 switch-case문을 정교하게 구성했습니다.
  • 예외 처리(Fallback): 페이로드에 mockApplyId가 없는 예외적인 알림(일반 공지 등)의 경우, 에러 없이 읽음 처리만 수행하고 앱이 중단되지 않도록 방어 로직을 추가했습니다.

3. '모두 읽음' 기능의 낙관적 업데이트(Optimistic Update) 적용

  • 프롭스 드릴링(Props Drilling) 해결: 최상위 부모 컴포넌트에서 정의한 onMarkAllRead 함수를 LnbLnbDefaultFooterLnbNotificationButtonLnbNotificationPanel 하위 뎁스까지 정상적으로 연결했습니다.
  • UX 개선: 사용자가 '모두 읽음 표시'를 클릭할 때, 서버 새로고침을 기다리지 않고 즉각적으로 뱃지(빨간 점)가 사라지고 알림 아이콘 상태가 회색(읽음)으로 변환되도록 개선했습니다.

4. 미확인 알림 최상단 정렬 (Sorting)

  • 정렬 로직 추가: 리스트를 렌더링하기 전, 아직 읽지 않은 알림(read: false)이 읽은 알림(read: true)보다 무조건 위로 올라오도록 .sort() 로직을 추가하여 사용자의 확인 편의성을 높였습니다.

5. 오래된 알림 자동 숨김 처리 (Filtering)

  • 7일 경과 알림 제외: 알림 목록이 지저분해지는 것을 방지하기 위해, '읽음(read: true)' 상태이면서 동시에 '읽은 시간(readAt)' 기준으로 7일(168시간)이 지난 알림은 목록에서 노출되지 않도록 filter 로직을 구현했습니다.
  • Hydration & Linter 에러 방지: Date.now()와 같은 Impure Function을 렌더링 중에 직접 호출하여 발생하는 Linter 에러 및 하이드레이션 이슈를 방지하기 위해, useState, useEffect(setTimeout), useMemo를 조합하여 클라이언트 사이드에서 안전하게 시간을 계산하도록 처리했습니다.

💡 코드 설명 및 참고사항

📸 스크린샷 (UI 변경 시)

🔍 리뷰 요구사항 (Reviewers)

  • [ ]

⚠️ 로컬 실행 시 유의사항

Summary by CodeRabbit

  • New Features

    • Added real-time notifications with unread indicators, read status, “mark all as read,” and notification-based navigation.
    • Added configurable labels and icons for mock application next-step actions.
    • Added a retry prompt when resume analysis fails.
    • Improved scrolling feedback with gradient indicators in notification and analysis panels.
  • Bug Fixes

    • Improved mock application question loading, deletion, and blank-question creation.
    • Updated modal behavior to prevent background scrolling while open.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds server-driven sidebar notifications with SSE updates and read actions, while refactoring mock-apply question handling, shared modal/CTA components, analysis failure UI, and scroll-gradient behavior.

Changes

Notification API integration

Layer / File(s) Summary
Notification API contracts and operations
jobdri/package.json, jobdri/src/lib/api/notification.ts
Adds notification types, authenticated fetching, SSE subscription, and single/all read endpoints.
Sidebar notification state and wiring
jobdri/src/components/common/lnb/*
Loads and maps notifications, tracks unread state, subscribes to updates, and forwards read handlers through the sidebar.
Notification mapping, actions, and scrolling
jobdri/src/components/common/lnb/LnbNotification.tsx, jobdri/src/hooks/useScrollGradient.ts, jobdri/src/constants/notificationIconStyles.ts
Adds API-to-sidebar mapping, read/navigation actions, filtering, sorting, scroll gradients, and icon styles.
Sidebar import and layout integration
jobdri/src/app/page.tsx
Switches to the default Lnb import and updates page stacking classes while preserving existing loading behavior.

Mock-apply UI and analysis flow

Layer / File(s) Summary
Shared mock-apply CTA and modal components
jobdri/src/components/common/modal/ModalOverlay.tsx, jobdri/src/components/common/MockApplyTemplate.tsx, jobdri/src/app/mockApply/[mockApplyId]/page.tsx
Adds shared modal overlay behavior and configurable next-action labels and icons.
Question loading, editing, and submission flow
jobdri/src/app/mockApply/[mockApplyId]/page.tsx
Updates selected-question loading, job-posting effects, question creation/deletion, layout wiring, and submission routing.
Analysis loading failure presentation
jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx, jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx
Adds failed-state overlay handling and preserves existing analysis loading and polling behavior.
Analysis result scrolling and formatting
jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx, jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx
Uses the shared scroll-gradient hook and applies formatting-only result-page changes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Lnb
  participant NotificationApi
  participant NotificationStream
  Lnb->>NotificationApi: Fetch current notifications
  NotificationApi-->>Lnb: Return notification list
  Lnb->>NotificationApi: Subscribe to notification stream
  NotificationApi->>NotificationStream: Open SSE connection
  NotificationStream-->>Lnb: Deliver new notification
  Lnb->>NotificationApi: Mark notification as read
  NotificationApi-->>Lnb: Complete read request
Loading

Possibly related PRs

Suggested labels: 🎨 UI, ⭐ Feature

Suggested reviewers: minnngo

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Several unrelated mockApply/result/error-page, modal, and export changes go beyond notification API integration. Move the unrelated mockApply/result/error-page, modal, and export refactors into a separate PR, or document them as part of #88.
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: notification API integration.
Linked Issues check ✅ Passed Notification API helpers, SSE stream, read/unread actions, and sidebar wiring implement #88's requested integration.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/#88-notification_api-feat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (4)
jobdri/src/hooks/useScrollGradient.ts (1)

20-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Spreading a dynamic-length deps array into useEffect's dependency list.

[checkScroll, ...deps] (Line 36) works today since the only call site passes a fixed single-element array, but React's hooks lint rule expects the dependency array to have the same number of elements on every render; a future call site with a variably-sized deps array would trip react-hooks/exhaustive-deps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/hooks/useScrollGradient.ts` around lines 20 - 36, The useEffect
dependency list in useScrollGradient currently spreads the dynamic-length deps
array, which can change its element count between renders. Replace this pattern
with a stable dependency strategy that preserves updates when deps values
change, without varying the number of entries in the hook dependency array.
jobdri/src/components/common/lnb/Lnb.tsx (1)

102-171: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove large commented-out legacy implementation.

Lines 102-171 contain two full commented-out useEffect blocks (old fetchMyMockApplies-based recent items loader and the old raw fetch/EventSource notification loader) superseded by the working code below (Lines 173-205). Dead code adds noise and risk of accidental reactivation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/Lnb.tsx` around lines 102 - 171, Remove the
two obsolete commented-out useEffect blocks containing the old
fetchMyMockApplies recent-items loader and raw fetch/EventSource notification
loader. Keep the active implementation beginning below them unchanged.
jobdri/src/components/common/lnb/LnbNotification.tsx (1)

414-430: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove debug logging before release.

console.log("클릭한 알림 전체 데이터:", notificationItem) and the accompanying console.warn dump the full notification payload (title/description/target ids) on every click — leftover debug artifacts (marked with 🚨/⚠️ emoji) that should be cleaned up.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/LnbNotification.tsx` around lines 414 - 430,
Remove the debug console.log and console.warn calls from
handleNotificationClick, including the full notificationItem payload dumps and
emoji-marked messages. Preserve the existing notification read handling and
router navigation behavior, including the missing mockApplyId fallback.
jobdri/src/hooks/useNotification.ts (1)

3-3: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Remove the unused useNotifications hook.

useNotifications is only exported from jobdri/src/hooks/useNotification.ts and has no imports/callers. It also still uses unauthenticated fetch, wrong wrapper shape, and native EventSource, while the live notification path in Lnb.tsx uses the new API helpers instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/hooks/useNotification.ts` at line 3, Remove the unused
useNotifications hook from useNotification.ts, including its export and any
hook-specific imports or supporting code that become unnecessary. Do not alter
the active notification implementation or the Lnb.tsx live notification path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@jobdri/src/app/mockApply/`[mockApplyId]/page.tsx:
- Around line 266-268: Update performDelete so that when the deleted targetId
matches the active selectedId, it also selects a remaining question after
filtering. Preserve the current selection when deleting another question, and
clear or safely reset the selection when no questions remain.

In `@jobdri/src/components/common/lnb/index.ts`:
- Around line 1-8: Restore the Lnb barrel export in the common LNB index so the
existing import in app/credit/page.tsx continues to resolve, or update that
import to reference the Lnb module directly; ensure Lnb remains available
through the chosen import path.

In `@jobdri/src/components/common/lnb/Lnb.tsx`:
- Around line 242-257: Update handleReadItem and handleMarkAllRead to set readAt
to the current timestamp whenever notifications are marked read, while
preserving their existing read-state and hasNotification updates. Ensure both
optimistic paths provide the timestamp required by LnbNotificationList’s
seven-day visibility filtering.
- Around line 173-205: Update the initial notification handling in the
useEffect’s loadInitialNotifications flow so its setNotificationItems update
merges fetched items with notifications already added by
subscribeToNotificationStream, preserving concurrently received SSE
notifications instead of replacing the current state. Keep the fetched
notification ordering and existing hasNotification calculation consistent with
the merged result.

In `@jobdri/src/components/common/lnb/LnbNotification.tsx`:
- Around line 128-149: Update the id mapping in mapApiToLnbItem to treat numeric
id 0 as a valid identifier, generating a UUID only when item.id is nullish or
otherwise absent. Preserve string conversion for all provided ids so
markNotificationAsRead uses the server-side identifier.

In `@jobdri/src/components/common/MockApplyTemplate.tsx`:
- Around line 47-48: Update the nextIconType prop defaults in
MockApplyTemplate’s destructuring at both referenced locations so omitted
callers receive the existing SPARKLE icon. Preserve explicitly provided icon
values and the current nextLabel behavior.

In `@jobdri/src/components/common/modal/ModalOverlay.tsx`:
- Around line 10-14: Update the useEffect in ModalOverlay to capture
document.body.style.overflow before setting it to "hidden", then restore that
captured value in the cleanup instead of forcing "unset", preserving any
pre-existing body overflow setting.

In `@jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx`:
- Around line 17-18: Update ResumeAnalysisLoading and its failure-confirmation
handler to invoke the optional onErrorConfirm callback when provided, falling
back to router.replace("/") only when it is absent; otherwise remove the unused
prop and related declarations.

In `@jobdri/src/lib/api/notification.ts`:
- Around line 60-82: Update subscribeToNotificationStream so fetchEventSource
uses the notifications stream endpoint without the accessToken query parameter;
retain the Authorization header from headers and remove the now-unused tokenOnly
extraction.

---

Nitpick comments:
In `@jobdri/src/components/common/lnb/Lnb.tsx`:
- Around line 102-171: Remove the two obsolete commented-out useEffect blocks
containing the old fetchMyMockApplies recent-items loader and raw
fetch/EventSource notification loader. Keep the active implementation beginning
below them unchanged.

In `@jobdri/src/components/common/lnb/LnbNotification.tsx`:
- Around line 414-430: Remove the debug console.log and console.warn calls from
handleNotificationClick, including the full notificationItem payload dumps and
emoji-marked messages. Preserve the existing notification read handling and
router navigation behavior, including the missing mockApplyId fallback.

In `@jobdri/src/hooks/useNotification.ts`:
- Line 3: Remove the unused useNotifications hook from useNotification.ts,
including its export and any hook-specific imports or supporting code that
become unnecessary. Do not alter the active notification implementation or the
Lnb.tsx live notification path.

In `@jobdri/src/hooks/useScrollGradient.ts`:
- Around line 20-36: The useEffect dependency list in useScrollGradient
currently spreads the dynamic-length deps array, which can change its element
count between renders. Replace this pattern with a stable dependency strategy
that preserves updates when deps values change, without varying the number of
entries in the hook dependency array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 27adaaad-21ee-40b0-9220-4b41f51ccbbb

📥 Commits

Reviewing files that changed from the base of the PR and between f114a4c and 2320752.

⛔ Files ignored due to path filters (1)
  • jobdri/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (19)
  • jobdri/package.json
  • jobdri/src/app/mockApply/[mockApplyId]/page.tsx
  • jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx
  • jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx
  • jobdri/src/app/page.tsx
  • jobdri/src/components/common/MockApplyTemplate.tsx
  • jobdri/src/components/common/lnb/Lnb.tsx
  • jobdri/src/components/common/lnb/LnbDefault.tsx
  • jobdri/src/components/common/lnb/LnbFolded.tsx
  • jobdri/src/components/common/lnb/LnbNotification.tsx
  • jobdri/src/components/common/lnb/LnbShared.tsx
  • jobdri/src/components/common/lnb/index.ts
  • jobdri/src/components/common/modal/ModalOverlay.tsx
  • jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx
  • jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx
  • jobdri/src/constants/notificationIconStyles.ts
  • jobdri/src/hooks/useNotification.ts
  • jobdri/src/hooks/useScrollGradient.ts
  • jobdri/src/lib/api/notification.ts

Comment on lines +266 to +268
const performDelete = async (targetId: string) => {
setQuestions(questions.filter((q) => q.id !== targetId));
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Select a remaining question after deleting the active one.

Deleting the selected item leaves selectedId pointing to a removed question, so the form disappears and submission becomes disabled until another item is clicked.

Proposed fix
-const performDelete = async (targetId: string) => {
-  setQuestions(questions.filter((q) => q.id !== targetId));
+const performDelete = (targetId: string) => {
+  const remainingQuestions = questions.filter((q) => q.id !== targetId);
+  setQuestions(remainingQuestions);
+  if (selectedId === targetId) {
+    setSelectedId(remainingQuestions[0]?.id ?? null);
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const performDelete = async (targetId: string) => {
setQuestions(questions.filter((q) => q.id !== targetId));
};
const performDelete = (targetId: string) => {
const remainingQuestions = questions.filter((q) => q.id !== targetId);
setQuestions(remainingQuestions);
if (selectedId === targetId) {
setSelectedId(remainingQuestions[0]?.id ?? null);
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/app/mockApply/`[mockApplyId]/page.tsx around lines 266 - 268,
Update performDelete so that when the deleted targetId matches the active
selectedId, it also selects a remaining question after filtering. Preserve the
current selection when deleting another question, and clear or safely reset the
selection when no questions remain.

Comment on lines +1 to +8
// export { default as Lnb } from "./Lnb";
// export { default as LnbDefault } from "./LnbDefault";
// export { default as LnbFolded } from "./LnbFolded";
// export {
// defaultNotificationItems,
// LnbNotificationPanel,
// type LnbNotificationItem,
// } from "./LnbNotification";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'from ["'"'"']`@/components/common/lnb`["'"'"']' -g '*.ts' -g '*.tsx'

Repository: JobDri-Developer/FrontEnd

Length of output: 163


🏁 Script executed:

#!/bin/bash
set -e

echo "== matching imports involving common/lnb =="
rg -n -C2 '["'"'"'][^\s"'"'"'<=-]*/lnb|["'"'"'][^\s"'"'"'<=-]*/components/common/lnb|["'"'"'][^\s"'"'"'<=-]*/lnb/' -g '*.ts' -g '*.tsx' . || true

echo
echo "== matching import from @ path containing lnb =="
rg -n -C2 '["'"'"']@.*lnb|["'"'"']`@/`.*/lnb[<>/]|["'"'"']`@/components/common/lnb`' -g '*.ts' -g '*.tsx' . || true

echo
echo "== export status =="
cat -n jobdri/src/components/common/lnb/index.ts

echo
echo "== lnb barrel exports (direct/indirect references in changed files if available) =="
git diff --stat || true
git status --short || true

Repository: JobDri-Developer/FrontEnd

Length of output: 9896


🏁 Script executed:

#!/bin/bash
set -e

echo "== unresolved barrel imports =="
python3 - <<'PY'
import re, pathlib
for path in pathlib.Path('.').rglob('*.ts'):
    text = path.read_text(errors='ignore').splitlines()
    for i,line in enumerate(text,1):
        m = re.search(r'\bimport\s+(?:type\s+)?(?P<brace_start>(?:{.*?})|\w+|\*\s+as\s+\w+)\s+from\s+[\'"](`@/components/common/lnb`(?:/index)?)[\'"]', line)
        print(f"{path}:{i}:{line.strip()}")
PY

echo
echo "== app/credit/page.tsx relevant section =="
cat -n jobdri/src/app/credit/page.tsx | sed -n '1,40p'

echo
echo "== files at exact barrel path =="
find . -path './jobdri/src/components/common/lnb/index.ts' -type f -exec sh -c 'echo "--- $1"; cat -n "$1"' sh {} \;

echo
echo "== git tracked files for Lnb barrel =="
git ls-files | rg '(^|/)jobdri/src/(components|app)/.*Lnb|jobdri/src/components/common/lnb/index\.ts' || true

Repository: JobDri-Developer/FrontEnd

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -e

echo "== active unresolved barrels =="
rg -n '^\s*[A-Za-z_$][A-Za-z0-9_$,{}<>\s*]*' jobdri/src/app/credit/page.tsx | head -40
echo "--- exact line 12 ---"
sed -n '12p' jobdri/src/app/credit/page.tsx

echo
echo "== unresolved imports to exact barrel with any exported name =="
python3 - <<'PY'
import pathlib, re
names = {'Lnb', 'LnbDefault', 'LnbFolded', 'defaultNotificationItems', 'LnbNotificationPanel', 'LnbNotificationItem'}
for path in pathlib.Path('jobdri/src').rglob('*'):
    if path.is_file() and path.suffix in {'.ts', '.tsx'}:
        text = path.read_text(errors='ignore').splitlines()
        for i,line in enumerate(text,1):
            if 'from "`@/components/common/lnb`"' in line or 'from "`@/components/common/lnb/index`"' in line:
                brace_start = re.search(r'\{(?P<body>.*?)\}', line)
                if brace_start:
                    if any(n in brace_start.group('body').split() or re.search(r'\b'+re.escape(n)+r'\b', brace_start.group('body')) for n in names):
                        print(f"{path}:{i}:{line.strip()}")
PY

Repository: JobDri-Developer/FrontEnd

Length of output: 2248


Restore the LNB barrel export or update app/credit/page.tsx.

jobdri/src/app/credit/page.tsx:12 still does import { Lnb } from "@/components/common/lnb", but this barrel no longer exports Lnb, so that import will fail. Move it to @/components/common/lnb/Lnb or restore the export.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/index.ts` around lines 1 - 8, Restore the
Lnb barrel export in the common LNB index so the existing import in
app/credit/page.tsx continues to resolve, or update that import to reference the
Lnb module directly; ensure Lnb remains available through the chosen import
path.

Comment on lines 173 to 205
useEffect(() => {
const loadData = async () => {
// 1. 기존 알림 먼저 불러오기
const loadInitialNotifications = async () => {
try {
const data = await fetchMyMockApplies({
redirectOnUnauthorized: false,
});
const allItems = [...data.inProgress, ...data.completed];

const mappedItems: LnbRecentItem[] = allItems.map((item) => ({
id: String(item.mockApplyId),
companyName: item.companyName,
jobTitle:
item.jobTitle || item.detailClassificationName || "직무 미지정",
version: item.version ?? 1,
}));

setRecentItems(mappedItems);
if (mappedItems.length > 0) {
setSelectedRecentItemId(mappedItems[0].id);
const data = await fetchNotifications();
if (data.isSuccess && data.result) {
const mappedItems = data.result.map(mapApiToLnbItem);
setNotificationItems(mappedItems);
setHasNotification(mappedItems.some((item) => !item.read));
}
} catch (error) {
console.error("데이터 로드 실패:", error);
setRecentItems([]);
console.error("초기 알림 목록 로드 실패:", error);
}
};

loadData();
loadInitialNotifications();

const unsubscribe = subscribeToNotificationStream(
(newNotification) => {
const mappedNewItem = mapApiToLnbItem(newNotification);
setNotificationItems((prev) => [mappedNewItem, ...prev]);
setHasNotification(true);
},
// 에러가 났을 때
(error) => {
console.error("실시간 알림 연결 문제 발생:", error);
},
);

return () => {
unsubscribe();
};
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Initial fetch can clobber a concurrently-arriving SSE notification.

loadInitialNotifications() is fired (not awaited) and then subscribeToNotificationStream is set up immediately after. If the stream delivers a message before the initial fetch resolves, the SSE handler prepends it via setNotificationItems((prev) => [mappedNewItem, ...prev]), but once the fetch resolves, setNotificationItems(mappedItems) performs a full unconditional replace, silently dropping that notification.

🐛 Suggested fix — merge instead of replace
         const data = await fetchNotifications();
         if (data.isSuccess && data.result) {
           const mappedItems = data.result.map(mapApiToLnbItem);
-          setNotificationItems(mappedItems);
+          setNotificationItems((prev) => {
+            const existingIds = new Set(mappedItems.map((item) => item.id));
+            const streamOnly = prev.filter((item) => !existingIds.has(item.id));
+            return [...streamOnly, ...mappedItems];
+          });
           setHasNotification(mappedItems.some((item) => !item.read));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const loadData = async () => {
// 1. 기존 알림 먼저 불러오기
const loadInitialNotifications = async () => {
try {
const data = await fetchMyMockApplies({
redirectOnUnauthorized: false,
});
const allItems = [...data.inProgress, ...data.completed];
const mappedItems: LnbRecentItem[] = allItems.map((item) => ({
id: String(item.mockApplyId),
companyName: item.companyName,
jobTitle:
item.jobTitle || item.detailClassificationName || "직무 미지정",
version: item.version ?? 1,
}));
setRecentItems(mappedItems);
if (mappedItems.length > 0) {
setSelectedRecentItemId(mappedItems[0].id);
const data = await fetchNotifications();
if (data.isSuccess && data.result) {
const mappedItems = data.result.map(mapApiToLnbItem);
setNotificationItems(mappedItems);
setHasNotification(mappedItems.some((item) => !item.read));
}
} catch (error) {
console.error("데이터 로드 실패:", error);
setRecentItems([]);
console.error("초기 알림 목록 로드 실패:", error);
}
};
loadData();
loadInitialNotifications();
const unsubscribe = subscribeToNotificationStream(
(newNotification) => {
const mappedNewItem = mapApiToLnbItem(newNotification);
setNotificationItems((prev) => [mappedNewItem, ...prev]);
setHasNotification(true);
},
// 에러가 났을 때
(error) => {
console.error("실시간 알림 연결 문제 발생:", error);
},
);
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
// 1. 기존 알림 먼저 불러오기
const loadInitialNotifications = async () => {
try {
const data = await fetchNotifications();
if (data.isSuccess && data.result) {
const mappedItems = data.result.map(mapApiToLnbItem);
setNotificationItems((prev) => {
const existingIds = new Set(mappedItems.map((item) => item.id));
const streamOnly = prev.filter((item) => !existingIds.has(item.id));
return [...streamOnly, ...mappedItems];
});
setHasNotification(mappedItems.some((item) => !item.read));
}
} catch (error) {
console.error("초기 알림 목록 로드 실패:", error);
}
};
loadInitialNotifications();
const unsubscribe = subscribeToNotificationStream(
(newNotification) => {
const mappedNewItem = mapApiToLnbItem(newNotification);
setNotificationItems((prev) => [mappedNewItem, ...prev]);
setHasNotification(true);
},
// 에러가 났을 때
(error) => {
console.error("실시간 알림 연결 문제 발생:", error);
},
);
return () => {
unsubscribe();
};
}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/Lnb.tsx` around lines 173 - 205, Update the
initial notification handling in the useEffect’s loadInitialNotifications flow
so its setNotificationItems update merges fetched items with notifications
already added by subscribeToNotificationStream, preserving concurrently received
SSE notifications instead of replacing the current state. Keep the fetched
notification ordering and existing hasNotification calculation consistent with
the merged result.

Comment on lines +242 to +257
const handleReadItem = (id: string) => {
setNotificationItems((prev) => {
const updated = prev.map((item) =>
item.id === id ? { ...item, read: true } : item,
);
setHasNotification(updated.some((item) => !item.read));
return updated;
});
};

const handleMarkAllRead = () => {
setNotificationItems((prev) =>
prev.map((item) => ({ ...item, read: true })),
);
setHasNotification(false);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Optimistic read actions never set readAt, breaking the 7-day auto-hide feature.

handleReadItem and handleMarkAllRead set read: true but leave readAt untouched. LnbNotificationList (in jobdri/src/components/common/lnb/LnbNotification.tsx, Lines 318-331) filters out items only when read && readAt is older than 7 days; if readAt is missing it always keeps the item visible. Since these are the two paths that mark items read locally (markNotificationAsRead/markAllNotificationsAsRead don't return a timestamp, and neither handler sets one), items read through the UI will never be auto-hidden until a full page refetch repopulates readAt from the server.

🐛 Suggested fix — stamp `readAt` optimistically
   const handleReadItem = (id: string) => {
     setNotificationItems((prev) => {
       const updated = prev.map((item) =>
-        item.id === id ? { ...item, read: true } : item,
+        item.id === id
+          ? { ...item, read: true, readAt: new Date().toISOString() }
+          : item,
       );
       setHasNotification(updated.some((item) => !item.read));
       return updated;
     });
   };

   const handleMarkAllRead = () => {
     setNotificationItems((prev) =>
-      prev.map((item) => ({ ...item, read: true })),
+      prev.map((item) => ({
+        ...item,
+        read: true,
+        readAt: item.readAt ?? new Date().toISOString(),
+      })),
     );
     setHasNotification(false);
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleReadItem = (id: string) => {
setNotificationItems((prev) => {
const updated = prev.map((item) =>
item.id === id ? { ...item, read: true } : item,
);
setHasNotification(updated.some((item) => !item.read));
return updated;
});
};
const handleMarkAllRead = () => {
setNotificationItems((prev) =>
prev.map((item) => ({ ...item, read: true })),
);
setHasNotification(false);
};
const handleReadItem = (id: string) => {
setNotificationItems((prev) => {
const updated = prev.map((item) =>
item.id === id
? { ...item, read: true, readAt: new Date().toISOString() }
: item,
);
setHasNotification(updated.some((item) => !item.read));
return updated;
});
};
const handleMarkAllRead = () => {
setNotificationItems((prev) =>
prev.map((item) => ({
...item,
read: true,
readAt: item.readAt ?? new Date().toISOString(),
})),
);
setHasNotification(false);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/Lnb.tsx` around lines 242 - 257, Update
handleReadItem and handleMarkAllRead to set readAt to the current timestamp
whenever notifications are marked read, while preserving their existing
read-state and hasNotification updates. Ensure both optimistic paths provide the
timestamp required by LnbNotificationList’s seven-day visibility filtering.

Comment on lines +128 to +149
export function mapApiToLnbItem(
item: ApiNotificationItem,
): LnbNotificationItem {
const mockApplyId =
item.payload?.mockApplyId !== undefined &&
item.payload?.mockApplyId !== null
? String(item.payload.mockApplyId)
: undefined;

return {
id: item.id ? String(item.id) : crypto.randomUUID(),
title: item.title,
description: item.body,
timestamp: formatDate(item.createdAt),
type: mapNotificationType(item.type),
read: item.isRead,
readAt: item.readAt,
targetType: item.targetType,
mockApplyId,
apiType: item.type,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Falsy-id check mishandles id 0.

item.id ? String(item.id) : crypto.randomUUID() treats a notification id of 0 as absent and substitutes a random UUID, which would then desync from the id used server-side for markNotificationAsRead.

🐛 Suggested fix
-    id: item.id ? String(item.id) : crypto.randomUUID(),
+    id: item.id !== undefined && item.id !== null ? String(item.id) : crypto.randomUUID(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function mapApiToLnbItem(
item: ApiNotificationItem,
): LnbNotificationItem {
const mockApplyId =
item.payload?.mockApplyId !== undefined &&
item.payload?.mockApplyId !== null
? String(item.payload.mockApplyId)
: undefined;
return {
id: item.id ? String(item.id) : crypto.randomUUID(),
title: item.title,
description: item.body,
timestamp: formatDate(item.createdAt),
type: mapNotificationType(item.type),
read: item.isRead,
readAt: item.readAt,
targetType: item.targetType,
mockApplyId,
apiType: item.type,
};
}
export function mapApiToLnbItem(
item: ApiNotificationItem,
): LnbNotificationItem {
const mockApplyId =
item.payload?.mockApplyId !== undefined &&
item.payload?.mockApplyId !== null
? String(item.payload.mockApplyId)
: undefined;
return {
id: item.id !== undefined && item.id !== null ? String(item.id) : crypto.randomUUID(),
title: item.title,
description: item.body,
timestamp: formatDate(item.createdAt),
type: mapNotificationType(item.type),
read: item.isRead,
readAt: item.readAt,
targetType: item.targetType,
mockApplyId,
apiType: item.type,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/lnb/LnbNotification.tsx` around lines 128 - 149,
Update the id mapping in mapApiToLnbItem to treat numeric id 0 as a valid
identifier, generating a UUID only when item.id is nullish or otherwise absent.
Preserve string conversion for all provided ids so markNotificationAsRead uses
the server-side identifier.

Comment on lines +47 to +48
nextLabel = "다음으로",
nextIconType,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the existing default next icon.

nextIconType is optional, but omitted callers now receive no icon instead of the prior SPARKLE icon. Default this prop to retain the existing CTA contract.

-  nextIconType,
+  nextIconType = "SPARKLE",

Also applies to: 100-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/MockApplyTemplate.tsx` around lines 47 - 48,
Update the nextIconType prop defaults in MockApplyTemplate’s destructuring at
both referenced locations so omitted callers receive the existing SPARKLE icon.
Preserve explicitly provided icon values and the current nextLabel behavior.

Comment on lines +10 to +14
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "unset";
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the prior body overflow value.

Unmounting currently resets to unset, which can overwrite an existing page-level scroll setting and can re-enable scrolling while another overlay remains mounted.

Proposed fix
 useEffect(() => {
+  const previousOverflow = document.body.style.overflow;
   document.body.style.overflow = "hidden";
   return () => {
-    document.body.style.overflow = "unset";
+    document.body.style.overflow = previousOverflow;
   };
 }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "unset";
};
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/common/modal/ModalOverlay.tsx` around lines 10 - 14,
Update the useEffect in ModalOverlay to capture document.body.style.overflow
before setting it to "hidden", then restore that captured value in the cleanup
instead of forcing "unset", preserving any pre-existing body overflow setting.

Comment on lines +17 to +18
isFailed?: boolean;
onErrorConfirm?: () => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Honor or remove onErrorConfirm.

The new callback prop is never used; failure confirmation always calls router.replace("/"), preventing callers from customizing the recovery action.

Proposed fix
   applicationLabel,
   isFailed = false,
+  onErrorConfirm,
 }: ResumeAnalysisLoadingProps) {
- onSecondaryClick={() => router.replace("/")}
+ onSecondaryClick={() => {
+   if (onErrorConfirm) {
+     onErrorConfirm();
+     return;
+   }
+   router.replace("/");
+ }}

Also applies to: 32-38, 159-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx` around lines 17 -
18, Update ResumeAnalysisLoading and its failure-confirmation handler to invoke
the optional onErrorConfirm callback when provided, falling back to
router.replace("/") only when it is absent; otherwise remove the unused prop and
related declarations.

Comment on lines +60 to +82
export function subscribeToNotificationStream(
onMessage: (data: ApiNotificationItem) => void,
onError?: (error: unknown) => void,
) {
const headers = getAuthHeaders();
const ctrl = new AbortController();

if (!headers.Authorization) {
console.warn("로그인 토큰이 없어 실시간 알림을 연결하지 않습니다.");
return () => ctrl.abort();
}

const tokenOnly = headers.Authorization.replace("Bearer ", "");

fetchEventSource(
`${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`,
{
method: "GET",
headers: {
...headers,
Accept: "text/event-stream",
},
signal: ctrl.signal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does @microsoft/fetch-event-source support sending an Authorization header for SSE requests, and is embedding tokens in the URL discouraged?

💡 Result:

Yes, @microsoft/fetch-event-source fully supports sending Authorization headers for Server-Sent Events (SSE) requests [1][2]. Unlike the native browser EventSource API, which prohibits custom headers [1][3], this library leverages the Fetch API, allowing you to pass a standard headers object to your SSE request [4][5]. You can even pass headers as a function to support dynamic, short-lived tokens (e.g., refreshing a Bearer token during retries) [6]. Embedding authentication tokens directly in the URL (e.g., query parameters) is strongly discouraged [3][7][8]. This practice is considered a significant security risk for several reasons: - Leakage in Logs: Authentication tokens will appear in plain text within server access logs (such as those from Nginx, Apache, or cloud load balancers) [3][7][9]. - Browser History and Referrer Headers: Tokens can be persisted in browser history or inadvertently leaked to external sites via Referer headers [3][7][9]. - Caching Risks: Proxies and other intermediary network components may cache the request URL, potentially exposing the token to unauthorized parties [7]. Given these risks, using headers—facilitated by libraries like @microsoft/fetch-event-source or by using the native fetch API with ReadableStream—is the industry-standard approach for authenticated SSE [2][7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Candidate files:\n'
fd -a 'notification\.ts$|package\.json$' . | sed 's#^\./##'

printf '\nRelevant notification.ts excerpt:\n'
if [ -f jobdri/src/lib/api/notification.ts ]; then
  nl -ba jobdri/src/lib/api/notification.ts | sed -n '1,120p'
fi

printf '\nAuthorization headers across repo (focused list):\\n'
rg -n "Authorization|accessToken|fetchEventSource|EventSource" jobdri -S || true

printf '\nImports/dependencies for fetch-event-source:\n'
rg -n "`@microsoft/fetch-event-source`|fetch-event-source|fetchEventSource" . -S || true

Repository: JobDri-Developer/FrontEnd

Length of output: 403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant notification.ts excerpt:\n'
if [ -f jobdri/src/lib/api/notification.ts ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' jobdri/src/lib/api/notification.ts | sed -n '1,120p'
fi

printf '\nAuthorization headers across repo:\n'
grep -RInE "Authorization|accessToken|fetchEventSource|EventSource" jobdri || true

printf '\nImports/dependencies for fetch-event-source:\n'
grep -RInE "`@microsoft/fetch-event-source`|fetch-event-source|fetchEventSource" . || true

printf '\nPackage dependency excerpt:\n'
grep -A40 '"dependencies"' jobdri/package.json | sed -n '1,60p'

Repository: JobDri-Developer/FrontEnd

Length of output: 8385


Stop duplicating the access token in the SSE URL.

fetchEventSource sends the Authorization header, so ?accessToken=${tokenOnly} is redundant and exposes the bearer token in server/proxy logs and history. Use the header-only URL unless the backend/gateway explicitly requires the query token.

🔒 Suggested fix
-  const tokenOnly = headers.Authorization.replace("Bearer ", "");
-
   fetchEventSource(
-    `${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`,
+    `${API_BASE_URL}/api/notifications/stream`,
     {
       method: "GET",
       headers: {
         ...headers,
         Accept: "text/event-stream",
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function subscribeToNotificationStream(
onMessage: (data: ApiNotificationItem) => void,
onError?: (error: unknown) => void,
) {
const headers = getAuthHeaders();
const ctrl = new AbortController();
if (!headers.Authorization) {
console.warn("로그인 토큰이 없어 실시간 알림을 연결하지 않습니다.");
return () => ctrl.abort();
}
const tokenOnly = headers.Authorization.replace("Bearer ", "");
fetchEventSource(
`${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`,
{
method: "GET",
headers: {
...headers,
Accept: "text/event-stream",
},
signal: ctrl.signal,
export function subscribeToNotificationStream(
onMessage: (data: ApiNotificationItem) => void,
onError?: (error: unknown) => void,
) {
const headers = getAuthHeaders();
const ctrl = new AbortController();
if (!headers.Authorization) {
console.warn("로그인 토큰이 없어 실시간 알림을 연결하지 않습니다.");
return () => ctrl.abort();
}
fetchEventSource(
`${API_BASE_URL}/api/notifications/stream`,
{
method: "GET",
headers: {
...headers,
Accept: "text/event-stream",
},
signal: ctrl.signal,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/notification.ts` around lines 60 - 82, Update
subscribeToNotificationStream so fetchEventSource uses the notifications stream
endpoint without the accessToken query parameter; retain the Authorization
header from headers and remove the now-unused tokenOnly extraction.

@minnngo minnngo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생했네ㅜ 수고 많았어~~

@yiyoonseo
yiyoonseo merged commit 4971576 into develop Jul 24, 2026
1 check passed
@yiyoonseo
yiyoonseo deleted the feature/#88-notification_api-feat branch July 24, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: 알림 API 연동

2 participants