Skip to content
Merged
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
10 changes: 7 additions & 3 deletions src/app/workspaces/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 워크스페이스 공통 사이드바와 헤더를 적용하는 라우트 레이아웃입니다.
import { notFound } from 'next/navigation';
import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id';
import { getCurrentWorkspaceMember } from '@/entities/workspace-member/api/get-current-workspace-member';
import { WorkspaceShell } from '@/widgets/workspace-shell';

interface WorkspaceLayoutProps {
Expand All @@ -12,14 +13,17 @@ interface WorkspaceLayoutProps {

export default async function WorkspaceLayout({ children, params }: WorkspaceLayoutProps) {
const { workspaceId } = await params;
const workspace = await getWorkspaceById(workspaceId);
const [workspace, currentMember] = await Promise.all([
getWorkspaceById(workspaceId),
getCurrentWorkspaceMember(workspaceId),
]);

if (!workspace) {
if (!workspace || !currentMember) {
notFound();
}

return (
<WorkspaceShell workspace={workspace} workspaceId={workspaceId}>
<WorkspaceShell workspace={workspace} workspaceId={workspaceId} currentMember={currentMember}>
{children}
</WorkspaceShell>
);
Expand Down
4 changes: 3 additions & 1 deletion src/app/workspaces/[workspaceId]/notices/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// 워크스페이스 공지 페이지의 라우트 진입점입니다.
import { getNoticeBoard } from '@/entities/notice/api/get-notice-board';
import { NoticesView } from '@/views/store-operation/notices';

interface NoticesPageProps {
Expand All @@ -9,6 +10,7 @@ interface NoticesPageProps {

export default async function NoticesPage({ params }: NoticesPageProps) {
const { workspaceId } = await params;
const initialData = await getNoticeBoard(workspaceId);

return <NoticesView workspaceId={workspaceId} />;
return <NoticesView workspaceId={workspaceId} initialData={initialData} />;
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

초기 조회 직후 동일한 공지 보드를 다시 요청하지 않도록 freshness 정책을 설정하세요.

useNoticeBoardStateuseQueryinitialData만 받고 기본 staleTime을 사용하므로, 서버에서 이미 조회한 데이터가 클라이언트 마운트 직후 stale로 처리되어 재조회됩니다. staleTime/initialDataUpdatedAt을 명시하거나 hydration 전략으로 중복 Supabase 조회를 막아주세요.

🤖 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/workspaces/`[workspaceId]/notices/page.tsx around lines 13 - 15,
Update the notice-board data flow around getNoticeBoard and
NoticesView/useNoticeBoardState so server-fetched initialData is treated as
fresh on client mount. Configure the query’s staleTime and/or
initialDataUpdatedAt, or use the existing hydration strategy, to prevent an
immediate duplicate Supabase request while preserving normal refetch behavior
after the freshness window.

}
4 changes: 2 additions & 2 deletions src/app/workspaces/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export default async function WorkspaceHomePage({ params }: WorkspaceHomePagePro
}

if (workspace.purpose === 'store-operation') {
redirect(`/workspaces/${workspaceId}/work-schedule`);
redirect(`/workspaces/${workspaceId}/dashboard`);
}
if (workspace.purpose === 'side-project') {
redirect(`/workspaces/${workspaceId}/sprint-board`);
redirect(`/workspaces/${workspaceId}/dashboard`);
}

redirect(`/workspaces/${workspaceId}/project-management`);
Expand Down
84 changes: 84 additions & 0 deletions src/entities/notice/api/get-notice-board.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use server';

// 워크스페이스 공지와 현재 사용자의 역할을 함께 조회해 공지 화면·위젯의 데이터 기준을 통일합니다.
import { z } from 'zod';
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';
import type { Notice, NoticeBoardData } from '../model/notice.types';

const workspaceIdSchema = z.guid();

function toCreatedAtLabel(value: string): string {
return value.slice(0, 10);
}

export async function getNoticeBoard(workspaceId: string): Promise<NoticeBoardData> {
const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId);
const supabase = await createSupabaseServerClient();
const currentUserId = await getCurrentUserId();

const [
{ data: announcements, error: announcementError },
{ data: membership, error: memberError },
] = await Promise.all([
supabase
.from('announcements')
.select('id, workspace_id, author_id, title, content, is_pinned, created_at')
.eq('workspace_id', parsedWorkspaceId)
.order('is_pinned', { ascending: false })
.order('created_at', { ascending: false }),
supabase
.from('workspace_members')
.select('user_id, role')
.eq('workspace_id', parsedWorkspaceId)
.eq('user_id', currentUserId)
.maybeSingle(),
]);

if (announcementError) {
throw new Error(`공지 조회에 실패했습니다: ${announcementError.message}`);
}

if (memberError) {
throw new Error(`현재 멤버 조회에 실패했습니다: ${memberError.message}`);
}

const authorIds = [
...new Set(
(announcements ?? []).flatMap((notice) => (notice.author_id ? [notice.author_id] : [])),
),
];
const { data: profiles, error: profileError } = authorIds.length
? await supabase.from('profiles').select('id, real_name').in('id', authorIds)
: { data: [], error: null };

if (profileError) {
throw new Error(`공지 작성자 조회에 실패했습니다: ${profileError.message}`);
}

const profileNameById = new Map(
(profiles ?? []).map((profile) => [profile.id, profile.real_name]),
);
const notices: Notice[] = (announcements ?? []).map((notice) => ({
id: notice.id,
workspaceId: notice.workspace_id,
authorId: notice.author_id,
title: notice.title,
content: notice.content,
authorName: notice.author_id
? (profileNameById.get(notice.author_id) ?? '알 수 없음')
: '탈퇴한 사용자',
createdAt: toCreatedAtLabel(notice.created_at),
isPinned: notice.is_pinned,
}));

return {
notices,
viewer: membership
? {
userId: membership.user_id,
role: membership.role,
}
: null,
};
}
208 changes: 208 additions & 0 deletions src/entities/notice/api/notice-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
'use server';

// 공지 작성·수정·삭제·고정 변경을 입력 검증과 워크스페이스 권한 확인 후 처리합니다.
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

const uuidSchema = z.guid();
const noticeContentSchema = z.object({
title: z.string().trim().min(1, '공지 제목을 입력해주세요.').max(120),
content: z.string().trim().min(1, '공지 내용을 입력해주세요.').max(10_000),
});

type WorkspaceMember = { user_id: string; role: 'owner' | 'member' };

export type NoticeActionResult<T> = { ok: true; data: T } | { ok: false; message: string };

class NoticeActionError extends Error {}

function throwNoticeActionError(message: string): never {
throw new NoticeActionError(message);
}

function toActionFailure(error: unknown, fallbackMessage: string): NoticeActionResult<never> {
if (error instanceof NoticeActionError) {
return { ok: false, message: error.message };
}

console.error('[notice action] 예상하지 못한 오류:', error);
return { ok: false, message: fallbackMessage };
}

function revalidateNoticePages(workspaceId: string): void {
revalidatePath(`/workspaces/${workspaceId}/notices`);
revalidatePath(`/workspaces/${workspaceId}/dashboard`);
}

async function getCurrentWorkspaceMember(workspaceId: string): Promise<{
supabase: Awaited<ReturnType<typeof createSupabaseServerClient>>;
member: WorkspaceMember;
}> {
const supabase = await createSupabaseServerClient();
const currentUserId = await getCurrentUserId();
const { data, error } = await supabase
.from('workspace_members')
.select('user_id, role')
.eq('workspace_id', workspaceId)
.eq('user_id', currentUserId)
.maybeSingle();

if (error) {
console.error('[notice action] 워크스페이스 멤버 확인 실패:', error);
throwNoticeActionError('워크스페이스 멤버 정보를 확인하지 못했습니다.');
}

if (!data) {
throwNoticeActionError('워크스페이스 멤버만 공지를 관리할 수 있습니다.');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { supabase, member: data };
}

async function getEditableAnnouncement(input: { workspaceId: string; noticeId: string }) {
const { supabase, member } = await getCurrentWorkspaceMember(input.workspaceId);
const { data: notice, error } = await supabase
.from('announcements')
.select('id, author_id')
.eq('id', input.noticeId)
.eq('workspace_id', input.workspaceId)
.maybeSingle();

if (error) {
console.error('[notice action] 공지 조회 실패:', error);
throwNoticeActionError('공지 정보를 확인하지 못했습니다.');
}

if (!notice) {
throwNoticeActionError('공지를 찾을 수 없습니다.');
}

if (member.role !== 'owner' && notice.author_id !== member.user_id) {
throwNoticeActionError(
'작성자 또는 워크스페이스 소유자만 공지를 수정하거나 삭제할 수 있습니다.',
);
}

return { supabase, member, notice };
}

export async function createNotice(input: {
workspaceId: string;
title: string;
content: string;
}): Promise<NoticeActionResult<{ id: string }>> {
try {
const value = z.object({ workspaceId: uuidSchema }).merge(noticeContentSchema).parse(input);
const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId);
const { data, error } = await supabase
.from('announcements')
.insert({
workspace_id: value.workspaceId,
author_id: member.user_id,
title: value.title,
content: value.content,
})
.select('id')
.single();

if (error) {
console.error('[notice action] 공지 등록 실패:', error);
throwNoticeActionError('공지 등록에 실패했습니다. 잠시 후 다시 시도해주세요.');
}

revalidateNoticePages(value.workspaceId);
return { ok: true, data: { id: data.id } };
} catch (error) {
return toActionFailure(error, '공지 등록에 실패했습니다. 입력값을 확인해주세요.');
}
}

export async function updateNotice(input: {
workspaceId: string;
noticeId: string;
title: string;
content: string;
}): Promise<NoticeActionResult<void>> {
try {
const value = z
.object({ workspaceId: uuidSchema, noticeId: uuidSchema })
.merge(noticeContentSchema)
.parse(input);
const { supabase } = await getEditableAnnouncement(value);
const { error } = await supabase
.from('announcements')
.update({ title: value.title, content: value.content })
.eq('id', value.noticeId)
.eq('workspace_id', value.workspaceId);

if (error) {
console.error('[notice action] 공지 수정 실패:', error);
throwNoticeActionError('공지 수정에 실패했습니다. 잠시 후 다시 시도해주세요.');
}

revalidateNoticePages(value.workspaceId);
return { ok: true, data: undefined };
} catch (error) {
return toActionFailure(error, '공지 수정에 실패했습니다. 입력값을 확인해주세요.');
}
}

export async function deleteNotice(input: {
workspaceId: string;
noticeId: string;
}): Promise<NoticeActionResult<void>> {
try {
const value = z.object({ workspaceId: uuidSchema, noticeId: uuidSchema }).parse(input);
const { supabase } = await getEditableAnnouncement(value);
const { error } = await supabase
.from('announcements')
.delete()
.eq('id', value.noticeId)
.eq('workspace_id', value.workspaceId);

if (error) {
console.error('[notice action] 공지 삭제 실패:', error);
throwNoticeActionError('공지 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.');
}

revalidateNoticePages(value.workspaceId);
return { ok: true, data: undefined };
} catch (error) {
return toActionFailure(error, '공지 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}

export async function setNoticePinned(input: {
workspaceId: string;
noticeId: string;
isPinned: boolean;
}): Promise<NoticeActionResult<void>> {
try {
const value = z
.object({ workspaceId: uuidSchema, noticeId: uuidSchema, isPinned: z.boolean() })
.parse(input);
const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId);

if (member.role !== 'owner') {
throwNoticeActionError('워크스페이스 소유자만 공지를 고정할 수 있습니다.');
}

const { error } = await supabase
.from('announcements')
.update({ is_pinned: value.isPinned })
.eq('id', value.noticeId)
.eq('workspace_id', value.workspaceId);

if (error) {
console.error('[notice action] 공지 고정 상태 변경 실패:', error);
throwNoticeActionError('공지 고정 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요.');
}

revalidateNoticePages(value.workspaceId);
return { ok: true, data: undefined };
} catch (error) {
return toActionFailure(error, '공지 고정 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
Comment on lines +177 to +208

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

setNoticePinned는 공지 존재 여부를 확인하지 않아 잘못된 noticeId에도 조용히 성공합니다.

createNotice/updateNotice/deleteNotice는 대상 공지를 먼저 조회(getEditableAnnouncement)해 없으면 에러를 던지지만, setNoticePinnedgetCurrentWorkspaceMember만 호출하고 곧바로 UPDATE합니다. noticeId가 존재하지 않거나 다른 워크스페이스 소속이면 UPDATE는 0 rows에 영향을 주고도 error가 없어 그대로 성공 응답(revalidateNoticePages + return)이 반환됩니다.

🐛 존재 확인 추가 제안
   const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId);

   if (member.role !== 'owner') {
     throw new Error('워크스페이스 소유자만 공지를 고정할 수 있습니다.');
   }

-  const { error } = await supabase
+  const { data, error } = await supabase
     .from('announcements')
     .update({ is_pinned: value.isPinned })
     .eq('id', value.noticeId)
-    .eq('workspace_id', value.workspaceId);
+    .eq('workspace_id', value.workspaceId)
+    .select('id')
+    .maybeSingle();

   if (error) {
     throw new Error(`공지 고정 상태 변경에 실패했습니다: ${error.message}`);
   }
+
+  if (!data) {
+    throw new Error('공지를 찾을 수 없습니다.');
+  }
📝 Committable suggestion

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

Suggested change
export async function setNoticePinned(input: {
workspaceId: string;
noticeId: string;
isPinned: boolean;
}): Promise<void> {
const value = z
.object({ workspaceId: uuidSchema, noticeId: uuidSchema, isPinned: z.boolean() })
.parse(input);
const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId);
if (member.role !== 'owner') {
throw new Error('워크스페이스 소유자만 공지를 고정할 수 있습니다.');
}
const { error } = await supabase
.from('announcements')
.update({ is_pinned: value.isPinned })
.eq('id', value.noticeId)
.eq('workspace_id', value.workspaceId);
if (error) {
throw new Error(`공지 고정 상태 변경에 실패했습니다: ${error.message}`);
}
revalidateNoticePages(value.workspaceId);
}
export async function setNoticePinned(input: {
workspaceId: string;
noticeId: string;
isPinned: boolean;
}): Promise<void> {
const value = z
.object({ workspaceId: uuidSchema, noticeId: uuidSchema, isPinned: z.boolean() })
.parse(input);
const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId);
if (member.role !== 'owner') {
throw new Error('워크스페이스 소유자만 공지를 고정할 수 있습니다.');
}
const { data, error } = await supabase
.from('announcements')
.update({ is_pinned: value.isPinned })
.eq('id', value.noticeId)
.eq('workspace_id', value.workspaceId)
.select('id')
.maybeSingle();
if (error) {
throw new Error(`공지 고정 상태 변경에 실패했습니다: ${error.message}`);
}
if (!data) {
throw new Error('공지를 찾을 수 없습니다.');
}
revalidateNoticePages(value.workspaceId);
}
🤖 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/notice/api/notice-actions.ts` around lines 139 - 164, Update
setNoticePinned to load and validate the target announcement with the existing
getEditableAnnouncement helper before performing the update, using both
workspaceId and noticeId. Preserve the owner authorization and update flow, but
ensure missing or cross-workspace notices throw the same not-found error
behavior as the other notice actions instead of revalidating successfully.

5 changes: 2 additions & 3 deletions src/entities/notice/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
// 공지 도메인의 타입과 목업 데이터 공개 API입니다.
export type { Notice, NoticeFormValues } from './model/notice.types';
export { mockNotices } from './model/mock-notices';
// 공지 도메인이 외부 레이어에 제공하는 타입 공개 API입니다.
export type { Notice, NoticeBoardData, NoticeFormValues, NoticeViewer } from './model/notice.types';
Loading