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:
yunchan8804 2026-04-08 15:21:08 +09:00
parent 3524e958ba
commit 97eb886ec3
16 changed files with 1675 additions and 8 deletions

5
server/supabase/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Supabase 로컬 실행 시 생성되는 파일
.branches/
.temp/
.env
.env.*

View 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

View 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
}

View 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
}

View 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 }
})
}

View 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"
}
}

View 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' }
})
}
})

View 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' }
})
}
})

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

View 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());

View 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;

View file

@ -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
View file

@ -0,0 +1,10 @@
-- ============================================================================
-- Phase V2-2: 개발 시드 데이터
-- 로컬 supabase 실행 시 `supabase db reset`으로 자동 적용.
-- 프로덕션 배포에는 사용되지 않음.
-- ============================================================================
-- 테스트 유저는 supabase/cli로 생성하거나 studio에서 수동 생성.
-- 여기서는 테이블 구조 확인용 더미 레퍼런스만.
-- (향후 개발 데이터 추가 예정)