From aeaca4f52763e6310baebe7a26b475c3342636b0 Mon Sep 17 00:00:00 2001 From: JiHo Jeon Date: Thu, 9 Jul 2026 12:34:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20Supabase=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20DB=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=85=8B?= =?UTF-8?q?=EC=97=85=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierignore | 4 + docs/conventions/supabase-convention.md | 113 +++ package.json | 3 +- src/entities/workspace/index.ts | 6 + .../workspace/model/workspace.db.types.ts | 21 + src/shared/model/database.types.ts | 855 ++++++++++++++++++ src/shared/model/supabase.types.ts | 29 + ...709025327_create_enums_and_core_tables.sql | 215 +++++ ...0260709025457_seed_test_users_and_data.sql | 178 ++++ .../20260709025820_add_auth_rls_policies.sql | 89 ++ .../20260709032015_add_constraints.sql | 32 + 11 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 docs/conventions/supabase-convention.md create mode 100644 src/entities/workspace/model/workspace.db.types.ts create mode 100644 src/shared/model/database.types.ts create mode 100644 src/shared/model/supabase.types.ts create mode 100644 supabase/migrations/20260709025327_create_enums_and_core_tables.sql create mode 100644 supabase/migrations/20260709025457_seed_test_users_and_data.sql create mode 100644 supabase/migrations/20260709025820_add_auth_rls_policies.sql create mode 100644 supabase/migrations/20260709032015_add_constraints.sql diff --git a/.prettierignore b/.prettierignore index d00c30f..f8bd3ca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,7 @@ coverage public package-lock.json next-env.d.ts + +# supabase 자동 생성 파일 (gen:types 출력 그대로 유지) +src/shared/model/database.types.ts +supabase/migrations diff --git a/docs/conventions/supabase-convention.md b/docs/conventions/supabase-convention.md new file mode 100644 index 0000000..4f6367c --- /dev/null +++ b/docs/conventions/supabase-convention.md @@ -0,0 +1,113 @@ +# Supabase Convention + +Syncly의 DB 타입 사용, 쓰기 경로, ENUM, 마이그레이션 규칙을 통일하기 위한 문서입니다. + +## 1. DB 타입 시스템 + +타입은 세 계층으로 관리합니다. + +| 파일 | 역할 | 규칙 | +| -------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------- | +| `src/shared/model/database.types.ts` | Supabase 스키마 자동 생성 타입 | **직접 수정 금지, 직접 import 금지.** `npm run gen:types`로만 갱신 | +| `src/shared/model/supabase.types.ts` | Generic 헬퍼 (단일 진입점) | 자동 생성 타입은 반드시 이 헬퍼를 거쳐 사용 | +| `src/entities/<도메인>/model/<도메인>.db.types.ts` | 도메인별 DB 타입 | 각 도메인 담당자가 자기 엔티티에 생성 (예: `entities/workspace/model/workspace.db.types.ts`) | + +스키마가 바뀌면 `npm run gen:types` 한 번으로 모든 도메인 타입이 최신화됩니다. + +## 2. Generic 헬퍼 종류와 사용처 + +| 헬퍼 | 용도 | 사용 시점 | +| --------------------------------- | --------------------------- | ---------------------------------------- | +| `GenericTables<'테이블'>` | select 결과 Row | 조회 결과 타입, 매퍼 함수 시그니처 | +| `GenericTablesInsert<'테이블'>` | insert/upsert 페이로드 | 단일 테이블 쓰기 (default 컬럼은 옵셔널) | +| `GenericTablesUpdate<'테이블'>` | 부분 수정 페이로드 | 상태 변경 등 patch성 update | +| `GenericEnums<'enum명'>` | 네이티브 ENUM 리터럴 유니언 | UI 상수 맵의 키, 폼 값, props 타입 | +| `GenericFunctionArgs<'RPC명'>` | RPC 인자 | RPC 호출 래퍼 함수 시그니처 | +| `GenericFunctionReturns<'RPC명'>` | RPC 반환 | RPC 결과 타입 | + +> supabase-js 클라이언트는 `Database` 제네릭으로 인라인 호출을 이미 추론합니다. 위 헬퍼는 **경계에 이름을 붙일 때** 사용합니다 — 서버액션 파라미터, 매퍼 함수, 컴포넌트 props 등. + +## 3. 사용 예시 + +도메인 타입 정의 (`entities/workspace/model/workspace.db.types.ts` 참고): + +```ts +import type { GenericEnums, GenericTables } from '@/shared/model/supabase.types'; + +export type WorkspaceRow = GenericTables<'workspaces'>; +export type WorkspacePurposeDb = GenericEnums<'workspace_purpose'>; +``` + +단일 테이블 upsert (대시보드 레이아웃 저장): + +```ts +const payload: GenericTablesInsert<'user_dashboard_layouts'> = { + user_id: userId, + workspace_id: workspaceId, + layout: nextLayout, +}; +await supabase.from('user_dashboard_layouts').upsert(payload); +``` + +부분 수정 (칸반 드래그 → 상태 변경): + +```ts +const patch: GenericTablesUpdate<'tasks'> = { status: 'in_progress', sort_order: 3 }; +await supabase.from('tasks').update(patch).eq('id', taskId); +``` + +ENUM을 Record 키로 사용 — 값이 추가되면 컴파일 에러로 누락을 잡습니다: + +```ts +type TaskStatusDb = GenericEnums<'task_status'>; + +const STATUS_LABEL: Record = { + todo: '대기', + in_progress: '진행 중', + done: '완료', +}; +``` + +zod 스키마 재사용 (자동 생성 `Constants` 활용): + +```ts +import { Constants } from '@/shared/model/database.types'; // 예외: Constants만 직접 import 허용 + +const statusSchema = z.enum(Constants.public.Enums.task_status); +``` + +## 4. 쓰기 경로 규칙 + +| 상황 | 경로 | +| -------------------------------------- | ----------------------------------------------------------------- | +| 여러 테이블을 트랜잭션으로 묶는 쓰기 | **RPC** (예: `create_workspace` — workspaces + members + modules) | +| 단일 테이블 한 방 쓰기 (insert/upsert) | 클라이언트 직접 쿼리 (예: 레이아웃 upsert, 채팅 insert) | +| 단일 컬럼 부분 수정 | 클라이언트 직접 update (예: 칸반 상태 변경) | +| 집계가 필요한 조회 | RPC (예: `get_my_workspaces` — count/progress 계산) | + +### auth 연동 전 임시 규칙 + +- RPC는 `auth.uid()` 대신 **`p_user_id uuid` 파라미터**로 유저를 받습니다. 테스트는 시드 계정 id를 하드코딩합니다. +- auth 연동이 완료되면 `auth.uid()`로 교체하고, `dev_full_access` RLS 정책을 drop해 실 정책을 발동시킵니다. + +## 5. 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. 마이그레이션 규칙 + +- 스키마 변경은 반드시 마이그레이션으로 기록하고, 원격에 적용된 버전과 **동일한 파일명**으로 `supabase/migrations/`에 동기화합니다. +- 스키마 변경 후에는 `npm run gen:types`를 실행해 `database.types.ts` 갱신분을 같은 PR에 포함합니다. + +## 7. 테스트 시드 + +| 항목 | 값 | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| 계정 | `test1@test.com` ~ `test5@test.com` / `test1234!` (로그인 가능) | +| user_id | `00000000-0000-0000-0000-000000000001` ~ `...0005` | +| 워크스페이스 | `...1001` 캡스톤 디자인 팀(team_project) · `...1002` Fitto 앱 개발팀(side_project) · `...1003` 카페 그레이 운영(store_operation) | +| 멤버십 | 5명 전원이 3개 워크스페이스 모두 소속 | +| 스프린트 | `...2001` Sprint 1(완료) · `...2002` Sprint 2(진행 중) | diff --git a/package.json b/package.json index 494d2db..99c9a05 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", - "check": "npm run lint && npm run lint:tw && npm run typecheck && npm run format:check" + "check": "npm run lint && npm run lint:tw && npm run typecheck && npm run format:check", + "gen:types": "npx supabase gen types typescript --project-id atkvaovvzdiznndrkjgt --schema public > src/shared/model/database.types.ts" }, "dependencies": { "@hookform/resolvers": "^5.4.0", diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index d2cd1b1..84b4aed 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -1,5 +1,11 @@ // workspace 엔티티 Public API export type { Workspace, WorkspacePurpose, WorkspaceSummary } from './model/workspace.types'; +export type { + WorkspaceRow, + WorkspaceInsert, + WorkspaceUpdate, + WorkspacePurposeDb, +} from './model/workspace.db.types'; export { getMockWorkspaceById, mockWorkspace } from './model/mock-workspace'; export { WORKSPACE_PURPOSE_META, FALLBACK_PURPOSE_META } from './config/purpose'; export { diff --git a/src/entities/workspace/model/workspace.db.types.ts b/src/entities/workspace/model/workspace.db.types.ts new file mode 100644 index 0000000..9c6b3f4 --- /dev/null +++ b/src/entities/workspace/model/workspace.db.types.ts @@ -0,0 +1,21 @@ +// workspace 도메인 DB 타입 — 자동 생성 스키마(database.types)에서 파생한다. +// 스키마 변경 시 `npm run gen:types` 실행하면 전부 최신화된다. +import type { + GenericEnums, + GenericTables, + GenericTablesInsert, + GenericTablesUpdate, +} from '@/shared/model/supabase.types'; + +/** workspaces 테이블 Row — select 결과 */ +export type WorkspaceRow = GenericTables<'workspaces'>; + +/** workspaces insert 페이로드 — default 컬럼(id, created_at 등)은 옵셔널 */ +export type WorkspaceInsert = GenericTablesInsert<'workspaces'>; + +/** workspaces update 페이로드 */ +export type WorkspaceUpdate = GenericTablesUpdate<'workspaces'>; + +// DB enum: 'team_project' | 'side_project' | 'store_operation' +// 프론트 WorkspacePurpose(hyphen)는 snake_case 통일 리팩터링 때 이 타입으로 교체한다. +export type WorkspacePurposeDb = GenericEnums<'workspace_purpose'>; diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts new file mode 100644 index 0000000..bcf540d --- /dev/null +++ b/src/shared/model/database.types.ts @@ -0,0 +1,855 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.5" + } + public: { + Tables: { + announcements: { + Row: { + author_id: string | null + content: string + created_at: string + id: string + is_pinned: boolean + title: string + updated_at: string + workspace_id: string + } + Insert: { + author_id?: string | null + content: string + created_at?: string + id?: string + is_pinned?: boolean + title: string + updated_at?: string + workspace_id: string + } + Update: { + author_id?: string | null + content?: string + created_at?: string + id?: string + is_pinned?: boolean + title?: string + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "announcements_author_id_fkey" + columns: ["author_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "announcements_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + calendar_events: { + Row: { + created_at: string + created_by: string | null + description: string | null + ends_at: string | null + event_type: Database["public"]["Enums"]["calendar_event_type"] + id: string + starts_at: string + task_id: string | null + title: string + updated_at: string + workspace_id: string + } + Insert: { + created_at?: string + created_by?: string | null + description?: string | null + ends_at?: string | null + event_type?: Database["public"]["Enums"]["calendar_event_type"] + id?: string + starts_at: string + task_id?: string | null + title: string + updated_at?: string + workspace_id: string + } + Update: { + created_at?: string + created_by?: string | null + description?: string | null + ends_at?: string | null + event_type?: Database["public"]["Enums"]["calendar_event_type"] + id?: string + starts_at?: string + task_id?: string | null + title?: string + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "calendar_events_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "calendar_events_task_id_fkey" + columns: ["task_id"] + isOneToOne: false + referencedRelation: "tasks" + referencedColumns: ["id"] + }, + { + foreignKeyName: "calendar_events_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + chat_messages: { + Row: { + content: string + created_at: string + id: string + sender_id: string | null + updated_at: string + workspace_id: string + } + Insert: { + content: string + created_at?: string + id?: string + sender_id?: string | null + updated_at?: string + workspace_id: string + } + Update: { + content?: string + created_at?: string + id?: string + sender_id?: string | null + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "chat_messages_sender_id_fkey" + columns: ["sender_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "chat_messages_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + meeting_notes: { + Row: { + author_id: string | null + content: string | null + created_at: string + decisions: string[] + follow_up_actions: string[] + id: string + meeting_at: string + participants: string[] + title: string + updated_at: string + workspace_id: string + } + Insert: { + author_id?: string | null + content?: string | null + created_at?: string + decisions?: string[] + follow_up_actions?: string[] + id?: string + meeting_at: string + participants?: string[] + title: string + updated_at?: string + workspace_id: string + } + Update: { + author_id?: string | null + content?: string | null + created_at?: string + decisions?: string[] + follow_up_actions?: string[] + id?: string + meeting_at?: string + participants?: string[] + title?: string + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "meeting_notes_author_id_fkey" + columns: ["author_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "meeting_notes_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + module_registry: { + Row: { + created_at: string + default_settings: Json + description: string | null + display_name: string + is_active: boolean + type: string + updated_at: string + } + Insert: { + created_at?: string + default_settings?: Json + description?: string | null + display_name: string + is_active?: boolean + type: string + updated_at?: string + } + Update: { + created_at?: string + default_settings?: Json + description?: string | null + display_name?: string + is_active?: boolean + type?: string + updated_at?: string + } + Relationships: [] + } + profiles: { + Row: { + avatar_url: string | null + created_at: string + email: string + id: string + real_name: string + updated_at: string + } + Insert: { + avatar_url?: string | null + created_at?: string + email: string + id: string + real_name: string + updated_at?: string + } + Update: { + avatar_url?: string | null + created_at?: string + email?: string + id?: string + real_name?: string + updated_at?: string + } + Relationships: [] + } + resources: { + Row: { + created_at: string + description: string | null + id: string + resource_type: Database["public"]["Enums"]["resource_type"] + storage_path: string | null + title: string + updated_at: string + uploaded_by: string | null + url: string | null + workspace_id: string + } + Insert: { + created_at?: string + description?: string | null + id?: string + resource_type: Database["public"]["Enums"]["resource_type"] + storage_path?: string | null + title: string + updated_at?: string + uploaded_by?: string | null + url?: string | null + workspace_id: string + } + Update: { + created_at?: string + description?: string | null + id?: string + resource_type?: Database["public"]["Enums"]["resource_type"] + storage_path?: string | null + title?: string + updated_at?: string + uploaded_by?: string | null + url?: string | null + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "resources_uploaded_by_fkey" + columns: ["uploaded_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "resources_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + sprints: { + Row: { + created_at: string + end_date: string + id: string + name: string + start_date: string + updated_at: string + workspace_id: string + } + Insert: { + created_at?: string + end_date: string + id?: string + name: string + start_date: string + updated_at?: string + workspace_id: string + } + Update: { + created_at?: string + end_date?: string + id?: string + name?: string + start_date?: string + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "sprints_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + tasks: { + Row: { + assignee_id: string | null + category: Database["public"]["Enums"]["task_category"] | null + created_at: string + created_by: string | null + description: string | null + due_date: string | null + id: string + point: number | null + priority: Database["public"]["Enums"]["task_priority"] + sort_order: number + sprint_id: string | null + status: Database["public"]["Enums"]["task_status"] + title: string + updated_at: string + workspace_id: string + } + Insert: { + assignee_id?: string | null + category?: Database["public"]["Enums"]["task_category"] | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + point?: number | null + priority?: Database["public"]["Enums"]["task_priority"] + sort_order?: number + sprint_id?: string | null + status?: Database["public"]["Enums"]["task_status"] + title: string + updated_at?: string + workspace_id: string + } + Update: { + assignee_id?: string | null + category?: Database["public"]["Enums"]["task_category"] | null + created_at?: string + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + point?: number | null + priority?: Database["public"]["Enums"]["task_priority"] + sort_order?: number + sprint_id?: string | null + status?: Database["public"]["Enums"]["task_status"] + title?: string + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "tasks_assignee_id_fkey" + columns: ["assignee_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tasks_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tasks_sprint_id_fkey" + columns: ["sprint_id"] + isOneToOne: false + referencedRelation: "sprints" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tasks_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + user_dashboard_layouts: { + Row: { + created_at: string + layout: Json + updated_at: string + user_id: string + workspace_id: string + } + Insert: { + created_at?: string + layout?: Json + updated_at?: string + user_id: string + workspace_id: string + } + Update: { + created_at?: string + layout?: Json + updated_at?: string + user_id?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "user_dashboard_layouts_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_dashboard_layouts_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + work_schedule_entries: { + Row: { + created_at: string + created_by: string | null + id: string + note: string | null + shift_type: string + updated_at: string + user_id: string + work_date: string + workspace_id: string + } + Insert: { + created_at?: string + created_by?: string | null + id?: string + note?: string | null + shift_type: string + updated_at?: string + user_id: string + work_date: string + workspace_id: string + } + Update: { + created_at?: string + created_by?: string | null + id?: string + note?: string | null + shift_type?: string + updated_at?: string + user_id?: string + work_date?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "work_schedule_entries_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "work_schedule_entries_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "work_schedule_entries_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + workspace_members: { + Row: { + created_at: string + id: string + joined_at: string + role: Database["public"]["Enums"]["member_role"] + updated_at: string + user_id: string + workspace_id: string + workspace_nickname: string + } + Insert: { + created_at?: string + id?: string + joined_at?: string + role?: Database["public"]["Enums"]["member_role"] + updated_at?: string + user_id: string + workspace_id: string + workspace_nickname: string + } + Update: { + created_at?: string + id?: string + joined_at?: string + role?: Database["public"]["Enums"]["member_role"] + updated_at?: string + user_id?: string + workspace_id?: string + workspace_nickname?: string + } + Relationships: [ + { + foreignKeyName: "workspace_members_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "workspace_members_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + workspace_modules: { + Row: { + created_at: string + id: string + is_enabled: boolean + module_type: string + settings: Json + sort_order: number + updated_at: string + workspace_id: string + } + Insert: { + created_at?: string + id?: string + is_enabled?: boolean + module_type: string + settings?: Json + sort_order?: number + updated_at?: string + workspace_id: string + } + Update: { + created_at?: string + id?: string + is_enabled?: boolean + module_type?: string + settings?: Json + sort_order?: number + updated_at?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "workspace_modules_module_type_fkey" + columns: ["module_type"] + isOneToOne: false + referencedRelation: "module_registry" + referencedColumns: ["type"] + }, + { + foreignKeyName: "workspace_modules_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: false + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } + workspaces: { + Row: { + created_at: string + description: string | null + id: string + invite_code: string | null + invite_enabled: boolean + name: string + owner_id: string + purpose: Database["public"]["Enums"]["workspace_purpose"] + setup_status: string + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + id?: string + invite_code?: string | null + invite_enabled?: boolean + name: string + owner_id: string + purpose: Database["public"]["Enums"]["workspace_purpose"] + setup_status?: string + updated_at?: string + } + Update: { + created_at?: string + description?: string | null + id?: string + invite_code?: string | null + invite_enabled?: boolean + name?: string + owner_id?: string + purpose?: Database["public"]["Enums"]["workspace_purpose"] + setup_status?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "workspaces_owner_id_fkey" + columns: ["owner_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + is_workspace_member: { + Args: { p_workspace_id: string } + Returns: boolean + } + is_workspace_owner: { Args: { p_workspace_id: string }; Returns: boolean } + } + Enums: { + calendar_event_type: "meeting" | "deadline" + member_role: "owner" | "member" + resource_type: "file" | "link" + task_category: "design" | "frontend" | "backend" | "planning" + task_priority: "high" | "medium" | "low" + task_status: "todo" | "in_progress" | "done" + workspace_purpose: "team_project" | "side_project" | "store_operation" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + calendar_event_type: ["meeting", "deadline"], + member_role: ["owner", "member"], + resource_type: ["file", "link"], + task_category: ["design", "frontend", "backend", "planning"], + task_priority: ["high", "medium", "low"], + task_status: ["todo", "in_progress", "done"], + workspace_purpose: ["team_project", "side_project", "store_operation"], + }, + }, +} as const diff --git a/src/shared/model/supabase.types.ts b/src/shared/model/supabase.types.ts new file mode 100644 index 0000000..f9d350c --- /dev/null +++ b/src/shared/model/supabase.types.ts @@ -0,0 +1,29 @@ +// Supabase DB 타입 Generic 헬퍼 — 자동 생성 파일(database.types.ts)에서 도메인 타입을 꺼내 쓴다. +// 자동 생성 파일을 직접 import하지 않고 이 헬퍼를 단일 진입점으로 사용한다. +// 도메인별 사용처: entities/<도메인>/model/<도메인>.db.types.ts (예: entities/workspace) +import type { Database } from './database.types'; + +type PublicSchema = Database['public']; + +/** 테이블 조회(Row) 타입 — 예: GenericTables<'workspaces'> */ +export type GenericTables = + PublicSchema['Tables'][T]['Row']; + +/** 테이블 insert 페이로드 타입 — default/자동 생성 컬럼은 옵셔널 */ +export type GenericTablesInsert = + PublicSchema['Tables'][T]['Insert']; + +/** 테이블 update 페이로드 타입 — 모든 컬럼 옵셔널 */ +export type GenericTablesUpdate = + PublicSchema['Tables'][T]['Update']; + +/** 네이티브 ENUM 타입 — 예: GenericEnums<'task_status'> = 'todo' | 'in_progress' | 'done' */ +export type GenericEnums = PublicSchema['Enums'][T]; + +/** RPC 인자 타입 — 예: GenericFunctionArgs<'get_my_workspaces'> */ +export type GenericFunctionArgs = + PublicSchema['Functions'][T]['Args']; + +/** RPC 반환 타입 — 예: GenericFunctionReturns<'get_my_workspaces'> */ +export type GenericFunctionReturns = + PublicSchema['Functions'][T]['Returns']; diff --git a/supabase/migrations/20260709025327_create_enums_and_core_tables.sql b/supabase/migrations/20260709025327_create_enums_and_core_tables.sql new file mode 100644 index 0000000..c93b026 --- /dev/null +++ b/supabase/migrations/20260709025327_create_enums_and_core_tables.sql @@ -0,0 +1,215 @@ +-- Syncly 초기 스키마 — ERD 확정본 (2026-07-09) +-- enum은 팀 방침대로 전부 Postgres 네이티브 ENUM + +create extension if not exists moddatetime schema extensions; + +-- ===== ENUM 타입 ===== +create type workspace_purpose as enum ('team_project', 'side_project', 'store_operation'); +create type task_status as enum ('todo', 'in_progress', 'done'); +create type task_priority as enum ('high', 'medium', 'low'); +create type task_category as enum ('design', 'frontend', 'backend', 'planning'); +create type resource_type as enum ('file', 'link'); +create type calendar_event_type as enum ('meeting', 'deadline'); +create type member_role as enum ('owner', 'member'); + +-- ===== 테이블 ===== + +-- PROFILES.id = auth.users.id (auth.uid() 통합 결정) +create table public.profiles ( + id uuid primary key references auth.users(id) on delete cascade, + email text not null, + real_name text not null, + avatar_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.module_registry ( + type text primary key, + display_name text not null, + description text, + is_active boolean not null default true, + default_settings jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.workspaces ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references public.profiles(id), + name text not null, + description text, + purpose workspace_purpose not null, + invite_code text unique, + invite_enabled boolean not null default false, + setup_status text not null default 'completed', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.workspace_members ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + user_id uuid not null references public.profiles(id) on delete cascade, + workspace_nickname text not null, + role member_role not null default 'member', + joined_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, user_id) +); + +create table public.workspace_modules ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + module_type text not null references public.module_registry(type), + is_enabled boolean not null default true, + sort_order int not null default 0, + settings jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, module_type) +); + +-- 대시보드 레이아웃: 개인별 저장, (user_id, workspace_id) 복합 PK — page_type 없음(확정) +create table public.user_dashboard_layouts ( + user_id uuid not null references public.profiles(id) on delete cascade, + workspace_id uuid not null references public.workspaces(id) on delete cascade, + layout jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (user_id, workspace_id) +); + +-- 신규: 스프린트 (PR #27 모델 기준 — 포인트 합계/남은 일수는 집계로 파생) +create table public.sprints ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + name text not null, + start_date date not null, + end_date date not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- sprint_id null = 백로그 (별도 백로그 테이블 없음 — PR #27 확정 구조) +create table public.tasks ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + sprint_id uuid references public.sprints(id) on delete set null, + assignee_id uuid references public.profiles(id) on delete set null, + created_by uuid references public.profiles(id) on delete set null, + title text not null, + description text, + status task_status not null default 'todo', + priority task_priority not null default 'medium', + category task_category, + point int, + due_date date, + sort_order int not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.calendar_events ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + created_by uuid references public.profiles(id) on delete set null, + task_id uuid references public.tasks(id) on delete set null, + title text not null, + description text, + event_type calendar_event_type not null default 'meeting', + starts_at timestamptz not null, + ends_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.announcements ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + author_id uuid references public.profiles(id) on delete set null, + title text not null, + content text not null, + is_pinned boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- participants/decisions/follow_up_actions: 회의록 페이지(PR #24)가 쓰는 구조화 필드 +create table public.meeting_notes ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + author_id uuid references public.profiles(id) on delete set null, + title text not null, + content text, + meeting_at timestamptz not null, + participants uuid[] not null default '{}', + decisions text[] not null default '{}', + follow_up_actions text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.resources ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + uploaded_by uuid references public.profiles(id) on delete set null, + title text not null, + description text, + resource_type resource_type not null, + url text, + storage_path text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.chat_messages ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + sender_id uuid references public.profiles(id) on delete set null, + content text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- 방식(날짜 vs 요일)은 회의 예정 — 일단 ERD(work_date)대로. 뒤집히면 ALTER 1회 +create table public.work_schedule_entries ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + user_id uuid not null references public.profiles(id) on delete cascade, + work_date date not null, + shift_type text not null, + note text, + created_by uuid references public.profiles(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (workspace_id, user_id, work_date) +); + +-- ===== 인덱스 (FK 조회 경로) ===== +create index idx_workspace_members_workspace on public.workspace_members (workspace_id); +create index idx_workspace_members_user on public.workspace_members (user_id); +create index idx_workspace_modules_workspace on public.workspace_modules (workspace_id); +create index idx_sprints_workspace on public.sprints (workspace_id); +create index idx_tasks_workspace on public.tasks (workspace_id); +create index idx_tasks_sprint on public.tasks (sprint_id); +create index idx_tasks_assignee on public.tasks (assignee_id); +create index idx_calendar_events_workspace_starts on public.calendar_events (workspace_id, starts_at); +create index idx_announcements_workspace on public.announcements (workspace_id); +create index idx_meeting_notes_workspace on public.meeting_notes (workspace_id); +create index idx_resources_workspace on public.resources (workspace_id); +create index idx_chat_messages_workspace_created on public.chat_messages (workspace_id, created_at); +create index idx_work_schedule_workspace_date on public.work_schedule_entries (workspace_id, work_date); + +-- ===== updated_at 자동 갱신 + 임시 RLS ===== +-- RLS: auth 연동 전까지 permissive(전체 허용). 로그인 붙으면 auth.uid() 기반 정책으로 교체 (TODO) +do $$ +declare t text; +begin + for t in select tablename from pg_tables where schemaname = 'public' loop + execute format('create trigger set_updated_at before update on public.%I for each row execute procedure extensions.moddatetime(updated_at)', t); + execute format('alter table public.%I enable row level security', t); + execute format('create policy dev_full_access on public.%I for all using (true) with check (true)', t); + end loop; +end $$; diff --git a/supabase/migrations/20260709025457_seed_test_users_and_data.sql b/supabase/migrations/20260709025457_seed_test_users_and_data.sql new file mode 100644 index 0000000..ffa0484 --- /dev/null +++ b/supabase/migrations/20260709025457_seed_test_users_and_data.sql @@ -0,0 +1,178 @@ +-- 테스트 시드 — auth 유저 5명(test1~5@test.com / test1234!), 워크스페이스 3개, 도메인별 샘플 +-- id는 식별 쉬운 고정 uuid: 유저 ...0001~0005, 워크스페이스 ...1001~1003, 스프린트 ...2001~2002 + +-- ===== auth 유저 (로그인 가능한 실제 계정) ===== +do $$ +declare + i int; + uid uuid; + mail text; +begin + for i in 1..5 loop + uid := ('00000000-0000-0000-0000-00000000000' || i)::uuid; + mail := 'test' || i || '@test.com'; + + insert into auth.users ( + instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at, + confirmation_token, recovery_token, email_change, email_change_token_new, is_sso_user + ) values ( + '00000000-0000-0000-0000-000000000000', uid, 'authenticated', 'authenticated', + mail, extensions.crypt('test1234!', extensions.gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, + jsonb_build_object('real_name', '테스트유저' || i), + now(), now(), '', '', '', '', false + ) on conflict (id) do nothing; + + insert into auth.identities ( + id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at + ) values ( + gen_random_uuid(), uid, uid::text, + jsonb_build_object('sub', uid::text, 'email', mail, 'email_verified', true), + 'email', now(), now(), now() + ) on conflict do nothing; + + insert into public.profiles (id, email, real_name) + values (uid, mail, '테스트유저' || i) + on conflict (id) do nothing; + end loop; +end $$; + +-- ===== 모듈 레지스트리 ===== +insert into public.module_registry (type, display_name, description) values + ('dashboard', '대시보드', '위젯 기반 개인화 대시보드'), + ('project_board', '프로젝트 관리', '칸반 보드 기반 업무 관리'), + ('sprint_board', '스프린트 보드', '스프린트/백로그 기반 업무 관리'), + ('calendar', '캘린더', '일정 관리'), + ('announcements', '공지', '워크스페이스 공지사항'), + ('meeting_notes', '회의록', '회의록 작성/조회'), + ('resources', '자료실', '파일/링크 자료 관리'), + ('chat', '채팅', '실시간 채팅'), + ('work_schedule', '업무 스케줄', '근무 교대 일정 관리'); + +-- ===== 워크스페이스 3개 (도메인별 1개씩, 전원 멤버) ===== +insert into public.workspaces (id, owner_id, name, description, purpose) values + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', + '캡스톤 디자인 팀', '팀 프로젝트 데모 워크스페이스', 'team_project'), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', + 'Fitto 앱 개발팀', '사이드 프로젝트 데모 워크스페이스', 'side_project'), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', + '카페 그레이 운영', '매장 운영 데모 워크스페이스', 'store_operation'); + +insert into public.workspace_members (workspace_id, user_id, workspace_nickname, role) +select w.id, p.id, p.real_name, + case when w.owner_id = p.id then 'owner'::member_role else 'member'::member_role end +from public.workspaces w cross join public.profiles p; + +-- 템플릿별 모듈 활성화 +insert into public.workspace_modules (workspace_id, module_type, sort_order) +select '00000000-0000-0000-0000-000000001001', m.type, m.ord +from (values ('dashboard',0),('project_board',1),('calendar',2),('announcements',3),('meeting_notes',4),('resources',5),('chat',6)) m(type, ord); +insert into public.workspace_modules (workspace_id, module_type, sort_order) +select '00000000-0000-0000-0000-000000001002', m.type, m.ord +from (values ('dashboard',0),('sprint_board',1),('calendar',2),('announcements',3),('meeting_notes',4),('resources',5),('chat',6)) m(type, ord); +insert into public.workspace_modules (workspace_id, module_type, sort_order) +select '00000000-0000-0000-0000-000000001003', m.type, m.ord +from (values ('dashboard',0),('work_schedule',1),('calendar',2),('announcements',3),('resources',4),('chat',5)) m(type, ord); + +-- ===== 스프린트 (side_project) ===== +insert into public.sprints (id, workspace_id, name, start_date, end_date) values + ('00000000-0000-0000-0000-000000002001', '00000000-0000-0000-0000-000000001002', 'Sprint 1', '2026-06-17', '2026-06-30'), + ('00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000001002', 'Sprint 2', '2026-07-01', '2026-07-14'); + +-- ===== 업무 ===== +-- side_project: Sprint 2 진행분 (프론트 mock과 동일한 데이터) +insert into public.tasks (workspace_id, sprint_id, assignee_id, created_by, title, status, priority, category, point, sort_order) values + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', '운동 통계 차트', 'in_progress', 'high', 'frontend', 8, 0), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', '푸시 알림 설정', 'in_progress', 'medium', 'backend', 3, 1), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', '이번 주 배포 준비', 'in_progress', 'high', 'backend', 5, 2), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000002', '온보딩 플로우 개선', 'todo', 'medium', 'design', 5, 3), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000002', '성능 최적화 (Lighthouse)', 'todo', 'low', 'frontend', 5, 4), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002002', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', '스플래시 화면 개선', 'done', 'low', 'design', 2, 5), + -- Sprint 1 완료분 (벨로시티 이력용) + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002001', '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', '회원가입/로그인 화면', 'done', 'high', 'frontend', 13, 0), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002001', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', '운동 기록 CRUD', 'done', 'high', 'backend', 13, 1), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000002001', '00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000002', '디자인 시스템 셋업', 'done', 'medium', 'design', 8, 2), + -- 백로그 (sprint_id null) + ('00000000-0000-0000-0000-000000001002', null, null, '00000000-0000-0000-0000-000000000002', '소셜 피드 기능', 'todo', 'high', null, 13, 0), + ('00000000-0000-0000-0000-000000001002', null, null, '00000000-0000-0000-0000-000000000002', '운동 친구 매칭', 'todo', 'medium', null, 8, 1), + ('00000000-0000-0000-0000-000000001002', null, null, '00000000-0000-0000-0000-000000000002', '영상 가이드 연동', 'todo', 'low', null, 13, 2), + ('00000000-0000-0000-0000-000000001002', null, null, '00000000-0000-0000-0000-000000000002', '다크모드 지원', 'todo', 'low', null, 5, 3); + +-- team_project: 칸반용 (스프린트 없음, due_date 기반) +insert into public.tasks (workspace_id, assignee_id, created_by, title, status, priority, due_date, sort_order) values + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', '발표 자료 초안 작성', 'in_progress', 'high', '2026-07-13', 0), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', '설문조사 결과 분석', 'in_progress', 'medium', '2026-07-11', 1), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', '프로토타입 사용성 테스트', 'todo', 'high', '2026-07-15', 2), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000001', '참고 문헌 정리', 'todo', 'low', '2026-07-20', 3), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000001', '주제 선정 회의록 공유', 'done', 'medium', '2026-07-03', 4); + +-- store_operation: 매장 업무 +insert into public.tasks (workspace_id, assignee_id, created_by, title, status, priority, due_date, sort_order) values + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', '신메뉴 원가 계산', 'in_progress', 'high', '2026-07-10', 0), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000003', '주간 재고 발주', 'todo', 'high', '2026-07-11', 1), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000003', '위생 점검 체크리스트', 'done', 'medium', '2026-07-07', 2); + +-- ===== 공지 ===== +insert into public.announcements (workspace_id, author_id, title, content, is_pinned) values + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '중간 발표 일정 안내', '다음 주 수요일(7/15) 중간 발표입니다. 발표 자료는 월요일까지 공유해주세요.', true), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', 'Sprint 2 목표 공유', '이번 스프린트 목표는 통계 차트와 알림 기능 완성입니다.', true), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '7월 둘째 주 영업시간 변경', '금요일은 재고 정리로 1시간 일찍 마감합니다.', true), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '신메뉴 출시 준비', '다음 주 신메뉴 2종 출시 예정입니다. 레시피 숙지 부탁드려요.', false); + +-- ===== 회의록 ===== +insert into public.meeting_notes (workspace_id, author_id, title, content, meeting_at, participants, decisions, follow_up_actions) values + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', 'Sprint 2 플래닝', '스프린트 목표와 백로그 우선순위를 확정했습니다.', '2026-07-01 10:00+09', + array['00000000-0000-0000-0000-000000000001','00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003']::uuid[], + array['통계 차트를 최우선으로 진행', '알림 기능은 백엔드 선행'], + array['차트 라이브러리 리서치 (테스트유저1)', 'FCM 키 발급 (테스트유저2)']), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', 'Sprint 1 회고', '지난 스프린트의 성과와 개선점을 논의했습니다.', '2026-06-30 17:00+09', + array['00000000-0000-0000-0000-000000000001','00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000004']::uuid[], + array['데일리 스크럼 15분 제한', '리뷰 승인 1명 이상 필수'], + array['회고 액션 아이템 노션 정리 (테스트유저4)']), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '중간 발표 준비 회의', '발표 파트 분배와 데모 시나리오를 정했습니다.', '2026-07-08 14:00+09', + array['00000000-0000-0000-0000-000000000001','00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005']::uuid[], + array['데모는 실데이터로 진행', '발표 10분 + 질의 5분'], + array['발표 리허설 월요일 진행']); + +-- ===== 자료실 ===== +insert into public.resources (workspace_id, uploaded_by, title, description, resource_type, url, storage_path) values + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '중간 발표 템플릿', '학과 공식 발표 템플릿', 'file', null, 'resources/1001/presentation-template.pptx'), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000002', '설문조사 시트', '구글폼 응답 시트', 'link', 'https://docs.google.com/spreadsheets/example', null), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '신메뉴 레시피', '7월 신메뉴 레시피 문서', 'file', null, 'resources/1003/new-menu-recipe.pdf'), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000004', '위생 점검 가이드', '시청 위생과 공지 링크', 'link', 'https://example.com/hygiene-guide', null); + +-- ===== 캘린더 ===== +insert into public.calendar_events (workspace_id, created_by, title, event_type, starts_at, ends_at) values + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', '디자인 리뷰 회의', 'meeting', '2026-07-09 11:00+09', '2026-07-09 12:00+09'), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', '스프린트 데일리', 'meeting', '2026-07-09 14:00+09', '2026-07-09 14:15+09'), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', 'API 명세 마감', 'deadline', '2026-07-10 18:00+09', null), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', 'Sprint 2 리뷰', 'meeting', '2026-07-14 16:00+09', '2026-07-14 17:00+09'), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '중간 발표', 'deadline', '2026-07-15 13:00+09', null), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '신메뉴 시식회', 'meeting', '2026-07-12 15:00+09', '2026-07-12 16:00+09'); + +-- ===== 채팅 ===== +insert into public.chat_messages (workspace_id, sender_id, content) values + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000001', '발표 자료 초안 공유했습니다. 피드백 부탁해요!'), + ('00000000-0000-0000-0000-000000001001', '00000000-0000-0000-0000-000000000002', '확인했습니다. 2페이지 그래프만 수정하면 될 것 같아요.'), + ('00000000-0000-0000-0000-000000001002', '00000000-0000-0000-0000-000000000002', '오늘 데일리는 14시에 시작할게요.'), + ('00000000-0000-0000-0000-000000001003', '00000000-0000-0000-0000-000000000003', '오늘 마감 조 재고 정리까지 부탁드립니다.'); + +-- ===== 근무 스케줄 (이번 주, store_operation) ===== +insert into public.work_schedule_entries (workspace_id, user_id, work_date, shift_type, created_by) +select '00000000-0000-0000-0000-000000001003', u.uid, d::date, + (array['open','middle','close','off'])[((extract(day from d)::int + u.n) % 4) + 1], + '00000000-0000-0000-0000-000000000003' +from (values + ('00000000-0000-0000-0000-000000000001'::uuid, 0), + ('00000000-0000-0000-0000-000000000002'::uuid, 1), + ('00000000-0000-0000-0000-000000000003'::uuid, 2), + ('00000000-0000-0000-0000-000000000004'::uuid, 3), + ('00000000-0000-0000-0000-000000000005'::uuid, 4) +) u(uid, n) +cross join generate_series('2026-07-06'::date, '2026-07-12'::date, interval '1 day') d; + +-- ===== 대시보드 레이아웃 샘플 (테스트유저1 × side_project) ===== +insert into public.user_dashboard_layouts (user_id, workspace_id, layout) values + ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000001002', + '[{"i":"my-tasks","x":0,"y":0,"w":12,"h":5},{"i":"velocity","x":0,"y":5,"w":6,"h":5},{"i":"calendar","x":6,"y":5,"w":6,"h":8},{"i":"recent-notes","x":0,"y":13,"w":6,"h":5}]'::jsonb); diff --git a/supabase/migrations/20260709025820_add_auth_rls_policies.sql b/supabase/migrations/20260709025820_add_auth_rls_policies.sql new file mode 100644 index 0000000..f8c68db --- /dev/null +++ b/supabase/migrations/20260709025820_add_auth_rls_policies.sql @@ -0,0 +1,89 @@ +-- 실 RLS 정책 (auth.uid() 기반) — 지금은 dev_full_access와 공존(OR)하므로 무해. +-- auth 연동 완료 시 dev_full_access만 drop하면 이 정책들이 즉시 발동한다. + +-- 멤버십 헬퍼 — security definer로 workspace_members 정책의 자기참조 재귀를 회피 +create or replace function public.is_workspace_member(p_workspace_id uuid) +returns boolean +language sql stable security definer +set search_path = public, pg_temp +as $$ + select exists ( + select 1 from workspace_members + where workspace_id = p_workspace_id and user_id = auth.uid() + ); +$$; + +create or replace function public.is_workspace_owner(p_workspace_id uuid) +returns boolean +language sql stable security definer +set search_path = public, pg_temp +as $$ + select exists ( + select 1 from workspaces + where id = p_workspace_id and owner_id = auth.uid() + ); +$$; + +-- ===== profiles: 조회는 로그인 유저 전체, 쓰기는 본인만 ===== +create policy profiles_select on public.profiles + for select to authenticated using (true); +create policy profiles_insert_own on public.profiles + for insert to authenticated with check (id = auth.uid()); +create policy profiles_update_own on public.profiles + for update to authenticated using (id = auth.uid()) with check (id = auth.uid()); + +-- ===== module_registry: 읽기 전용 참조 데이터 ===== +create policy module_registry_select on public.module_registry + for select to authenticated using (true); + +-- ===== workspaces: 멤버만 조회, 소유자만 수정/삭제, 생성은 본인 소유로만 ===== +create policy workspaces_select_member on public.workspaces + for select to authenticated using (public.is_workspace_member(id)); +create policy workspaces_insert_own on public.workspaces + for insert to authenticated with check (owner_id = auth.uid()); +create policy workspaces_update_owner on public.workspaces + for update to authenticated using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy workspaces_delete_owner on public.workspaces + for delete to authenticated using (owner_id = auth.uid()); + +-- ===== workspace_members: 같은 워크스페이스 멤버만 조회, 가입/닉네임변경/탈퇴는 본인 ===== +create policy members_select on public.workspace_members + for select to authenticated using (public.is_workspace_member(workspace_id)); +create policy members_insert_self on public.workspace_members + for insert to authenticated with check (user_id = auth.uid()); +create policy members_update_self on public.workspace_members + for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); +create policy members_delete_self on public.workspace_members + for delete to authenticated using (user_id = auth.uid()); + +-- ===== workspace_modules: 멤버 조회, 소유자만 변경 ===== +create policy modules_select_member on public.workspace_modules + for select to authenticated using (public.is_workspace_member(workspace_id)); +create policy modules_write_owner on public.workspace_modules + for insert to authenticated with check (public.is_workspace_owner(workspace_id)); +create policy modules_update_owner on public.workspace_modules + for update to authenticated using (public.is_workspace_owner(workspace_id)) with check (public.is_workspace_owner(workspace_id)); +create policy modules_delete_owner on public.workspace_modules + for delete to authenticated using (public.is_workspace_owner(workspace_id)); + +-- ===== user_dashboard_layouts: 본인 것만 (개인별 레이아웃) ===== +create policy layouts_own on public.user_dashboard_layouts + for all to authenticated + using (user_id = auth.uid() and public.is_workspace_member(workspace_id)) + with check (user_id = auth.uid() and public.is_workspace_member(workspace_id)); + +-- ===== 워크스페이스 콘텐츠 테이블: 멤버면 CRUD 가능 ===== +-- (작성자 본인만 수정 같은 세밀한 규칙은 추후 필요 시 강화) +do $$ +declare t text; +begin + foreach t in array array[ + 'sprints','tasks','calendar_events','announcements', + 'meeting_notes','resources','chat_messages','work_schedule_entries' + ] loop + execute format( + 'create policy %I on public.%I for all to authenticated using (public.is_workspace_member(workspace_id)) with check (public.is_workspace_member(workspace_id))', + t || '_member_all', t + ); + end loop; +end $$; diff --git a/supabase/migrations/20260709032015_add_constraints.sql b/supabase/migrations/20260709032015_add_constraints.sql new file mode 100644 index 0000000..b395bc9 --- /dev/null +++ b/supabase/migrations/20260709032015_add_constraints.sql @@ -0,0 +1,32 @@ +-- 설계 점검 확정 제약 (2026-07-09, 지호님 승인) +-- sprints(workspace_id, name) unique는 제외 — 스프린트를 미리 여러 개 만들어두는 워크플로우 허용 + +-- 1) 같은 워크스페이스 안 닉네임 중복 방지 (ERD 문서 권장) +alter table public.workspace_members + add constraint workspace_members_nickname_unique unique (workspace_id, workspace_nickname); + +-- 2) 레이아웃은 실제 멤버만 소유 가능 — 멤버 탈퇴 시 레이아웃도 삭제 (ERD 문서 권장) +alter table public.user_dashboard_layouts + add constraint user_dashboard_layouts_member_fk + foreign key (workspace_id, user_id) + references public.workspace_members (workspace_id, user_id) on delete cascade; + +-- 3) 근무 배정 대상은 실제 멤버만 — 멤버 탈퇴 시 근무 기록도 삭제 (ERD 문서 권장) +alter table public.work_schedule_entries + add constraint work_schedule_entries_member_fk + foreign key (workspace_id, user_id) + references public.workspace_members (workspace_id, user_id) on delete cascade; + +-- 4) 이메일 중복 방어 (auth.users와 별개의 방어선) +alter table public.profiles + add constraint profiles_email_unique unique (email); + +-- 5~7) 값 검증 (ERD 문서의 "시간 범위 검증 필요" 반영) +alter table public.sprints + add constraint sprints_date_range_check check (end_date >= start_date); + +alter table public.calendar_events + add constraint calendar_events_time_range_check check (ends_at is null or ends_at >= starts_at); + +alter table public.tasks + add constraint tasks_point_check check (point is null or point >= 0); From c4e398b3a4fb09e9c9803cc583d1c9cfdcd644c3 Mon Sep 17 00:00:00 2001 From: JiHo Jeon Date: Thu, 9 Jul 2026 12:50:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20RLS=20=ED=97=AC=ED=8D=BC=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20private=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20API=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/model/database.types.ts | 6 +----- ...4833_move_rls_helpers_to_private_schema.sql | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20260709034833_move_rls_helpers_to_private_schema.sql diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index bcf540d..5ad966f 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -702,11 +702,7 @@ export type Database = { [_ in never]: never } Functions: { - is_workspace_member: { - Args: { p_workspace_id: string } - Returns: boolean - } - is_workspace_owner: { Args: { p_workspace_id: string }; Returns: boolean } + [_ in never]: never } Enums: { calendar_event_type: "meeting" | "deadline" diff --git a/supabase/migrations/20260709034833_move_rls_helpers_to_private_schema.sql b/supabase/migrations/20260709034833_move_rls_helpers_to_private_schema.sql new file mode 100644 index 0000000..354ca45 --- /dev/null +++ b/supabase/migrations/20260709034833_move_rls_helpers_to_private_schema.sql @@ -0,0 +1,18 @@ +-- Security Advisor 대응: RLS 헬퍼 함수를 API 비노출 스키마로 이동 +-- public 스키마의 함수는 PostgREST가 /rest/v1/rpc/로 자동 노출한다. +-- is_workspace_member/is_workspace_owner는 RLS 정책 내부 전용이므로 private 스키마로 옮겨 API 노출을 제거한다. +-- (정책은 함수를 OID로 참조하므로 스키마 이동 후에도 그대로 동작한다) + +create schema if not exists private; + +alter function public.is_workspace_member(uuid) set schema private; +alter function public.is_workspace_owner(uuid) set schema private; + +-- RLS 정책 평가는 쿼리 실행 유저 권한으로 이뤄지므로 authenticated에는 EXECUTE가 필요하다. +grant usage on schema private to authenticated; +grant execute on function private.is_workspace_member(uuid) to authenticated; +grant execute on function private.is_workspace_owner(uuid) to authenticated; + +-- anon과 public 롤에서는 실행 권한 제거 (정책상 anon은 이 함수를 평가할 일이 없음) +revoke execute on function private.is_workspace_member(uuid) from anon, public; +revoke execute on function private.is_workspace_owner(uuid) from anon, public;