feat:헤더 통합 검색 및 알림 구현(#69)#69
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthrough워크스페이스 통합 검색과 알림 기능이 추가되었습니다. 검색은 여러 콘텐츠 유형을 조회하며, 알림은 데이터베이스 트리거·Realtime·읽음 처리를 통해 헤더 패널에 표시됩니다. Changes워크스페이스 검색 및 알림
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)검색 요청 흐름sequenceDiagram
participant WorkspaceSearchPanel
participant useWorkspaceSearch
participant searchWorkspace
participant WorkspaceSearchRoute
WorkspaceSearchPanel->>useWorkspaceSearch: 검색어 입력
useWorkspaceSearch->>searchWorkspace: 디바운스된 query 전달
searchWorkspace->>WorkspaceSearchRoute: GET 요청
WorkspaceSearchRoute-->>searchWorkspace: 검색 결과 반환
searchWorkspace-->>WorkspaceSearchPanel: 결과 렌더링
알림 수신 및 표시 흐름sequenceDiagram
participant AnnouncementOrTask
participant NotificationTrigger
participant SupabaseRealtime
participant NotificationPanel
AnnouncementOrTask->>NotificationTrigger: 공지 또는 업무 변경
NotificationTrigger->>SupabaseRealtime: notifications INSERT 이벤트
SupabaseRealtime-->>NotificationPanel: 사용자 알림 전달
NotificationPanel-->>NotificationPanel: 읽지 않은 개수와 목록 갱신
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 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 `@src/app/api/workspaces/`[workspaceId]/search/route.ts:
- Around line 53-56: Update the request handler’s try/catch around
getCurrentUserId so authentication failures are detected and returned as HTTP
401 instead of being converted to a 500 response. Preserve the existing error
handling for non-authentication failures, using the handler’s established
response format.
In `@src/entities/notification/api/get-notifications.ts`:
- Around line 42-58: The notification API must derive unreadCount from the full
unread set, not the latest 10 notifications: update the query flow in
getNotifications to separately count rows where read_at is null and return that
exact count. In src/entities/notification/api/get-notifications.ts lines 42-58,
apply this API change; in
src/features/workspace-notifications/model/use-workspace-notifications.ts lines
62-79, stop recalculating unreadCount from the cached list after mutations and
instead decrement by the actual number changed or invalidate the query to
refetch the server count.
In `@src/entities/notification/model/notification-query.ts`:
- Around line 1-3: Update notificationsQueryKey to accept and include viewerId
alongside workspaceId, then update every getNotifications call site and related
query/cache usage to pass getCurrentUserId() so notification data is separated
when users switch within the same QueryClient.
In `@src/features/workspace-notifications/model/use-workspace-notifications.ts`:
- Around line 32-43: Update addNotification to create and return initial
NotificationData when currentData is undefined, so the first INSERT is cached
instead of discarded. Also update the realtime subscription using
notificationsQueryKey(workspaceId) to handle both INSERT and UPDATE events,
either by invalidating the query for both or directly applying UPDATE changes.
In `@src/features/workspace-notifications/ui/NotificationPanel.tsx`:
- Around line 49-60: Update openNotification to remove event.preventDefault(),
start markOneAsRead(notification.id) without awaiting it, and close the panel
immediately; remove manual router.push so the Link handles normal,
Cmd/Ctrl-click, and new-tab navigation.
- Around line 25-31: Update formatCreatedAt to explicitly set the product time
zone to Asia/Seoul in its Intl.DateTimeFormat options, preserving the existing
Korean locale and date/time formatting.
In `@src/features/workspace-search/ui/WorkspaceSearchPanel.tsx`:
- Around line 78-119: Update the results list rendering in WorkspaceSearchPanel
so previous results remain hidden while the query is debouncing or loading.
Render the list only when the current trimmed query matches normalizedQuery, is
not loading, is not in error, and results are non-empty; keep the existing
status messages unchanged.
In `@src/widgets/workspace-shell/ui/WorkspaceHeader.tsx`:
- Around line 71-110: Update WorkspaceHeader’s mobile layout to prevent
horizontal overflow within the 320px available width: use a compact search
trigger or otherwise recompose the title and action controls so the search,
invite, notification, and profile controls fit without overflow. Preserve the
full search panel and existing desktop layout for larger breakpoints.
In `@supabase/migrations/20260716030000_create_workspace_notifications.sql`:
- Around line 48-76: Update private.prevent_notification_mutation to validate
read_at transitions: allow only a NULL old.read_at changing to the current
timestamp via now(), reject clearing it or replacing an existing value, and
preserve the existing protections for all other notification fields.
🪄 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: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: a0e5bbac-da45-495b-91b8-c8c93833db0b
📒 Files selected for processing (20)
src/app/api/workspaces/[workspaceId]/search/route.tssrc/entities/notification/api/get-notifications.tssrc/entities/notification/api/notification-actions.tssrc/entities/notification/index.tssrc/entities/notification/model/notification-query.tssrc/entities/notification/model/notification.types.tssrc/entities/workspace-search/api/search-workspace.tssrc/entities/workspace-search/index.tssrc/entities/workspace-search/model/workspace-search-query.tssrc/entities/workspace-search/model/workspace-search.types.tssrc/features/workspace-notifications/index.tssrc/features/workspace-notifications/model/use-workspace-notifications.tssrc/features/workspace-notifications/ui/NotificationPanel.tsxsrc/features/workspace-search/index.tssrc/features/workspace-search/model/use-workspace-search.tssrc/features/workspace-search/ui/WorkspaceSearchPanel.tsxsrc/shared/model/database.types.tssrc/widgets/workspace-shell/ui/WorkspaceHeader.tsxsupabase/migrations/20260716030000_create_workspace_notifications.sqlsupabase/migrations/20260716040000_fix_notification_trigger_permissions.sql
| try { | ||
| const supabase = await createSupabaseServerClient(); | ||
| // 모든 검색 원본을 현재 사용자와 선택한 워크스페이스 범위로 제한하는 기준값입니다. | ||
| const currentUserId = await getCurrentUserId(); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map and inspect the target route handler with line numbers.
FILE="src/app/api/workspaces/[workspaceId]/search/route.ts"
echo "== file exists =="
git ls-files "$FILE"
echo
echo "== outline =="
ast-grep outline "$FILE" --view expanded || true
echo
echo "== relevant lines 1-340 =="
nl -ba "$FILE" | sed -n '1,340p'Repository: TeampleRun/syncly
Length of output: 738
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find auth/error handling helpers referenced by the route to understand status code behavior.
rg -n "getCurrentUserId|Unauthorized|unauth|401|catch \(|throw new" src/app/api/workspaces/[workspaceId]/search/route.ts src -g '!**/*.map' || trueRepository: TeampleRun/syncly
Length of output: 17710
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect nearby workspace search API files for shared auth/error handling patterns.
fd -a "route.ts" src/app/api | sed -n '1,200p'Repository: TeampleRun/syncly
Length of output: 227
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== route excerpt =="
sed -n '1,340p' "src/app/api/workspaces/[workspaceId]/search/route.ts" | cat -n | sed -n '1,340p'
echo
echo "== current-user helper =="
sed -n '1,120p' "src/shared/api/supabase/current-user.ts" | cat -n
echo
echo "== workspace search API helper file if any =="
sed -n '1,220p' "src/entities/workspace-search/api/search-workspace.ts" | cat -n || trueRepository: TeampleRun/syncly
Length of output: 12819
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for existing API error-to-status mapping patterns that may apply here.
rg -n "NextResponse\.json|status:\s*401|status:\s*403|Unauthorized|인증된 사용자가 필요합니다|문제가 발생|catch \(error\)" src/app src/entities src/shared -g '!**/*.map' | sed -n '1,240p'Repository: TeampleRun/syncly
Length of output: 2137
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== route excerpt =="
sed -n '1,340p' "src/app/api/workspaces/[workspaceId]/search/route.ts" | cat -n
echo
echo "== current-user helper =="
sed -n '1,120p' "src/shared/api/supabase/current-user.ts" | cat -nRepository: TeampleRun/syncly
Length of output: 11959
인증 실패는 401로 분리하세요.
getCurrentUserId()의 인증 실패가 바깥 catch에서 500으로 바뀝니다. 세션 만료나 미인증 요청은 401로 반환하도록 분기하세요.
🤖 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 `@src/app/api/workspaces/`[workspaceId]/search/route.ts around lines 53 - 56,
Update the request handler’s try/catch around getCurrentUserId so authentication
failures are detected and returned as HTTP 401 instead of being converted to a
500 response. Preserve the existing error handling for non-authentication
failures, using the handler’s established response format.
| const { data, error } = await supabase | ||
| .from('notifications') | ||
| .select('id, workspace_id, type, title, body, link_path, read_at, created_at') | ||
| .eq('workspace_id', parsedWorkspaceId) | ||
| .eq('recipient_id', currentUserId) | ||
| .order('created_at', { ascending: false }) | ||
| .limit(10); | ||
|
|
||
| if (error) { | ||
| console.error('[notification] 알림 조회 실패:', error); | ||
| throw new Error('알림을 불러오지 못했습니다.'); | ||
| } | ||
|
|
||
| const notifications = (data ?? []).map(toNotificationItem); | ||
| return { | ||
| notifications, | ||
| unreadCount: notifications.filter((notification) => !notification.readAt).length, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== get-notifications.ts ==\n'
sed -n '1,220p' src/entities/notification/api/get-notifications.ts
printf '\n== use-workspace-notifications.ts ==\n'
sed -n '1,260p' src/features/workspace-notifications/model/use-workspace-notifications.ts
printf '\n== notification-related files ==\n'
rg -n "unreadCount|notificationsQueryKey|readAt|read_at|count\(|select\(" src/entities/notification src/features/workspace-notifications -g '!**/node_modules/**'Repository: TeampleRun/syncly
Length of output: 9318
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== notification-actions.ts ==\n'
sed -n '1,220p' src/entities/notification/api/notification-actions.ts
printf '\n== notification.types.ts ==\n'
sed -n '1,200p' src/entities/notification/model/notification.types.ts
printf '\n== NotificationPanel.tsx ==\n'
sed -n '1,220p' src/features/workspace-notifications/ui/NotificationPanel.tsxRepository: TeampleRun/syncly
Length of output: 7669
최근 10건에서 unreadCount를 계산하지 마세요.
읽지 않은 알림이 10건을 넘고 최신 10건이 모두 읽음이면 unreadCount가 0으로 떨어져 전체 읽음이 비활성화될 수 있습니다.
src/entities/notification/api/get-notifications.ts:read_at IS NULL조건의 정확한 count를 별도로 가져와unreadCount에 넣으세요.src/features/workspace-notifications/model/use-workspace-notifications.ts: mutation 뒤에 현재 캐시 목록만으로unreadCount를 다시 세지 말고, 실제 변경 수만큼 감소시키거나 쿼리를 무효화해 서버 count를 다시 받으세요.
📍 Affects 2 files
src/entities/notification/api/get-notifications.ts#L42-L58(this comment)src/features/workspace-notifications/model/use-workspace-notifications.ts#L62-L79
🤖 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 `@src/entities/notification/api/get-notifications.ts` around lines 42 - 58, The
notification API must derive unreadCount from the full unread set, not the
latest 10 notifications: update the query flow in getNotifications to separately
count rows where read_at is null and return that exact count. In
src/entities/notification/api/get-notifications.ts lines 42-58, apply this API
change; in
src/features/workspace-notifications/model/use-workspace-notifications.ts lines
62-79, stop recalculating unreadCount from the cached list after mutations and
instead decrement by the actual number changed or invalidate the query to
refetch the server count.
Source: MCP tools
| // 워크스페이스별 알림 목록과 읽지 않은 개수를 함께 갱신하기 위한 Query 키입니다. | ||
| export const notificationsQueryKey = (workspaceId: string) => | ||
| ['notifications', workspaceId] as const; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the file under review and related usages of the query key.
git ls-files 'src/entities/notification/**' 'src/**' | rg 'notification|notifications'
echo '--- notification-query.ts ---'
cat -n src/entities/notification/model/notification-query.ts
echo '--- search notificationsQueryKey usages ---'
rg -n "notificationsQueryKey\(" src
echo '--- search viewerId in notification-related code ---'
rg -n "viewerId|workspaceId" src/entities/notification src/features src/widgets src/views src/appRepository: TeampleRun/syncly
Length of output: 41090
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the notification entity files to inspect nearby query/hook logic.
ast-grep outline src/entities/notification --view expanded || trueRepository: TeampleRun/syncly
Length of output: 1386
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the query key definition and the consumer hook implementation in small slices.
for f in \
src/entities/notification/model/notification-query.ts \
src/entities/notification/model/*.ts \
src/entities/notification/ui/*.ts \
src/entities/notification/*.ts
do
[ -f "$f" ] && { echo "--- $f ---"; cat -n "$f"; echo; }
doneRepository: TeampleRun/syncly
Length of output: 1883
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the notification hook and API to see what data the query result actually depends on.
echo '--- src/features/workspace-notifications/model/use-workspace-notifications.ts ---'
cat -n src/features/workspace-notifications/model/use-workspace-notifications.ts
echo '--- src/entities/notification/api/get-notifications.ts ---'
cat -n src/entities/notification/api/get-notifications.ts
echo '--- src/entities/notification/api/notification-actions.ts ---'
cat -n src/entities/notification/api/notification-actions.tsRepository: TeampleRun/syncly
Length of output: 9322
사용자별 알림 캐시를 분리하세요.
getNotifications()가 getCurrentUserId()에 따라 결과를 바꾸는데 notificationsQueryKey는 workspaceId만 써서, 같은 QueryClient에서 사용자 전환 시 이전 사용자의 알림이 재사용될 수 있습니다. viewerId를 키에 포함하고 호출부도 함께 갱신하세요.
🤖 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 `@src/entities/notification/model/notification-query.ts` around lines 1 - 3,
Update notificationsQueryKey to accept and include viewerId alongside
workspaceId, then update every getNotifications call site and related
query/cache usage to pass getCurrentUserId() so notification data is separated
when users switch within the same QueryClient.
Source: MCP tools
| function addNotification( | ||
| currentData: NotificationData | undefined, | ||
| notification: NotificationItem, | ||
| ): NotificationData | undefined { | ||
| if (!currentData || currentData.notifications.some((item) => item.id === notification.id)) { | ||
| return currentData; | ||
| } | ||
|
|
||
| return { | ||
| notifications: [notification, ...currentData.notifications].slice(0, 10), | ||
| unreadCount: currentData.unreadCount + (notification.readAt ? 0 : 1), | ||
| }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate relevant files
git ls-files 'src/features/workspace-notifications/**' 'src/**/notifications*' 'src/**/supabase*' 'src/**/query*' | sed -n '1,200p'
echo '--- outline: use-workspace-notifications.ts ---'
ast-grep outline src/features/workspace-notifications/model/use-workspace-notifications.ts --view expanded || true
echo '--- outline: related files (if present) ---'
for f in \
src/features/workspace-notifications/model/use-workspace-notifications.ts \
src/features/workspace-notifications/model/*.ts \
src/features/workspace-notifications/**/use-*.ts \
src/shared/**/supabase*.ts \
src/shared/**/query*.ts
do
[ -e "$f" ] && ast-grep outline "$f" --view expanded || true
doneRepository: TeampleRun/syncly
Length of output: 2311
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the target file and adjacent related code in small slices.
for f in \
src/features/workspace-notifications/model/use-workspace-notifications.ts \
src/features/workspace-notifications/model/*.ts
do
[ -e "$f" ] || continue
echo "=== $f (wc) ==="
wc -l "$f"
echo "=== $f (lines 1-220) ==="
sed -n '1,220p' "$f" | cat -n
doneRepository: TeampleRun/syncly
Length of output: 10361
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the notification query key and data model to check cache semantics.
files=(
src/entities/notification/**/*.ts
src/entities/notification/*.ts
src/entities/notification/**
)
for f in "${files[@]}"; do
: # placeholder
done
echo '--- find notification entity files ---'
fd -a 'notification' src/entities src/features | sed -n '1,200p'
echo '--- outline entity files ---'
for f in $(fd -a 'notification' src/entities | sed -n '1,50p'); do
[ -f "$f" ] || continue
echo "### $f"
ast-grep outline "$f" --view expanded || true
done
echo '--- text search for notificationsQueryKey/getNotifications/markNotificationsRead ---'
rg -n "notificationsQueryKey|getNotifications|markNotificationsRead|type NotificationData|type NotificationItem" src/entities src/features -SRepository: TeampleRun/syncly
Length of output: 4386
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the notification query and related actions to determine whether INSERT/UPDATE handling is already covered elsewhere.
for f in \
src/entities/notification/model/notification-query.ts \
src/entities/notification/api/get-notifications.ts \
src/entities/notification/api/notification-actions.ts \
src/entities/notification/model/notification.types.ts \
src/entities/notification/index.ts
do
echo "=== $f (wc) ==="
wc -l "$f"
echo "=== $f (lines 1-220) ==="
sed -n '1,220p' "$f" | cat -n
done
echo '--- search for invalidateQueries and postgres_changes in notification-related code ---'
rg -n "invalidateQueries|postgres_changes|event: 'UPDATE'|event: 'INSERT'|event: 'DELETE'" src -SRepository: TeampleRun/syncly
Length of output: 10758
Realtime 알림 캐시를 초기화하고 INSERT/UPDATE를 모두 반영하세요.
currentData가 없을 때 undefined를 반환하면 첫 INSERT가 캐시에 안 들어가고, INSERT만 구독하면 다른 탭의 읽음 변경도 이 목록에 반영되지 않습니다. currentData가 없을 때는 초기 NotificationData를 만들고, notificationsQueryKey(workspaceId)는 INSERT/UPDATE 시 무효화하거나 UPDATE도 직접 처리하세요.
🤖 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 `@src/features/workspace-notifications/model/use-workspace-notifications.ts`
around lines 32 - 43, Update addNotification to create and return initial
NotificationData when currentData is undefined, so the first INSERT is cached
instead of discarded. Also update the realtime subscription using
notificationsQueryKey(workspaceId) to handle both INSERT and UPDATE events,
either by invalidating the query for both or directly applying UPDATE changes.
Source: MCP tools
| function formatCreatedAt(value: string): string { | ||
| return new Intl.DateTimeFormat('ko-KR', { | ||
| month: 'numeric', | ||
| day: 'numeric', | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| }).format(new Date(value)); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the file structure first
ast-grep outline src/features/workspace-notifications/ui/NotificationPanel.tsx --view expanded
# Read the relevant file with line numbers
echo '--- FILE: src/features/workspace-notifications/ui/NotificationPanel.tsx ---'
cat -n src/features/workspace-notifications/ui/NotificationPanel.tsx | sed -n '1,220p'
# Find nearby date formatting / timezone usage in workspace-notifications
echo '--- SEARCH: date/time formatting in workspace-notifications ---'
rg -n "DateTimeFormat|timeZone|new Date\(" src/features/workspace-notifications -S
# Find any project-wide conventions for timezone formatting
echo '--- SEARCH: timeZone conventions in src ---'
rg -n "timeZone:\s*'Asia/Seoul'|timeZone:\s*\"Asia/Seoul\"|Intl\.DateTimeFormat" src -SRepository: TeampleRun/syncly
Length of output: 8079
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# If the previous command didn't surface output, inspect the specific file and related code only.
cat -n src/features/workspace-notifications/ui/NotificationPanel.tsx | sed -n '1,220p'Repository: TeampleRun/syncly
Length of output: 6432
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- FILE: src/features/workspace-notifications/ui/NotificationPanel.tsx ---'
cat -n src/features/workspace-notifications/ui/NotificationPanel.tsx | sed -n '1,220p'
echo '--- SEARCH: date/time formatting in workspace-notifications ---'
rg -n "DateTimeFormat|timeZone|new Date\(" src/features/workspace-notifications -S || true
echo '--- SEARCH: timeZone conventions in src ---'
rg -n "timeZone:\s*'Asia/Seoul'|timeZone:\s*\"Asia/Seoul\"|Intl\.DateTimeFormat" src -S || trueRepository: TeampleRun/syncly
Length of output: 7700
기준 시간대를 고정하세요.
Intl.DateTimeFormat이 런타임 기본 시간대를 쓰면 서버/브라우저 렌더링 결과가 달라질 수 있습니다. 알림 시간은 timeZone: 'Asia/Seoul'처럼 제품 기준 시간대로 고정하는 편이 안전합니다.
수정 예시
return new Intl.DateTimeFormat('ko-KR', {
+ timeZone: 'Asia/Seoul',
month: 'numeric',📝 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.
| function formatCreatedAt(value: string): string { | |
| return new Intl.DateTimeFormat('ko-KR', { | |
| month: 'numeric', | |
| day: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| }).format(new Date(value)); | |
| function formatCreatedAt(value: string): string { | |
| return new Intl.DateTimeFormat('ko-KR', { | |
| timeZone: 'Asia/Seoul', | |
| month: 'numeric', | |
| day: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| }).format(new Date(value)); |
🧰 Tools
🪛 React Doctor (0.7.6)
[error] 26-26: This can cause a hydration mismatch because Intl.DateTimeFormat().format() (reached from JSX through "formatCreatedAt") formats with the server's locale and timezone during server rendering but the user's in the browser. Format it in a post-mount useEffect, or pass an explicit locale and timeZone.
Format locale/timezone-dependent values in a post-mount useEffect + state, or pass an explicit locale and timeZone so the server and the browser render the same text. Only runs on SSR-capable projects.
(no-locale-format-in-render)
🤖 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 `@src/features/workspace-notifications/ui/NotificationPanel.tsx` around lines
25 - 31, Update formatCreatedAt to explicitly set the product time zone to
Asia/Seoul in its Intl.DateTimeFormat options, preserving the existing Korean
locale and date/time formatting.
Source: Linters/SAST tools
| const openNotification = async ( | ||
| event: React.MouseEvent<HTMLAnchorElement>, | ||
| notification: NotificationItem, | ||
| ) => { | ||
| event.preventDefault(); | ||
| if (!notification.readAt) { | ||
| const result = await markOneAsRead(notification.id); | ||
| if (!result.ok) toast.error(result.message); | ||
| } | ||
| onOpenChange(false); | ||
| router.push(notification.linkPath); | ||
| }; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== NotificationPanel outline ==\n'
ast-grep outline src/features/workspace-notifications/ui/NotificationPanel.tsx --view expanded || true
printf '\n== Relevant slice ==\n'
sed -n '1,220p' src/features/workspace-notifications/ui/NotificationPanel.tsx | cat -n
printf '\n== Search for NotificationItem link usage ==\n'
rg -n "NotificationItem|openNotification|markOneAsRead|linkPath|router.push|preventDefault|<Link|onClick=" src/features/workspace-notifications -S || trueRepository: TeampleRun/syncly
Length of output: 9365
링크 클릭에서 preventDefault()를 제거하세요.
Link의 기본 동작을 막아 Cmd/Ctrl+클릭과 새 탭 열기가 깨집니다. 읽음 처리는 void markOneAsRead(...)로 비동기 시작만 하고, 패널은 즉시 닫은 뒤 탐색은 Link에 맡기세요.
🤖 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 `@src/features/workspace-notifications/ui/NotificationPanel.tsx` around lines
49 - 60, Update openNotification to remove event.preventDefault(), start
markOneAsRead(notification.id) without awaiting it, and close the panel
immediately; remove manual router.push so the Link handles normal,
Cmd/Ctrl-click, and new-tab navigation.
| {query.trim().length < 2 && ( | ||
| <p className="px-4 py-4 text-sm text-slate-500">두 글자 이상 입력해 검색하세요.</p> | ||
| )} | ||
| {query.trim().length >= 2 && isLoading && ( | ||
| <p className="px-4 py-4 text-sm text-slate-500">검색 중입니다.</p> | ||
| )} | ||
| {query.trim().length >= 2 && !isLoading && isError && ( | ||
| <p className="px-4 py-4 text-sm text-rose-600">검색 결과를 불러오지 못했습니다.</p> | ||
| )} | ||
| {normalizedQuery.length >= 2 && !isLoading && !isError && results.length === 0 && ( | ||
| <p className="px-4 py-4 text-sm text-slate-500">검색 결과가 없습니다.</p> | ||
| )} | ||
| {results.length > 0 && ( | ||
| <ul className="max-h-96 overflow-y-auto py-1"> | ||
| {results.map((result) => ( | ||
| <li key={`${result.type}-${result.id}`}> | ||
| <Link | ||
| href={result.href} | ||
| onClick={() => onOpenChange(false)} | ||
| className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50" | ||
| > | ||
| <SearchTypeIcon type={result.type} /> | ||
| <span className="min-w-0 flex-1"> | ||
| <span className="mb-1 flex items-center gap-2"> | ||
| <span className="rounded bg-indigo-50 px-1.5 py-0.5 text-xs font-semibold text-indigo-600"> | ||
| {SEARCH_TYPE_LABEL[result.type]} | ||
| </span> | ||
| <span className="truncate text-sm font-semibold text-slate-800"> | ||
| {result.title} | ||
| </span> | ||
| </span> | ||
| {result.description && ( | ||
| <span className="block truncate text-xs text-slate-500"> | ||
| {result.description} | ||
| </span> | ||
| )} | ||
| </span> | ||
| </Link> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
로딩 중에는 이전 검색 결과를 숨기세요.
검색어가 바뀐 뒤 debounce가 끝나기 전에도 results.length > 0이면 이전 결과와 로딩 문구가 함께 표시됩니다. 현재 검색어와 정규화된 검색어가 같고 로딩·오류가 아닐 때만 목록을 렌더링하세요.
수정 예시
- {results.length > 0 && (
+ {query.trim() === normalizedQuery &&
+ normalizedQuery.length >= 2 &&
+ !isLoading &&
+ !isError &&
+ results.length > 0 && (📝 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.
| {query.trim().length < 2 && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">두 글자 이상 입력해 검색하세요.</p> | |
| )} | |
| {query.trim().length >= 2 && isLoading && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">검색 중입니다.</p> | |
| )} | |
| {query.trim().length >= 2 && !isLoading && isError && ( | |
| <p className="px-4 py-4 text-sm text-rose-600">검색 결과를 불러오지 못했습니다.</p> | |
| )} | |
| {normalizedQuery.length >= 2 && !isLoading && !isError && results.length === 0 && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">검색 결과가 없습니다.</p> | |
| )} | |
| {results.length > 0 && ( | |
| <ul className="max-h-96 overflow-y-auto py-1"> | |
| {results.map((result) => ( | |
| <li key={`${result.type}-${result.id}`}> | |
| <Link | |
| href={result.href} | |
| onClick={() => onOpenChange(false)} | |
| className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50" | |
| > | |
| <SearchTypeIcon type={result.type} /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="mb-1 flex items-center gap-2"> | |
| <span className="rounded bg-indigo-50 px-1.5 py-0.5 text-xs font-semibold text-indigo-600"> | |
| {SEARCH_TYPE_LABEL[result.type]} | |
| </span> | |
| <span className="truncate text-sm font-semibold text-slate-800"> | |
| {result.title} | |
| </span> | |
| </span> | |
| {result.description && ( | |
| <span className="block truncate text-xs text-slate-500"> | |
| {result.description} | |
| </span> | |
| )} | |
| </span> | |
| </Link> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| {query.trim().length < 2 && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">두 글자 이상 입력해 검색하세요.</p> | |
| )} | |
| {query.trim().length >= 2 && isLoading && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">검색 중입니다.</p> | |
| )} | |
| {query.trim().length >= 2 && !isLoading && isError && ( | |
| <p className="px-4 py-4 text-sm text-rose-600">검색 결과를 불러오지 못했습니다.</p> | |
| )} | |
| {normalizedQuery.length >= 2 && !isLoading && !isError && results.length === 0 && ( | |
| <p className="px-4 py-4 text-sm text-slate-500">검색 결과가 없습니다.</p> | |
| )} | |
| {query.trim() === normalizedQuery && | |
| normalizedQuery.length >= 2 && | |
| !isLoading && | |
| !isError && | |
| results.length > 0 && ( | |
| <ul className="max-h-96 overflow-y-auto py-1"> | |
| {results.map((result) => ( | |
| <li key={`${result.type}-${result.id}`}> | |
| <Link | |
| href={result.href} | |
| onClick={() => onOpenChange(false)} | |
| className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50" | |
| > | |
| <SearchTypeIcon type={result.type} /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="mb-1 flex items-center gap-2"> | |
| <span className="rounded bg-indigo-50 px-1.5 py-0.5 text-xs font-semibold text-indigo-600"> | |
| {SEARCH_TYPE_LABEL[result.type]} | |
| </span> | |
| <span className="truncate text-sm font-semibold text-slate-800"> | |
| {result.title} | |
| </span> | |
| </span> | |
| {result.description && ( | |
| <span className="block truncate text-xs text-slate-500"> | |
| {result.description} | |
| </span> | |
| )} | |
| </span> | |
| </Link> | |
| </li> | |
| ))} | |
| </ul> | |
| )} |
🤖 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 `@src/features/workspace-search/ui/WorkspaceSearchPanel.tsx` around lines 78 -
119, Update the results list rendering in WorkspaceSearchPanel so previous
results remain hidden while the query is debouncing or loading. Render the list
only when the current trimmed query matches normalizedQuery, is not loading, is
not in error, and results are non-empty; keep the existing status messages
unchanged.
| <header className="flex h-[72px] items-center justify-between gap-4 border-b border-slate-200 bg-white px-4 sm:px-8"> | ||
| <h1 className="shrink-0 text-lg font-bold text-slate-950">{title}</h1> | ||
|
|
||
| <div className="flex items-center gap-3"> | ||
| <label className="flex h-10 w-60 items-center gap-2 rounded-2xl bg-slate-100 px-4 text-slate-400"> | ||
| <Search className="h-4 w-4" aria-hidden="true" /> | ||
| <input | ||
| type="search" | ||
| placeholder="검색..." | ||
| className="min-w-0 flex-1 bg-transparent text-sm font-medium text-slate-700 outline-none placeholder:text-slate-400" | ||
| <div className="flex min-w-0 items-center gap-2 sm:gap-3"> | ||
| <div ref={searchRef}> | ||
| <WorkspaceSearchPanel | ||
| workspaceId={workspaceId} | ||
| isOpen={isSearchOpen} | ||
| onOpenChange={(isOpen) => { | ||
| setIsSearchOpen(isOpen); | ||
| if (isOpen) { | ||
| setIsNotificationOpen(false); | ||
| setIsMenuOpen(false); | ||
| } | ||
| }} | ||
| /> | ||
| </label> | ||
| </div> | ||
|
|
||
| <button | ||
| type="button" | ||
| className="flex h-10 items-center gap-2 rounded-2xl bg-[var(--color-brand)] px-4 text-sm font-bold text-white hover:bg-indigo-500" | ||
| <Link | ||
| href={`/workspaces/${workspaceId}/settings?tab=members`} | ||
| className="flex h-10 shrink-0 items-center gap-2 rounded-xl bg-[var(--color-brand)] px-3 text-sm font-bold whitespace-nowrap text-white hover:bg-indigo-500 sm:px-4" | ||
| > | ||
| <UserRoundPlus className="h-4 w-4" aria-hidden="true" /> | ||
| 멤버 초대 | ||
| </button> | ||
| <span className="hidden sm:inline">멤버 초대</span> | ||
| </Link> | ||
|
|
||
| <button | ||
| type="button" | ||
| aria-label="알림" | ||
| className="relative flex h-10 w-10 items-center justify-center rounded-full text-slate-500 hover:bg-slate-100" | ||
| > | ||
| <Bell className="h-5 w-5" aria-hidden="true" /> | ||
| <span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-rose-500" /> | ||
| </button> | ||
| <div ref={notificationRef}> | ||
| <NotificationPanel | ||
| workspaceId={workspaceId} | ||
| viewerId={currentMember.userId} | ||
| isOpen={isNotificationOpen} | ||
| onOpenChange={(isOpen) => { | ||
| setIsNotificationOpen(isOpen); | ||
| if (isOpen) { | ||
| setIsSearchOpen(false); | ||
| setIsMenuOpen(false); | ||
| } | ||
| }} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
모바일 헤더의 가로 오버플로를 방지하세요.
검색창만 192px이고 초대·알림·프로필 버튼과 간격까지 더하면 320px 화면의 사용 가능 폭을 이미 초과하며, 제목도 함께 렌더링됩니다. 작은 화면에서는 검색을 축약형 트리거로 전환하거나 제목/액션 배치를 재구성하세요.
🤖 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 `@src/widgets/workspace-shell/ui/WorkspaceHeader.tsx` around lines 71 - 110,
Update WorkspaceHeader’s mobile layout to prevent horizontal overflow within the
320px available width: use a compact search trigger or otherwise recompose the
title and action controls so the search, invite, notification, and profile
controls fit without overflow. Preserve the full search panel and existing
desktop layout for larger breakpoints.
| -- 수신자는 읽음 시각만 변경할 수 있고, 알림의 내용·대상·이동 경로는 불변으로 둔다. | ||
| create function private.prevent_notification_mutation() | ||
| returns trigger | ||
| language plpgsql | ||
| security invoker | ||
| set search_path = '' | ||
| as $$ | ||
| begin | ||
| if new.id is distinct from old.id | ||
| or new.recipient_id is distinct from old.recipient_id | ||
| or new.actor_id is distinct from old.actor_id | ||
| or new.workspace_id is distinct from old.workspace_id | ||
| or new.type is distinct from old.type | ||
| or new.title is distinct from old.title | ||
| or new.body is distinct from old.body | ||
| or new.link_path is distinct from old.link_path | ||
| or new.metadata is distinct from old.metadata | ||
| or new.created_at is distinct from old.created_at then | ||
| raise exception '알림의 읽음 상태 외 정보는 변경할 수 없습니다.'; | ||
| end if; | ||
|
|
||
| return new; | ||
| end; | ||
| $$; | ||
|
|
||
| create trigger prevent_notification_mutation | ||
| before update on public.notifications | ||
| for each row | ||
| execute function private.prevent_notification_mutation(); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
read_at을 되돌리거나 임의 시각으로 덮어쓰지 못하게 하세요.
현재 트리거는 다른 열만 보호하므로 수신자가 직접 read_at = null 또는 임의 시각을 저장할 수 있습니다. NULL → now() 전이만 허용하고 이미 읽은 상태는 불변으로 유지해야 합니다.
수정 예시
if new.id is distinct from old.id
...
raise exception '알림의 읽음 상태 외 정보는 변경할 수 없습니다.';
end if;
+ if old.read_at is not null
+ and new.read_at is distinct from old.read_at then
+ raise exception '이미 읽은 알림의 읽음 상태는 변경할 수 없습니다.';
+ end if;
+
+ if old.read_at is null then
+ if new.read_at is null then
+ raise exception '읽음 시각이 필요합니다.';
+ end if;
+ new.read_at := now();
+ end if;
+
return new;📝 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.
| -- 수신자는 읽음 시각만 변경할 수 있고, 알림의 내용·대상·이동 경로는 불변으로 둔다. | |
| create function private.prevent_notification_mutation() | |
| returns trigger | |
| language plpgsql | |
| security invoker | |
| set search_path = '' | |
| as $$ | |
| begin | |
| if new.id is distinct from old.id | |
| or new.recipient_id is distinct from old.recipient_id | |
| or new.actor_id is distinct from old.actor_id | |
| or new.workspace_id is distinct from old.workspace_id | |
| or new.type is distinct from old.type | |
| or new.title is distinct from old.title | |
| or new.body is distinct from old.body | |
| or new.link_path is distinct from old.link_path | |
| or new.metadata is distinct from old.metadata | |
| or new.created_at is distinct from old.created_at then | |
| raise exception '알림의 읽음 상태 외 정보는 변경할 수 없습니다.'; | |
| end if; | |
| return new; | |
| end; | |
| $$; | |
| create trigger prevent_notification_mutation | |
| before update on public.notifications | |
| for each row | |
| execute function private.prevent_notification_mutation(); | |
| -- 수신자는 읽음 시각만 변경할 수 있고, 알림의 내용·대상·이동 경로는 불변으로 둔다. | |
| create function private.prevent_notification_mutation() | |
| returns trigger | |
| language plpgsql | |
| security invoker | |
| set search_path = '' | |
| as $$ | |
| begin | |
| if new.id is distinct from old.id | |
| or new.recipient_id is distinct from old.recipient_id | |
| or new.actor_id is distinct from old.actor_id | |
| or new.workspace_id is distinct from old.workspace_id | |
| or new.type is distinct from old.type | |
| or new.title is distinct from old.title | |
| or new.body is distinct from old.body | |
| or new.link_path is distinct from old.link_path | |
| or new.metadata is distinct from old.metadata | |
| or new.created_at is distinct from old.created_at then | |
| raise exception '알림의 읽음 상태 외 정보는 변경할 수 없습니다.'; | |
| end if; | |
| if old.read_at is not null | |
| and new.read_at is distinct from old.read_at then | |
| raise exception '이미 읽은 알림의 읽음 상태는 변경할 수 없습니다.'; | |
| end if; | |
| if old.read_at is null then | |
| if new.read_at is null then | |
| raise exception '읽음 시각이 필요합니다.'; | |
| end if; | |
| new.read_at := now(); | |
| end if; | |
| return new; | |
| end; | |
| $$; | |
| create trigger prevent_notification_mutation | |
| before update on public.notifications | |
| for each row | |
| execute function private.prevent_notification_mutation(); |
🤖 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 `@supabase/migrations/20260716030000_create_workspace_notifications.sql` around
lines 48 - 76, Update private.prevent_notification_mutation to validate read_at
transitions: allow only a NULL old.read_at changing to the current timestamp via
now(), reject clearing it or replacing an existing value, and preserve the
existing protections for all other notification fields.
Pull Request
작업 내용
작업 결과
변경 사항
Added
notifications테이블, RLS 정책, 공지·업무 알림 트리거 및 Realtime publicationChanged
/settings?tab=members로 연결했습니다.Fixed
SECURITY DEFINER트리거 함수와 공개 실행 권한 회수로 보완했습니다.실행화면
-알림

테스트
npm run lintnpm run typechecknpm run build리뷰 체크리스트
feature/*->develop, 배포 시develop또는release/*->main)Type/#issue-number/description형식을 따릅니다.console.log, 주석, 임시 코드를 제거했습니다.리뷰 요청사항
notificationsRLS와 SECURITY DEFINER 트리거 함수가 의도한 권한 경계를 유지하는지 확인 부탁드립니다.20260716030000_create_workspace_notifications.sql20260716040000_fix_notification_trigger_permissions.sql도 추가 적용관련 이슈
Closes #69
Summary by CodeRabbit
새로운 기능
개선 사항