From ed08b29205306698e7bc55b67d13326846d9bba8 Mon Sep 17 00:00:00 2001 From: JiHo Jeon Date: Thu, 9 Jul 2026 15:07:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=C2=B7?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20Supabase=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + .gitignore | 1 + docs/conventions/supabase-convention.md | 20 +++- src/app/layout.tsx | 5 +- src/app/providers.tsx | 22 ++++ .../workspace/api/create-workspace.ts | 45 +++++--- .../workspace/api/get-my-workspaces.ts | 57 ++-------- .../workspace/api/use-my-workspaces.ts | 16 +++ src/entities/workspace/index.ts | 12 +- .../model/create-workspace.schema.ts | 20 ++++ .../workspace/model/purpose.mapper.ts | 20 ++++ .../workspace/model/workspace.db.types.ts | 8 ++ src/features/create-workspace/model/schema.ts | 14 +-- .../ui/CreateWorkspaceDialog.tsx | 8 +- src/shared/api/supabase/client.ts | 14 +++ src/shared/api/supabase/env.ts | 13 +++ src/shared/api/supabase/server.ts | 25 +++++ src/shared/config/dev-user.ts | 3 + src/shared/model/database.types.ts | 37 +++++- src/shared/model/supabase.types.ts | 3 + src/views/workspaces/ui/WorkspacesPage.tsx | 32 +++++- .../20260709054147_create_workspace_rpcs.sql | 105 ++++++++++++++++++ 22 files changed, 396 insertions(+), 88 deletions(-) create mode 100644 .env.example create mode 100644 src/app/providers.tsx create mode 100644 src/entities/workspace/api/use-my-workspaces.ts create mode 100644 src/entities/workspace/model/create-workspace.schema.ts create mode 100644 src/entities/workspace/model/purpose.mapper.ts create mode 100644 src/shared/api/supabase/client.ts create mode 100644 src/shared/api/supabase/env.ts create mode 100644 src/shared/api/supabase/server.ts create mode 100644 src/shared/config/dev-user.ts create mode 100644 supabase/migrations/20260709054147_create_workspace_rpcs.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5abe02a --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Supabase 연결 정보 — 값은 Supabase 대시보드(Settings > API Keys) 또는 팀 Discord 참고 +# PUBLISHABLE_KEY에는 publishable key(sb_publishable_...)를 사용한다 +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= diff --git a/.gitignore b/.gitignore index 32939d8..2d1cf45 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ AGENTS.md CLAUDE.md .agents/ .claude/ +!.env.example diff --git a/docs/conventions/supabase-convention.md b/docs/conventions/supabase-convention.md index 4f6367c..67172c4 100644 --- a/docs/conventions/supabase-convention.md +++ b/docs/conventions/supabase-convention.md @@ -90,19 +90,33 @@ const statusSchema = z.enum(Constants.public.Enums.task_status); - RPC는 `auth.uid()` 대신 **`p_user_id uuid` 파라미터**로 유저를 받습니다. 테스트는 시드 계정 id를 하드코딩합니다. - auth 연동이 완료되면 `auth.uid()`로 교체하고, `dev_full_access` RLS 정책을 drop해 실 정책을 발동시킵니다. -## 5. ENUM 규칙 +## 5. 데이터 페칭 규칙 (프론트) + +| 작업 | 방식 | 위치 | +| -------------------- | --------------------------------------------------- | -------------------------------------- | +| 조회(GET) | **tanstack-query `useQuery`** + 브라우저 클라이언트 | 쿼리 함수·훅: `entities/<도메인>/api/` | +| 생성/수정/삭제(쓰기) | **server action**(`'use server'`) + 서버 클라이언트 | `entities/<도메인>/api/` | + +- Supabase 클라이언트는 `@/shared/api/supabase/client`의 `getSupabaseBrowserClient()`(클라이언트 컴포넌트) / `@/shared/api/supabase/server`의 `createSupabaseServerClient()`(서버액션·RSC)를 사용합니다. **배럴(index)로 묶지 않습니다** — server 클라이언트(`next/headers`)가 클라이언트 번들에 딸려 들어가 빌드가 깨집니다. +- 쿼리 키는 `['<도메인>', ...스코프]` 배열로 훅 파일에서 export합니다 (예: `myWorkspacesQueryKey`). +- 쓰기 성공 후에는 관련 쿼리를 `queryClient.invalidateQueries({ queryKey: ['<도메인>'] })`로 무효화합니다. +- 입력 검증은 **클라이언트(react-hook-form + zod)와 서버액션 양쪽**에서 같은 zod 스키마로 수행합니다. 스키마는 `entities/<도메인>/model/`이 소유하고 feature가 가져다 씁니다. +- 임시 유저 id는 `@/shared/config/dev-user`의 `DEV_USER_ID`만 사용합니다 (하드코딩 분산 금지 — auth 연동 시 일괄 교체). +- 기준 구현: **workspace 도메인** (`entities/workspace/api/use-my-workspaces.ts` 조회 / `create-workspace.ts` 서버액션) + +## 6. ENUM 규칙 - enum성 컬럼은 전부 **Postgres 네이티브 ENUM + snake_case** 값으로 통일합니다. - 현재 7종: `workspace_purpose`, `task_status`, `task_priority`, `task_category`, `resource_type`, `calendar_event_type`, `member_role` - 값 추가는 `alter type add value '<값>'` 마이그레이션 → `npm run gen:types` 재실행 순서로 진행합니다. - 프론트에서 enum 값을 문자열 리터럴로 중복 정의하지 않고 `GenericEnums`로 파생합니다. -## 6. 마이그레이션 규칙 +## 7. 마이그레이션 규칙 - 스키마 변경은 반드시 마이그레이션으로 기록하고, 원격에 적용된 버전과 **동일한 파일명**으로 `supabase/migrations/`에 동기화합니다. - 스키마 변경 후에는 `npm run gen:types`를 실행해 `database.types.ts` 갱신분을 같은 PR에 포함합니다. -## 7. 테스트 시드 +## 8. 테스트 시드 | 항목 | 값 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bc58872..aecb4ca 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import { cn } from '@/shared/lib/utils'; import type { Metadata } from 'next'; import { Geist, Geist_Mono, Inter } from 'next/font/google'; +import Providers from './providers'; import './globals.css'; const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); @@ -37,7 +38,9 @@ export default function RootLayout({ inter.variable, )} > - {children} + + {children} + ); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..d584af2 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,22 @@ +'use client'; + +// 전역 프로바이더 — tanstack-query 클라이언트를 앱 전체에 제공한다 +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export default function Providers({ children }: { children: React.ReactNode }) { + // 요청 간 캐시가 섞이지 않도록 컴포넌트 수명과 함께 생성한다 + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, + }), + ); + + return {children}; +} diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts index 4a62307..d0fecfc 100644 --- a/src/entities/workspace/api/create-workspace.ts +++ b/src/entities/workspace/api/create-workspace.ts @@ -1,22 +1,33 @@ -// create_workspace RPC의 Mock 구현 -// 백엔드 준비 시 supabase.rpc('create_workspace')로 교체한다 -// (실제로는 workspaces insert → invite_code 생성 → owner 등록 → purpose 기준 workspace_modules 생성 후 id 반환) -import type { WorkspacePurpose } from '../model/workspace.types'; +'use server'; -export interface CreateWorkspaceInput { - name: string; - description?: string; - purpose: WorkspacePurpose; -} +// 워크스페이스 생성 서버액션 — create_workspace RPC +// (RPC가 workspaces + owner 멤버십 + purpose별 기본 모듈 + invite_code 생성을 한 트랜잭션으로 처리) +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { DEV_USER_ID } from '@/shared/config/dev-user'; +import { + createWorkspaceInputSchema, + type CreateWorkspaceInput, +} from '../model/create-workspace.schema'; +import { toDbPurpose } from '../model/purpose.mapper'; export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id: string }> { - // Mock: 실제 저장 없이 워크스페이스 id만 생성해 반환한다 - const slug = - input.name - .trim() - .toLowerCase() - .replace(/[^a-z0-9가-힣]+/g, '-') - .replace(/^-+|-+$/g, '') || 'workspace'; + // 클라이언트(rhf+zod) 검증과 별개로 서버에서 재검증한다 + const parsed = createWorkspaceInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.rpc('create_workspace', { + p_user_id: DEV_USER_ID, + p_name: parsed.data.name, + p_purpose: toDbPurpose(parsed.data.purpose), + ...(parsed.data.description ? { p_description: parsed.data.description } : {}), + }); + + if (error) { + throw new Error(`워크스페이스 생성에 실패했습니다: ${error.message}`); + } - return { id: `ws-${slug}-${Date.now().toString(36)}` }; + return { id: data }; } diff --git a/src/entities/workspace/api/get-my-workspaces.ts b/src/entities/workspace/api/get-my-workspaces.ts index 16a4d6c..9b7b802 100644 --- a/src/entities/workspace/api/get-my-workspaces.ts +++ b/src/entities/workspace/api/get-my-workspaces.ts @@ -1,51 +1,16 @@ -// get_my_workspaces RPC의 Mock 구현 -// 백엔드 준비 시 supabase.rpc('get_my_workspaces') 호출로 교체한다 (반환 shape 동일) +// 내 워크스페이스 목록 조회 — get_my_workspaces RPC (집계 포함 단일 쿼리, N+1 없음) +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; +import { DEV_USER_ID } from '@/shared/config/dev-user'; +import { toUiPurpose } from '../model/purpose.mapper'; import type { WorkspaceSummary } from '../model/workspace.types'; -// updated_at은 호출 시점 기준 상대값으로 생성해 "최근 활동" 표기가 자연스럽게 유지되도록 한다 -type MockWorkspaceSeed = Omit & { - activityMinutesAgo: number; -}; - -const MOCK_WORKSPACE_SEEDS: MockWorkspaceSeed[] = [ - { - id: 'ws-capstone-design', - name: '캡스톤 디자인 팀', - purpose: 'team-project', - member_count: 5, - task_count: 12, - done_task_count: 7, - activityMinutesAgo: 10, - }, - { - id: 'ws-fitto-app', - name: 'Fitto 앱 개발팀', - purpose: 'side-project', - member_count: 4, - task_count: 24, - done_task_count: 18, - activityMinutesAgo: 60, - }, - { - id: 'ws-cafe-gray', - name: '카페 그레이 운영', - purpose: 'store-operation', - member_count: 6, - task_count: 8, - done_task_count: 5, - activityMinutesAgo: 180, - }, -]; - export async function getMyWorkspaces(): Promise { - const now = Date.now(); + const supabase = getSupabaseBrowserClient(); + const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: DEV_USER_ID }); + + if (error) { + throw new Error(`워크스페이스 목록 조회에 실패했습니다: ${error.message}`); + } - return MOCK_WORKSPACE_SEEDS.map(({ activityMinutesAgo, ...summary }) => ({ - ...summary, - progress: - summary.task_count === 0 - ? 0 - : Math.round((summary.done_task_count / summary.task_count) * 100), - updated_at: new Date(now - activityMinutesAgo * 60_000).toISOString(), - })); + return (data ?? []).map((row) => ({ ...row, purpose: toUiPurpose(row.purpose) })); } diff --git a/src/entities/workspace/api/use-my-workspaces.ts b/src/entities/workspace/api/use-my-workspaces.ts new file mode 100644 index 0000000..4ddba28 --- /dev/null +++ b/src/entities/workspace/api/use-my-workspaces.ts @@ -0,0 +1,16 @@ +'use client'; + +// 내 워크스페이스 목록 쿼리 훅 — GET은 tanstack-query 컨벤션 +import { useQuery } from '@tanstack/react-query'; +import { DEV_USER_ID } from '@/shared/config/dev-user'; +import { getMyWorkspaces } from './get-my-workspaces'; + +// 생성/수정 후 invalidateQueries({ queryKey: ['workspaces'] })로 무효화한다 +export const myWorkspacesQueryKey = ['workspaces', 'my', DEV_USER_ID] as const; + +export function useMyWorkspaces() { + return useQuery({ + queryKey: myWorkspacesQueryKey, + queryFn: getMyWorkspaces, + }); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 84b4aed..f7f8771 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -5,7 +5,16 @@ export type { WorkspaceInsert, WorkspaceUpdate, WorkspacePurposeDb, + MyWorkspaceRpcRow, + CreateWorkspaceRpcArgs, } from './model/workspace.db.types'; +export { toUiPurpose, toDbPurpose } from './model/purpose.mapper'; +export { + createWorkspaceSchema, + createWorkspaceInputSchema, + type CreateWorkspaceForm, + type CreateWorkspaceInput, +} from './model/create-workspace.schema'; export { getMockWorkspaceById, mockWorkspace } from './model/mock-workspace'; export { WORKSPACE_PURPOSE_META, FALLBACK_PURPOSE_META } from './config/purpose'; export { @@ -14,4 +23,5 @@ export { type WorkspaceTemplateDetail, } from './config/template'; export { getMyWorkspaces } from './api/get-my-workspaces'; -export { createWorkspace, type CreateWorkspaceInput } from './api/create-workspace'; +export { useMyWorkspaces, myWorkspacesQueryKey } from './api/use-my-workspaces'; +export { createWorkspace } from './api/create-workspace'; diff --git a/src/entities/workspace/model/create-workspace.schema.ts b/src/entities/workspace/model/create-workspace.schema.ts new file mode 100644 index 0000000..635e307 --- /dev/null +++ b/src/entities/workspace/model/create-workspace.schema.ts @@ -0,0 +1,20 @@ +// 워크스페이스 생성 입력 검증 — 폼(react-hook-form)과 서버액션이 같은 스키마를 공유한다 +import { z } from 'zod'; + +export const createWorkspaceSchema = z.object({ + name: z + .string() + .trim() + .min(1, '워크스페이스 이름을 입력해주세요') + .max(50, '이름은 50자 이내로 입력해주세요'), + description: z.string().trim().max(200, '설명은 200자 이내로 입력해주세요').optional(), +}); + +export type CreateWorkspaceForm = z.infer; + +// 서버액션용 — 폼 필드에 더해 purpose까지 재검증한다 (클라이언트 입력을 신뢰하지 않음) +export const createWorkspaceInputSchema = createWorkspaceSchema.extend({ + purpose: z.enum(['team-project', 'side-project', 'store-operation']), +}); + +export type CreateWorkspaceInput = z.infer; diff --git a/src/entities/workspace/model/purpose.mapper.ts b/src/entities/workspace/model/purpose.mapper.ts new file mode 100644 index 0000000..67d4efd --- /dev/null +++ b/src/entities/workspace/model/purpose.mapper.ts @@ -0,0 +1,20 @@ +// DB(snake_case) ↔ 프론트(hyphen) purpose 표기 매퍼 +// TODO: 프론트 WorkspacePurpose를 snake_case로 통일하는 리팩터링이 끝나면 이 파일을 제거한다 +import type { WorkspacePurposeDb } from './workspace.db.types'; +import type { WorkspacePurpose } from './workspace.types'; + +const DB_TO_UI: Record = { + team_project: 'team-project', + side_project: 'side-project', + store_operation: 'store-operation', +}; + +const UI_TO_DB: Record = { + 'team-project': 'team_project', + 'side-project': 'side_project', + 'store-operation': 'store_operation', +}; + +export const toUiPurpose = (purpose: WorkspacePurposeDb): WorkspacePurpose => DB_TO_UI[purpose]; + +export const toDbPurpose = (purpose: WorkspacePurpose): WorkspacePurposeDb => UI_TO_DB[purpose]; diff --git a/src/entities/workspace/model/workspace.db.types.ts b/src/entities/workspace/model/workspace.db.types.ts index 9c6b3f4..6f45917 100644 --- a/src/entities/workspace/model/workspace.db.types.ts +++ b/src/entities/workspace/model/workspace.db.types.ts @@ -2,6 +2,8 @@ // 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. import type { GenericEnums, + GenericFunctionArgs, + GenericFunctionReturns, GenericTables, GenericTablesInsert, GenericTablesUpdate, @@ -19,3 +21,9 @@ export type WorkspaceUpdate = GenericTablesUpdate<'workspaces'>; // DB enum: 'team_project' | 'side_project' | 'store_operation' // 프론트 WorkspacePurpose(hyphen)는 snake_case 통일 리팩터링 때 이 타입으로 교체한다. export type WorkspacePurposeDb = GenericEnums<'workspace_purpose'>; + +/** get_my_workspaces RPC 반환 행 */ +export type MyWorkspaceRpcRow = GenericFunctionReturns<'get_my_workspaces'>[number]; + +/** create_workspace RPC 인자 */ +export type CreateWorkspaceRpcArgs = GenericFunctionArgs<'create_workspace'>; diff --git a/src/features/create-workspace/model/schema.ts b/src/features/create-workspace/model/schema.ts index 0366cc3..916ccd5 100644 --- a/src/features/create-workspace/model/schema.ts +++ b/src/features/create-workspace/model/schema.ts @@ -1,12 +1,2 @@ -import { z } from 'zod'; - -export const createWorkspaceSchema = z.object({ - name: z - .string() - .trim() - .min(1, '워크스페이스 이름을 입력해주세요') - .max(50, '이름은 50자 이내로 입력해주세요'), - description: z.string().trim().max(200, '설명은 200자 이내로 입력해주세요').optional(), -}); - -export type CreateWorkspaceForm = z.infer; +// 폼 스키마는 서버액션 재검증과 공유하기 위해 entities/workspace가 소유한다 — 여기서는 재노출만 +export { createWorkspaceSchema, type CreateWorkspaceForm } from '@/entities/workspace'; diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx index 038f20f..36ca222 100644 --- a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -1,9 +1,10 @@ 'use client'; -// 워크스페이스 생성 Dialog — 선택된 템플릿 요약 + 이름/설명 입력 후 Mock 생성 +// 워크스페이스 생성 Dialog — 선택된 템플릿 요약 + 이름/설명 입력 후 서버액션으로 생성 import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { useQueryClient } from '@tanstack/react-query'; import { zodResolver } from '@hookform/resolvers/zod'; import { WORKSPACE_PURPOSE_META, @@ -32,6 +33,7 @@ export default function CreateWorkspaceDialog({ onOpenChange, }: CreateWorkspaceDialogProps) { const router = useRouter(); + const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); const { register, @@ -54,10 +56,12 @@ export default function CreateWorkspaceDialog({ setIsSubmitting(true); try { await createWorkspace({ name: values.name, description: values.description, purpose }); + // 새 워크스페이스가 목록에 반영되도록 캐시 무효화 후 이동 + await queryClient.invalidateQueries({ queryKey: ['workspaces'] }); router.push('/workspaces'); // 성공 시 페이지 이동으로 언마운트되므로 isSubmitting을 리셋하지 않는다(버튼 깜빡임 방지) } catch (error) { - // TODO: 실패 알림 UI(toast 등) 추가 — 현재는 Mock이라 실패하지 않음 + // TODO: 실패 알림 UI(toast 등) 추가 console.error(error); setIsSubmitting(false); } diff --git a/src/shared/api/supabase/client.ts b/src/shared/api/supabase/client.ts new file mode 100644 index 0000000..60d83f5 --- /dev/null +++ b/src/shared/api/supabase/client.ts @@ -0,0 +1,14 @@ +// 브라우저용 Supabase 클라이언트 — 모듈 스코프 싱글턴으로 재사용한다 +import { createBrowserClient } from '@supabase/ssr'; +import type { Database } from '@/shared/model/supabase.types'; +import { getSupabaseEnv } from './env'; + +let client: ReturnType> | undefined; + +export function getSupabaseBrowserClient() { + if (!client) { + const { url, publishableKey } = getSupabaseEnv(); + client = createBrowserClient(url, publishableKey); + } + return client; +} diff --git a/src/shared/api/supabase/env.ts b/src/shared/api/supabase/env.ts new file mode 100644 index 0000000..efeeb73 --- /dev/null +++ b/src/shared/api/supabase/env.ts @@ -0,0 +1,13 @@ +// Supabase 환경 변수 접근 — 누락 시 초기에 바로 실패시켜 원인 파악을 쉽게 한다 +export function getSupabaseEnv() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + + if (!url || !publishableKey) { + throw new Error( + 'NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 환경 변수가 필요합니다 (.env.local 확인 — 양식은 .env.example 참고)', + ); + } + + return { url, publishableKey }; +} diff --git a/src/shared/api/supabase/server.ts b/src/shared/api/supabase/server.ts new file mode 100644 index 0000000..3f9e206 --- /dev/null +++ b/src/shared/api/supabase/server.ts @@ -0,0 +1,25 @@ +// 서버용 Supabase 클라이언트 — RSC/서버액션에서 요청 단위로 생성한다 +import { cookies } from 'next/headers'; +import { createServerClient } from '@supabase/ssr'; +import type { Database } from '@/shared/model/supabase.types'; +import { getSupabaseEnv } from './env'; + +export async function createSupabaseServerClient() { + const cookieStore = await cookies(); + const { url, publishableKey } = getSupabaseEnv(); + + return createServerClient(url, publishableKey, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); + } catch { + // RSC에서는 쿠키 쓰기가 불가 — auth 세션 갱신은 미들웨어 도입 시 처리한다 + } + }, + }, + }); +} diff --git a/src/shared/config/dev-user.ts b/src/shared/config/dev-user.ts new file mode 100644 index 0000000..a098dfc --- /dev/null +++ b/src/shared/config/dev-user.ts @@ -0,0 +1,3 @@ +// auth 연동 전 임시 개발용 유저 — 시드 계정 테스트유저1 (test1@test.com / test1234!) +// TODO: auth 연동 시 세션(auth.uid()) 기반으로 대체하고 이 파일을 제거한다 +export const DEV_USER_ID = '00000000-0000-0000-0000-000000000001'; diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index 5ad966f..d3cc1c2 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -480,6 +480,13 @@ export type Database = { workspace_id?: string } Relationships: [ + { + foreignKeyName: "user_dashboard_layouts_member_fk" + columns: ["workspace_id", "user_id"] + isOneToOne: false + referencedRelation: "workspace_members" + referencedColumns: ["workspace_id", "user_id"] + }, { foreignKeyName: "user_dashboard_layouts_user_id_fkey" columns: ["user_id"] @@ -538,6 +545,13 @@ export type Database = { referencedRelation: "profiles" referencedColumns: ["id"] }, + { + foreignKeyName: "work_schedule_entries_member_fk" + columns: ["workspace_id", "user_id"] + isOneToOne: false + referencedRelation: "workspace_members" + referencedColumns: ["workspace_id", "user_id"] + }, { foreignKeyName: "work_schedule_entries_user_id_fkey" columns: ["user_id"] @@ -702,7 +716,28 @@ export type Database = { [_ in never]: never } Functions: { - [_ in never]: never + create_workspace: { + Args: { + p_description?: string + p_name: string + p_purpose: Database["public"]["Enums"]["workspace_purpose"] + p_user_id: string + } + Returns: string + } + get_my_workspaces: { + Args: { p_user_id: string } + Returns: { + done_task_count: number + id: string + member_count: number + name: string + progress: number + purpose: Database["public"]["Enums"]["workspace_purpose"] + task_count: number + updated_at: string + }[] + } } Enums: { calendar_event_type: "meeting" | "deadline" diff --git a/src/shared/model/supabase.types.ts b/src/shared/model/supabase.types.ts index f9d350c..5b7f488 100644 --- a/src/shared/model/supabase.types.ts +++ b/src/shared/model/supabase.types.ts @@ -3,6 +3,9 @@ // 도메인별 사용처: entities/<도메인>/model/<도메인>.db.types.ts (예: entities/workspace) import type { Database } from './database.types'; +// 인프라 계층(Supabase 클라이언트 생성)에서 스키마 전체 제네릭이 필요할 때 사용 +export type { Database }; + type PublicSchema = Database['public']; /** 테이블 조회(Row) 타입 — 예: GenericTables<'workspaces'> */ diff --git a/src/views/workspaces/ui/WorkspacesPage.tsx b/src/views/workspaces/ui/WorkspacesPage.tsx index cc3baf9..b8b9225 100644 --- a/src/views/workspaces/ui/WorkspacesPage.tsx +++ b/src/views/workspaces/ui/WorkspacesPage.tsx @@ -1,8 +1,11 @@ +'use client'; + // 내 워크스페이스 페이지 — 헤더 + 참여 중인 워크스페이스 목록(빈 상태 포함)을 조립한다 +// 목록 조회는 tanstack-query(useQuery) — GET 컨벤션 (docs/conventions/supabase-convention.md) import Link from 'next/link'; -import { Plus } from 'lucide-react'; +import { Plus, RotateCcw } from 'lucide-react'; import { Plus_Jakarta_Sans } from 'next/font/google'; -import { getMyWorkspaces } from '@/entities/workspace'; +import { useMyWorkspaces } from '@/entities/workspace'; import { WorkspaceList } from '@/widgets/workspace-list'; // Figma 지정 폰트 — 한글은 시스템 폰트로 fallback된다 @@ -11,8 +14,8 @@ const jakarta = Plus_Jakarta_Sans({ weight: ['400', '600', '700', '800'], }); -export default async function WorkspacesPage() { - const workspaces = await getMyWorkspaces(); +export default function WorkspacesPage() { + const { data: workspaces, isPending, isError, refetch } = useMyWorkspaces(); return (
@@ -29,7 +32,26 @@ export default async function WorkspacesPage() { 새 워크스페이스 - + + {isPending ? ( +
+ 워크스페이스를 불러오는 중... +
+ ) : isError ? ( +
+

워크스페이스 목록을 불러오지 못했습니다.

+ +
+ ) : ( + + )}
); diff --git a/supabase/migrations/20260709054147_create_workspace_rpcs.sql b/supabase/migrations/20260709054147_create_workspace_rpcs.sql new file mode 100644 index 0000000..a84424c --- /dev/null +++ b/supabase/migrations/20260709054147_create_workspace_rpcs.sql @@ -0,0 +1,105 @@ +-- 워크스페이스 도메인 RPC 2종 +-- 공통: auth 연동 전이므로 p_user_id 파라미터로 유저를 받는다 (연동 후 auth.uid()로 교체 예정) + +-- 내 워크스페이스 목록 — 카드에 필요한 집계를 단일 쿼리로 반환 (워크스페이스 수와 무관하게 쿼리 1회, N+1 없음) +create or replace function public.get_my_workspaces(p_user_id uuid) +returns table ( + id uuid, + name text, + purpose workspace_purpose, + member_count int, + task_count int, + done_task_count int, + progress int, + updated_at timestamptz +) +language sql +stable +set search_path = public, pg_temp +as $$ + select + w.id, + w.name, + w.purpose, + m.member_count, + t.task_count, + t.done_task_count, + coalesce(round(t.done_task_count::numeric / nullif(t.task_count, 0) * 100), 0)::int as progress, + w.updated_at + from workspaces w + join workspace_members me + on me.workspace_id = w.id and me.user_id = p_user_id + cross join lateral ( + select count(*)::int as member_count + from workspace_members wm + where wm.workspace_id = w.id + ) m + cross join lateral ( + select + count(*)::int as task_count, + (count(*) filter (where ts.status = 'done'))::int as done_task_count + from tasks ts + where ts.workspace_id = w.id + ) t + order by w.updated_at desc; +$$; + +-- 워크스페이스 생성 — workspaces + owner 멤버십 + purpose별 기본 모듈을 한 트랜잭션으로 생성 +create or replace function public.create_workspace( + p_user_id uuid, + p_name text, + p_purpose workspace_purpose, + p_description text default null +) +returns uuid +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_workspace_id uuid; + v_nickname text; +begin + -- 프로필 없는 유저면 여기서 실패 (no rows 에러) + select real_name into strict v_nickname from profiles where id = p_user_id; + + -- invite_code는 unique 제약이 있어 동시 생성 충돌 시 재시도한다 (예측 불가 랜덤 hex 16자) + for i in 1..3 loop + begin + insert into workspaces (owner_id, name, description, purpose, invite_code) + values ( + p_user_id, + p_name, + p_description, + p_purpose, + encode(extensions.gen_random_bytes(8), 'hex') + ) + returning workspaces.id into v_workspace_id; + exit; + exception when unique_violation then + if i = 3 then + raise; + end if; + end; + end loop; + + insert into workspace_members (workspace_id, user_id, workspace_nickname, role) + values (v_workspace_id, p_user_id, v_nickname, 'owner'); + + -- purpose별 기본 모듈 활성화 (module_registry의 활성 모듈만) + insert into workspace_modules (workspace_id, module_type, sort_order) + select v_workspace_id, t.module_type, (t.ord - 1)::int + from unnest( + case p_purpose + when 'team_project' then + array['dashboard','project_board','calendar','announcements','meeting_notes','resources','chat'] + when 'side_project' then + array['dashboard','sprint_board','calendar','announcements','meeting_notes','resources','chat'] + when 'store_operation' then + array['dashboard','work_schedule','calendar','announcements','resources','chat'] + end + ) with ordinality as t(module_type, ord) + join module_registry mr on mr.type = t.module_type and mr.is_active; + + return v_workspace_id; +end; +$$; From fda3a9008bfc93a72fa50a84977b654362720743 Mon Sep 17 00:00:00 2001 From: JiHo Jeon Date: Thu, 9 Jul 2026 16:01:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20=EC=97=90=EB=9F=AC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=9D=BC=EB=B0=98=ED=99=94,=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=ED=86=A0=EC=8A=A4=ED=8A=B8(sonner),=20?= =?UTF-8?q?purpose=20=EB=A7=A4=ED=95=91=20=EA=B0=80=EB=93=9C=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 22 ++++++ package.json | 2 + src/app/layout.tsx | 2 + .../workspace/api/create-workspace.ts | 4 +- .../ui/CreateWorkspaceDialog.tsx | 4 +- src/shared/ui/sonner.tsx | 45 +++++++++++++ ...9065317_create_workspace_purpose_guard.sql | 67 +++++++++++++++++++ 7 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/shared/ui/sonner.tsx create mode 100644 supabase/migrations/20260709065317_create_workspace_purpose_guard.sql diff --git a/package-lock.json b/package-lock.json index 314852e..5316680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,14 @@ "lucide-react": "^1.22.0", "motion": "^12.42.2", "next": "16.2.9", + "next-themes": "^0.4.6", "radix-ui": "^1.6.0", "react": "19.2.4", "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", "shadcn": "^4.12.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.4.3", @@ -8717,6 +8719,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10518,6 +10530,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 99c9a05..6befaca 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,14 @@ "lucide-react": "^1.22.0", "motion": "^12.42.2", "next": "16.2.9", + "next-themes": "^0.4.6", "radix-ui": "^1.6.0", "react": "19.2.4", "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", "shadcn": "^4.12.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.4.3", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index aecb4ca..baedd65 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import { cn } from '@/shared/lib/utils'; import type { Metadata } from 'next'; import { Geist, Geist_Mono, Inter } from 'next/font/google'; import Providers from './providers'; +import { Toaster } from '@/shared/ui/sonner'; import './globals.css'; const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); @@ -40,6 +41,7 @@ export default function RootLayout({ > {children} + ); diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts index d0fecfc..8034b3f 100644 --- a/src/entities/workspace/api/create-workspace.ts +++ b/src/entities/workspace/api/create-workspace.ts @@ -26,7 +26,9 @@ export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id }); if (error) { - throw new Error(`워크스페이스 생성에 실패했습니다: ${error.message}`); + // 상세 원인은 서버 로그에만 남기고, 클라이언트에는 일반화된 메시지만 반환한다 + console.error('[createWorkspace] RPC 실패:', error); + throw new Error('워크스페이스 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); } return { id: data }; diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx index 36ca222..04ca715 100644 --- a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { zodResolver } from '@hookform/resolvers/zod'; import { WORKSPACE_PURPOSE_META, @@ -61,8 +62,9 @@ export default function CreateWorkspaceDialog({ router.push('/workspaces'); // 성공 시 페이지 이동으로 언마운트되므로 isSubmitting을 리셋하지 않는다(버튼 깜빡임 방지) } catch (error) { - // TODO: 실패 알림 UI(toast 등) 추가 + // 상세 원인은 콘솔에만 — 사용자에게는 일반화된 메시지 (네트워크 오류 등 영문 노출 방지) console.error(error); + toast.error('워크스페이스 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); setIsSubmitting(false); } }; diff --git a/src/shared/ui/sonner.tsx b/src/shared/ui/sonner.tsx new file mode 100644 index 0000000..12ca2d0 --- /dev/null +++ b/src/shared/ui/sonner.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; +import { + CircleCheckIcon, + InfoIcon, + TriangleAlertIcon, + OctagonXIcon, + Loader2Icon, +} from 'lucide-react'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + '--normal-bg': 'var(--popover)', + '--normal-text': 'var(--popover-foreground)', + '--normal-border': 'var(--border)', + '--border-radius': 'var(--radius)', + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: 'cn-toast', + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/supabase/migrations/20260709065317_create_workspace_purpose_guard.sql b/supabase/migrations/20260709065317_create_workspace_purpose_guard.sql new file mode 100644 index 0000000..16c873e --- /dev/null +++ b/supabase/migrations/20260709065317_create_workspace_purpose_guard.sql @@ -0,0 +1,67 @@ +-- create_workspace 방어 분기 추가 (CodeRabbit 리뷰 반영) +-- workspace_purpose enum이 확장됐는데 모듈 매핑을 갱신하지 않으면 +-- 기존에는 조용히 모듈 0개짜리 워크스페이스가 생성됐다 → 이제는 즉시 실패시켜 회귀를 조기 발견한다. +create or replace function public.create_workspace( + p_user_id uuid, + p_name text, + p_purpose workspace_purpose, + p_description text default null +) +returns uuid +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_workspace_id uuid; + v_nickname text; + v_modules text[]; +begin + -- 프로필 없는 유저면 여기서 실패 (no rows 에러) + select real_name into strict v_nickname from profiles where id = p_user_id; + + v_modules := case p_purpose + when 'team_project' then + array['dashboard','project_board','calendar','announcements','meeting_notes','resources','chat'] + when 'side_project' then + array['dashboard','sprint_board','calendar','announcements','meeting_notes','resources','chat'] + when 'store_operation' then + array['dashboard','work_schedule','calendar','announcements','resources','chat'] + end; + + -- enum 값 추가 시 매핑 누락을 조용한 실패 대신 즉시 에러로 노출 + if v_modules is null then + raise exception 'create_workspace: purpose %에 대한 기본 모듈 매핑이 없습니다', p_purpose; + end if; + + -- invite_code는 unique 제약이 있어 동시 생성 충돌 시 재시도한다 (예측 불가 랜덤 hex 16자) + for i in 1..3 loop + begin + insert into workspaces (owner_id, name, description, purpose, invite_code) + values ( + p_user_id, + p_name, + p_description, + p_purpose, + encode(extensions.gen_random_bytes(8), 'hex') + ) + returning workspaces.id into v_workspace_id; + exit; + exception when unique_violation then + if i = 3 then + raise; + end if; + end; + end loop; + + insert into workspace_members (workspace_id, user_id, workspace_nickname, role) + values (v_workspace_id, p_user_id, v_nickname, 'owner'); + + -- purpose별 기본 모듈 활성화 (module_registry의 활성 모듈만) + insert into workspace_modules (workspace_id, module_type, sort_order) + select v_workspace_id, t.module_type, (t.ord - 1)::int + from unnest(v_modules) with ordinality as t(module_type, ord) + join module_registry mr on mr.type = t.module_type and mr.is_active; + + return v_workspace_id; +end; +$$;