feat(V2-2): Supabase 인프라 — 스키마 + RLS + Edge Functions 스캐폴딩
server/supabase/ 신규 디렉토리:
config.toml — Supabase CLI 설정
- 프로젝트 ID, DB 포트, auth providers (Google/GitHub/Apple),
edge_runtime, functions.*.verify_jwt 설정
migrations/ (PostgreSQL DDL, 시간순):
- 20260409000001_initial_schema.sql
12개 테이블: profiles, teams, team_members, meetings,
meeting_memos, meeting_documents, transcripts, history,
dictionary, memo_tags, daily_usage, subscriptions
+ 인덱스 + FK CASCADE + transcripts Realtime publication
- 20260409000002_rls_policies.sql
개인 전용(history/dictionary): user_id = auth.uid()
팀 공유(meetings 등): 본인 OR team_members 조회 subquery
daily_usage: 읽기만, 쓰기는 service_role RPC
- 20260409000003_auth_triggers.sql
handle_new_user — auth.users INSERT → profiles+subscriptions 자동 생성
moddatetime — 8개 테이블 updated_at 자동 갱신
increment_daily_usage — service_role 전용 쿼터 RPC
- 20260409000004_storage_buckets.sql
audio/exports/avatars 3개 버킷 + 경로 기반 접근 정책
(파일 경로가 {user_id}/...로 시작해야 쓰기 허용)
functions/ (Deno/TypeScript Edge Functions):
- _shared/cors.ts — CORS 헤더 + preflight 핸들러
- _shared/auth.ts — requireUser (JWT 검증 + User 반환)
- _shared/quota.ts — 티어별 쿼터 체크 + consume + service role client
- stt-proxy/index.ts — Google Cloud STT 래퍼 스캐폴딩
placeholder 응답, 실제 API 호출 코드는 주석으로 포함
- llm-proxy/index.ts — Anthropic Messages API 래퍼 스캐폴딩
티어별 허용 모델 정책 (free=Haiku, pro=Sonnet, team=Opus)
설계 문서:
- docs/v2/phase-V2-2.md — 상세 설계 (스키마/RLS/Edge Functions/Realtime)
- docs/v2/phase-V2-2-setup.md — 사용자 액션 가이드 (Supabase 계정/
OAuth 등록/CLI/배포/검증)
apps/desktop typecheck 통과 (server/는 Deno 런타임이라 별도).
실제 Supabase 프로젝트 배포는 사용자가 phase-V2-2-setup.md 따라 수행.
This commit is contained in:
parent
3524e958ba
commit
97eb886ec3
16 changed files with 1675 additions and 8 deletions
184
docs/v2/phase-V2-2-setup.md
Normal file
184
docs/v2/phase-V2-2-setup.md
Normal file
|
|
@ -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 <YOUR_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://<YOUR_PROJECT_REF>.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://<YOUR_PROJECT_REF>.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=<google_api_key>
|
||||
supabase secrets set ANTHROPIC_API_KEY=<anthropic_api_key>
|
||||
|
||||
# 함수 배포
|
||||
supabase functions deploy stt-proxy
|
||||
supabase functions deploy llm-proxy
|
||||
```
|
||||
|
||||
배포 후 호출 URL:
|
||||
- `https://<YOUR_PROJECT_REF>.supabase.co/functions/v1/stt-proxy`
|
||||
- `https://<YOUR_PROJECT_REF>.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://<YOUR_PROJECT_REF>.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=<anon_public_key>
|
||||
|
||||
# apps/web (Phase V2-3에서 사용)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<YOUR_PROJECT_REF>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon_public_key>
|
||||
|
||||
# Edge Functions 로컬 개발용 (server/supabase/.env)
|
||||
GOOGLE_CLOUD_STT_KEY=<google_api_key>
|
||||
ANTHROPIC_API_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://<YOUR_PROJECT_REF>.supabase.co/functions/v1/llm-proxy \
|
||||
-H "Authorization: Bearer <user_jwt>" \
|
||||
-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`로 런타임 에러 확인. 대부분 시크릿 미설정
|
||||
263
docs/v2/phase-V2-2.md
Normal file
263
docs/v2/phase-V2-2.md
Normal file
|
|
@ -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<User> {
|
||||
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
|
||||
|
|
@ -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 <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, 팀 기능, 결제/출시
|
||||
|
||||
## 알려진 차단/이슈
|
||||
|
||||
|
|
|
|||
5
server/supabase/.gitignore
vendored
Normal file
5
server/supabase/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Supabase 로컬 실행 시 생성되는 파일
|
||||
.branches/
|
||||
.temp/
|
||||
.env
|
||||
.env.*
|
||||
88
server/supabase/config.toml
Normal file
88
server/supabase/config.toml
Normal file
|
|
@ -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
|
||||
49
server/supabase/functions/_shared/auth.ts
Normal file
49
server/supabase/functions/_shared/auth.ts
Normal file
|
|
@ -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<string, string>): 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<User> {
|
||||
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
|
||||
}
|
||||
16
server/supabase/functions/_shared/cors.ts
Normal file
16
server/supabase/functions/_shared/cors.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// server/supabase/functions/_shared/cors.ts
|
||||
// Edge Function 공통 CORS 헤더
|
||||
|
||||
export const corsHeaders: Record<string, string> = {
|
||||
'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
|
||||
}
|
||||
108
server/supabase/functions/_shared/quota.ts
Normal file
108
server/supabase/functions/_shared/quota.ts
Normal file
|
|
@ -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<Tier, Record<Feature, number>> = {
|
||||
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<typeof createClient>
|
||||
): Promise<QuotaCheck> {
|
||||
// 티어 조회
|
||||
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<typeof createClient>,
|
||||
amount: number = 1
|
||||
): Promise<number> {
|
||||
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<typeof createClient> {
|
||||
// @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 }
|
||||
})
|
||||
}
|
||||
10
server/supabase/functions/deno.json
Normal file
10
server/supabase/functions/deno.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
138
server/supabase/functions/llm-proxy/index.ts
Normal file
138
server/supabase/functions/llm-proxy/index.ts
Normal file
|
|
@ -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<Tier, string[]> = {
|
||||
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<Tier, string> = {
|
||||
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' }
|
||||
})
|
||||
}
|
||||
})
|
||||
116
server/supabase/functions/stt-proxy/index.ts
Normal file
116
server/supabase/functions/stt-proxy/index.ts
Normal file
|
|
@ -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' }
|
||||
})
|
||||
}
|
||||
})
|
||||
237
server/supabase/migrations/20260409000001_initial_schema.sql
Normal file
237
server/supabase/migrations/20260409000001_initial_schema.sql
Normal file
|
|
@ -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);
|
||||
205
server/supabase/migrations/20260409000002_rls_policies.sql
Normal file
205
server/supabase/migrations/20260409000002_rls_policies.sql
Normal file
|
|
@ -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());
|
||||
120
server/supabase/migrations/20260409000003_auth_triggers.sql
Normal file
120
server/supabase/migrations/20260409000003_auth_triggers.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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]
|
||||
);
|
||||
10
server/supabase/seed.sql
Normal file
10
server/supabase/seed.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- ============================================================================
|
||||
-- Phase V2-2: 개발 시드 데이터
|
||||
-- 로컬 supabase 실행 시 `supabase db reset`으로 자동 적용.
|
||||
-- 프로덕션 배포에는 사용되지 않음.
|
||||
-- ============================================================================
|
||||
|
||||
-- 테스트 유저는 supabase/cli로 생성하거나 studio에서 수동 생성.
|
||||
-- 여기서는 테이블 구조 확인용 더미 레퍼런스만.
|
||||
|
||||
-- (향후 개발 데이터 추가 예정)
|
||||
Loading…
Add table
Add a link
Reference in a new issue