d3ro-voice/server/supabase/migrations/20260409000001_initial_schema.sql
yunchan8804 97eb886ec3 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 따라 수행.
2026-04-08 15:21:08 +09:00

237 lines
11 KiB
SQL

-- ============================================================================
-- 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);