diff --git a/src/app/api/workspaces/[workspaceId]/search/route.ts b/src/app/api/workspaces/[workspaceId]/search/route.ts new file mode 100644 index 0000000..d7c0a19 --- /dev/null +++ b/src/app/api/workspaces/[workspaceId]/search/route.ts @@ -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(); + 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 }); + } +} diff --git a/src/entities/notification/api/get-notifications.ts b/src/entities/notification/api/get-notifications.ts new file mode 100644 index 0000000..c70ab2e --- /dev/null +++ b/src/entities/notification/api/get-notifications.ts @@ -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 { + 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, + }; +} diff --git a/src/entities/notification/api/notification-actions.ts b/src/entities/notification/api/notification-actions.ts new file mode 100644 index 0000000..402aa5e --- /dev/null +++ b/src/entities/notification/api/notification-actions.ts @@ -0,0 +1,51 @@ +'use server'; + +// 현재 사용자가 수신한 알림을 개별 또는 일괄 읽음 처리합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { NotificationActionResult } from '../model/notification.types'; + +const markNotificationsReadSchema = z.object({ + workspaceId: z.guid(), + notificationIds: z.array(z.guid()).min(1).optional(), +}); + +export async function markNotificationsRead(input: { + workspaceId: string; + notificationIds?: string[]; +}): Promise> { + const parsed = markNotificationsReadSchema.safeParse(input); + + if (!parsed.success) { + return { ok: false, message: '읽음 처리할 알림 정보가 올바르지 않습니다.' }; + } + + try { + const supabase = await createSupabaseServerClient(); + // 수신자 본인의 미읽음 알림만 수정하기 위한 세션 사용자 ID입니다. + const currentUserId = await getCurrentUserId(); + let query = supabase + .from('notifications') + .update({ read_at: new Date().toISOString() }) + .eq('workspace_id', parsed.data.workspaceId) + .eq('recipient_id', currentUserId) + .is('read_at', null); + + if (parsed.data.notificationIds) { + query = query.in('id', parsed.data.notificationIds); + } + + const { error } = await query; + + if (error) { + console.error('[notification] 읽음 처리 실패:', error); + return { ok: false, message: '알림 읽음 처리에 실패했습니다.' }; + } + + return { ok: true, data: undefined }; + } catch (error) { + console.error('[notification] 읽음 처리 중 예상하지 못한 오류:', error); + return { ok: false, message: '알림 읽음 처리에 실패했습니다.' }; + } +} diff --git a/src/entities/notification/index.ts b/src/entities/notification/index.ts new file mode 100644 index 0000000..00a505f --- /dev/null +++ b/src/entities/notification/index.ts @@ -0,0 +1,10 @@ +// 알림 도메인이 헤더 기능에 제공하는 조회·읽음 API와 타입 공개 진입점입니다. +export { getNotifications } from './api/get-notifications'; +export { markNotificationsRead } from './api/notification-actions'; +export { notificationsQueryKey } from './model/notification-query'; +export type { + NotificationActionResult, + NotificationData, + NotificationItem, + NotificationType, +} from './model/notification.types'; diff --git a/src/entities/notification/model/notification-query.ts b/src/entities/notification/model/notification-query.ts new file mode 100644 index 0000000..47990af --- /dev/null +++ b/src/entities/notification/model/notification-query.ts @@ -0,0 +1,3 @@ +// 워크스페이스별 알림 목록과 읽지 않은 개수를 함께 갱신하기 위한 Query 키입니다. +export const notificationsQueryKey = (workspaceId: string) => + ['notifications', workspaceId] as const; diff --git a/src/entities/notification/model/notification.types.ts b/src/entities/notification/model/notification.types.ts new file mode 100644 index 0000000..3a37a31 --- /dev/null +++ b/src/entities/notification/model/notification.types.ts @@ -0,0 +1,20 @@ +// 헤더 알림 목록과 읽음 처리에서 공통으로 사용하는 알림 도메인 타입입니다. +export type NotificationType = 'announcement_created' | 'task_assigned' | 'task_status_changed'; + +export interface NotificationItem { + id: string; + workspaceId: string; + type: NotificationType; + title: string; + body: string | null; + linkPath: string; + readAt: string | null; + createdAt: string; +} + +export interface NotificationData { + notifications: NotificationItem[]; + unreadCount: number; +} + +export type NotificationActionResult = { ok: true; data: T } | { ok: false; message: string }; diff --git a/src/entities/workspace-search/api/search-workspace.ts b/src/entities/workspace-search/api/search-workspace.ts new file mode 100644 index 0000000..8b37aa2 --- /dev/null +++ b/src/entities/workspace-search/api/search-workspace.ts @@ -0,0 +1,20 @@ +// 헤더의 TanStack Query가 워크스페이스 통합 검색 Route Handler를 호출하는 클라이언트 API입니다. +import type { WorkspaceSearchResult } from '../model/workspace-search.types'; + +export async function searchWorkspace(input: { + workspaceId: string; + query: string; + signal?: AbortSignal; +}): Promise { + const searchParams = new URLSearchParams({ q: input.query }); + const response = await fetch(`/api/workspaces/${input.workspaceId}/search?${searchParams}`, { + signal: input.signal, + }); + + if (!response.ok) { + throw new Error('검색 결과를 불러오지 못했습니다.'); + } + + const payload = (await response.json()) as { results: WorkspaceSearchResult[] }; + return payload.results; +} diff --git a/src/entities/workspace-search/index.ts b/src/entities/workspace-search/index.ts new file mode 100644 index 0000000..ea78aca --- /dev/null +++ b/src/entities/workspace-search/index.ts @@ -0,0 +1,4 @@ +// 워크스페이스 통합 검색 도메인의 타입과 Query 키 공개 진입점입니다. +export { searchWorkspace } from './api/search-workspace'; +export { workspaceSearchQueryKey } from './model/workspace-search-query'; +export type { WorkspaceSearchResult, WorkspaceSearchType } from './model/workspace-search.types'; diff --git a/src/entities/workspace-search/model/workspace-search-query.ts b/src/entities/workspace-search/model/workspace-search-query.ts new file mode 100644 index 0000000..bce2495 --- /dev/null +++ b/src/entities/workspace-search/model/workspace-search-query.ts @@ -0,0 +1,3 @@ +// 워크스페이스와 검색어별로 결과 캐시를 분리하기 위한 TanStack Query 키입니다. +export const workspaceSearchQueryKey = (workspaceId: string, query: string) => + ['workspace-search', workspaceId, query] as const; diff --git a/src/entities/workspace-search/model/workspace-search.types.ts b/src/entities/workspace-search/model/workspace-search.types.ts new file mode 100644 index 0000000..b1b2e4a --- /dev/null +++ b/src/entities/workspace-search/model/workspace-search.types.ts @@ -0,0 +1,11 @@ +// 워크스페이스 통합 검색 결과를 모든 검색 원본에서 동일하게 표현합니다. +export type WorkspaceSearchType = + 'announcement' | 'resource' | 'task' | 'chat' | 'meetingNote' | 'calendarEvent'; + +export interface WorkspaceSearchResult { + id: string; + type: WorkspaceSearchType; + title: string; + description: string | null; + href: string; +} diff --git a/src/features/workspace-notifications/index.ts b/src/features/workspace-notifications/index.ts new file mode 100644 index 0000000..9fc9389 --- /dev/null +++ b/src/features/workspace-notifications/index.ts @@ -0,0 +1,2 @@ +// 헤더가 사용하는 워크스페이스 알림 UI 공개 진입점입니다. +export { NotificationPanel } from './ui/NotificationPanel'; diff --git a/src/features/workspace-notifications/model/use-workspace-notifications.ts b/src/features/workspace-notifications/model/use-workspace-notifications.ts new file mode 100644 index 0000000..2caa95d --- /dev/null +++ b/src/features/workspace-notifications/model/use-workspace-notifications.ts @@ -0,0 +1,123 @@ +'use client'; + +// 알림 목록 캐시, 읽음 처리 mutation, Supabase Realtime INSERT 구독을 한 곳에서 관리합니다. +import { useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getNotifications, + markNotificationsRead, + notificationsQueryKey, + type NotificationData, + type NotificationItem, + type NotificationType, +} from '@/entities/notification'; +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import type { GenericTables } from '@/shared/model/supabase.types'; + +type NotificationRow = GenericTables<'notifications'>; + +function toNotificationItem(row: NotificationRow): 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, + }; +} + +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), + }; +} + +export function useWorkspaceNotifications({ + workspaceId, + viewerId, +}: { + workspaceId: string; + viewerId: string; +}) { + // 동일 워크스페이스의 헤더 알림 UI를 함께 갱신하기 위한 TanStack Query 클라이언트입니다. + const queryClient = useQueryClient(); + const notificationQuery = useQuery({ + queryKey: notificationsQueryKey(workspaceId), + queryFn: () => getNotifications(workspaceId), + }); + const markReadMutation = useMutation({ + mutationFn: (notificationIds?: string[]) => + markNotificationsRead({ workspaceId, notificationIds }), + onSuccess: (result, notificationIds) => { + if (!result.ok) return; + + const readAt = new Date().toISOString(); + queryClient.setQueryData( + notificationsQueryKey(workspaceId), + (currentData) => { + if (!currentData) return currentData; + const shouldMarkRead = (notification: NotificationItem) => + !notification.readAt && (!notificationIds || notificationIds.includes(notification.id)); + const notifications = currentData.notifications.map((notification) => + shouldMarkRead(notification) ? { ...notification, readAt } : notification, + ); + + return { + notifications, + unreadCount: notifications.filter((notification) => !notification.readAt).length, + }; + }, + ); + }, + }); + + useEffect(() => { + // RLS와 recipient_id를 함께 확인해 현재 사용자에게 도착한 이벤트만 캐시에 넣습니다. + const supabase = getSupabaseBrowserClient(); + const channel = supabase + .channel(`workspace-notifications:${workspaceId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'notifications', + filter: `workspace_id=eq.${workspaceId}`, + }, + (payload) => { + const row = payload.new as NotificationRow; + if (row.recipient_id !== viewerId) return; + + queryClient.setQueryData( + notificationsQueryKey(workspaceId), + (currentData) => addNotification(currentData, toNotificationItem(row)), + ); + }, + ) + .subscribe(); + + return () => { + void supabase.removeChannel(channel); + }; + }, [queryClient, viewerId, workspaceId]); + + return { + notifications: notificationQuery.data?.notifications ?? [], + unreadCount: notificationQuery.data?.unreadCount ?? 0, + isLoading: notificationQuery.isPending, + isError: notificationQuery.isError, + markOneAsRead: async (notificationId: string) => markReadMutation.mutateAsync([notificationId]), + markAllAsRead: async () => markReadMutation.mutateAsync(undefined), + }; +} diff --git a/src/features/workspace-notifications/ui/NotificationPanel.tsx b/src/features/workspace-notifications/ui/NotificationPanel.tsx new file mode 100644 index 0000000..84e9a2c --- /dev/null +++ b/src/features/workspace-notifications/ui/NotificationPanel.tsx @@ -0,0 +1,143 @@ +'use client'; + +// 헤더 종 아이콘에서 최근 알림, 읽지 않은 개수, 읽음 처리 명령을 표시합니다. +import { Bell, CheckCheck, ClipboardList, Megaphone } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import type { NotificationItem } from '@/entities/notification'; +import { useWorkspaceNotifications } from '../model/use-workspace-notifications'; + +interface NotificationPanelProps { + workspaceId: string; + viewerId: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +function getNotificationIcon(type: NotificationItem['type']) { + const className = 'h-4 w-4 text-indigo-500'; + if (type === 'announcement_created') + return