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
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]
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue