Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions src/app/api/workspaces/[workspaceId]/search/route.ts
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();
Comment on lines +53 to +56

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

🧩 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' || true

Repository: 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 || true

Repository: 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 -n

Repository: 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: 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 });
}
}
60 changes: 60 additions & 0 deletions src/entities/notification/api/get-notifications.ts
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

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

🧩 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.tsx

Repository: 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

};
}
Loading