Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ AGENTS.md
CLAUDE.md
.agents/
.claude/
!.env.example
20 changes: 17 additions & 3 deletions docs/conventions/supabase-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <enum명> add value '<값>'` 마이그레이션 → `npm run gen:types` 재실행 순서로 진행합니다.
- 프론트에서 enum 값을 문자열 리터럴로 중복 정의하지 않고 `GenericEnums`로 파생합니다.

## 6. 마이그레이션 규칙
## 7. 마이그레이션 규칙

- 스키마 변경은 반드시 마이그레이션으로 기록하고, 원격에 적용된 버전과 **동일한 파일명**으로 `supabase/migrations/`에 동기화합니다.
- 스키마 변경 후에는 `npm run gen:types`를 실행해 `database.types.ts` 갱신분을 같은 PR에 포함합니다.

## 7. 테스트 시드
## 8. 테스트 시드

| 항목 | 값 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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' });
Expand Down Expand Up @@ -37,7 +39,10 @@ export default function RootLayout({
inter.variable,
)}
>
<body className="flex min-h-full flex-col">{children}</body>
<body className="flex min-h-full flex-col">
<Providers>{children}</Providers>
<Toaster position="top-center" richColors />
</body>
</html>
);
}
22 changes: 22 additions & 0 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
47 changes: 30 additions & 17 deletions src/entities/workspace/api/create-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
// 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) {
// 상세 원인은 서버 로그에만 남기고, 클라이언트에는 일반화된 메시지만 반환한다
console.error('[createWorkspace] RPC 실패:', error);
throw new Error('워크스페이스 생성에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { id: `ws-${slug}-${Date.now().toString(36)}` };
return { id: data };
}
57 changes: 11 additions & 46 deletions src/entities/workspace/api/get-my-workspaces.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceSummary, 'updated_at' | 'progress'> & {
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<WorkspaceSummary[]> {
const now = Date.now();
const supabase = getSupabaseBrowserClient();
const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: DEV_USER_ID });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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) }));
}
16 changes: 16 additions & 0 deletions src/entities/workspace/api/use-my-workspaces.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
12 changes: 11 additions & 1 deletion src/entities/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
20 changes: 20 additions & 0 deletions src/entities/workspace/model/create-workspace.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createWorkspaceSchema>;

// 서버액션용 — 폼 필드에 더해 purpose까지 재검증한다 (클라이언트 입력을 신뢰하지 않음)
export const createWorkspaceInputSchema = createWorkspaceSchema.extend({
purpose: z.enum(['team-project', 'side-project', 'store-operation']),
});

export type CreateWorkspaceInput = z.infer<typeof createWorkspaceInputSchema>;
20 changes: 20 additions & 0 deletions src/entities/workspace/model/purpose.mapper.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspacePurposeDb, WorkspacePurpose> = {
team_project: 'team-project',
side_project: 'side-project',
store_operation: 'store-operation',
};

const UI_TO_DB: Record<WorkspacePurpose, WorkspacePurposeDb> = {
'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];
8 changes: 8 additions & 0 deletions src/entities/workspace/model/workspace.db.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다.
import type {
GenericEnums,
GenericFunctionArgs,
GenericFunctionReturns,
GenericTables,
GenericTablesInsert,
GenericTablesUpdate,
Expand All @@ -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'>;
14 changes: 2 additions & 12 deletions src/features/create-workspace/model/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createWorkspaceSchema>;
// 폼 스키마는 서버액션 재검증과 공유하기 위해 entities/workspace가 소유한다 — 여기서는 재노출만
export { createWorkspaceSchema, type CreateWorkspaceForm } from '@/entities/workspace';
Loading