From 88c52e414108b078ca13fcc1184776c8e83f54c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=EC=9B=85?= Date: Tue, 14 Jul 2026 09:05:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20task=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20supabase=20=EC=A1=B0=ED=9A=8C=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/task/api/create-task.ts | 48 +++ src/entities/task/api/delete-task.ts | 13 + .../task/api/get-tasks-by-workspace-id.ts | 22 ++ src/entities/task/api/update-task-board.ts | 40 +++ src/entities/task/api/use-create-task.ts | 21 ++ src/entities/task/api/use-delete-task.ts | 21 ++ .../task/api/use-tasks-by-workspace-id.ts | 15 + .../task/api/use-update-task-board.ts | 30 ++ src/entities/task/index.ts | 9 +- .../task/model/mock-tasks-by-workspace.ts | 324 ------------------ src/entities/task/model/task.db.types.ts | 4 + src/entities/task/model/task.mapper.ts | 109 ++++++ src/entities/task/model/task.schema.ts | 18 + src/entities/task/model/task.types.ts | 2 + .../ui/ProgressChartView.tsx | 19 +- .../project-board/ui/ProjectBoard.tsx | 98 ++++-- .../progress-chart/ui/ProgressChartPage.tsx | 2 +- src/views/signup/ui/SignupView.tsx | 17 +- 18 files changed, 447 insertions(+), 365 deletions(-) create mode 100644 src/entities/task/api/create-task.ts create mode 100644 src/entities/task/api/delete-task.ts create mode 100644 src/entities/task/api/get-tasks-by-workspace-id.ts create mode 100644 src/entities/task/api/update-task-board.ts create mode 100644 src/entities/task/api/use-create-task.ts create mode 100644 src/entities/task/api/use-delete-task.ts create mode 100644 src/entities/task/api/use-tasks-by-workspace-id.ts create mode 100644 src/entities/task/api/use-update-task-board.ts delete mode 100644 src/entities/task/model/mock-tasks-by-workspace.ts create mode 100644 src/entities/task/model/task.db.types.ts create mode 100644 src/entities/task/model/task.mapper.ts create mode 100644 src/entities/task/model/task.schema.ts diff --git a/src/entities/task/api/create-task.ts b/src/entities/task/api/create-task.ts new file mode 100644 index 0000000..63c2f90 --- /dev/null +++ b/src/entities/task/api/create-task.ts @@ -0,0 +1,48 @@ +'use server'; + +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +import { toTaskInsert } from '../model/task.mapper'; +import { taskTitleSchema } from '../model/task.schema'; + +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 { data: lastTask, error: lastTaskError } = await supabase + .from('tasks') + .select('sort_order') + .eq('workspace_id', params.workspaceId) + .is('sprint_id', null) + .order('sort_order', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (lastTaskError) { + throw new Error(`업무 순서 조회에 실패했습니다: ${lastTaskError.message}`); + } + + const today = new Date(); + const dueDate = today.toISOString().slice(0, 10); + const payload = toTaskInsert({ + workspaceId: params.workspaceId, + title: parsedTitle.data, + assigneeId: currentUserId, + createdBy: currentUserId, + dueDate, + sortOrder: (lastTask?.sort_order ?? -1) + 1, + }); + + const { error } = await supabase.from('tasks').insert(payload); + + if (error) { + console.error('[task/createTask] insert 실패:', 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/update-task-board.ts b/src/entities/task/api/update-task-board.ts new file mode 100644 index 0000000..42a0181 --- /dev/null +++ b/src/entities/task/api/update-task-board.ts @@ -0,0 +1,40 @@ +'use server'; + +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +import { toTaskBoardUpdate } from '../model/task.mapper'; +import { updateTaskBoardSchema } from '../model/task.schema'; + +export async function updateTaskBoard(params: { + workspaceId: string; + tasks: Array<{ + id: string; + status: 'todo' | 'in-progress' | 'done'; + sortOrder: number; + }>; +}): Promise { + const parsed = updateTaskBoardSchema.safeParse(params); + + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + + const results = await Promise.all( + parsed.data.tasks.map((task) => + supabase + .from('tasks') + .update(toTaskBoardUpdate(task)) + .eq('id', task.id) + .eq('workspace_id', parsed.data.workspaceId), + ), + ); + + const failed = results.find((result) => result.error); + + if (failed?.error) { + console.error('[task/updateTaskBoard] update 실패:', failed.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..60f7f1b --- /dev/null +++ b/src/entities/task/api/use-update-task-board.ts @@ -0,0 +1,30 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { updateTaskBoard } from './update-task-board'; +import { tasksByWorkspaceQueryKey } from './use-tasks-by-workspace-id'; + +interface UpdateTaskBoardParams { + workspaceId: string; + tasks: Array<{ + id: string; + status: 'todo' | 'in-progress' | 'done'; + 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..eee3bd4 --- /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().uuid(), + status: z.enum(['todo', 'in-progress', 'done']), + sortOrder: z.number().int().min(0), +}); + +export const updateTaskBoardSchema = z.object({ + workspaceId: z.string().uuid(), + 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/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..5b2af96 100644 --- a/src/features/project-board/ui/ProjectBoard.tsx +++ b/src/features/project-board/ui/ProjectBoard.tsx @@ -2,37 +2,43 @@ 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 tasks = tasksQuery.data ?? []; const columns = createProjectBoardColumns(tasks); useEffect(() => { @@ -43,43 +49,38 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { inputRef.current?.focus(); }, [isComposerOpen]); - const createTask = () => { + const createTask = async () => { const trimmedTitle = taskTitle.trim(); if (!trimmedTitle) { 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]); + await createTaskMutation.mutateAsync(trimmedTitle); setTaskTitle(''); setIsComposerOpen(false); }; - const handleComposerKeyDown = (event: KeyboardEvent) => { + const handleComposerKeyDown = async (event: KeyboardEvent) => { if (event.key !== 'Enter') { 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 +148,28 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { }; }); - setTasks(flattenProjectBoardColumns(reorderedColumns)); + const nextTasks = flattenProjectBoardColumns(reorderedColumns).map((task, index) => ({ + ...task, + sortOrder: index, + })); + + const previousTasks = tasks; + queryClient.setQueryData(tasksByWorkspaceQueryKey(workspaceId), nextTasks); + updateTaskBoardMutation.mutate( + { + workspaceId, + tasks: nextTasks.map((task) => ({ + id: task.id, + status: task.status, + sortOrder: task.sortOrder, + })), + }, + { + onError: () => { + queryClient.setQueryData(tasksByWorkspaceQueryKey(workspaceId), previousTasks); + }, + }, + ); setDraggingTaskId(null); setDragOverState(null); }; @@ -163,6 +185,20 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { setDragOverState(null); }; + if (tasksQuery.isPending) { + return ( +
업무를 불러오는 중…
+ ); + } + + if (tasksQuery.isError) { + return ( +
+ 프로젝트 업무를 불러오지 못했습니다. +
+ ); + } + return (
diff --git a/src/views/progress-chart/ui/ProgressChartPage.tsx b/src/views/progress-chart/ui/ProgressChartPage.tsx index 5e958ce..daea48e 100644 --- a/src/views/progress-chart/ui/ProgressChartPage.tsx +++ b/src/views/progress-chart/ui/ProgressChartPage.tsx @@ -21,7 +21,7 @@ export default async function ProgressChartPage({ workspaceId }: ProgressChartPa } return (
- +
); } diff --git a/src/views/signup/ui/SignupView.tsx b/src/views/signup/ui/SignupView.tsx index cf6abc3..9d75788 100644 --- a/src/views/signup/ui/SignupView.tsx +++ b/src/views/signup/ui/SignupView.tsx @@ -49,7 +49,7 @@ function formatTimer(seconds: number) { } export default function SignupView() { - const supabase = createClient(); + const [supabase] = useState(() => createClient()); const router = useRouter(); const [step, setStep] = useState('email'); @@ -68,7 +68,9 @@ export default function SignupView() { useEffect(() => { const checkSession = async () => { - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); if (!user) { setCheckingSession(false); return; @@ -86,7 +88,7 @@ export default function SignupView() { } }; checkSession(); - }, []); + }, [router, supabase]); const startTimer = () => { setTimer(OTP_SECONDS); @@ -171,7 +173,10 @@ export default function SignupView() { if (error) { setError(toKoreanError(error.message)); } else { - const { data: { user }, error: userError } = await supabase.auth.getUser(); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); if (userError || !user) { setError('가입 정보를 확인하지 못했어요. 다시 시도해주세요'); } else { @@ -378,7 +383,9 @@ export default function SignupView() { )} {step !== 'done' && ( -
+
{step === 'email' && ( Date: Tue, 14 Jul 2026 12:00:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20task=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=9B=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=83=81=ED=98=B8=EC=9E=91=EC=9A=A9=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-column/ui/ProjectColumn.tsx | 22 +++++- src/entities/task/model/task.schema.ts | 4 +- .../api/get-workspace-members-by-id.client.ts | 72 +++++++++++++++++++ .../api/use-workspace-members-by-id.ts | 15 ++++ src/entities/workspace-member/index.ts | 5 ++ .../project-board/ui/ProjectBoard.tsx | 25 +++++-- .../ui/OverallProgress.tsx | 37 +++++++++- .../dashboard-work-summary/ui/WorkSummary.tsx | 26 ++++--- 8 files changed, 182 insertions(+), 24 deletions(-) create mode 100644 src/entities/workspace-member/api/get-workspace-members-by-id.client.ts create mode 100644 src/entities/workspace-member/api/use-workspace-members-by-id.ts 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/model/task.schema.ts b/src/entities/task/model/task.schema.ts index eee3bd4..e91842a 100644 --- a/src/entities/task/model/task.schema.ts +++ b/src/entities/task/model/task.schema.ts @@ -7,12 +7,12 @@ export const taskTitleSchema = z .max(100, '업무 제목은 100자 이내로 입력해주세요'); export const taskBoardItemSchema = z.object({ - id: z.string().uuid(), + 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().uuid(), + workspaceId: z.string().min(1, '워크스페이스 ID가 올바르지 않습니다'), tasks: z.array(taskBoardItemSchema), }); 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..fb8df21 --- /dev/null +++ b/src/entities/workspace-member/api/get-workspace-members-by-id.client.ts @@ -0,0 +1,72 @@ +'use client'; + +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; + +import type { WorkspaceMember } from '../model/workspace-member.types'; + +interface WorkspaceMemberQueryRow { + workspace_id: string; + user_id: string; + workspace_nickname: string; + role: WorkspaceMember['role']; +} + +interface ProfileQueryRow { + id: string; + email: string; + real_name: string; +} + +export async function getWorkspaceMembersByWorkspaceIdClient( + workspaceId: string, +): Promise { + const supabase = getSupabaseBrowserClient(); + + const { data: memberships, error: membershipError } = await supabase + .from('workspace_members') + .select('workspace_id, user_id, workspace_nickname, role') + .eq('workspace_id', workspaceId) + .order('joined_at'); + + if (membershipError) { + throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); + } + + const membershipRows = (memberships ?? []) as WorkspaceMemberQueryRow[]; + const userIds = membershipRows.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 ?? []) as ProfileQueryRow[]).map((profile) => [profile.id, profile]), + ); + + return membershipRows.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', + }; + }); +} 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/project-board/ui/ProjectBoard.tsx b/src/features/project-board/ui/ProjectBoard.tsx index 5b2af96..89bb134 100644 --- a/src/features/project-board/ui/ProjectBoard.tsx +++ b/src/features/project-board/ui/ProjectBoard.tsx @@ -38,6 +38,7 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { index: number; } | null>(null); const inputRef = useRef(null); + const isCreatingTaskRef = useRef(false); const tasks = tasksQuery.data ?? []; const columns = createProjectBoardColumns(tasks); @@ -52,17 +53,23 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { const createTask = async () => { const trimmedTitle = taskTitle.trim(); - if (!trimmedTitle) { + if (!trimmedTitle || createTaskMutation.isPending || isCreatingTaskRef.current) { return; } - await createTaskMutation.mutateAsync(trimmedTitle); - setTaskTitle(''); - setIsComposerOpen(false); + isCreatingTaskRef.current = true; + + try { + await createTaskMutation.mutateAsync(trimmedTitle); + setTaskTitle(''); + setIsComposerOpen(false); + } finally { + isCreatingTaskRef.current = false; + } }; const handleComposerKeyDown = async (event: KeyboardEvent) => { - if (event.key !== 'Enter') { + if (event.key !== 'Enter' || event.repeat || event.nativeEvent.isComposing) { return; } @@ -221,6 +228,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)} @@ -229,10 +237,15 @@ export function ProjectBoard({ workspaceId }: ProjectBoardProps) { ); diff --git a/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx index ab50920..e4d2271 100644 --- a/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx +++ b/src/widgets/team-project/dashboard-work-summary/ui/WorkSummary.tsx @@ -60,11 +60,28 @@ export default function WorkSummary({ }) { const tasksQuery = useTasksByWorkspaceId(workspaceId); const membersQuery = useWorkspaceMembersByWorkspaceId(workspaceId); + + if (tasksQuery.isError || membersQuery.isError) { + return ( +
+

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

+ +
+ ); + } + const summary = createProgressChartSummary(tasksQuery.data ?? []); - const taskSummaryValue = - tasksQuery.isPending || tasksQuery.isError ? '-' : undefined; - const memberCountValue = - membersQuery.isPending || membersQuery.isError ? '-' : undefined; + const taskSummaryValue = tasksQuery.isPending ? '-' : undefined; + const memberCountValue = membersQuery.isPending ? '-' : undefined; const cards = [ { 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..011701c --- /dev/null +++ b/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql @@ -0,0 +1,115 @@ +-- task 생성/보드 갱신 RPC + 브라우저 직접 조회에 필요한 RLS 정리 + +-- 브라우저에서 직접 읽는 테이블은 dev_full_access를 제거하고 실제 멤버십 정책만 남긴다. +drop policy if exists dev_full_access on public.tasks; +drop policy if exists dev_full_access on public.workspace_members; +drop policy if exists dev_full_access on public.profiles; + +-- 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; +$$; + +-- 보드 갱신: 여러 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; +$$; From 89aa588b17677ed3df96c6854b30393f5af3bf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=EC=9B=85?= Date: Tue, 14 Jul 2026 15:13:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20task=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EB=B0=8F=20=EB=A9=A4=EB=B2=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/task/api/create-task.ts | 28 +++++++++++----- src/entities/task/api/rpc-client.ts | 6 ++++ src/entities/task/api/update-task-board.ts | 8 +---- .../api/get-workspace-members-by-id.client.ts | 32 ++++--------------- .../api/get-workspace-members-by-id.ts | 32 ++++--------------- src/entities/workspace-member/api/shared.ts | 29 +++++++++++++++++ ...ate_task_rpcs_and_restrict_browser_rls.sql | 23 +++++++++++-- 7 files changed, 90 insertions(+), 68 deletions(-) create mode 100644 src/entities/task/api/rpc-client.ts create mode 100644 src/entities/workspace-member/api/shared.ts diff --git a/src/entities/task/api/create-task.ts b/src/entities/task/api/create-task.ts index a507dba..99339e2 100644 --- a/src/entities/task/api/create-task.ts +++ b/src/entities/task/api/create-task.ts @@ -4,13 +4,26 @@ 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('현재 날짜를 계산하지 못했습니다.'); + } -type UntypedRpcClient = { - rpc: ( - fn: string, - args?: Record, - ) => Promise<{ data: unknown; error: { message: string } | null }>; -}; + return `${year}-${month}-${day}`; +} export async function createTask(params: { workspaceId: string; title: string }): Promise { const parsedTitle = taskTitleSchema.safeParse(params.title); @@ -21,8 +34,7 @@ export async function createTask(params: { workspaceId: string; title: string }) const supabase = await createSupabaseServerClient(); const currentUserId = await getCurrentUserId(); - const today = new Date(); - const dueDate = today.toISOString().slice(0, 10); + const dueDate = getTodayIsoDateInKst(); const { error } = await (supabase as unknown as UntypedRpcClient).rpc('create_task', { p_workspace_id: params.workspaceId, 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 index 2108840..bbf9652 100644 --- a/src/entities/task/api/update-task-board.ts +++ b/src/entities/task/api/update-task-board.ts @@ -4,13 +4,7 @@ import { createSupabaseServerClient } from '@/shared/api/supabase/server'; import type { TaskStatus } from '../model/task.types'; import { updateTaskBoardSchema } from '../model/task.schema'; - -type UntypedRpcClient = { - rpc: ( - fn: string, - args?: Record, - ) => Promise<{ data: unknown; error: { message: string } | null }>; -}; +import type { UntypedRpcClient } from './rpc-client'; export async function updateTaskBoard(params: { workspaceId: string; 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 index 32e31b3..9421140 100644 --- 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 @@ -3,17 +3,11 @@ import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; import type { WorkspaceMember } from '../model/workspace-member.types'; - -interface WorkspaceMemberQueryRow { - workspace_id: string; - user_id: string; - workspace_nickname: string; - role: WorkspaceMember['role']; - profile: { - email: string; - real_name: string; - } | null; -} +import { + mapWorkspaceMemberRows, + workspaceMemberSelectQuery, + type WorkspaceMemberQueryRow, +} from './shared'; export async function getWorkspaceMembersByWorkspaceIdClient( workspaceId: string, @@ -22,9 +16,7 @@ export async function getWorkspaceMembersByWorkspaceIdClient( const { data: memberships, error: membershipError } = await supabase .from('workspace_members') - .select( - 'workspace_id, user_id, workspace_nickname, role, profile:profiles!workspace_members_user_id_fkey(email, real_name)', - ) + .select(workspaceMemberSelectQuery) .eq('workspace_id', workspaceId) .order('joined_at'); @@ -32,15 +24,5 @@ export async function getWorkspaceMembersByWorkspaceIdClient( throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); } - return ((memberships ?? []) as WorkspaceMemberQueryRow[]).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', - })); + 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 5eaa88f..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,17 +3,11 @@ import { cache } from 'react'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; import type { WorkspaceMember } from '@/entities/workspace-member/model/workspace-member.types'; - -interface WorkspaceMemberQueryRow { - workspace_id: string; - user_id: string; - workspace_nickname: string; - role: WorkspaceMember['role']; - profile: { - email: string; - real_name: string; - } | null; -} +import { + mapWorkspaceMemberRows, + workspaceMemberSelectQuery, + type WorkspaceMemberQueryRow, +} from './shared'; export const getWorkspaceMembersByWorkspaceId = cache( async (workspaceId: string): Promise => { @@ -21,9 +15,7 @@ export const getWorkspaceMembersByWorkspaceId = cache( const { data: memberships, error: membershipError } = await supabase .from('workspace_members') - .select( - 'workspace_id, user_id, workspace_nickname, role, profile:profiles!workspace_members_user_id_fkey(email, real_name)', - ) + .select(workspaceMemberSelectQuery) .eq('workspace_id', workspaceId) .order('joined_at'); @@ -31,16 +23,6 @@ export const getWorkspaceMembersByWorkspaceId = cache( throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); } - return ((memberships ?? []) as WorkspaceMemberQueryRow[]).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', - })); + 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/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql b/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql index 011701c..987060d 100644 --- a/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql +++ b/supabase/migrations/20260714100000_create_task_rpcs_and_restrict_browser_rls.sql @@ -1,9 +1,20 @@ -- task 생성/보드 갱신 RPC + 브라우저 직접 조회에 필요한 RLS 정리 --- 브라우저에서 직접 읽는 테이블은 dev_full_access를 제거하고 실제 멤버십 정책만 남긴다. -drop policy if exists dev_full_access on public.tasks; -drop policy if exists dev_full_access on public.workspace_members; +-- 실 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; @@ -72,6 +83,9 @@ begin 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, @@ -113,3 +127,6 @@ begin 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;