-
Notifications
You must be signed in to change notification settings - Fork 3
feat: task 도메인 Supabase 연동 #52
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
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,50 @@ | ||
| 'use server'; | ||
|
|
||
| import { getCurrentUserId } from '@/shared/api/supabase/current-user'; | ||
| import { createSupabaseServerClient } from '@/shared/api/supabase/server'; | ||
|
|
||
| import { taskTitleSchema } from '../model/task.schema'; | ||
| import type { UntypedRpcClient } from './rpc-client'; | ||
|
|
||
| function getTodayIsoDateInKst() { | ||
| const parts = new Intl.DateTimeFormat('en-CA', { | ||
| timeZone: 'Asia/Seoul', | ||
| year: 'numeric', | ||
| month: '2-digit', | ||
| day: '2-digit', | ||
| }).formatToParts(new Date()); | ||
|
|
||
| const year = parts.find((part) => part.type === 'year')?.value; | ||
| const month = parts.find((part) => part.type === 'month')?.value; | ||
| const day = parts.find((part) => part.type === 'day')?.value; | ||
|
|
||
| if (!year || !month || !day) { | ||
| throw new Error('현재 날짜를 계산하지 못했습니다.'); | ||
| } | ||
|
|
||
| return `${year}-${month}-${day}`; | ||
| } | ||
|
|
||
| export async function createTask(params: { workspaceId: string; title: string }): Promise<void> { | ||
| const parsedTitle = taskTitleSchema.safeParse(params.title); | ||
|
|
||
| if (!parsedTitle.success) { | ||
| throw new Error(parsedTitle.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); | ||
| } | ||
|
|
||
| const supabase = await createSupabaseServerClient(); | ||
| const currentUserId = await getCurrentUserId(); | ||
| const dueDate = getTodayIsoDateInKst(); | ||
|
|
||
| const { error } = await (supabase as unknown as UntypedRpcClient).rpc('create_task', { | ||
| p_workspace_id: params.workspaceId, | ||
| p_title: parsedTitle.data, | ||
| p_user_id: currentUserId, | ||
| p_due_date: dueDate, | ||
| }); | ||
|
|
||
| if (error) { | ||
| console.error('[task/createTask] RPC 실패:', error); | ||
| throw new Error('업무 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| 'use server'; | ||
|
|
||
| import { createSupabaseServerClient } from '@/shared/api/supabase/server'; | ||
|
|
||
| export async function deleteTask(taskId: string): Promise<void> { | ||
| const supabase = await createSupabaseServerClient(); | ||
| const { error } = await supabase.from('tasks').delete().eq('id', taskId); | ||
|
|
||
| if (error) { | ||
| console.error('[task/deleteTask] delete 실패:', error); | ||
| throw new Error('업무 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; | ||
|
|
||
| import { toTask, type TaskQueryRow } from '../model/task.mapper'; | ||
| import type { Task } from '../model/task.types'; | ||
|
|
||
| export async function getTasksByWorkspaceId(workspaceId: string): Promise<Task[]> { | ||
| const supabase = getSupabaseBrowserClient(); | ||
| const { data, error } = await supabase | ||
| .from('tasks') | ||
| .select( | ||
| 'id, workspace_id, title, assignee_id, due_date, status, sort_order, assignee_profile:profiles!tasks_assignee_id_fkey(real_name)', | ||
| ) | ||
| .eq('workspace_id', workspaceId) | ||
| .is('sprint_id', null) | ||
| .order('sort_order'); | ||
|
|
||
| if (error) { | ||
| throw new Error(`업무 조회에 실패했습니다: ${error.message}`); | ||
| } | ||
|
|
||
| return ((data ?? []) as TaskQueryRow[]).map(toTask); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| export type UntypedRpcClient = { | ||
| rpc: ( | ||
| fn: string, | ||
| args?: Record<string, unknown>, | ||
| ) => Promise<{ data: unknown; error: { message: string } | null }>; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 'use server'; | ||
|
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. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🤖 Prompt for AI Agents
Contributor
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. 지금 상황에서는 해당 status가 고정이라 큰 상관이 없어보이긴합니다. 다만 이후 코드 리펙토링 혹은 수정 시에 오류가 발생한 가능성이 있어서 고려하면 좋을 거 같습니다 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. The PR description image shows the Syncly "프로젝트 관리" (Project Management) kanban board UI: a left sidebar with navigation items (대시보드, 프로젝트 관리 highlighted, 캘린더, 공지, 회의록, 자료실, 채팅, 진행률 차트, 설정) under a "캡스톤 디자인 팀" workspace label, a top header with search, "멤버 초대" button, notification bell, and a profile avatar "김". The main area shows three kanban columns — 대기 (1), 진행 중 (1), 완료 (1) — each with one task card ("대기 테스트", "진행중 테스트", "완료 테스트") showing assignee "JiWoongE" with a blue avatar and due date "7/14". A "+ 새업무" button sits top-right of the board.
✏️ Learnings added
Contributor
Author
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. 반영했습니다. update-task-board.ts와 use-update-task-board.ts에서 중복으로 선언하던 status 리터럴 유니온을 제거하고 TaskStatus 타입을 재사용하도록 정리했습니다. 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.
|
||
|
|
||
| import { createSupabaseServerClient } from '@/shared/api/supabase/server'; | ||
|
|
||
| import type { TaskStatus } from '../model/task.types'; | ||
| import { updateTaskBoardSchema } from '../model/task.schema'; | ||
| import type { UntypedRpcClient } from './rpc-client'; | ||
|
|
||
| export async function updateTaskBoard(params: { | ||
| workspaceId: string; | ||
| tasks: Array<{ | ||
| id: string; | ||
| status: TaskStatus; | ||
| sortOrder: number; | ||
| }>; | ||
| }): Promise<void> { | ||
| const parsed = updateTaskBoardSchema.safeParse(params); | ||
|
|
||
| if (!parsed.success) { | ||
| throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); | ||
| } | ||
|
|
||
| const supabase = await createSupabaseServerClient(); | ||
| const { error } = await (supabase as unknown as UntypedRpcClient).rpc('update_task_board', { | ||
| p_workspace_id: parsed.data.workspaceId, | ||
| p_tasks: parsed.data.tasks, | ||
| }); | ||
|
|
||
| if (error) { | ||
| console.error('[task/updateTaskBoard] RPC 실패:', error); | ||
| throw new Error('업무 정렬 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| 'use client'; | ||
|
|
||
| import { useMutation, useQueryClient } from '@tanstack/react-query'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| import { createTask } from './create-task'; | ||
| import { tasksByWorkspaceQueryKey } from './use-tasks-by-workspace-id'; | ||
|
|
||
| export function useCreateTask(workspaceId: string) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (title: string) => createTask({ workspaceId, title }), | ||
| onSettled: () => { | ||
| queryClient.invalidateQueries({ queryKey: tasksByWorkspaceQueryKey(workspaceId) }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error instanceof Error ? error.message : '업무 생성에 실패했습니다'); | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| 'use client'; | ||
|
|
||
| import { useMutation, useQueryClient } from '@tanstack/react-query'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| import { deleteTask } from './delete-task'; | ||
| import { tasksByWorkspaceQueryKey } from './use-tasks-by-workspace-id'; | ||
|
|
||
| export function useDeleteTask(workspaceId: string) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (taskId: string) => deleteTask(taskId), | ||
| onSettled: () => { | ||
| queryClient.invalidateQueries({ queryKey: tasksByWorkspaceQueryKey(workspaceId) }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error instanceof Error ? error.message : '업무 삭제에 실패했습니다'); | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 'use client'; | ||
|
|
||
| import { useQuery } from '@tanstack/react-query'; | ||
|
|
||
| import { getTasksByWorkspaceId } from './get-tasks-by-workspace-id'; | ||
|
|
||
| export const tasksByWorkspaceQueryKey = (workspaceId: string) => | ||
| ['tasks', 'workspace', workspaceId] as const; | ||
|
|
||
| export function useTasksByWorkspaceId(workspaceId: string) { | ||
| return useQuery({ | ||
| queryKey: tasksByWorkspaceQueryKey(workspaceId), | ||
| queryFn: () => getTasksByWorkspaceId(workspaceId), | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| 'use client'; | ||
|
|
||
| import { useMutation, useQueryClient } from '@tanstack/react-query'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| import type { TaskStatus } from '../model/task.types'; | ||
| import { updateTaskBoard } from './update-task-board'; | ||
| import { tasksByWorkspaceQueryKey } from './use-tasks-by-workspace-id'; | ||
|
|
||
| interface UpdateTaskBoardParams { | ||
| workspaceId: string; | ||
| tasks: Array<{ | ||
| id: string; | ||
| status: TaskStatus; | ||
| sortOrder: number; | ||
| }>; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export function useUpdateTaskBoard(workspaceId: string) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (params: UpdateTaskBoardParams) => updateTaskBoard(params), | ||
| onSettled: () => { | ||
| queryClient.invalidateQueries({ queryKey: tasksByWorkspaceQueryKey(workspaceId) }); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error instanceof Error ? error.message : '업무 정렬 저장에 실패했습니다'); | ||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,10 @@ | ||
| export { getMockTasksByWorkspaceId } from './model/mock-tasks-by-workspace'; | ||
| export type { Task, TaskStatus } from './model/task.types'; | ||
| export type { TaskRow, TaskStatusDb } from './model/task.db.types'; | ||
| export { toTask, toDbTaskStatus, toUiTaskStatus } from './model/task.mapper'; | ||
| export { taskTitleSchema, taskBoardItemSchema, updateTaskBoardSchema } from './model/task.schema'; | ||
| export { getTasksByWorkspaceId } from './api/get-tasks-by-workspace-id'; | ||
| export { useTasksByWorkspaceId, tasksByWorkspaceQueryKey } from './api/use-tasks-by-workspace-id'; | ||
| export { useCreateTask } from './api/use-create-task'; | ||
| export { useDeleteTask } from './api/use-delete-task'; | ||
| export { useUpdateTaskBoard } from './api/use-update-task-board'; | ||
| export { TaskCard } from './ui/TaskCard'; |
Uh oh!
There was an error while loading. Please reload this page.
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.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 155
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 2397
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 4327
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 14310
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 221
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 3571
브라우저 조회가 현재는 멤버십으로 제한되지 않습니다.
src/entities/task/api/get-tasks-by-workspace-id.ts와src/entities/workspace-member/api/get-workspace-members-by-id.client.ts는 브라우저 Supabase 클라이언트로 직접 조회하지만,supabase/migrations/20260709025327_create_enums_and_core_tables.sql의dev_full_access가 남아 있어tasks/workspace_members/profiles접근이 사실상 열려 있습니다. 브라우저 경로를 유지하려면 이 정책을 제거하고 멤버십 기반 RLS만 남겨야 합니다.🤖 Prompt for AI Agents
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.
동의합니다. 현재 브라우저 Supabase 클라이언트에서 tasks, workspace_members, profiles를 직접 조회하고 있으므로, RLS가 실질적인 접근 제어 역할을 해야 합니다. dev_full_access 정책을 제거하고 요청 사용자가 해당 워크스페이스의 멤버인지 검증하는 정책만 유지하도록 보완하면 좋겠습니다.
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.
🧩 Analysis chain
🏁 Script executed:
Length of output: 15359
@seongjinss555네, 확인했습니다. 마이그레이션을 보니 실제로 필요한 멤버십 기반 정책은 이미 존재합니다:profiles:profiles_select(로그인 유저 전체 조회 허용은 의도된 설계),profiles_insert_own,profiles_update_ownworkspace_members:members_select(private.is_workspace_member기반),members_insert_self등tasks:tasks_member_all(private.is_workspace_member(workspace_id)기반 CRUD)문제는
20260709025327_create_enums_and_core_tables.sql에서 모든 public 테이블에 대해for all using (true) with check (true)인dev_full_access정책이 여전히 남아 있고, RLS는 정책들을 OR로 평가하므로 이 정책 하나가 실질적으로 전체 접근을 허용해버립니다. 즉is_workspace_member검증 정책들이 이미 있음에도dev_full_access가 살아있어 무력화되고 있는 상태입니다.가장 간단하고 안전한 해결책은 새 마이그레이션을 추가해 모든 public 테이블의
dev_full_access정책만drop policy하는 것입니다(테이블 구조나 기존 정책은 그대로 유지). 아래와 같은 형태를 제안합니다.이 마이그레이션만 추가하면 브라우저 클라이언트 조회 시(
getTasksByWorkspaceId,getWorkspaceMembersByWorkspaceIdClient)private.is_workspace_member(workspace_id)검증이 실제로 강제됩니다. 원하시면 이 변경을 이번 PR 범위로 반영할지, 별도 후속 이슈로 분리할지 알려주세요. 후속 이슈로 진행을 원하시면 GitHub 이슈를 생성해 드리겠습니다.✏️ Learnings added
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.
반영했습니다. 브라우저 직접 조회를 유지하는 대신 RLS를 실제 멤버십 기준으로 맞췄습니다. 새 migration에서 tasks / workspace_members / profiles의 dev_full_access를 제거했고, profiles 조회도 본인 또는 같은 워크스페이스 멤버의 프로필만 보이도록 정책을 좁혔습니다.
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.