-
Notifications
You must be signed in to change notification settings - Fork 0
feat:헤더 통합 검색 및 알림 구현(#69) #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| // 현재 워크스페이스 멤버만 공지·자료실·업무·채팅을 통합 검색할 수 있는 Route Handler입니다. | ||
| import { NextResponse, type NextRequest } from 'next/server'; | ||
| import { z } from 'zod'; | ||
| import type { WorkspaceSearchResult } from '@/entities/workspace-search'; | ||
| import { getCurrentUserId } from '@/shared/api/supabase/current-user'; | ||
| import { createSupabaseServerClient } from '@/shared/api/supabase/server'; | ||
|
|
||
| const requestSchema = z.object({ | ||
| workspaceId: z.guid(), | ||
| query: z.string().trim().max(100), | ||
| }); | ||
| const SEARCH_RESULT_LIMIT = 5; | ||
|
|
||
| type SearchableRow = { id: string; title: string; description: string | null }; | ||
|
|
||
| function escapeLikePattern(query: string): string { | ||
| return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); | ||
| } | ||
|
|
||
| function mergeRows(...groups: SearchableRow[][]): SearchableRow[] { | ||
| const rowById = new Map<string, SearchableRow>(); | ||
| groups.flat().forEach((row) => rowById.set(row.id, row)); | ||
| return [...rowById.values()].slice(0, SEARCH_RESULT_LIMIT); | ||
| } | ||
|
|
||
| function getTaskHref(workspaceId: string, purpose: string): string { | ||
| if (purpose === 'side_project') return `/workspaces/${workspaceId}/sprint-board`; | ||
| if (purpose === 'team_project') return `/workspaces/${workspaceId}/project-management`; | ||
| return `/workspaces/${workspaceId}/dashboard`; | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: NextRequest, | ||
| context: { params: Promise<{ workspaceId: string }> }, | ||
| ) { | ||
| const { workspaceId } = await context.params; | ||
| const parsed = requestSchema.safeParse({ | ||
| workspaceId, | ||
| query: request.nextUrl.searchParams.get('q') ?? '', | ||
| }); | ||
|
|
||
| if (!parsed.success) { | ||
| return NextResponse.json( | ||
| { message: '검색어 또는 워크스페이스 정보가 올바르지 않습니다.' }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| if (parsed.data.query.length < 2) { | ||
| return NextResponse.json({ results: [] satisfies WorkspaceSearchResult[] }); | ||
| } | ||
|
|
||
| try { | ||
| const supabase = await createSupabaseServerClient(); | ||
| // 모든 검색 원본을 현재 사용자와 선택한 워크스페이스 범위로 제한하는 기준값입니다. | ||
| const currentUserId = await getCurrentUserId(); | ||
| const [ | ||
| { data: membership, error: membershipError }, | ||
| { data: workspace, error: workspaceError }, | ||
| ] = await Promise.all([ | ||
| supabase | ||
| .from('workspace_members') | ||
| .select('user_id') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .eq('user_id', currentUserId) | ||
| .maybeSingle(), | ||
| supabase.from('workspaces').select('purpose').eq('id', parsed.data.workspaceId).maybeSingle(), | ||
| ]); | ||
|
|
||
| if (membershipError || workspaceError) { | ||
| console.error('[workspace search] 권한 확인 실패:', membershipError ?? workspaceError); | ||
| return NextResponse.json({ message: '검색 권한을 확인하지 못했습니다.' }, { status: 500 }); | ||
| } | ||
|
|
||
| if (!membership || !workspace) { | ||
| return NextResponse.json( | ||
| { message: '워크스페이스 멤버만 검색할 수 있습니다.' }, | ||
| { status: 403 }, | ||
| ); | ||
| } | ||
|
|
||
| const pattern = `%${escapeLikePattern(parsed.data.query)}%`; | ||
| const [ | ||
| announcementTitle, | ||
| announcementContent, | ||
| resourceTitle, | ||
| resourceDescription, | ||
| taskTitle, | ||
| taskDescription, | ||
| chatContent, | ||
| meetingNoteTitle, | ||
| meetingNoteContent, | ||
| calendarEventTitle, | ||
| calendarEventDescription, | ||
| ] = await Promise.all([ | ||
| supabase | ||
| .from('announcements') | ||
| .select('id, title, content') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('title', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('announcements') | ||
| .select('id, title, content') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('content', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('resources') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('title', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('resources') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('description', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('tasks') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('title', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('tasks') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('description', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('chat_messages') | ||
| .select('id, content') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('content', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('meeting_notes') | ||
| .select('id, title, content') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('title', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('meeting_notes') | ||
| .select('id, title, content') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('content', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('calendar_events') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('title', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| supabase | ||
| .from('calendar_events') | ||
| .select('id, title, description') | ||
| .eq('workspace_id', parsed.data.workspaceId) | ||
| .ilike('description', pattern) | ||
| .limit(SEARCH_RESULT_LIMIT), | ||
| ]); | ||
|
|
||
| const errors = [ | ||
| announcementTitle.error, | ||
| announcementContent.error, | ||
| resourceTitle.error, | ||
| resourceDescription.error, | ||
| taskTitle.error, | ||
| taskDescription.error, | ||
| chatContent.error, | ||
| meetingNoteTitle.error, | ||
| meetingNoteContent.error, | ||
| calendarEventTitle.error, | ||
| calendarEventDescription.error, | ||
| ]; | ||
|
|
||
| if (errors.some(Boolean)) { | ||
| console.error('[workspace search] 검색 조회 실패:', errors.find(Boolean)); | ||
| return NextResponse.json({ message: '검색 결과를 불러오지 못했습니다.' }, { status: 500 }); | ||
| } | ||
|
|
||
| const announcementRows = mergeRows( | ||
| (announcementTitle.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.content, | ||
| })), | ||
| (announcementContent.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.content, | ||
| })), | ||
| ); | ||
| const resourceRows = mergeRows( | ||
| (resourceTitle.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| (resourceDescription.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| ); | ||
| const taskRows = mergeRows( | ||
| (taskTitle.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| (taskDescription.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| ); | ||
| const chatRows = (chatContent.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.content, | ||
| description: null, | ||
| })); | ||
| const meetingNoteRows = mergeRows( | ||
| (meetingNoteTitle.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.content, | ||
| })), | ||
| (meetingNoteContent.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.content, | ||
| })), | ||
| ); | ||
| const calendarEventRows = mergeRows( | ||
| (calendarEventTitle.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| (calendarEventDescription.data ?? []).map((row) => ({ | ||
| id: row.id, | ||
| title: row.title, | ||
| description: row.description, | ||
| })), | ||
| ); | ||
|
|
||
| const results: WorkspaceSearchResult[] = [ | ||
| ...announcementRows.map((row) => ({ | ||
| ...row, | ||
| type: 'announcement' as const, | ||
| href: `/workspaces/${parsed.data.workspaceId}/notices`, | ||
| })), | ||
| ...resourceRows.map((row) => ({ | ||
| ...row, | ||
| type: 'resource' as const, | ||
| href: `/workspaces/${parsed.data.workspaceId}/files`, | ||
| })), | ||
| ...taskRows.map((row) => ({ | ||
| ...row, | ||
| type: 'task' as const, | ||
| href: getTaskHref(parsed.data.workspaceId, workspace.purpose), | ||
| })), | ||
| ...chatRows.map((row) => ({ | ||
| ...row, | ||
| type: 'chat' as const, | ||
| href: `/workspaces/${parsed.data.workspaceId}/chat`, | ||
| })), | ||
| ...meetingNoteRows.map((row) => ({ | ||
| ...row, | ||
| type: 'meetingNote' as const, | ||
| href: `/workspaces/${parsed.data.workspaceId}/meeting-notes`, | ||
| })), | ||
| ...calendarEventRows.map((row) => ({ | ||
| ...row, | ||
| type: 'calendarEvent' as const, | ||
| href: `/workspaces/${parsed.data.workspaceId}/calendar`, | ||
| })), | ||
| ]; | ||
|
|
||
| return NextResponse.json({ results }); | ||
| } catch (error) { | ||
| console.error('[workspace search] 예상하지 못한 오류:', error); | ||
| return NextResponse.json({ message: '검색 결과를 불러오지 못했습니다.' }, { status: 500 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| 'use server'; | ||
|
|
||
| // 현재 사용자가 수신한 워크스페이스 알림 최근 10건과 읽지 않은 개수를 조회합니다. | ||
| import { z } from 'zod'; | ||
| import { getCurrentUserId } from '@/shared/api/supabase/current-user'; | ||
| import { createSupabaseServerClient } from '@/shared/api/supabase/server'; | ||
| import type { | ||
| NotificationData, | ||
| NotificationItem, | ||
| NotificationType, | ||
| } from '../model/notification.types'; | ||
|
|
||
| const workspaceIdSchema = z.guid(); | ||
|
|
||
| function toNotificationItem(row: { | ||
| id: string; | ||
| workspace_id: string; | ||
| type: string; | ||
| title: string; | ||
| body: string | null; | ||
| link_path: string; | ||
| read_at: string | null; | ||
| created_at: string; | ||
| }): NotificationItem { | ||
| return { | ||
| id: row.id, | ||
| workspaceId: row.workspace_id, | ||
| type: row.type as NotificationType, | ||
| title: row.title, | ||
| body: row.body, | ||
| linkPath: row.link_path, | ||
| readAt: row.read_at, | ||
| createdAt: row.created_at, | ||
| }; | ||
| } | ||
|
|
||
| export async function getNotifications(workspaceId: string): Promise<NotificationData> { | ||
| const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); | ||
| const supabase = await createSupabaseServerClient(); | ||
| // 현재 사용자가 수신자인 알림만 조회하기 위한 세션 사용자 ID입니다. | ||
| const currentUserId = await getCurrentUserId(); | ||
| 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, | ||
|
Comment on lines
+42
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 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건에서
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: MCP tools |
||
| }; | ||
| } | ||
There was a problem hiding this comment.
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
🧩 Analysis chain
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 738
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 17710
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 227
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 12819
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 2137
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 11959
인증 실패는 401로 분리하세요.
getCurrentUserId()의 인증 실패가 바깥catch에서 500으로 바뀝니다. 세션 만료나 미인증 요청은 401로 반환하도록 분기하세요.🤖 Prompt for AI Agents