diff --git a/docs/v2/phase-V2-2-setup.md b/docs/v2/phase-V2-2-setup.md new file mode 100644 index 0000000..cd56e81 --- /dev/null +++ b/docs/v2/phase-V2-2-setup.md @@ -0,0 +1,184 @@ +# Phase V2-2: Supabase 인프라 — 사용자 설정 가이드 + +> 이 문서는 사용자가 **로컬 밖에서 수행해야 하는 작업**만 안내합니다. +> 로컬 코드 변경(SQL 마이그레이션, Edge Functions 스캐폴딩)은 이미 `server/supabase/`에 포함되어 있습니다. + +--- + +## 1. Supabase 계정/프로젝트 생성 + +1. https://supabase.com 접속 → `Sign in with GitHub` (권장) +2. `New project` 클릭 + - **Name**: `d3ro-voice` + - **Database Password**: 강력한 비밀번호 (1Password 등에 저장) + - **Region**: `ap-northeast-2` (Seoul) 권장 + - **Pricing Plan**: Free (시작 후 Pro로 업그레이드 가능) +3. 프로젝트 생성 완료(~2분) 후 대시보드 접속 + +## 2. API 키 확인 (Settings → API) + +다음 4개 값을 메모: + +| 키 | 용도 | 노출 범위 | +|---|---|---| +| `Project URL` | `SUPABASE_URL` — 모든 클라이언트 | 공개 가능 | +| `anon public` | `SUPABASE_ANON_KEY` — 클라이언트 읽기/쓰기 (RLS 보호) | 공개 가능 | +| `service_role` | `SUPABASE_SERVICE_ROLE_KEY` — 서버 전용 (RLS 우회) | **절대 공개 금지** | +| `Project Ref` | CLI 연동용 | 공개 가능 | + +## 3. Supabase CLI 설치 + +### Windows +```bash +scoop install supabase +# 또는 +winget install Supabase.CLI +``` + +### macOS +```bash +brew install supabase/tap/supabase +``` + +## 4. 로컬 프로젝트 연결 + +```bash +cd D:/workspace/voice + +# Supabase 계정 로그인 (브라우저 열림) +supabase login + +# 원격 프로젝트와 연결 (Project Ref 필요) +cd server/supabase +supabase link --project-ref +``` + +## 5. 마이그레이션 푸시 + +```bash +# server/supabase/migrations/* 를 원격에 적용 +supabase db push +``` + +실행 결과: +- `20260409000001_initial_schema.sql` — 테이블/인덱스 생성 +- `20260409000002_rls_policies.sql` — RLS 정책 +- `20260409000003_auth_triggers.sql` — handle_new_user, moddatetime +- `20260409000004_storage_buckets.sql` — storage 버킷 + 정책 + +검증: Studio → Database → Tables에서 12개 테이블 확인 + +## 6. OAuth Provider 등록 + +### 6.1 Google OAuth + +1. https://console.cloud.google.com → 프로젝트 생성 (`d3ro-voice`) +2. `APIs & Services` → `Credentials` → `Create Credentials` → `OAuth client ID` +3. `Application type`: Web application +4. `Authorized redirect URIs`: + - `https://.supabase.co/auth/v1/callback` +5. `Client ID`와 `Client Secret` 복사 +6. Supabase 대시보드 → Authentication → Providers → Google + - Enable 체크 + - Client ID / Client Secret 입력 → Save + +### 6.2 GitHub OAuth + +1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App +2. `Homepage URL`: `https://d3ro.dev` +3. `Authorization callback URL`: `https://.supabase.co/auth/v1/callback` +4. `Client ID`, `Client Secret` 복사 → Supabase Authentication → Providers → GitHub + +### 6.3 Apple OAuth (선택, 나중에 가능) + +Apple Developer Program 계정 필요. iOS 출시 전에 등록. +가이드: https://supabase.com/docs/guides/auth/social-login/auth-apple + +## 7. Edge Functions 배포 + +```bash +cd server/supabase + +# 시크릿 설정 (배포 전) +supabase secrets set GOOGLE_CLOUD_STT_KEY= +supabase secrets set ANTHROPIC_API_KEY= + +# 함수 배포 +supabase functions deploy stt-proxy +supabase functions deploy llm-proxy +``` + +배포 후 호출 URL: +- `https://.supabase.co/functions/v1/stt-proxy` +- `https://.supabase.co/functions/v1/llm-proxy` + +### 7.1 AI API 키 발급 + +**Google Cloud STT**: +1. Google Cloud Console → API & Services → Library → Speech-to-Text API → Enable +2. Credentials → Create API Key (Speech-to-Text API로 제한 권장) +3. 빌링 활성화 필요 ($300 free credit) + +**Anthropic API**: +1. https://console.anthropic.com → API Keys → Create Key +2. 최소 $5 credit 충전 + +## 8. 환경변수 관리 + +### 8.1 로컬 개발 (.env) + +루트에 `.env.local` 생성 (`.gitignore`되어 있음): + +```bash +# Supabase +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= + +# apps/web (Phase V2-3에서 사용) +NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Edge Functions 로컬 개발용 (server/supabase/.env) +GOOGLE_CLOUD_STT_KEY= +ANTHROPIC_API_KEY= +``` + +### 8.2 프로덕션 (Supabase Dashboard) + +- Supabase 내부 시크릿: `supabase secrets set KEY=value` +- Vercel/Netlify 등 호스팅: 각 플랫폼의 환경변수 설정 페이지 사용 + +## 9. 검증 체크리스트 + +- [ ] Studio에서 12개 테이블 확인 (profiles, teams, team_members, meetings, meeting_memos, meeting_documents, transcripts, history, dictionary, memo_tags, daily_usage, subscriptions) +- [ ] Authentication → Users에서 첫 테스트 유저 로그인 가능 +- [ ] 테스트 유저 로그인 후 `profiles` 테이블에 자동 행 생성됨 (handle_new_user 트리거 동작) +- [ ] `subscriptions` 테이블에도 `tier=free` 행 자동 생성 +- [ ] Storage → 3개 버킷 (audio/exports/avatars) 확인 +- [ ] Edge Functions → stt-proxy, llm-proxy 배포 상태 확인 +- [ ] Edge Functions 호출 테스트 (curl 또는 Studio의 Invoke): + ```bash + curl -X POST https://.supabase.co/functions/v1/llm-proxy \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":"hi"}]}' + ``` + → `"[llm-proxy placeholder ...]"` 응답이 오면 인증/쿼터 체인 정상 + +## 10. 다음 페이즈와의 연결 + +V2-2가 완료되면: +- **V2-3 (Web MVP)**: `NEXT_PUBLIC_SUPABASE_*` 환경변수로 Next.js 앱이 백엔드 연결 +- **V2-4 (데스크톱 동기화)**: `VITE_SUPABASE_*`로 Electron 앱이 백엔드 연결 +- Edge Functions의 placeholder를 실제 Google STT / Anthropic API 호출로 교체 + +## 11. 문제 해결 + +**`supabase db push` 시 FK 오류**: +→ 기존 스키마가 있으면 `supabase db reset` (주의: 데이터 삭제) 또는 수동 drop 후 재시도 + +**RLS로 인해 INSERT 차단**: +→ `auth.uid()`가 null이면 정책 통과 못함. 반드시 로그인된 클라이언트에서 호출 + +**Edge Function 배포 후 500 에러**: +→ `supabase functions logs stt-proxy`로 런타임 에러 확인. 대부분 시크릿 미설정 diff --git a/docs/v2/phase-V2-2.md b/docs/v2/phase-V2-2.md new file mode 100644 index 0000000..53cd812 --- /dev/null +++ b/docs/v2/phase-V2-2.md @@ -0,0 +1,263 @@ +# Phase V2-2: Supabase 인프라 — 상세 설계 + +> 멀티플랫폼(desktop/web/mobile)의 **단일 백엔드**를 Supabase 기반으로 구축. +> 로컬 완결 작업(SQL 마이그레이션, Edge Functions)은 이 페이즈에서 전부 작성. +> 사용자 액션(계정 생성, OAuth 등록, 배포)은 `phase-V2-2-setup.md` 참조. + +--- + +## 1. 목표 + +1. **인증**: Supabase Auth — Google/GitHub/Apple OAuth + Email +2. **데이터 SSOT**: PostgreSQL — V1 SQLite 스키마를 멀티테넌시(user_id, team_id)로 포팅 +3. **보안**: Row Level Security — 개인 데이터 격리 + 팀 공유 지원 +4. **AI 프록시**: Edge Functions — Google STT, Claude LLM 호출을 서버에서 래핑 (API 키 은닉 + 쿼터 집계) +5. **파일 스토리지**: Supabase Storage 버킷 3개 (audio, exports, avatars) +6. **실시간**: 회의 진행 중 전사 세그먼트 실시간 동기화 (Realtime subscription) + +## 2. 디렉토리 구조 + +``` +server/supabase/ +├── config.toml # 프로젝트 설정 (로컬 개발용) +├── seed.sql # 개발 시드 데이터 +├── migrations/ +│ ├── 20260409000001_initial_schema.sql # 테이블 + 인덱스 +│ ├── 20260409000002_rls_policies.sql # RLS policies +│ ├── 20260409000003_auth_triggers.sql # handle_new_user, moddatetime +│ └── 20260409000004_storage_buckets.sql # storage 버킷 + policies +└── functions/ + ├── _shared/ + │ ├── cors.ts # CORS 헤더 헬퍼 + │ ├── auth.ts # JWT 검증 + user_id 추출 + │ └── quota.ts # 티어별 쿼터 체크 + ├── stt-proxy/ + │ └── index.ts # Google Cloud STT 래퍼 + └── llm-proxy/ + └── index.ts # Claude API 래퍼 +``` + +## 3. 데이터 모델 + +### 3.1 테이블 요약 + +| 테이블 | 설명 | 멀티테넌시 | +|---|---|---| +| `public.profiles` | auth.users 확장 (name, avatar_url, locale, tier) | `id = auth.users.id` | +| `public.meetings` | 회의 세션 (V1 meeting_sessions) | `user_id`, `team_id?` | +| `public.meeting_memos` | 회의 중 타임스탬프 메모 | `meeting_id → meetings` | +| `public.meeting_documents` | LLM 생성 문서 (minutes/report/mindmap) | `meeting_id → meetings` | +| `public.transcripts` | 전사 세그먼트 (실시간 동기화 대상) | `meeting_id → meetings` | +| `public.history` | 음성 입력 히스토리 (dictation/translate 등) | `user_id` | +| `public.dictionary` | 사용자 사전 | `user_id` | +| `public.memo_tags` | 히스토리 태그 | `user_id`, `history_id → history` | +| `public.daily_usage` | 기능별 일일 사용량 (쿼터 집계) | `user_id` | +| `public.teams` | 팀 | `owner_id → auth.users` | +| `public.team_members` | 팀 멤버십 | `team_id`, `user_id`, role | +| `public.subscriptions` | Stripe 구독 상태 | `user_id` | + +### 3.2 V1 SQLite ↔ V2 PostgreSQL 타입 매핑 + +| V1 (drizzle sqlite-core) | V2 (PostgreSQL) | 비고 | +|---|---|---| +| `text('id').primaryKey()` (nanoid) | `uuid primary key default gen_random_uuid()` | 신규는 UUID 사용 | +| `integer('created_at')` (epoch ms) | `timestamptz not null default now()` | Date/Time 네이티브 | +| `integer('indexed', mode: 'boolean')` | `boolean not null default false` | | +| `real('duration')` | `double precision` | | +| `text('status', {enum: ...})` | `text check (status in (...))` 또는 enum | enum 타입으로 | +| `text('embedding')` (JSON float[]) | `vector(768)` via pgvector | RAG 전용 | + +### 3.3 공통 컬럼 + +모든 유저 소유 테이블: +- `user_id uuid not null references auth.users(id) on delete cascade` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` (트리거로 자동 갱신) + +회의 관련 테이블만 추가: +- `team_id uuid references public.teams(id) on delete set null` (nullable — 개인 회의는 null) + +## 4. RLS 정책 패턴 + +### 4.1 개인 전용 테이블 (history, dictionary, memo_tags, daily_usage) + +```sql +CREATE POLICY "users_own_rows" ON public.history + FOR ALL + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); +``` + +### 4.2 팀 공유 가능 테이블 (meetings, meeting_memos, meeting_documents, transcripts) + +```sql +-- SELECT: 본인 소유 OR 소속 팀의 리소스 +CREATE POLICY "meetings_read" ON public.meetings + FOR SELECT + USING ( + user_id = auth.uid() + OR team_id IN ( + SELECT team_id FROM public.team_members WHERE user_id = auth.uid() + ) + ); + +-- INSERT: 본인 user_id 강제 +CREATE POLICY "meetings_insert" ON public.meetings + FOR INSERT + WITH CHECK (user_id = auth.uid()); + +-- UPDATE: 본인 소유 OR 팀 admin 이상 +CREATE POLICY "meetings_update" ON public.meetings + FOR UPDATE + USING ( + user_id = auth.uid() + OR team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +-- DELETE: 본인 소유만 +CREATE POLICY "meetings_delete" ON public.meetings + FOR DELETE + USING (user_id = auth.uid()); +``` + +### 4.3 팀 자체 (teams, team_members) + +```sql +-- 팀 조회: 본인이 소유 OR 멤버 +CREATE POLICY "teams_read" ON public.teams + FOR SELECT + USING ( + owner_id = auth.uid() + OR id IN (SELECT team_id FROM public.team_members WHERE user_id = auth.uid()) + ); + +-- 팀 생성: 누구나 (owner = 본인) +CREATE POLICY "teams_insert" ON public.teams + FOR INSERT WITH CHECK (owner_id = auth.uid()); + +-- 팀 수정: owner만 +CREATE POLICY "teams_update" ON public.teams + FOR UPDATE USING (owner_id = auth.uid()); +``` + +## 5. Auth 트리거 + +### 5.1 `handle_new_user` — auth.users → profiles 자동 생성 + +```sql +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.profiles (id, name, avatar_url, locale) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)), + NEW.raw_user_meta_data->>'avatar_url', + COALESCE(NEW.raw_user_meta_data->>'locale', 'ko') + ); + RETURN NEW; +END; +$$; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); +``` + +### 5.2 `moddatetime` — updated_at 자동 갱신 + +```sql +CREATE OR REPLACE FUNCTION public.moddatetime() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- 각 테이블마다: +CREATE TRIGGER set_updated_at + BEFORE UPDATE ON public.meetings + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); +``` + +## 6. Edge Functions + +### 6.1 공통: `_shared/auth.ts` + +```typescript +import { createClient, User } from 'https://esm.sh/@supabase/supabase-js@2' + +export async function requireUser(req: Request): Promise { + const authHeader = req.headers.get('Authorization') + if (!authHeader) throw new Response('Unauthorized', { status: 401 }) + + const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } } + ) + + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) throw new Response('Unauthorized', { status: 401 }) + return user +} +``` + +### 6.2 `stt-proxy/` — Google Cloud STT 래퍼 + +- 입력: `multipart/form-data` (audio blob + sample_rate + language_code) +- 인증: JWT → user 추출 +- 쿼터: `daily_usage` 확인 → 초과 시 429 +- 호출: Google Cloud Speech-to-Text API (v2 recognize) +- 응답: `{ transcript, confidence, language_code, duration_seconds }` +- 사이드 이펙트: `daily_usage.count` 증가 + +### 6.3 `llm-proxy/` — Claude API 래퍼 + +- 입력: `application/json` (messages + system + max_tokens + model) +- 인증: 동일 +- 쿼터: 동일 (`llm_process` feature) +- 호출: Anthropic Messages API (스트리밍 지원) +- 응답: SSE 스트림 또는 JSON +- 모델 정책: Free → Haiku 강제, Pro+ → Sonnet 허용 + +## 7. Storage 버킷 + +```sql +INSERT INTO storage.buckets (id, name, public) VALUES + ('audio', 'audio', false), -- 녹음 원본 (비공개) + ('exports', 'exports', false), -- PDF/DOCX 내보내기 + ('avatars', 'avatars', true); -- 프로필 사진 (공개) +``` + +정책: 파일 경로가 `{user_id}/...`로 시작해야 쓰기 허용. + +## 8. 실시간 구독 전략 + +- 회의 중 `transcripts` INSERT → Realtime 구독으로 팀원에게 전파 +- 회의록 편집 중 `meeting_documents` UPDATE → CRDT 병합 (V2-7 팀 기능에서 구현) + +## 9. 검증 전략 + +V2-2는 로컬에서 전체 검증 불가 (Supabase 프로젝트 필요). 이 페이즈 완료 판정 기준: + +1. ✅ SQL 마이그레이션 파일 작성 완료 +2. ✅ Edge Functions 스캐폴딩 완료 (TypeScript 컴파일 가능) +3. ✅ 설정 가이드 문서 완료 +4. ⏳ 실제 deploy 검증은 사용자가 `supabase db push` 후 진행 (V2-2b) + +## 10. 다음 페이즈와의 관계 + +- **V2-3 (Web App)**: Next.js에서 `@supabase/supabase-js` 클라이언트로 직접 연결 +- **V2-4 (데스크톱 동기화)**: apps/desktop에 supabase 클라이언트 추가, 로컬 SQLite ↔ Postgres 양방향 sync 레이어 +- **V2-7 (팀 기능)**: teams/team_members 테이블 + invite flow diff --git a/memory/project_status.md b/memory/project_status.md index 6eb478f..735bed2 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -6,12 +6,12 @@ ## 현재 단계 **V1 (Electron) 완료** → **V2 (Monorepo) 진행 중** -- Phase V2-1 (전체) ✅ 완료 - - V2-1a: Monorepo 구조 이동 - - V2-1b: packages/core 추출 - - V2-1c: packages/ui 추출 - - V2-1d: packages/i18n 추출 -- 다음: Phase V2-2 (Supabase 인프라) +- Phase V2-1 ✅ 완료 (Monorepo 전환 — a/b/c/d) +- Phase V2-2 🟡 로컬 작업 완료, 사용자 액션 대기 + - SQL 마이그레이션 4개 파일 + Edge Functions 2개 스캐폴딩 작성 완료 + - Supabase 프로젝트 생성/OAuth 등록/배포는 사용자가 직접 수행 + - 가이드: `docs/v2/phase-V2-2-setup.md` +- 다음: Phase V2-3 (Web App MVP — Next.js) ## V1 완료 페이즈 @@ -138,8 +138,43 @@ - `npm run dev` ✅ 실제 실행 → DB 경로 `d3ro-voice/`로 복원, 기존 핫키(Alt keyCode=2) 로드, Ollama 자동 실행 감지 모두 정상 - `npm run dist` (electron-builder installer)은 V2-1 완료 후 별도 검증 -### Phase V2-2~V2-8: 미착수 -Supabase 인프라, Web App MVP, 데스크톱 동기화, Mac 빌드, Mobile App, 팀 기능, 결제/출시 +### Phase V2-2: Supabase 인프라 — 로컬 작업 완료 + +**작성된 파일** (`server/supabase/`) +- `config.toml` — Supabase CLI 설정 (로컬 + OAuth providers) +- `migrations/20260409000001_initial_schema.sql` — 12개 테이블 (profiles, teams, team_members, meetings, meeting_memos, meeting_documents, transcripts, history, dictionary, memo_tags, daily_usage, subscriptions) + 인덱스 + FK CASCADE +- `migrations/20260409000002_rls_policies.sql` — 개인/팀 이중 RLS 패턴 +- `migrations/20260409000003_auth_triggers.sql` — `handle_new_user` (profiles + subscriptions 자동 생성), `moddatetime` (8개 테이블 updated_at 트리거), `increment_daily_usage` (service_role 전용 쿼터 RPC) +- `migrations/20260409000004_storage_buckets.sql` — audio/exports/avatars 버킷 + 경로 기반 정책 +- `functions/_shared/` — cors.ts, auth.ts (requireUser), quota.ts (티어별 쿼터) +- `functions/stt-proxy/index.ts` — Google Cloud STT 래퍼 스캐폴딩 (placeholder 응답) +- `functions/llm-proxy/index.ts` — Anthropic Messages API 래퍼 스캐폴딩 (티어별 모델 정책) +- `seed.sql` — 개발 시드 (빈 상태) +- `.gitignore` — .branches/, .temp/, .env + +**설계 문서** +- `docs/v2/phase-V2-2.md` — 상세 설계 (스키마/RLS/Edge Functions/실시간/Storage) +- `docs/v2/phase-V2-2-setup.md` — 사용자 액션 가이드 (Supabase 계정/OAuth/CLI/배포) + +**사용자 액션 대기 중** (로컬 외 작업) +1. Supabase 프로젝트 생성 (https://supabase.com) +2. `supabase login && supabase link --project-ref ` +3. `supabase db push` (4개 마이그레이션 적용) +4. Google/GitHub OAuth 앱 등록 + Supabase Auth Providers 설정 +5. Google Cloud STT 키 + Anthropic API 키 발급 +6. `supabase secrets set` + `supabase functions deploy` + +**설계 결정사항** +- **UUID PK**: V1은 nanoid(text), V2는 PostgreSQL `gen_random_uuid()`. V2-4 (동기화)에서 기존 SQLite 데이터는 nanoid 유지, 새 데이터는 UUID. +- **timestamptz**: V1은 epoch ms(integer), V2는 `timestamptz`. V2-4 동기화 시 타입 변환 필요. +- **멀티테넌시**: 모든 테이블에 `user_id`. 회의 관련 테이블은 `team_id` nullable 추가. +- **RLS 이중 패턴**: 개인 전용(history/dictionary)은 단순 `user_id = auth.uid()`. 팀 공유(meetings)는 본인 OR `team_members` 조회 subquery. +- **쿼터 집계**: `increment_daily_usage` RPC는 `SECURITY DEFINER` + `service_role` only. 일반 유저는 읽기만. +- **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용). +- **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지. + +### Phase V2-3~V2-8: 미착수 +Web App MVP, 데스크톱 동기화, Mac 빌드, Mobile App, 팀 기능, 결제/출시 ## 알려진 차단/이슈 diff --git a/server/supabase/.gitignore b/server/supabase/.gitignore new file mode 100644 index 0000000..0fb3c61 --- /dev/null +++ b/server/supabase/.gitignore @@ -0,0 +1,5 @@ +# Supabase 로컬 실행 시 생성되는 파일 +.branches/ +.temp/ +.env +.env.* diff --git a/server/supabase/config.toml b/server/supabase/config.toml new file mode 100644 index 0000000..d4298e4 --- /dev/null +++ b/server/supabase/config.toml @@ -0,0 +1,88 @@ +# Supabase 프로젝트 설정 (로컬 개발 + CLI 기준) +# 공식 문서: https://supabase.com/docs/guides/cli/config + +project_id = "d3ro-voice" + +[api] +enabled = true +port = 54321 +schemas = ["public", "storage"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[db.pooler] +enabled = false + +[db.seed] +enabled = true +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true + +[studio] +enabled = true +port = 54323 + +[inbucket] +enabled = true +port = 54324 + +[storage] +enabled = true +file_size_limit = "50MiB" + +[auth] +enabled = true +site_url = "http://localhost:5173" +additional_redirect_urls = [ + "http://localhost:5173", + "https://d3ro.dev", + "d3ro-voice://auth-callback" +] +jwt_expiry = 3600 +enable_signup = true +enable_anonymous_sign_ins = false +enable_manual_linking = false + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false + +[auth.external.google] +enabled = true +client_id = "env(GOOGLE_OAUTH_CLIENT_ID)" +secret = "env(GOOGLE_OAUTH_SECRET)" +redirect_uri = "" + +[auth.external.github] +enabled = true +client_id = "env(GITHUB_OAUTH_CLIENT_ID)" +secret = "env(GITHUB_OAUTH_SECRET)" +redirect_uri = "" + +[auth.external.apple] +enabled = true +client_id = "env(APPLE_OAUTH_CLIENT_ID)" +secret = "env(APPLE_OAUTH_SECRET)" +redirect_uri = "" + +[edge_runtime] +enabled = true +policy = "per_worker" +inspector_port = 8083 + +[functions.stt-proxy] +verify_jwt = true + +[functions.llm-proxy] +verify_jwt = true + +[analytics] +enabled = false diff --git a/server/supabase/functions/_shared/auth.ts b/server/supabase/functions/_shared/auth.ts new file mode 100644 index 0000000..12cb6a7 --- /dev/null +++ b/server/supabase/functions/_shared/auth.ts @@ -0,0 +1,49 @@ +// server/supabase/functions/_shared/auth.ts +// JWT 검증 + 인증된 유저 반환 + +// @ts-expect-error — Deno 런타임 import (타입 보강은 deno.json 또는 skipLibCheck) +import { createClient, type User } from 'https://esm.sh/@supabase/supabase-js@2.39.7' + +export interface AuthError { + status: number + message: string +} + +export function authErrorResponse(error: AuthError, corsHeaders: Record): Response { + return new Response(JSON.stringify({ error: error.message }), { + status: error.status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) +} + +/** + * Authorization 헤더에서 JWT를 추출하여 유저를 검증한다. + * 실패 시 예외 대신 AuthError 객체를 throw. + */ +export async function requireUser(req: Request): Promise { + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw { status: 401, message: 'Missing Authorization header' } as AuthError + } + + // @ts-expect-error — Deno.env는 Deno 런타임 전역 + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '' + // @ts-expect-error — Deno.env는 Deno 런타임 전역 + const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '' + + const supabase = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: authHeader } } + }) + + const { + data: { user }, + error + } = await supabase.auth.getUser() + + if (error || !user) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw { status: 401, message: error?.message ?? 'Invalid auth token' } as AuthError + } + return user +} diff --git a/server/supabase/functions/_shared/cors.ts b/server/supabase/functions/_shared/cors.ts new file mode 100644 index 0000000..a00c7c1 --- /dev/null +++ b/server/supabase/functions/_shared/cors.ts @@ -0,0 +1,16 @@ +// server/supabase/functions/_shared/cors.ts +// Edge Function 공통 CORS 헤더 + +export const corsHeaders: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': + 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS' +} + +export function handleCorsPreflightRequest(req: Request): Response | null { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + return null +} diff --git a/server/supabase/functions/_shared/quota.ts b/server/supabase/functions/_shared/quota.ts new file mode 100644 index 0000000..0e99ce7 --- /dev/null +++ b/server/supabase/functions/_shared/quota.ts @@ -0,0 +1,108 @@ +// server/supabase/functions/_shared/quota.ts +// 티어별 기능 쿼터 확인 + 증가 + +// @ts-expect-error — Deno 런타임 import +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7' + +export type Tier = 'free' | 'pro' | 'team' +export type Feature = 'stt_transcribe' | 'llm_process' + +/** 일일 쿼터 정책 (-1 = 무제한) */ +const DAILY_QUOTA: Record> = { + free: { + stt_transcribe: 50, + llm_process: 50 + }, + pro: { + stt_transcribe: -1, + llm_process: -1 + }, + team: { + stt_transcribe: -1, + llm_process: -1 + } +} + +export interface QuotaCheck { + allowed: boolean + current: number + limit: number + tier: Tier +} + +/** + * 유저의 오늘 사용량을 확인하고 쿼터 초과 여부를 반환. + * 실제 증가는 performQuotaConsume 호출 시 수행. + */ +export async function checkQuota( + userId: string, + feature: Feature, + serviceRoleClient: ReturnType +): Promise { + // 티어 조회 + const { data: sub } = await serviceRoleClient + .from('subscriptions') + .select('tier') + .eq('user_id', userId) + .single() + + const tier: Tier = (sub?.tier as Tier) ?? 'free' + const limit = DAILY_QUOTA[tier][feature] + + if (limit === -1) { + return { allowed: true, current: 0, limit, tier } + } + + // 오늘 사용량 조회 + const today = new Date().toISOString().slice(0, 10) + const { data: usage } = await serviceRoleClient + .from('daily_usage') + .select('count') + .eq('user_id', userId) + .eq('date', today) + .eq('feature', feature) + .maybeSingle() + + const current = (usage?.count as number) ?? 0 + return { + allowed: current < limit, + current, + limit, + tier + } +} + +/** + * 쿼터 소비. increment_daily_usage 함수 호출 (service_role 전용). + */ +export async function consumeQuota( + userId: string, + feature: Feature, + serviceRoleClient: ReturnType, + amount: number = 1 +): Promise { + const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', { + p_user_id: userId, + p_feature: feature, + p_amount: amount + }) + + if (error) { + throw new Error(`Failed to increment quota: ${error.message}`) + } + + return (data as number) ?? 0 +} + +/** + * service role 클라이언트 생성 헬퍼 + */ +export function createServiceRoleClient(): ReturnType { + // @ts-expect-error — Deno.env는 Deno 런타임 전역 + const url = Deno.env.get('SUPABASE_URL') ?? '' + // @ts-expect-error — Deno.env는 Deno 런타임 전역 + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + return createClient(url, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false } + }) +} diff --git a/server/supabase/functions/deno.json b/server/supabase/functions/deno.json new file mode 100644 index 0000000..44baa82 --- /dev/null +++ b/server/supabase/functions/deno.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true, + "lib": ["deno.window", "deno.unstable"] + }, + "imports": { + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.7" + } +} diff --git a/server/supabase/functions/llm-proxy/index.ts b/server/supabase/functions/llm-proxy/index.ts new file mode 100644 index 0000000..053d57a --- /dev/null +++ b/server/supabase/functions/llm-proxy/index.ts @@ -0,0 +1,138 @@ +// server/supabase/functions/llm-proxy/index.ts +// Anthropic Claude Messages API 프록시. +// 요청: application/json { messages, system?, max_tokens?, model? } +// 응답: JSON (non-stream) 또는 SSE (stream=true) + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { checkQuota, consumeQuota, createServiceRoleClient, type Tier } from '../_shared/quota.ts' + +interface LlmRequest { + messages: Array<{ role: 'user' | 'assistant'; content: string }> + system?: string + max_tokens?: number + model?: string + stream?: boolean +} + +/** 티어별 허용 모델 */ +const TIER_MODELS: Record = { + free: ['claude-haiku-4-5-20251001'], + pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'], + team: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'] +} + +const DEFAULT_MODEL: Record = { + free: 'claude-haiku-4-5-20251001', + pro: 'claude-sonnet-4-6', + team: 'claude-sonnet-4-6' +} + +// @ts-expect-error — Deno 런타임 전역 +Deno.serve(async (req: Request) => { + const preflight = handleCorsPreflightRequest(req) + if (preflight) return preflight + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + try { + const user = await requireUser(req) + const serviceClient = createServiceRoleClient() + const quota = await checkQuota(user.id, 'llm_process', serviceClient) + if (!quota.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + current: quota.current, + limit: quota.limit, + tier: quota.tier + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + const body = (await req.json()) as LlmRequest + + // 모델 선택 + 티어 검증 + const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier] + if (!TIER_MODELS[quota.tier].includes(requestedModel)) { + return new Response( + JSON.stringify({ + error: 'model_not_allowed', + tier: quota.tier, + requested: requestedModel, + allowed: TIER_MODELS[quota.tier] + }), + { + status: 403, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + // Anthropic API 호출 — placeholder + // + // 실제 구현 시: + // const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')! + // const resp = await fetch('https://api.anthropic.com/v1/messages', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // 'x-api-key': anthropicKey, + // 'anthropic-version': '2023-06-01' + // }, + // body: JSON.stringify({ + // model: requestedModel, + // max_tokens: body.max_tokens ?? 2048, + // system: body.system, + // messages: body.messages, + // stream: body.stream ?? false + // }) + // }) + // if (body.stream) { + // return new Response(resp.body, { + // headers: { ...corsHeaders, 'Content-Type': 'text/event-stream' } + // }) + // } + // const data = await resp.json() + // ... + + await consumeQuota(user.id, 'llm_process', serviceClient, 1) + + const placeholder = { + id: `msg_placeholder_${Date.now()}`, + model: requestedModel, + role: 'assistant', + content: [ + { + type: 'text', + text: '[llm-proxy placeholder — Anthropic API not yet wired]' + } + ], + stop_reason: 'end_turn', + usage: { input_tokens: 0, output_tokens: 0 } + } + + return new Response(JSON.stringify(placeholder), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/functions/stt-proxy/index.ts b/server/supabase/functions/stt-proxy/index.ts new file mode 100644 index 0000000..795e8eb --- /dev/null +++ b/server/supabase/functions/stt-proxy/index.ts @@ -0,0 +1,116 @@ +// server/supabase/functions/stt-proxy/index.ts +// Google Cloud Speech-to-Text 프록시. +// 요청: multipart/form-data (audio + sample_rate + language_code) +// 응답: { transcript, confidence, language_code, duration_seconds } + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { checkQuota, consumeQuota, createServiceRoleClient } from '../_shared/quota.ts' + +interface SttResult { + transcript: string + confidence: number + language_code: string + duration_seconds: number +} + +// @ts-expect-error — Deno 런타임 전역 +Deno.serve(async (req: Request) => { + const preflight = handleCorsPreflightRequest(req) + if (preflight) return preflight + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + try { + // 1) 인증 + const user = await requireUser(req) + + // 2) 쿼터 체크 + const serviceClient = createServiceRoleClient() + const quota = await checkQuota(user.id, 'stt_transcribe', serviceClient) + if (!quota.allowed) { + return new Response( + JSON.stringify({ + error: 'quota_exceeded', + current: quota.current, + limit: quota.limit, + tier: quota.tier + }), + { + status: 429, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + // 3) 입력 파싱 + const formData = await req.formData() + const audio = formData.get('audio') + const sampleRate = Number(formData.get('sample_rate') ?? 16000) + const languageCode = String(formData.get('language_code') ?? 'ko-KR') + + if (!(audio instanceof Blob)) { + return new Response(JSON.stringify({ error: 'Missing audio field' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } + + // 4) Google Cloud STT 호출 — placeholder + // + // 실제 구현 시: + // const audioBytes = new Uint8Array(await audio.arrayBuffer()) + // const base64Audio = btoa(String.fromCharCode(...audioBytes)) + // const gcpKey = Deno.env.get('GOOGLE_CLOUD_STT_KEY')! + // const response = await fetch( + // `https://speech.googleapis.com/v1/speech:recognize?key=${gcpKey}`, + // { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // config: { + // encoding: 'LINEAR16', + // sampleRateHertz: sampleRate, + // languageCode, + // enableAutomaticPunctuation: true + // }, + // audio: { content: base64Audio } + // }) + // } + // ) + // const data = await response.json() + // const transcript = data.results?.[0]?.alternatives?.[0]?.transcript ?? '' + // const confidence = data.results?.[0]?.alternatives?.[0]?.confidence ?? 0 + // + // 스캐폴딩 단계에서는 placeholder 응답. + + const placeholder: SttResult = { + transcript: '[stt-proxy placeholder — Google Cloud STT not yet wired]', + confidence: 0, + language_code: languageCode, + duration_seconds: (audio.size / sampleRate / 2) // 16-bit mono 가정 + } + + // 5) 쿼터 소비 + await consumeQuota(user.id, 'stt_transcribe', serviceClient, 1) + + return new Response(JSON.stringify(placeholder), { + status: 200, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/migrations/20260409000001_initial_schema.sql b/server/supabase/migrations/20260409000001_initial_schema.sql new file mode 100644 index 0000000..f43bf36 --- /dev/null +++ b/server/supabase/migrations/20260409000001_initial_schema.sql @@ -0,0 +1,237 @@ +-- ============================================================================ +-- Phase V2-2: 초기 스키마 마이그레이션 +-- V1 SQLite schema를 PostgreSQL로 포팅 + 멀티테넌시(user_id/team_id) 추가 +-- ============================================================================ + +-- Extensions ------------------------------------------------------------------ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================================ +-- profiles: auth.users 확장 (1:1) +-- ============================================================================ +CREATE TABLE public.profiles ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + name text, + avatar_url text, + locale text NOT NULL DEFAULT 'ko', + tier text NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro', 'team')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.profiles IS 'auth.users 확장 — 유저 메타데이터와 구독 티어'; + +-- ============================================================================ +-- teams / team_members: 팀 협업 +-- ============================================================================ +CREATE TABLE public.teams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + avatar_url text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_teams_owner_id ON public.teams(owner_id); + +CREATE TABLE public.team_members ( + team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')) DEFAULT 'member', + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (team_id, user_id) +); + +CREATE INDEX idx_team_members_user_id ON public.team_members(user_id); + +-- ============================================================================ +-- meetings: 회의 세션 (V1 meeting_sessions) +-- ============================================================================ +CREATE TABLE public.meetings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + team_id uuid REFERENCES public.teams(id) ON DELETE SET NULL, + title text, + status text NOT NULL CHECK (status IN ('recording', 'processing', 'completed', 'error')) DEFAULT 'recording', + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + duration_ms bigint, + raw_transcript text, + edited_transcript text, + minutes_markdown text, + minutes_json jsonb, + stt_model text, + llm_model text, + stt_latency_ms integer, + llm_latency_ms integer, + error_message text, + audio_storage_key text, -- storage.audio 버킷 내 경로 + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_meetings_user_id ON public.meetings(user_id); +CREATE INDEX idx_meetings_team_id ON public.meetings(team_id); +CREATE INDEX idx_meetings_status ON public.meetings(status); +CREATE INDEX idx_meetings_started_at ON public.meetings(started_at DESC); + +-- ============================================================================ +-- meeting_memos: 회의 중 타임스탬프 메모 +-- ============================================================================ +CREATE TABLE public.meeting_memos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content text NOT NULL, + timestamp_ms bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_meeting_memos_meeting_id ON public.meeting_memos(meeting_id); +CREATE INDEX idx_meeting_memos_timestamp ON public.meeting_memos(meeting_id, timestamp_ms); + +-- ============================================================================ +-- meeting_documents: LLM 생성 문서 (minutes/report/mindmap/custom) +-- ============================================================================ +CREATE TABLE public.meeting_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + template_type text NOT NULL CHECK (template_type IN ('minutes', 'report', 'idea-note', 'custom', 'mindmap')), + title text NOT NULL, + content text NOT NULL DEFAULT '', + prompt_used text, + llm_model text, + llm_latency_ms integer, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_meeting_documents_meeting_id ON public.meeting_documents(meeting_id); + +-- ============================================================================ +-- transcripts: 전사 세그먼트 (실시간 동기화 대상) +-- ============================================================================ +CREATE TABLE public.transcripts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, + segment_index integer NOT NULL, + timestamp_ms bigint NOT NULL, + duration_ms integer, + text text NOT NULL, + speaker text, -- "화자 1", "화자 2" ... (Phase 15.5) + edited boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_transcripts_meeting_id ON public.transcripts(meeting_id); +CREATE UNIQUE INDEX idx_transcripts_segment ON public.transcripts(meeting_id, segment_index); + +-- Realtime publication에 추가 (회의 중 실시간 동기화) +ALTER PUBLICATION supabase_realtime ADD TABLE public.transcripts; + +-- ============================================================================ +-- history: 음성 입력 이력 (V1 history — dictation/translate 등) +-- ============================================================================ +CREATE TABLE public.history ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title text, + original_text text NOT NULL, + polished_text text, + focused_app text, + focused_app_name text, + focused_app_window_title text, + mode text NOT NULL CHECK (mode IN ('dictation', 'translate', 'command', 'caption', 'file-transcription')) DEFAULT 'dictation', + status text NOT NULL CHECK (status IN ('completed', 'cancelled', 'error')) DEFAULT 'completed', + error_code text, + audio_storage_key text, + duration double precision NOT NULL, + detected_language text, + mic_device text, + word_count integer NOT NULL DEFAULT 0, + stt_model text, + llm_model text, + stt_latency_ms integer, + llm_latency_ms integer, + app_version text NOT NULL DEFAULT '1.0.0', + summary_text text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_history_user_id ON public.history(user_id); +CREATE INDEX idx_history_created_at ON public.history(user_id, created_at DESC); +CREATE INDEX idx_history_status ON public.history(user_id, status); +CREATE INDEX idx_history_mode ON public.history(user_id, mode); +CREATE INDEX idx_history_detected_language ON public.history(detected_language); + +-- ============================================================================ +-- dictionary: 사용자 사전 +-- ============================================================================ +CREATE TABLE public.dictionary ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + word text NOT NULL, + pronunciation text, + category text NOT NULL CHECK (category IN ('user', 'auto', 'technical')) DEFAULT 'user', + usage_count integer NOT NULL DEFAULT 0, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, word, category) +); + +CREATE INDEX idx_dictionary_user_id ON public.dictionary(user_id); +CREATE INDEX idx_dictionary_usage_count ON public.dictionary(user_id, usage_count DESC); + +-- ============================================================================ +-- memo_tags: history 태그 +-- ============================================================================ +CREATE TABLE public.memo_tags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + history_id uuid NOT NULL REFERENCES public.history(id) ON DELETE CASCADE, + tag text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (history_id, tag) +); + +CREATE INDEX idx_memo_tags_user_id ON public.memo_tags(user_id); +CREATE INDEX idx_memo_tags_tag ON public.memo_tags(user_id, tag); + +-- ============================================================================ +-- daily_usage: 기능별 일일 사용량 (쿼터 집계) +-- ============================================================================ +CREATE TABLE public.daily_usage ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + date date NOT NULL, + feature text NOT NULL, + count integer NOT NULL DEFAULT 0, + UNIQUE (user_id, date, feature) +); + +CREATE INDEX idx_daily_usage_user_date ON public.daily_usage(user_id, date); + +-- ============================================================================ +-- subscriptions: Stripe 구독 연동 +-- ============================================================================ +CREATE TABLE public.subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + tier text NOT NULL CHECK (tier IN ('free', 'pro', 'team')) DEFAULT 'free', + stripe_customer_id text, + stripe_subscription_id text, + status text, -- active / canceled / past_due ... + current_period_start timestamptz, + current_period_end timestamptz, + cancel_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_subscriptions_stripe_customer ON public.subscriptions(stripe_customer_id); diff --git a/server/supabase/migrations/20260409000002_rls_policies.sql b/server/supabase/migrations/20260409000002_rls_policies.sql new file mode 100644 index 0000000..db86e89 --- /dev/null +++ b/server/supabase/migrations/20260409000002_rls_policies.sql @@ -0,0 +1,205 @@ +-- ============================================================================ +-- Phase V2-2: Row Level Security 정책 +-- 패턴: +-- - 개인 전용 테이블: user_id = auth.uid() +-- - 팀 공유 테이블: 본인 소유 OR 소속 팀의 리소스 +-- ============================================================================ + +-- RLS 활성화 ------------------------------------------------------------------ +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.meetings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.meeting_memos ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.meeting_documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.transcripts ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.history ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.dictionary ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.memo_tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.daily_usage ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; + +-- ============================================================================ +-- profiles: 본인 프로필만 읽기/수정 +-- ============================================================================ +CREATE POLICY "profiles_read_own" ON public.profiles + FOR SELECT USING (id = auth.uid()); + +CREATE POLICY "profiles_update_own" ON public.profiles + FOR UPDATE USING (id = auth.uid()) WITH CHECK (id = auth.uid()); + +-- ============================================================================ +-- teams: 본인 소유 OR 멤버인 팀만 조회, 본인만 생성/수정/삭제 +-- ============================================================================ +CREATE POLICY "teams_read_member" ON public.teams + FOR SELECT USING ( + owner_id = auth.uid() + OR id IN (SELECT team_id FROM public.team_members WHERE user_id = auth.uid()) + ); + +CREATE POLICY "teams_insert_own" ON public.teams + FOR INSERT WITH CHECK (owner_id = auth.uid()); + +CREATE POLICY "teams_update_owner" ON public.teams + FOR UPDATE USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid()); + +CREATE POLICY "teams_delete_owner" ON public.teams + FOR DELETE USING (owner_id = auth.uid()); + +-- ============================================================================ +-- team_members: 같은 팀 멤버는 서로 조회 가능, 관리는 owner/admin +-- ============================================================================ +CREATE POLICY "team_members_read_same_team" ON public.team_members + FOR SELECT USING ( + team_id IN (SELECT team_id FROM public.team_members WHERE user_id = auth.uid()) + ); + +CREATE POLICY "team_members_insert_admin" ON public.team_members + FOR INSERT WITH CHECK ( + team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +CREATE POLICY "team_members_update_admin" ON public.team_members + FOR UPDATE USING ( + team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +CREATE POLICY "team_members_delete_admin_or_self" ON public.team_members + FOR DELETE USING ( + user_id = auth.uid() -- 본인 탈퇴 + OR team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +-- ============================================================================ +-- meetings: 본인 또는 소속 팀의 회의 +-- ============================================================================ +CREATE POLICY "meetings_read" ON public.meetings + FOR SELECT USING ( + user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN ( + SELECT team_id FROM public.team_members WHERE user_id = auth.uid() + )) + ); + +CREATE POLICY "meetings_insert" ON public.meetings + FOR INSERT WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "meetings_update" ON public.meetings + FOR UPDATE USING ( + user_id = auth.uid() + OR (team_id IS NOT NULL AND team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + )) + ); + +CREATE POLICY "meetings_delete" ON public.meetings + FOR DELETE USING (user_id = auth.uid()); + +-- ============================================================================ +-- meeting_memos: 회의와 동일한 권한 (meeting RLS 경유) +-- ============================================================================ +CREATE POLICY "meeting_memos_read" ON public.meeting_memos + FOR SELECT USING ( + meeting_id IN (SELECT id FROM public.meetings) + ); + +CREATE POLICY "meeting_memos_insert" ON public.meeting_memos + FOR INSERT WITH CHECK ( + user_id = auth.uid() + AND meeting_id IN (SELECT id FROM public.meetings WHERE user_id = auth.uid() OR team_id IN ( + SELECT team_id FROM public.team_members WHERE user_id = auth.uid() + )) + ); + +CREATE POLICY "meeting_memos_update_own" ON public.meeting_memos + FOR UPDATE USING (user_id = auth.uid()); + +CREATE POLICY "meeting_memos_delete_own" ON public.meeting_memos + FOR DELETE USING (user_id = auth.uid()); + +-- ============================================================================ +-- meeting_documents: 동일 패턴 +-- ============================================================================ +CREATE POLICY "meeting_documents_read" ON public.meeting_documents + FOR SELECT USING ( + meeting_id IN (SELECT id FROM public.meetings) + ); + +CREATE POLICY "meeting_documents_insert" ON public.meeting_documents + FOR INSERT WITH CHECK ( + user_id = auth.uid() + AND meeting_id IN (SELECT id FROM public.meetings WHERE user_id = auth.uid() OR team_id IN ( + SELECT team_id FROM public.team_members WHERE user_id = auth.uid() + )) + ); + +CREATE POLICY "meeting_documents_update" ON public.meeting_documents + FOR UPDATE USING ( + user_id = auth.uid() + OR meeting_id IN ( + SELECT id FROM public.meetings WHERE team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ) + ); + +CREATE POLICY "meeting_documents_delete_own" ON public.meeting_documents + FOR DELETE USING (user_id = auth.uid()); + +-- ============================================================================ +-- transcripts: 회의 RLS 경유 +-- ============================================================================ +CREATE POLICY "transcripts_read" ON public.transcripts + FOR SELECT USING ( + meeting_id IN (SELECT id FROM public.meetings) + ); + +CREATE POLICY "transcripts_insert" ON public.transcripts + FOR INSERT WITH CHECK ( + meeting_id IN (SELECT id FROM public.meetings WHERE user_id = auth.uid()) + ); + +CREATE POLICY "transcripts_update" ON public.transcripts + FOR UPDATE USING ( + meeting_id IN (SELECT id FROM public.meetings WHERE user_id = auth.uid()) + ); + +CREATE POLICY "transcripts_delete" ON public.transcripts + FOR DELETE USING ( + meeting_id IN (SELECT id FROM public.meetings WHERE user_id = auth.uid()) + ); + +-- ============================================================================ +-- history, dictionary, memo_tags, daily_usage: 본인 전용 +-- ============================================================================ +CREATE POLICY "history_own" ON public.history + FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "dictionary_own" ON public.dictionary + FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "memo_tags_own" ON public.memo_tags + FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "daily_usage_read_own" ON public.daily_usage + FOR SELECT USING (user_id = auth.uid()); + +-- daily_usage INSERT/UPDATE는 Edge Function(service role)만 수행 +-- 일반 유저는 읽기만 가능 + +-- ============================================================================ +-- subscriptions: 본인 읽기만. Stripe webhook이 service role로 쓰기 +-- ============================================================================ +CREATE POLICY "subscriptions_read_own" ON public.subscriptions + FOR SELECT USING (user_id = auth.uid()); diff --git a/server/supabase/migrations/20260409000003_auth_triggers.sql b/server/supabase/migrations/20260409000003_auth_triggers.sql new file mode 100644 index 0000000..2f2e6ec --- /dev/null +++ b/server/supabase/migrations/20260409000003_auth_triggers.sql @@ -0,0 +1,120 @@ +-- ============================================================================ +-- Phase V2-2: Auth 트리거 + updated_at 자동 갱신 +-- ============================================================================ + +-- ============================================================================ +-- handle_new_user: auth.users → public.profiles + public.subscriptions 생성 +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.profiles (id, name, avatar_url, locale) + VALUES ( + NEW.id, + COALESCE( + NEW.raw_user_meta_data->>'full_name', + NEW.raw_user_meta_data->>'name', + split_part(NEW.email, '@', 1) + ), + NEW.raw_user_meta_data->>'avatar_url', + COALESCE(NEW.raw_user_meta_data->>'locale', 'ko') + ); + + INSERT INTO public.subscriptions (user_id, tier, status) + VALUES (NEW.id, 'free', 'active'); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ============================================================================ +-- handle_user_deleted: auth.users 삭제 시 cleanup +-- (FK CASCADE로 자동이지만 추가 정리 지점으로 남겨둠) +-- ============================================================================ +-- 현재는 FK CASCADE에 의존, 필요 시 확장. + +-- ============================================================================ +-- moddatetime: updated_at 자동 갱신 트리거 함수 +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.moddatetime() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- 각 테이블에 트리거 부착 ---------------------------------------------------- +CREATE TRIGGER set_updated_at_profiles + BEFORE UPDATE ON public.profiles + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_teams + BEFORE UPDATE ON public.teams + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_meetings + BEFORE UPDATE ON public.meetings + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_meeting_documents + BEFORE UPDATE ON public.meeting_documents + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_transcripts + BEFORE UPDATE ON public.transcripts + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_history + BEFORE UPDATE ON public.history + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_dictionary + BEFORE UPDATE ON public.dictionary + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +CREATE TRIGGER set_updated_at_subscriptions + BEFORE UPDATE ON public.subscriptions + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); + +-- ============================================================================ +-- increment_daily_usage: Edge Function에서 호출하는 쿼터 증가 헬퍼 +-- service_role 전용 (SECURITY DEFINER) +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.increment_daily_usage( + p_user_id uuid, + p_feature text, + p_amount integer DEFAULT 1 +) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_new_count integer; +BEGIN + INSERT INTO public.daily_usage (user_id, date, feature, count) + VALUES (p_user_id, CURRENT_DATE, p_feature, p_amount) + ON CONFLICT (user_id, date, feature) + DO UPDATE SET count = public.daily_usage.count + p_amount + RETURNING count INTO v_new_count; + + RETURN v_new_count; +END; +$$; + +-- 일반 유저는 호출 불가, service_role만 허용 +REVOKE ALL ON FUNCTION public.increment_daily_usage FROM PUBLIC; +REVOKE ALL ON FUNCTION public.increment_daily_usage FROM authenticated; +GRANT EXECUTE ON FUNCTION public.increment_daily_usage TO service_role; diff --git a/server/supabase/migrations/20260409000004_storage_buckets.sql b/server/supabase/migrations/20260409000004_storage_buckets.sql new file mode 100644 index 0000000..e5bc7be --- /dev/null +++ b/server/supabase/migrations/20260409000004_storage_buckets.sql @@ -0,0 +1,83 @@ +-- ============================================================================ +-- Phase V2-2: Storage 버킷 + 접근 정책 +-- ============================================================================ + +-- 버킷 생성 ------------------------------------------------------------------ +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES + ('audio', 'audio', false, 524288000, ARRAY['audio/wav', 'audio/webm', 'audio/mpeg', 'audio/mp4', 'audio/ogg']), + ('exports', 'exports', false, 52428800, ARRAY['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/markdown', 'text/html']), + ('avatars', 'avatars', true, 5242880, ARRAY['image/jpeg', 'image/png', 'image/webp']) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================================ +-- audio 버킷: 본인 경로 ({user_id}/...) 에서만 읽기/쓰기 +-- ============================================================================ +CREATE POLICY "audio_read_own" ON storage.objects + FOR SELECT USING ( + bucket_id = 'audio' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "audio_insert_own" ON storage.objects + FOR INSERT WITH CHECK ( + bucket_id = 'audio' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "audio_update_own" ON storage.objects + FOR UPDATE USING ( + bucket_id = 'audio' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "audio_delete_own" ON storage.objects + FOR DELETE USING ( + bucket_id = 'audio' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +-- ============================================================================ +-- exports 버킷: 동일 패턴 +-- ============================================================================ +CREATE POLICY "exports_read_own" ON storage.objects + FOR SELECT USING ( + bucket_id = 'exports' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "exports_insert_own" ON storage.objects + FOR INSERT WITH CHECK ( + bucket_id = 'exports' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "exports_delete_own" ON storage.objects + FOR DELETE USING ( + bucket_id = 'exports' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +-- ============================================================================ +-- avatars 버킷: 공개 읽기, 본인만 쓰기 +-- ============================================================================ +CREATE POLICY "avatars_read_public" ON storage.objects + FOR SELECT USING (bucket_id = 'avatars'); + +CREATE POLICY "avatars_insert_own" ON storage.objects + FOR INSERT WITH CHECK ( + bucket_id = 'avatars' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "avatars_update_own" ON storage.objects + FOR UPDATE USING ( + bucket_id = 'avatars' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "avatars_delete_own" ON storage.objects + FOR DELETE USING ( + bucket_id = 'avatars' + AND auth.uid()::text = (storage.foldername(name))[1] + ); diff --git a/server/supabase/seed.sql b/server/supabase/seed.sql new file mode 100644 index 0000000..4aae0af --- /dev/null +++ b/server/supabase/seed.sql @@ -0,0 +1,10 @@ +-- ============================================================================ +-- Phase V2-2: 개발 시드 데이터 +-- 로컬 supabase 실행 시 `supabase db reset`으로 자동 적용. +-- 프로덕션 배포에는 사용되지 않음. +-- ============================================================================ + +-- 테스트 유저는 supabase/cli로 생성하거나 studio에서 수동 생성. +-- 여기서는 테이블 구조 확인용 더미 레퍼런스만. + +-- (향후 개발 데이터 추가 예정)