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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { EditMeetingNotePage } from '@/views/meeting-notes';

interface WorkspaceEditMeetingNotePageProps {
params: Promise<{
workspaceId: string;
noteId: string;
}>;
}

export default async function WorkspaceEditMeetingNotePage({
params,
}: WorkspaceEditMeetingNotePageProps) {
const { workspaceId, noteId } = await params;

return <EditMeetingNotePage workspaceId={workspaceId} meetingNoteId={noteId} />;
}
2 changes: 1 addition & 1 deletion src/app/workspaces/[workspaceId]/sprint-board/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo
<SprintBoardView
workspaceId={workspaceId}
selectedSprintId={selectedSprintId}
members={members}
initialMembers={members}
/>
);
}
2 changes: 1 addition & 1 deletion src/app/workspaces/[workspaceId]/work-schedule/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function WorkSchedulePage({ params }: WorkSchedulePageProps
return (
<WorkScheduleView
workspaceId={workspaceId}
members={members}
initialMembers={members}
shifts={shifts}
schedule={schedule}
weekStartDate={startDate}
Expand Down
74 changes: 74 additions & 0 deletions src/entities/meeting-note/api/create-meeting-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'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';

import { toMeetingNoteInsert } from '../model/meeting-note.mapper';
import { meetingNoteContentSchema } from '../model/meeting-note.schema';

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

const createMeetingNoteSchema = meetingNoteContentSchema.extend({
workspaceId: z.guid(),
});

export async function createMeetingNote(
input: z.input<typeof createMeetingNoteSchema>,
): Promise<MeetingNoteActionResult<{ id: string }>> {
const parsed = createMeetingNoteSchema.safeParse(input);

if (!parsed.success) {
return { ok: false, message: parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다.' };
}

const value = parsed.data;
const supabase = await createSupabaseServerClient();
const currentUserId = await getCurrentUserId();

const { data: membership, error: memberError } = await supabase
.from('workspace_members')
.select('user_id')
.eq('workspace_id', value.workspaceId)
.eq('user_id', currentUserId)
.maybeSingle();

if (memberError) {
console.error('[meeting-note/createMeetingNote] 멤버 확인 실패:', memberError);
return { ok: false, message: '워크스페이스 멤버 정보를 확인하지 못했습니다.' };
}

if (!membership) {
return { ok: false, message: '워크스페이스 멤버만 회의록을 작성할 수 있습니다.' };
}

const { data, error } = await supabase
.from('meeting_notes')
.insert(
toMeetingNoteInsert({
workspaceId: value.workspaceId,
authorId: currentUserId,
title: value.title,
meetingDate: value.meetingDate,
participantIds: value.participantIds,
decisions: value.decisions,
followUpActions: value.followUpActions,
}),
)
.select('id')
.single();

if (error) {
console.error('[meeting-note/createMeetingNote] 저장 실패:', error);
return { ok: false, message: '회의록 저장에 실패했습니다. 잠시 후 다시 시도해주세요.' };
}

revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`);
revalidatePath(`/workspaces/${value.workspaceId}/dashboard`);

return { ok: true, data: { id: data.id } };
}
46 changes: 46 additions & 0 deletions src/entities/meeting-note/api/delete-meeting-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use server';

// 작성자 또는 워크스페이스 소유자만 회의록을 삭제할 수 있습니다.
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

import type { MeetingNoteActionResult } from './create-meeting-note';
import { authorizeMeetingNoteMutation } from './shared';

const deleteMeetingNoteSchema = z.object({
workspaceId: z.guid(),
meetingNoteId: z.guid(),
});

export async function deleteMeetingNote(
input: z.input<typeof deleteMeetingNoteSchema>,
): Promise<MeetingNoteActionResult<void>> {
const parsed = deleteMeetingNoteSchema.safeParse(input);

if (!parsed.success) {
return { ok: false, message: '입력값이 올바르지 않습니다.' };
}

const value = parsed.data;
const authorized = await authorizeMeetingNoteMutation(value);

if (!authorized.ok) {
return authorized;
}

const { error } = await authorized.context.supabase
.from('meeting_notes')
.delete()
.eq('id', value.meetingNoteId)
.eq('workspace_id', value.workspaceId);

if (error) {
console.error('[meeting-note/deleteMeetingNote] 삭제 실패:', error);
return { ok: false, message: '회의록 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.' };
}

revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`);
revalidatePath(`/workspaces/${value.workspaceId}/dashboard`);

return { ok: true, data: undefined };
}
47 changes: 47 additions & 0 deletions src/entities/meeting-note/api/get-meeting-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use server';

// 수정 페이지에서 단건 회의록과 참석자·작성자 이름을 함께 조회합니다.
import { z } from 'zod';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

import {
MEETING_NOTE_SELECT_QUERY,
toMeetingNote,
type MeetingNoteQueryRow,
} from '../model/meeting-note.mapper';
import type { MeetingNote } from '../model/meeting-note.types';

const idSchema = z.guid();

export async function getMeetingNote(
workspaceId: string,
meetingNoteId: string,
): Promise<MeetingNote | null> {
const parsedWorkspaceId = idSchema.parse(workspaceId);
const parsedNoteId = idSchema.parse(meetingNoteId);
const supabase = await createSupabaseServerClient();

const { data, error } = await supabase
.from('meeting_notes')
.select(MEETING_NOTE_SELECT_QUERY)
.eq('id', parsedNoteId)
.eq('workspace_id', parsedWorkspaceId)
.maybeSingle();

if (error) throw new Error(`회의록 조회에 실패했습니다: ${error.message}`);
if (!data) return null;

const row = data as MeetingNoteQueryRow;
const profileIds = [
...new Set([...row.participants, ...(row.author_id ? [row.author_id] : [])]),
];
const { data: profiles, error: profileError } = profileIds.length
? await supabase.from('profiles').select('id, real_name').in('id', profileIds)
: { data: [], error: null };

if (profileError) throw new Error(`참석자 정보를 불러오지 못했습니다: ${profileError.message}`);

const profileNameById = new Map((profiles ?? []).map((profile) => [profile.id, profile.real_name]));

return toMeetingNote(row, profileNameById);
}
60 changes: 60 additions & 0 deletions src/entities/meeting-note/api/get-meeting-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use server';

// 워크스페이스 범위의 회의록과 참석자·작성자 이름을 함께 조회합니다.
import { z } from 'zod';
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

import {
MEETING_NOTE_SELECT_QUERY,
toMeetingNote,
type MeetingNoteQueryRow,
} from '../model/meeting-note.mapper';
import type { MeetingNoteBoardData } from '../model/meeting-note.types';

const workspaceIdSchema = z.guid();

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 | 🔵 Trivial | ⚡ Quick win

엄격한 UUID 검증이 필요하면 z.uuid()를 사용하세요.

z.guid()는 Zod 4에서 "UUID-like"한 값을 허용하는 더 관대한 검증기입니다. workspaceId가 Postgres UUID 기본키라면 z.uuid()(RFC 표준 준수)를 사용하는 것이 더 명확한 입력 오류를 제공합니다. 현재는 형식이 다소 어긋난 값도 통과해 Supabase 쿼리 단계에서 원시 Postgrest 오류로 이어질 수 있습니다.

♻️ 제안
-const workspaceIdSchema = z.guid();
+const workspaceIdSchema = z.uuid();

Also applies to: 18-18

🤖 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/meeting-note/api/get-meeting-notes.ts` at line 15, Update the
workspaceIdSchema definition to use Zod’s strict UUID validator z.uuid() instead
of the more permissive z.guid(), ensuring workspace IDs are validated as
RFC-compliant UUIDs before database queries.


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

const [{ data: notes, error: notesError }, { data: membership, error: memberError }] =
await Promise.all([
supabase
.from('meeting_notes')
.select(MEETING_NOTE_SELECT_QUERY)
.eq('workspace_id', parsedWorkspaceId)
.order('meeting_at', { ascending: false }),
Comment on lines +24 to +28

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

페이지네이션 없이 워크스페이스 전체 회의록을 매번 조회합니다.

현재는 meeting_at 내림차순으로 전체 행을 가져옵니다. 워크스페이스가 오래될수록 목록 페이지 방문마다 전체 회의록 이력을 로드하게 되어 I/O 비용이 커질 수 있습니다. 초기 규모에서는 문제가 없지만, 추후 커서 기반 페이지네이션이나 limit을 고려해두면 좋습니다.

🤖 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/meeting-note/api/get-meeting-notes.ts` around lines 24 - 28,
Update the meeting-notes query in the getMeetingNotes flow to avoid fetching the
entire workspace history on every list request. Add an initial result limit
using the existing query builder, while preserving the workspace filter,
selected fields, and descending meeting_at order so the newest notes are
returned first.

supabase
.from('workspace_members')
.select('user_id, role')
.eq('workspace_id', parsedWorkspaceId)
.eq('user_id', currentUserId)
.maybeSingle(),
]);

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

const rows = (notes ?? []) as MeetingNoteQueryRow[];

// 참석자 + 작성자 이름을 한 번의 profiles 조회로 복원한다.
const profileIds = [
...new Set(
rows.flatMap((row) => [...row.participants, ...(row.author_id ? [row.author_id] : [])]),
),
];
const { data: profiles, error: profileError } = profileIds.length
? await supabase.from('profiles').select('id, real_name').in('id', profileIds)
: { data: [], error: null };

if (profileError) throw new Error(`참석자 정보를 불러오지 못했습니다: ${profileError.message}`);

const profileNameById = new Map((profiles ?? []).map((profile) => [profile.id, profile.real_name]));

return {
meetingNotes: rows.map((row) => toMeetingNote(row, profileNameById)),
viewer: membership ? { userId: membership.user_id, role: membership.role } : null,
};
}
59 changes: 59 additions & 0 deletions src/entities/meeting-note/api/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 회의록 수정·삭제 액션이 공유하는 권한 확인 헬퍼입니다.
// 'use server'가 아닌 일반 서버 모듈로, 서버 액션에서만 import 합니다.
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

type SupabaseServerClient = Awaited<ReturnType<typeof createSupabaseServerClient>>;

interface AuthorizedContext {
supabase: SupabaseServerClient;
currentUserId: string;
}

// 대상 회의록이 존재하고, 현재 사용자가 작성자이거나 워크스페이스 소유자인지 확인한다.
// RLS로도 막히지만, 명확한 메시지와 supabase 컨텍스트 재사용을 위해 액션에서 먼저 검증한다.
export async function authorizeMeetingNoteMutation(input: {
workspaceId: string;
meetingNoteId: string;
}): Promise<{ ok: true; context: AuthorizedContext } | { ok: false; message: string }> {
const supabase = await createSupabaseServerClient();
const currentUserId = await getCurrentUserId();

const [{ data: note, error: noteError }, { data: membership, error: memberError }] =
await Promise.all([
supabase
.from('meeting_notes')
.select('id, author_id')
.eq('id', input.meetingNoteId)
.eq('workspace_id', input.workspaceId)
.maybeSingle(),
supabase
.from('workspace_members')
.select('user_id, role')
.eq('workspace_id', input.workspaceId)
.eq('user_id', currentUserId)
.maybeSingle(),
]);

if (noteError || memberError) {
console.error('[meeting-note] 권한 확인 실패:', noteError ?? memberError);
return { ok: false, message: '회의록 정보를 확인하지 못했습니다.' };
}

if (!membership) {
return { ok: false, message: '워크스페이스 멤버만 회의록을 관리할 수 있습니다.' };
}

if (!note) {
return { ok: false, message: '회의록을 찾을 수 없습니다.' };
}

if (membership.role !== 'owner' && note.author_id !== currentUserId) {
return {
ok: false,
message: '작성자 또는 워크스페이스 소유자만 회의록을 수정하거나 삭제할 수 있습니다.',
};
}

return { ok: true, context: { supabase, currentUserId } };
}
59 changes: 59 additions & 0 deletions src/entities/meeting-note/api/update-meeting-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use server';

// 작성자 또는 워크스페이스 소유자만 회의록을 수정할 수 있습니다.
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

import { toMeetingNoteUpdate } from '../model/meeting-note.mapper';
import { meetingNoteContentSchema } from '../model/meeting-note.schema';
import type { MeetingNoteActionResult } from './create-meeting-note';
import { authorizeMeetingNoteMutation } from './shared';

const updateMeetingNoteSchema = meetingNoteContentSchema.extend({
workspaceId: z.guid(),
meetingNoteId: z.guid(),
});

export async function updateMeetingNote(
input: z.input<typeof updateMeetingNoteSchema>,
): Promise<MeetingNoteActionResult<{ id: string }>> {
const parsed = updateMeetingNoteSchema.safeParse(input);

if (!parsed.success) {
return { ok: false, message: parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다.' };
}

const value = parsed.data;
const authorized = await authorizeMeetingNoteMutation({
workspaceId: value.workspaceId,
meetingNoteId: value.meetingNoteId,
});

if (!authorized.ok) {
return authorized;
}

const { error } = await authorized.context.supabase
.from('meeting_notes')
.update(
toMeetingNoteUpdate({
title: value.title,
meetingDate: value.meetingDate,
participantIds: value.participantIds,
decisions: value.decisions,
followUpActions: value.followUpActions,
}),
)
.eq('id', value.meetingNoteId)
.eq('workspace_id', value.workspaceId);

if (error) {
console.error('[meeting-note/updateMeetingNote] 수정 실패:', error);
return { ok: false, message: '회의록 수정에 실패했습니다. 잠시 후 다시 시도해주세요.' };
}
Comment on lines +36 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

update/delete 서버 액션이 실제 반영 행 수를 확인하지 않아 거짓 성공을 반환할 수 있습니다.

두 파일 모두 authorizeMeetingNoteMutation으로 권한을 먼저 확인한 뒤 .update()/.delete()를 호출하지만 .select()가 없어, 매칭되는 행이 0개여도(권한 확인과 쓰기 사이의 레이스, 동시 삭제 등으로 RLS가 조용히 막는 경우) error가 발생하지 않고 { ok: true }가 반환됩니다. 근본 원인은 두 곳 모두 동일하게 결과 확인 없이 성공을 가정하는 것입니다.

  • src/entities/meeting-note/api/update-meeting-note.ts#L36-L53: .update(...).eq(...).eq(...).select('id').maybeSingle()을 추가하고, data가 없으면 실패로 처리하세요.
  • src/entities/meeting-note/api/delete-meeting-note.ts#L31-L40: .delete().eq(...).eq(...).select('id').maybeSingle()을 추가하고, data가 없으면 실패로 처리하세요.
📍 Affects 2 files
  • src/entities/meeting-note/api/update-meeting-note.ts#L36-L53 (this comment)
  • src/entities/meeting-note/api/delete-meeting-note.ts#L31-L40
🤖 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/meeting-note/api/update-meeting-note.ts` around lines 36 - 53,
Update src/entities/meeting-note/api/update-meeting-note.ts lines 36-53 in
updateMeetingNote to select the affected id with maybeSingle() after the update
and treat missing data as failure; apply the same change to
src/entities/meeting-note/api/delete-meeting-note.ts lines 31-40 in
deleteMeetingNote after the delete. Preserve the existing error response for
database errors and return failure whenever no row was actually affected.


revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`);
revalidatePath(`/workspaces/${value.workspaceId}/dashboard`);

return { ok: true, data: { id: value.meetingNoteId } };
}
Loading