diff --git a/src/entities/project-column/ui/ProjectColumn.tsx b/src/entities/project-column/ui/ProjectColumn.tsx index 02e81c6..03b1e55 100644 --- a/src/entities/project-column/ui/ProjectColumn.tsx +++ b/src/entities/project-column/ui/ProjectColumn.tsx @@ -90,9 +90,25 @@ export function ProjectColumn({ ))} - {dragOverIndex === column.tasks.length ? ( -
- ) : null} +
{ + event.preventDefault(); + event.stopPropagation(); + onDragOverTask(column.id, column.tasks.length); + }} + onDrop={(event) => handleDrop(event, column.tasks.length)} + > + {dragOverIndex === column.tasks.length ? ( +
+ ) : null} +
); diff --git a/src/entities/task/api/create-task.ts b/src/entities/task/api/create-task.ts new file mode 100644 index 0000000..99339e2 --- /dev/null +++ b/src/entities/task/api/create-task.ts @@ -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 { + 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('업무 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/task/api/delete-task.ts b/src/entities/task/api/delete-task.ts new file mode 100644 index 0000000..0d1ffeb --- /dev/null +++ b/src/entities/task/api/delete-task.ts @@ -0,0 +1,13 @@ +'use server'; + +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +export async function deleteTask(taskId: string): Promise { + 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('업무 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/task/api/get-tasks-by-workspace-id.ts b/src/entities/task/api/get-tasks-by-workspace-id.ts new file mode 100644 index 0000000..0e96f55 --- /dev/null +++ b/src/entities/task/api/get-tasks-by-workspace-id.ts @@ -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 { + 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); +} diff --git a/src/entities/task/api/rpc-client.ts b/src/entities/task/api/rpc-client.ts new file mode 100644 index 0000000..f09e0fa --- /dev/null +++ b/src/entities/task/api/rpc-client.ts @@ -0,0 +1,6 @@ +export type UntypedRpcClient = { + rpc: ( + fn: string, + args?: Record, + ) => Promise<{ data: unknown; error: { message: string } | null }>; +}; diff --git a/src/entities/task/api/update-task-board.ts b/src/entities/task/api/update-task-board.ts new file mode 100644 index 0000000..bbf9652 --- /dev/null +++ b/src/entities/task/api/update-task-board.ts @@ -0,0 +1,33 @@ +'use server'; + +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 { + 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('업무 정렬 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/task/api/use-create-task.ts b/src/entities/task/api/use-create-task.ts new file mode 100644 index 0000000..454564f --- /dev/null +++ b/src/entities/task/api/use-create-task.ts @@ -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 : '업무 생성에 실패했습니다'); + }, + }); +} diff --git a/src/entities/task/api/use-delete-task.ts b/src/entities/task/api/use-delete-task.ts new file mode 100644 index 0000000..8ef8b39 --- /dev/null +++ b/src/entities/task/api/use-delete-task.ts @@ -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 : '업무 삭제에 실패했습니다'); + }, + }); +} diff --git a/src/entities/task/api/use-tasks-by-workspace-id.ts b/src/entities/task/api/use-tasks-by-workspace-id.ts new file mode 100644 index 0000000..7ff6856 --- /dev/null +++ b/src/entities/task/api/use-tasks-by-workspace-id.ts @@ -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), + }); +} diff --git a/src/entities/task/api/use-update-task-board.ts b/src/entities/task/api/use-update-task-board.ts new file mode 100644 index 0000000..cb42744 --- /dev/null +++ b/src/entities/task/api/use-update-task-board.ts @@ -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; + }>; +} + +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 : '업무 정렬 저장에 실패했습니다'); + }, + }); +} diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index ec1c76b..04a481a 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -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'; diff --git a/src/entities/task/model/mock-tasks-by-workspace.ts b/src/entities/task/model/mock-tasks-by-workspace.ts deleted file mode 100644 index dc2381d..0000000 --- a/src/entities/task/model/mock-tasks-by-workspace.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { Task } from './task.types'; - -const TEAM_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001001'; -const SIDE_PROJECT_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001002'; -const STORE_OPERATION_WORKSPACE_UUID = '00000000-0000-0000-0000-000000001003'; - -const mockTasksByWorkspaceId: Record = { - test: [ - { - id: 'task-1', - workspaceId: 'test', - title: '사용자 인터뷰 설문지 제작', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#00C950', - dueDate: '7/5', - status: 'todo', - }, - { - id: 'task-2', - workspaceId: 'test', - title: 'DB 스키마 설계', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/6', - status: 'todo', - }, - { - id: 'task-3', - workspaceId: 'test', - title: '스프린트 1 회고 준비', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/10', - status: 'todo', - }, - { - id: 'task-4', - workspaceId: 'test', - title: '와이어프레임 초안 작성', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#00B8DB', - dueDate: '7/3', - status: 'in-progress', - }, - { - id: 'task-5', - workspaceId: 'test', - title: '랜딩 페이지 디자인', - assignee: '최민준', - assigneeInitial: '최', - assigneeColor: '#2B7FFF', - dueDate: '7/8', - status: 'in-progress', - }, - { - id: 'task-6', - workspaceId: 'test', - title: 'API 명세서 문서화', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/2', - status: 'done', - }, - { - id: 'task-7', - workspaceId: 'test', - title: '로고 시안 3종 작성', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#FE9A00', - dueDate: '6/30', - status: 'done', - }, - ], - 'team-workspace': [ - { - id: 'task-1', - workspaceId: 'team-workspace', - title: '사용자 인터뷰 설문지 제작', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#00C950', - dueDate: '7/5', - status: 'todo', - }, - { - id: 'task-2', - workspaceId: 'team-workspace', - title: 'DB 스키마 설계', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/6', - status: 'todo', - }, - { - id: 'task-3', - workspaceId: 'team-workspace', - title: '스프린트 1 회고 준비', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/10', - status: 'todo', - }, - { - id: 'task-4', - workspaceId: 'team-workspace', - title: '와이어프레임 초안 작성', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#00B8DB', - dueDate: '7/3', - status: 'in-progress', - }, - { - id: 'task-5', - workspaceId: 'team-workspace', - title: '랜딩 페이지 디자인', - assignee: '최민준', - assigneeInitial: '최', - assigneeColor: '#2B7FFF', - dueDate: '7/8', - status: 'in-progress', - }, - { - id: 'task-6', - workspaceId: 'team-workspace', - title: 'API 명세서 문서화', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/2', - status: 'done', - }, - { - id: 'task-7', - workspaceId: 'team-workspace', - title: '로고 시안 3종 작성', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#FE9A00', - dueDate: '6/30', - status: 'done', - }, - { - id: 'task-8', - workspaceId: 'team-workspace', - title: '중간 발표 리허설 진행', - assignee: '최민준', - assigneeInitial: '최', - assigneeColor: '#2B7FFF', - dueDate: '7/12', - status: 'in-progress', - }, - { - id: 'task-9', - workspaceId: 'team-workspace', - title: '최종 보고서 목차 정리', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/14', - status: 'todo', - }, - { - id: 'task-10', - workspaceId: 'team-workspace', - title: '인터뷰 대상자 일정 조율', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/16', - status: 'todo', - }, - { - id: 'task-11', - workspaceId: 'team-workspace', - title: '프로토타입 수정 사항 반영', - assignee: '최민준', - assigneeInitial: '최', - assigneeColor: '#2B7FFF', - dueDate: '7/4', - status: 'done', - }, - { - id: 'task-12', - workspaceId: 'team-workspace', - title: '경쟁 서비스 비교표 작성', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#00C950', - dueDate: '7/1', - status: 'done', - }, - { - id: 'task-13', - workspaceId: 'team-workspace', - title: '발표 스크립트 1차 작성', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/2', - status: 'done', - }, - { - id: 'task-14', - workspaceId: 'team-workspace', - title: '사용성 테스트 결과 정리', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/3', - status: 'done', - }, - { - id: 'task-15', - workspaceId: 'team-workspace', - title: '발표 자료 시각 보정', - assignee: '정우진', - assigneeInitial: '정', - assigneeColor: '#8B5CF6', - dueDate: '7/5', - status: 'done', - }, - ], - 'side-workspace': [ - { - id: 'side-task-1', - workspaceId: 'side-workspace', - title: '온보딩 플로우 개선', - assignee: '최민준', - assigneeInitial: '최', - assigneeColor: '#2B7FFF', - dueDate: '7/14', - status: 'todo', - }, - { - id: 'side-task-2', - workspaceId: 'side-workspace', - title: '베타 테스터 모집 공고', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/15', - status: 'todo', - }, - { - id: 'side-task-3', - workspaceId: 'side-workspace', - title: '운동 통계 차트', - assignee: '박서준', - assigneeInitial: '박', - assigneeColor: '#00B8DB', - dueDate: '7/12', - status: 'in-progress', - }, - { - id: 'side-task-4', - workspaceId: 'side-workspace', - title: '푸시 알림 설정', - assignee: '김지은', - assigneeInitial: '김', - assigneeColor: '#FE9A00', - dueDate: '7/11', - status: 'in-progress', - }, - { - id: 'side-task-5', - workspaceId: 'side-workspace', - title: '소셜 로그인 연동', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/9', - status: 'done', - }, - { - id: 'side-task-6', - workspaceId: 'side-workspace', - title: '운동 기록 CRUD API', - assignee: '이하은', - assigneeInitial: '이', - assigneeColor: '#615FFF', - dueDate: '7/8', - status: 'done', - }, - ], -}; - -const taskWorkspaceAliasById: Record = { - [TEAM_PROJECT_WORKSPACE_UUID]: 'team-workspace', - [SIDE_PROJECT_WORKSPACE_UUID]: 'side-workspace', - [STORE_OPERATION_WORKSPACE_UUID]: 'store-workspace', -}; - -function resolveTaskWorkspaceKey( - workspaceId: string, - fallbackWorkspaceId?: keyof typeof mockTasksByWorkspaceId, -) { - if (workspaceId in mockTasksByWorkspaceId) { - return workspaceId as keyof typeof mockTasksByWorkspaceId; - } - - return taskWorkspaceAliasById[workspaceId] ?? fallbackWorkspaceId; -} - -export function getMockTasksByWorkspaceId( - workspaceId: string, - fallbackWorkspaceId?: keyof typeof mockTasksByWorkspaceId, -): Task[] { - const resolvedWorkspaceId = resolveTaskWorkspaceKey(workspaceId, fallbackWorkspaceId); - - if (!resolvedWorkspaceId) { - return []; - } - - return mockTasksByWorkspaceId[resolvedWorkspaceId]?.map((task) => ({ ...task })) ?? []; -} diff --git a/src/entities/task/model/task.db.types.ts b/src/entities/task/model/task.db.types.ts new file mode 100644 index 0000000..44dd0db --- /dev/null +++ b/src/entities/task/model/task.db.types.ts @@ -0,0 +1,4 @@ +import type { GenericEnums, GenericTables } from '@/shared/model/supabase.types'; + +export type TaskRow = GenericTables<'tasks'>; +export type TaskStatusDb = GenericEnums<'task_status'>; diff --git a/src/entities/task/model/task.mapper.ts b/src/entities/task/model/task.mapper.ts new file mode 100644 index 0000000..3355604 --- /dev/null +++ b/src/entities/task/model/task.mapper.ts @@ -0,0 +1,109 @@ +import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types'; + +import type { TaskRow, TaskStatusDb } from './task.db.types'; +import type { Task, TaskStatus } from './task.types'; + +const AVATAR_PALETTE = [ + '#00C950', + '#FE9A00', + '#615FFF', + '#00B8DB', + '#2B7FFF', + '#F6339A', + '#7E22CE', +] as const; + +export interface TaskQueryRow extends Pick< + TaskRow, + 'id' | 'workspace_id' | 'title' | 'assignee_id' | 'due_date' | 'status' | 'sort_order' +> { + assignee_profile: { + real_name: string; + } | null; +} + +export function toUiTaskStatus(status: TaskStatusDb): TaskStatus { + if (status === 'in_progress') { + return 'in-progress'; + } + + return status; +} + +export function toDbTaskStatus(status: TaskStatus): TaskStatusDb { + if (status === 'in-progress') { + return 'in_progress'; + } + + return status; +} + +function formatDueDate(isoDate: string | null) { + if (!isoDate) { + return '미정'; + } + + const [year, month, day] = isoDate.split('-'); + + if (!year || !month || !day) { + return '미정'; + } + + return `${Number(month)}/${Number(day)}`; +} + +function getAvatarColor(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; +} + +export function toTask(row: TaskQueryRow): Task { + const assigneeName = row.assignee_profile?.real_name ?? '미배정'; + + return { + id: row.id, + workspaceId: row.workspace_id, + title: row.title, + assigneeId: row.assignee_id, + assignee: assigneeName, + assigneeInitial: assigneeName.slice(0, 1), + assigneeColor: getAvatarColor(assigneeName), + dueDate: formatDueDate(row.due_date), + status: toUiTaskStatus(row.status), + sortOrder: row.sort_order, + }; +} + +export function toTaskInsert(params: { + workspaceId: string; + title: string; + assigneeId: string; + createdBy: string; + dueDate: string; + sortOrder: number; +}): GenericTablesInsert<'tasks'> { + return { + workspace_id: params.workspaceId, + title: params.title, + assignee_id: params.assigneeId, + created_by: params.createdBy, + due_date: params.dueDate, + sort_order: params.sortOrder, + status: 'todo', + priority: 'medium', + sprint_id: null, + }; +} + +export function toTaskBoardUpdate( + task: Pick, +): GenericTablesUpdate<'tasks'> { + return { + status: toDbTaskStatus(task.status), + sort_order: task.sortOrder, + }; +} diff --git a/src/entities/task/model/task.schema.ts b/src/entities/task/model/task.schema.ts new file mode 100644 index 0000000..e91842a --- /dev/null +++ b/src/entities/task/model/task.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const taskTitleSchema = z + .string() + .trim() + .min(1, '업무 제목을 입력해주세요') + .max(100, '업무 제목은 100자 이내로 입력해주세요'); + +export const taskBoardItemSchema = z.object({ + id: z.string().min(1, '업무 ID가 올바르지 않습니다'), + status: z.enum(['todo', 'in-progress', 'done']), + sortOrder: z.number().int().min(0), +}); + +export const updateTaskBoardSchema = z.object({ + workspaceId: z.string().min(1, '워크스페이스 ID가 올바르지 않습니다'), + tasks: z.array(taskBoardItemSchema), +}); diff --git a/src/entities/task/model/task.types.ts b/src/entities/task/model/task.types.ts index ba506e6..383a812 100644 --- a/src/entities/task/model/task.types.ts +++ b/src/entities/task/model/task.types.ts @@ -4,9 +4,11 @@ export type Task = { id: string; workspaceId: string; title: string; + assigneeId: string | null; assignee: string; assigneeInitial: string; assigneeColor: string; dueDate: string; status: TaskStatus; + sortOrder: number; }; diff --git a/src/entities/workspace-member/api/get-workspace-members-by-id.client.ts b/src/entities/workspace-member/api/get-workspace-members-by-id.client.ts new file mode 100644 index 0000000..9421140 --- /dev/null +++ b/src/entities/workspace-member/api/get-workspace-members-by-id.client.ts @@ -0,0 +1,28 @@ +'use client'; + +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; + +import type { WorkspaceMember } from '../model/workspace-member.types'; +import { + mapWorkspaceMemberRows, + workspaceMemberSelectQuery, + type WorkspaceMemberQueryRow, +} from './shared'; + +export async function getWorkspaceMembersByWorkspaceIdClient( + workspaceId: string, +): Promise { + const supabase = getSupabaseBrowserClient(); + + const { data: memberships, error: membershipError } = await supabase + .from('workspace_members') + .select(workspaceMemberSelectQuery) + .eq('workspace_id', workspaceId) + .order('joined_at'); + + if (membershipError) { + throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); + } + + return mapWorkspaceMemberRows((memberships ?? []) as WorkspaceMemberQueryRow[]); +} diff --git a/src/entities/workspace-member/api/get-workspace-members-by-id.ts b/src/entities/workspace-member/api/get-workspace-members-by-id.ts index 428ea68..c14cb0c 100644 --- a/src/entities/workspace-member/api/get-workspace-members-by-id.ts +++ b/src/entities/workspace-member/api/get-workspace-members-by-id.ts @@ -3,6 +3,11 @@ import { cache } from 'react'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; import type { WorkspaceMember } from '@/entities/workspace-member/model/workspace-member.types'; +import { + mapWorkspaceMemberRows, + workspaceMemberSelectQuery, + type WorkspaceMemberQueryRow, +} from './shared'; export const getWorkspaceMembersByWorkspaceId = cache( async (workspaceId: string): Promise => { @@ -10,7 +15,7 @@ export const getWorkspaceMembersByWorkspaceId = cache( const { data: memberships, error: membershipError } = await supabase .from('workspace_members') - .select('workspace_id, user_id, workspace_nickname, role') + .select(workspaceMemberSelectQuery) .eq('workspace_id', workspaceId) .order('joined_at'); @@ -18,39 +23,6 @@ export const getWorkspaceMembersByWorkspaceId = cache( throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); } - const userIds = (memberships ?? []).map((member) => member.user_id); - - if (userIds.length === 0) { - return []; - } - - const { data: profiles, error: profileError } = await supabase - .from('profiles') - .select('id,email,real_name') - .in('id', userIds); - - if (profileError) { - throw new Error(`멤버 프로필 조회에 실패했습니다: ${profileError.message}`); - } - - const profilesById = new Map((profiles ?? []).map((profile) => [profile.id, profile])); - - return (memberships ?? []).flatMap((member) => { - const profile = profilesById.get(member.user_id); - - if (!profile) { - return []; - } - - return { - workspaceId: member.workspace_id, - userId: member.user_id, - workspaceNickname: member.workspace_nickname, - avatarLabel: profile.real_name.slice(0, 1), - email: profile.email, - role: member.role, - status: 'joined', - }; - }); + return mapWorkspaceMemberRows((memberships ?? []) as WorkspaceMemberQueryRow[]); }, ); diff --git a/src/entities/workspace-member/api/shared.ts b/src/entities/workspace-member/api/shared.ts new file mode 100644 index 0000000..b6021ef --- /dev/null +++ b/src/entities/workspace-member/api/shared.ts @@ -0,0 +1,29 @@ +import type { WorkspaceMember } from '../model/workspace-member.types'; + +export interface WorkspaceMemberQueryRow { + workspace_id: string; + user_id: string; + workspace_nickname: string; + role: WorkspaceMember['role']; + profile: { + email: string; + real_name: string; + } | null; +} + +export const workspaceMemberSelectQuery = + 'workspace_id, user_id, workspace_nickname, role, profile:profiles!workspace_members_user_id_fkey(email, real_name)'; + +export function mapWorkspaceMemberRows(rows: WorkspaceMemberQueryRow[]): WorkspaceMember[] { + return rows.map((member) => ({ + workspaceId: member.workspace_id, + userId: member.user_id, + workspaceNickname: member.workspace_nickname, + avatarLabel: member.profile?.real_name.slice(0, 1) ?? '?', + email: member.profile?.email ?? '', + role: member.role, + // 현재 실데이터 스키마는 초대 대기 상태를 별도 컬럼으로 저장하지 않는다. + // workspace_members row는 참여 확정 멤버만 의미하므로 joined로 노출한다. + status: 'joined', + })); +} diff --git a/src/entities/workspace-member/api/use-workspace-members-by-id.ts b/src/entities/workspace-member/api/use-workspace-members-by-id.ts new file mode 100644 index 0000000..3fb1c96 --- /dev/null +++ b/src/entities/workspace-member/api/use-workspace-members-by-id.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { getWorkspaceMembersByWorkspaceIdClient } from './get-workspace-members-by-id.client'; + +export const workspaceMembersByWorkspaceQueryKey = (workspaceId: string) => + ['workspace-members', 'workspace', workspaceId] as const; + +export function useWorkspaceMembersByWorkspaceId(workspaceId: string) { + return useQuery({ + queryKey: workspaceMembersByWorkspaceQueryKey(workspaceId), + queryFn: () => getWorkspaceMembersByWorkspaceIdClient(workspaceId), + }); +} diff --git a/src/entities/workspace-member/index.ts b/src/entities/workspace-member/index.ts index a4e1128..d76b1dd 100644 --- a/src/entities/workspace-member/index.ts +++ b/src/entities/workspace-member/index.ts @@ -5,6 +5,11 @@ export type { WorkspaceMemberStatus, } from './model/workspace-member.types'; export { mockCurrentWorkspaceMember } from './model/mock-current-workspace-member'; +// 서버 전용 getWorkspaceMembersByWorkspaceId는 next/headers 의존이 있어 배럴로 재노출하지 않는다. +export { + useWorkspaceMembersByWorkspaceId, + workspaceMembersByWorkspaceQueryKey, +} from './api/use-workspace-members-by-id'; export { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers, diff --git a/src/features/manage-progress-chart/ui/ProgressChartView.tsx b/src/features/manage-progress-chart/ui/ProgressChartView.tsx index edd8048..b90cfaf 100644 --- a/src/features/manage-progress-chart/ui/ProgressChartView.tsx +++ b/src/features/manage-progress-chart/ui/ProgressChartView.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ProgressChartAssigneeItem, ProgressChartStatusItem } from '@/entities/progress-chart'; -import { getMockTasksByWorkspaceId } from '@/entities/task'; +import { useTasksByWorkspaceId } from '@/entities/task'; import { cn } from '@/shared/lib/utils'; import { createProgressChartSummary } from '../model/progress-chart'; @@ -173,8 +173,21 @@ function StatusDistributionCard({ items }: { items: ProgressChartStatusItem[] }) } export function ProgressChartView({ workspaceId }: ProgressChartViewProps) { - const tasks = getMockTasksByWorkspaceId(workspaceId); - const summary = createProgressChartSummary(tasks); + const tasksQuery = useTasksByWorkspaceId(workspaceId); + + if (tasksQuery.isPending) { + return
불러오는 중…
; + } + + if (tasksQuery.isError) { + return ( +
+ 진행률 차트를 불러오지 못했습니다. +
+ ); + } + + const summary = createProgressChartSummary(tasksQuery.data); return (
diff --git a/src/features/project-board/ui/ProjectBoard.tsx b/src/features/project-board/ui/ProjectBoard.tsx index 2169b1a..0cb76a2 100644 --- a/src/features/project-board/ui/ProjectBoard.tsx +++ b/src/features/project-board/ui/ProjectBoard.tsx @@ -2,37 +2,45 @@ import type { DragEvent, KeyboardEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Plus, X } from 'lucide-react'; import { ProjectColumn } from '@/entities/project-column'; import type { ProjectBoardColumn } from '@/entities/project-column'; -import { getMockTasksByWorkspaceId } from '@/entities/task'; -import type { Task, TaskStatus } from '@/entities/task'; +import { + tasksByWorkspaceQueryKey, + useCreateTask, + useDeleteTask, + useTasksByWorkspaceId, + useUpdateTaskBoard, + type Task, + type TaskStatus, +} from '@/entities/task'; import { createProjectBoardColumns, flattenProjectBoardColumns, } from '../model/project-board-columns'; -const DEFAULT_ASSIGNEE = { - name: '김지은', - initial: '김', - color: '#1BB6DB', -}; - type ProjectBoardProps = { workspaceId: string; }; export function ProjectBoard({ workspaceId }: ProjectBoardProps) { + const queryClient = useQueryClient(); + const tasksQuery = useTasksByWorkspaceId(workspaceId); + const createTaskMutation = useCreateTask(workspaceId); + const deleteTaskMutation = useDeleteTask(workspaceId); + const updateTaskBoardMutation = useUpdateTaskBoard(workspaceId); const [isComposerOpen, setIsComposerOpen] = useState(false); const [taskTitle, setTaskTitle] = useState(''); - const [tasks, setTasks] = useState(() => getMockTasksByWorkspaceId(workspaceId)); const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverState, setDragOverState] = useState<{ columnId: TaskStatus; index: number; } | null>(null); const inputRef = useRef(null); - const taskCount = tasks.length; + const isCreatingTaskRef = useRef(false); + const boardMutationQueueRef = useRef(Promise.resolve()); + const tasks = tasksQuery.data ?? []; const columns = createProjectBoardColumns(tasks); useEffect(() => { @@ -43,43 +51,46 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { inputRef.current?.focus(); }, [isComposerOpen]); - const createTask = () => { + const createTask = async () => { const trimmedTitle = taskTitle.trim(); - if (!trimmedTitle) { + if (!trimmedTitle || createTaskMutation.isPending || isCreatingTaskRef.current) { return; } - const today = new Date(); - const formattedDate = `${today.getMonth() + 1}/${today.getDate()}`; - - const newTask: Task = { - id: `task-${taskCount + 1}-${Date.now()}`, - workspaceId, - title: trimmedTitle, - assignee: DEFAULT_ASSIGNEE.name, - assigneeInitial: DEFAULT_ASSIGNEE.initial, - assigneeColor: DEFAULT_ASSIGNEE.color, - dueDate: formattedDate, - status: 'todo', - }; - - setTasks((currentTasks) => [...currentTasks, newTask]); - setTaskTitle(''); - setIsComposerOpen(false); + isCreatingTaskRef.current = true; + + try { + await createTaskMutation.mutateAsync(trimmedTitle); + setTaskTitle(''); + setIsComposerOpen(false); + } catch { + return; + } finally { + isCreatingTaskRef.current = false; + } }; - const handleComposerKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Enter') { + const handleComposerKeyDown = async (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.repeat || event.nativeEvent.isComposing) { return; } event.preventDefault(); - createTask(); + await createTask(); }; - const handleDeleteTask = (taskId: string) => { - setTasks((currentTasks) => currentTasks.filter((task) => task.id !== taskId)); + const handleDeleteTask = async (taskId: string) => { + const previousTasks = tasks; + queryClient.setQueryData(tasksByWorkspaceQueryKey(workspaceId), (currentTasks) => + (currentTasks ?? []).filter((task) => task.id !== taskId), + ); + + try { + await deleteTaskMutation.mutateAsync(taskId); + } catch { + queryClient.setQueryData(tasksByWorkspaceQueryKey(workspaceId), previousTasks); + } }; const handleDropTask = ( @@ -147,7 +158,28 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { }; }); - setTasks(flattenProjectBoardColumns(reorderedColumns)); + const nextTasks = flattenProjectBoardColumns(reorderedColumns).map((task, index) => ({ + ...task, + sortOrder: index, + })); + + queryClient.setQueryData(tasksByWorkspaceQueryKey(workspaceId), nextTasks); + boardMutationQueueRef.current = boardMutationQueueRef.current + .catch(() => undefined) + .then(() => + updateTaskBoardMutation.mutateAsync({ + workspaceId, + tasks: nextTasks.map((task) => ({ + id: task.id, + status: task.status, + sortOrder: task.sortOrder, + })), + }), + ) + .catch(() => { + queryClient.invalidateQueries({ queryKey: tasksByWorkspaceQueryKey(workspaceId) }); + }); + setDraggingTaskId(null); setDragOverState(null); }; @@ -163,6 +195,20 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { setDragOverState(null); }; + if (tasksQuery.isPending) { + return ( +
업무를 불러오는 중…
+ ); + } + + if (tasksQuery.isError) { + return ( +
+ 프로젝트 업무를 불러오지 못했습니다. +
+ ); + } + return (
@@ -185,6 +231,7 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { ref={inputRef} aria-label="업무 제목 입력" className="text-brand-ink placeholder:text-brand-muted min-w-0 flex-1 bg-transparent text-base outline-none" + disabled={createTaskMutation.isPending} placeholder="업무 제목 입력 후 Enter" value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} @@ -193,10 +240,15 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { +
+ + ); + } + + const summary = createProgressChartSummary(tasksQuery.data); + return ( +

업무 요약을 불러오지 못했습니다.

+ +
+ ); + } + + const summary = createProgressChartSummary(tasksQuery.data ?? []); + const taskSummaryValue = tasksQuery.isPending ? '-' : undefined; + const memberCountValue = membersQuery.isPending ? '-' : undefined; const cards = [ { key: 'total' as const, - value: summary.totalTaskCount, + value: taskSummaryValue ?? summary.totalTaskCount, }, { key: 'done' as const, - value: summary.doneTaskCount, + value: taskSummaryValue ?? summary.doneTaskCount, }, { key: 'in-progress' as const, - value: summary.inProgressTaskCount, + value: taskSummaryValue ?? summary.inProgressTaskCount, }, { key: 'members' as const, - value: members.length, + value: memberCountValue ?? (membersQuery.data ?? []).length, }, ]; diff --git a/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql b/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql new file mode 100644 index 0000000..987060d --- /dev/null +++ b/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql @@ -0,0 +1,132 @@ +-- task 생성/보드 갱신 RPC + 브라우저 직접 조회에 필요한 RLS 정리 + +-- 실 RLS 정책이 있는 모든 초기 public 테이블에서 permissive dev policy를 제거한다. +drop policy if exists dev_full_access on public.profiles; +drop policy if exists dev_full_access on public.module_registry; +drop policy if exists dev_full_access on public.workspaces; +drop policy if exists dev_full_access on public.workspace_members; +drop policy if exists dev_full_access on public.workspace_modules; +drop policy if exists dev_full_access on public.user_dashboard_layouts; +drop policy if exists dev_full_access on public.sprints; +drop policy if exists dev_full_access on public.tasks; +drop policy if exists dev_full_access on public.calendar_events; +drop policy if exists dev_full_access on public.announcements; +drop policy if exists dev_full_access on public.meeting_notes; +drop policy if exists dev_full_access on public.resources; +drop policy if exists dev_full_access on public.chat_messages; +drop policy if exists dev_full_access on public.work_schedule_entries; + +-- profiles는 본인 또는 같은 워크스페이스 멤버의 프로필만 조회 가능하게 좁힌다. +drop policy if exists profiles_select on public.profiles; +create policy profiles_select_self_or_shared_workspace_member on public.profiles + for select to authenticated + using ( + id = auth.uid() + or exists ( + select 1 + from public.workspace_members current_member + join public.workspace_members target_member + on target_member.workspace_id = current_member.workspace_id + where current_member.user_id = auth.uid() + and target_member.user_id = profiles.id + ) + ); + +-- 업무 생성: 같은 워크스페이스 안에서 advisory lock으로 sort_order 경쟁을 직렬화한다. +create or replace function public.create_task( + p_workspace_id uuid, + p_title text, + p_user_id uuid, + p_due_date date default null +) +returns uuid +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_task_id uuid; + v_next_sort_order int; +begin + perform pg_advisory_xact_lock(hashtextextended(p_workspace_id::text, 0)); + + select coalesce(max(sort_order), -1) + 1 + into v_next_sort_order + from public.tasks + where workspace_id = p_workspace_id + and sprint_id is null; + + insert into public.tasks ( + workspace_id, + title, + assignee_id, + created_by, + due_date, + sort_order, + status, + priority, + sprint_id + ) + values ( + p_workspace_id, + p_title, + p_user_id, + p_user_id, + p_due_date, + v_next_sort_order, + 'todo', + 'medium', + null + ) + returning id into v_task_id; + + return v_task_id; +end; +$$; + +revoke all on function public.create_task(uuid, text, uuid, date) from public; +grant execute on function public.create_task(uuid, text, uuid, date) to authenticated; + +-- 보드 갱신: 여러 task 상태/정렬 변경을 단일 트랜잭션으로 반영한다. +create or replace function public.update_task_board( + p_workspace_id uuid, + p_tasks jsonb +) +returns void +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_payload_count int; + v_updated_count int; +begin + v_payload_count := jsonb_array_length(p_tasks); + + if v_payload_count = 0 then + return; + end if; + + with payload as ( + select + (item->>'id')::uuid as id, + (item->>'status')::task_status as status, + (item->>'sortOrder')::int as sort_order + from jsonb_array_elements(p_tasks) as item + ) + update public.tasks as task + set + status = payload.status, + sort_order = payload.sort_order + from payload + where task.workspace_id = p_workspace_id + and task.id = payload.id; + + get diagnostics v_updated_count = row_count; + + if v_updated_count <> v_payload_count then + raise exception 'task board update payload mismatch: expected %, updated %', v_payload_count, v_updated_count; + end if; +end; +$$; + +revoke all on function public.update_task_board(uuid, jsonb) from public; +grant execute on function public.update_task_board(uuid, jsonb) to authenticated;