-
Notifications
You must be signed in to change notification settings - Fork 3
feat: 회의록페이지/ 위젯 db연동 #61
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
Changes from all commits
47df302
79452f3
ac8dbfe
b327d8b
59e80bb
9527a94
0d6346c
34d5660
3bb9806
d10e95e
e6b74f7
b43bcfc
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,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} />; | ||
| } |
| 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 } }; | ||
| } |
| 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 }; | ||
| } |
| 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); | ||
| } |
| 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(); | ||
|
|
||
| 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
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. 🚀 Performance & Scalability | 🔵 Trivial 페이지네이션 없이 워크스페이스 전체 회의록을 매번 조회합니다. 현재는 🤖 Prompt for AI Agents |
||
| 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, | ||
| }; | ||
| } | ||
| 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 } }; | ||
| } |
| 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
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win update/delete 서버 액션이 실제 반영 행 수를 확인하지 않아 거짓 성공을 반환할 수 있습니다. 두 파일 모두
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`); | ||
| revalidatePath(`/workspaces/${value.workspaceId}/dashboard`); | ||
|
|
||
| return { ok: true, data: { id: value.meetingNoteId } }; | ||
| } | ||
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 | 🔵 Trivial | ⚡ Quick win
엄격한 UUID 검증이 필요하면
z.uuid()를 사용하세요.z.guid()는 Zod 4에서 "UUID-like"한 값을 허용하는 더 관대한 검증기입니다.workspaceId가 Postgres UUID 기본키라면z.uuid()(RFC 표준 준수)를 사용하는 것이 더 명확한 입력 오류를 제공합니다. 현재는 형식이 다소 어긋난 값도 통과해 Supabase 쿼리 단계에서 원시 Postgrest 오류로 이어질 수 있습니다.♻️ 제안
Also applies to: 18-18
🤖 Prompt for AI Agents