From c167737198c4c83b3accb678b04b26995605473b Mon Sep 17 00:00:00 2001 From: yunchan8804 Date: Fri, 10 Apr 2026 08:34:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(V2-3=EC=B0=A8):=20pull=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20+=20invite=20flow=20+=20Realtime=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20+=20mobile=20UI=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 묶음 E — V2-4b pull 확장: - CloudSyncService.pullAll()에 meetings/meeting_memos/meeting_documents 추가 - meetings: LWW, 모든 컬럼 매핑 (minutes_json JSON 직렬화) - meeting_memos: immutable INSERT-only 전략 - meeting_documents: LWW UPDATE 묶음 F — api-client types: - interface -> type alias 전환 (11개) - Database 타입에 TypedTable 유틸 도입 (Row & Record 교차로 GenericTable 제약 만족) - @supabase/ssr 2.102는 supabase-js 버전 불일치로 제네릭 주입 불가 - 다음 사이클로 이월, api-client index.ts는 types만 재수출 묶음 G — apps/mobile UI 교체: - login.tsx: MetalCard + PhosphorText(hero/label) + PhysicalButton - meetings.tsx: MetalCard + Led(status) + PhosphorText(body/meta) - record.tsx: Led + PhosphorText + PhysicalButton - profile.tsx: MetalCard + PhysicalButton(danger) + d3roNativePalette 묶음 H — V2-7b 이메일 invite flow: - migrations/20260410000001_team_invites.sql - team_invites 테이블 (token, email, role, expires_at 7일) - RLS: 같은 팀 멤버 + 초대 이메일 소유자 SELECT, owner/admin만 INSERT/DELETE - generate_invite_token() SECURITY DEFINER RPC (service_role) - functions/team-invite: 권한 체크 -> 토큰 생성 -> 초대 URL 반환 - functions/team-accept: 토큰 검증 -> expires_at/accepted_at/이메일 일치 -> team_members upsert -> 초대 accepted 표시 - config.toml에 team-invite/team-accept 함수 등록 - InviteMemberForm 재작성: 이메일 입력 + 역할 선택 -> URL 복사 UI - /accept-invite 페이지 신규 (Suspense 내 useSearchParams + token 수락) 묶음 I — Realtime transcripts: - apps/web/components/meetings/live-transcript-list.tsx (client) - supabase.channel('transcripts:meeting:${id}').on('postgres_changes') - INSERT -> 세그먼트 추가, UPDATE -> row 교체 - 중복 방지 segment_index 기준 - edited 배지 표시 - meetings/[id]/page.tsx의 transcript 섹션을 LiveTranscriptList로 교체 묶음 J — 테스트: - packages/api-client/__tests__/client.test.ts (9 tests) - createD3roSupabaseClient, isClientConfigured 팩토리 검증 - packages/api-client/__tests__/types.test.ts (10 tests) - 모든 Row 타입 + 리터럴 union + Database keyof - vitest.config.ts 신규 - apps/web/playwright.config.ts 신규 (baseURL, webServer dev 서버) - apps/web/e2e/smoke.spec.ts 신규 (6 스모크 테스트) - apps/web tsconfig exclude에 e2e/playwright.config.ts 추가 - package.json scripts: test, test:e2e, test:e2e:ui 검증: - desktop typecheck OK - web typecheck OK - web next build OK (12 라우트, /accept-invite Suspense 적용) - desktop build OK - api-client test 19 passed --- .../src/main/services/CloudSyncService.ts | 169 +++++++++++++++ apps/mobile/app/(tabs)/meetings.tsx | 65 ++++-- apps/mobile/app/(tabs)/profile.tsx | 71 +++---- apps/mobile/app/(tabs)/record.tsx | 146 ++++++++----- apps/mobile/app/login.tsx | 113 ++++------ apps/web/e2e/smoke.spec.ts | 45 ++++ apps/web/package.json | 4 +- apps/web/playwright.config.ts | 37 ++++ apps/web/src/app/(app)/meetings/[id]/page.tsx | 34 +-- apps/web/src/app/accept-invite/page.tsx | 183 +++++++++++++++++ .../meetings/live-transcript-list.tsx | 112 ++++++++++ .../components/teams/invite-member-form.tsx | 194 +++++++++++++----- apps/web/src/lib/supabase-browser.ts | 8 +- apps/web/src/lib/supabase-server.ts | 2 +- apps/web/tsconfig.json | 2 +- memory/project_status.md | 28 ++- packages/api-client/__tests__/client.test.ts | 64 ++++++ packages/api-client/__tests__/types.test.ts | 128 ++++++++++++ packages/api-client/package.json | 4 + packages/api-client/src/index.ts | 9 +- packages/api-client/src/types.ts | 174 +++++++--------- packages/api-client/vitest.config.ts | 10 + server/supabase/config.toml | 6 + .../supabase/functions/team-accept/index.ts | 116 +++++++++++ .../supabase/functions/team-invite/index.ts | 106 ++++++++++ .../20260410000001_team_invites.sql | 74 +++++++ 26 files changed, 1530 insertions(+), 374 deletions(-) create mode 100644 apps/web/e2e/smoke.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/src/app/accept-invite/page.tsx create mode 100644 apps/web/src/components/meetings/live-transcript-list.tsx create mode 100644 packages/api-client/__tests__/client.test.ts create mode 100644 packages/api-client/__tests__/types.test.ts create mode 100644 packages/api-client/vitest.config.ts create mode 100644 server/supabase/functions/team-accept/index.ts create mode 100644 server/supabase/functions/team-invite/index.ts create mode 100644 server/supabase/migrations/20260410000001_team_invites.sql diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index a4bb93a..f815c24 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -532,6 +532,175 @@ class CloudSyncService extends EventEmitter { return applied }, result) + // 3) meetings (V1 meeting_sessions) + result.pushed += await this._pullTable('meetings', async () => { + const { data: remoteRows, error } = await this._client!.from('meetings') + .select('*') + .gt('updated_at', sinceIso) + + if (error) throw new Error(error.message) + if (!remoteRows || remoteRows.length === 0) return 0 + + let applied = 0 + for (const remote of remoteRows as Array>) { + const remoteUpdated = remote.updated_at + ? new Date(remote.updated_at as string).getTime() + : 0 + const remoteStarted = remote.started_at + ? new Date(remote.started_at as string).getTime() + : remoteUpdated + const remoteEnded = remote.ended_at + ? new Date(remote.ended_at as string).getTime() + : null + const remoteCreated = remote.created_at + ? new Date(remote.created_at as string).getTime() + : remoteUpdated + + const existing = await db + .select() + .from(meetingSessions) + .where(eq(meetingSessions.id, remote.id as string)) + .limit(1) + + // V1 스키마는 minutesJson이 text (JSON 직렬화) + const minutesJsonStr = remote.minutes_json + ? JSON.stringify(remote.minutes_json) + : null + + const row = { + title: (remote.title as string | null) ?? null, + status: (remote.status as 'recording' | 'processing' | 'completed' | 'error'), + startedAt: remoteStarted, + endedAt: remoteEnded, + durationMs: (remote.duration_ms as number | null) ?? null, + rawTranscript: (remote.raw_transcript as string | null) ?? null, + editedTranscript: (remote.edited_transcript as string | null) ?? null, + minutesMarkdown: (remote.minutes_markdown as string | null) ?? null, + minutesJson: minutesJsonStr, + sttModel: (remote.stt_model as string | null) ?? null, + llmModel: (remote.llm_model as string | null) ?? null, + sttLatencyMs: (remote.stt_latency_ms as number | null) ?? null, + llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null, + errorMessage: (remote.error_message as string | null) ?? null, + updatedAt: remoteUpdated + } + + if (existing.length > 0) { + if (existing[0].updatedAt >= remoteUpdated) continue + await db + .update(meetingSessions) + .set(row) + .where(eq(meetingSessions.id, remote.id as string)) + applied++ + } else { + await db.insert(meetingSessions).values({ + id: remote.id as string, + ...row, + createdAt: remoteCreated + }) + applied++ + } + this.emit('sync-progress', { current: applied, total: remoteRows.length, table: 'meetings' }) + } + return applied + }, result) + + // 4) meeting_memos + result.pushed += await this._pullTable('meeting_memos', async () => { + const { data: remoteRows, error } = await this._client!.from('meeting_memos') + .select('*') + .gt('created_at', sinceIso) + + if (error) throw new Error(error.message) + if (!remoteRows || remoteRows.length === 0) return 0 + + let applied = 0 + for (const remote of remoteRows as Array>) { + const remoteCreated = remote.created_at + ? new Date(remote.created_at as string).getTime() + : Date.now() + + const existing = await db + .select() + .from(meetingMemos) + .where(eq(meetingMemos.id, remote.id as string)) + .limit(1) + + if (existing.length === 0) { + await db.insert(meetingMemos).values({ + id: remote.id as string, + sessionId: remote.meeting_id as string, + content: remote.content as string, + timestampMs: remote.timestamp_ms as number, + createdAt: remoteCreated + }) + applied++ + } + // memo는 immutable 전제 (수정 없음), INSERT-only + this.emit('sync-progress', { current: applied, total: remoteRows.length, table: 'meeting_memos' }) + } + return applied + }, result) + + // 5) meeting_documents + result.pushed += await this._pullTable('meeting_documents', async () => { + const { data: remoteRows, error } = await this._client!.from('meeting_documents') + .select('*') + .gt('updated_at', sinceIso) + + if (error) throw new Error(error.message) + if (!remoteRows || remoteRows.length === 0) return 0 + + let applied = 0 + for (const remote of remoteRows as Array>) { + const remoteUpdated = remote.updated_at + ? new Date(remote.updated_at as string).getTime() + : 0 + const remoteCreated = remote.created_at + ? new Date(remote.created_at as string).getTime() + : remoteUpdated + + const existing = await db + .select() + .from(meetingDocuments) + .where(eq(meetingDocuments.id, remote.id as string)) + .limit(1) + + const row = { + sessionId: remote.meeting_id as string, + templateType: (remote.template_type as 'minutes' | 'report' | 'idea-note' | 'custom' | 'mindmap'), + title: remote.title as string, + content: (remote.content as string) ?? '', + promptUsed: (remote.prompt_used as string | null) ?? null, + llmModel: (remote.llm_model as string | null) ?? null, + llmLatencyMs: (remote.llm_latency_ms as number | null) ?? null, + updatedAt: remoteUpdated + } + + if (existing.length > 0) { + if (existing[0].updatedAt >= remoteUpdated) continue + await db + .update(meetingDocuments) + .set(row) + .where(eq(meetingDocuments.id, remote.id as string)) + applied++ + } else { + await db.insert(meetingDocuments).values({ + id: remote.id as string, + ...row, + createdAt: remoteCreated + }) + applied++ + } + this.emit('sync-progress', { + current: applied, + total: remoteRows.length, + table: 'meeting_documents' + }) + } + return applied + }, result) + this._lastSyncAt = Date.now() logger.info(`Pull complete: applied=${result.pushed} errors=${result.errors.length}`) this.emit('sync-complete', result) diff --git a/apps/mobile/app/(tabs)/meetings.tsx b/apps/mobile/app/(tabs)/meetings.tsx index d18a0c1..70f0ea8 100644 --- a/apps/mobile/app/(tabs)/meetings.tsx +++ b/apps/mobile/app/(tabs)/meetings.tsx @@ -1,8 +1,9 @@ // apps/mobile/app/(tabs)/meetings.tsx -// 회의 리스트 — Supabase에서 fetch +// 회의 리스트 — @d3ro/ui-native 사용 import { useEffect, useState } from 'react' -import { View, Text, FlatList, Pressable, StyleSheet, ActivityIndicator, RefreshControl } from 'react-native' +import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Pressable } from 'react-native' +import { MetalCard, PhosphorText, Led, d3roNativePalette } from '@d3ro/ui-native' import { supabase } from '../../lib/supabase' interface Meeting { @@ -12,6 +13,13 @@ interface Meeting { status: string } +function statusLedColor(status: string): 'amber' | 'green' | 'red' | 'orange' { + if (status === 'recording') return 'red' + if (status === 'processing') return 'orange' + if (status === 'completed') return 'green' + return 'amber' +} + export default function MeetingsScreen(): React.ReactElement { const [meetings, setMeetings] = useState([]) const [loading, setLoading] = useState(true) @@ -41,7 +49,7 @@ export default function MeetingsScreen(): React.ReactElement { if (loading) { return ( - + ) } @@ -50,7 +58,8 @@ export default function MeetingsScreen(): React.ReactElement { item.id} - contentContainerStyle={meetings.length === 0 ? styles.center : { padding: 16 }} + contentContainerStyle={meetings.length === 0 ? styles.center : styles.listContent} + style={styles.list} refreshControl={ } ListEmptyComponent={ - 아직 회의가 없습니다. Record 탭에서 새 녹음을 시작하세요. + + 아직 회의가 없습니다. Record 탭에서 새 녹음을 시작하세요. + } renderItem={({ item }) => ( - - {item.title ?? '(제목 없음)'} - - {new Date(item.started_at).toLocaleString('ko-KR')} · {item.status} - + + + + + + {item.status.toUpperCase()} + + + + {item.title ?? '(제목 없음)'} + + + {new Date(item.started_at).toLocaleString('ko-KR')} + + )} /> @@ -77,16 +98,16 @@ export default function MeetingsScreen(): React.ReactElement { } const styles = StyleSheet.create({ - center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, - empty: { color: '#8e8e93', textAlign: 'center', fontSize: 13 }, - card: { - backgroundColor: '#242427', - padding: 16, - borderRadius: 12, - marginBottom: 12, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.04)' + list: { backgroundColor: d3roNativePalette.bg.app }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + backgroundColor: d3roNativePalette.bg.app }, - title: { color: '#ffffff', fontSize: 14, fontWeight: '500', marginBottom: 4 }, - meta: { color: '#8e8e93', fontSize: 11 } + listContent: { padding: 16 }, + empty: { textAlign: 'center' }, + card: { marginBottom: 12 }, + cardHeader: { flexDirection: 'row', alignItems: 'center' } }) diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 496807f..abfb47c 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,7 +1,8 @@ // apps/mobile/app/(tabs)/profile.tsx -// 프로필 — 사용자 정보 + 로그아웃 +// 프로필 — 사용자 정보 + 로그아웃. @d3ro/ui-native 사용 -import { View, Text, Pressable, StyleSheet, Alert } from 'react-native' +import { View, StyleSheet, Alert, ScrollView } from 'react-native' +import { MetalCard, PhosphorText, PhysicalButton, d3roNativePalette } from '@d3ro/ui-native' import { useAuth } from '../../lib/auth-context' import { supabase } from '../../lib/supabase' @@ -22,50 +23,36 @@ export default function ProfileScreen(): React.ReactElement { } return ( - - - 이메일 - {user?.email ?? '—'} - + + + + 이메일 + + + {user?.email ?? '—'} + + - - User ID - {user?.id ?? '—'} - + + + USER ID + + + {user?.id ?? '—'} + + - void handleLogout()}> - 로그아웃 - - + + void handleLogout()} /> + + ) } const styles = StyleSheet.create({ - container: { flex: 1, padding: 24 }, - section: { - backgroundColor: '#242427', - padding: 16, - borderRadius: 12, - marginBottom: 16, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.04)' - }, - label: { - color: '#8e8e93', - fontSize: 11, - fontWeight: '700', - letterSpacing: 1.5, - textTransform: 'uppercase', - marginBottom: 4 - }, - value: { color: '#ffffff', fontSize: 14 }, - logoutButton: { - marginTop: 16, - paddingVertical: 14, - borderRadius: 8, - borderWidth: 1, - borderColor: '#ef4444', - alignItems: 'center' - }, - logoutText: { color: '#ef4444', fontSize: 14, fontWeight: '600' } + container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, + content: { padding: 24 }, + section: { marginBottom: 16 }, + value: { marginTop: 6 }, + logoutWrap: { marginTop: 8 } }) diff --git a/apps/mobile/app/(tabs)/record.tsx b/apps/mobile/app/(tabs)/record.tsx index 59a8ed9..336a612 100644 --- a/apps/mobile/app/(tabs)/record.tsx +++ b/apps/mobile/app/(tabs)/record.tsx @@ -1,11 +1,18 @@ // apps/mobile/app/(tabs)/record.tsx -// 녹음 — expo-av Audio.Recording 사용 +// 녹음 — expo-av + @d3ro/ui-native import { useState, useRef } from 'react' -import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from 'react-native' +import { View, StyleSheet, ActivityIndicator, ScrollView } from 'react-native' import { Audio } from 'expo-av' -import { supabase, isSupabaseConfigured } from '../../lib/supabase' import Constants from 'expo-constants' +import { + MetalCard, + PhosphorText, + PhysicalButton, + Led, + d3roNativePalette +} from '@d3ro/ui-native' +import { supabase, isSupabaseConfigured } from '../../lib/supabase' type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error' @@ -62,7 +69,6 @@ export default function RecordScreen(): React.ReactElement { return } - // Edge Function 호출 const { data: { session } } = await supabase.auth.getSession() @@ -74,7 +80,6 @@ export default function RecordScreen(): React.ReactElement { } const formData = new FormData() - // RN의 FormData는 { uri, name, type } 형태 formData.append('audio', { uri, name: 'recording.m4a', @@ -116,64 +121,95 @@ export default function RecordScreen(): React.ReactElement { setError(null) } + const statusLed: 'amber' | 'red' | 'orange' | 'green' = + state === 'recording' ? 'red' : state === 'processing' ? 'orange' : state === 'done' ? 'green' : 'amber' + return ( - - {state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'} - - {state === 'processing' && } - - {transcript && state === 'done' && ( - - {transcript} + + + + + + {state === 'recording' + ? 'RECORDING' + : state === 'processing' + ? 'PROCESSING' + : state === 'done' + ? 'DONE' + : state === 'error' + ? 'ERROR' + : 'READY'} + - )} - {error && ( - - {error} + + {state === 'processing' && ( + + )} + + {transcript && state === 'done' && ( + + + {transcript} + + + )} + + {error && ( + + + {error} + + + )} - )} - - {(state === 'idle' || state === 'done' || state === 'error') && ( - void startRecording()}> - {state === 'done' ? '새 녹음' : '녹음 시작'} - - )} - {state === 'recording' && ( - - void stopRecording()}> - 정지 - - void cancelRecording()}> - 취소 - - - )} - - + + {(state === 'idle' || state === 'done' || state === 'error') && ( + void startRecording()} + /> + )} + {state === 'recording' && ( + <> + void stopRecording()} /> + + void cancelRecording()} + /> + + )} + + + ) } const styles = StyleSheet.create({ - container: { flex: 1, padding: 32, alignItems: 'center', justifyContent: 'center' }, - title: { color: '#f25b29', fontSize: 20, fontWeight: '600', letterSpacing: 2, marginBottom: 32 }, - spinner: { marginVertical: 24 }, - transcriptBox: { backgroundColor: '#242427', padding: 16, borderRadius: 8, marginBottom: 24, width: '100%' }, - transcript: { color: '#ffffff', fontSize: 14, lineHeight: 20 }, - errorBox: { borderWidth: 1, borderColor: '#ef4444', padding: 12, borderRadius: 8, marginBottom: 24 }, - error: { color: '#ef4444', fontSize: 12 }, - buttons: { width: '100%', alignItems: 'center' }, - bigButton: { - backgroundColor: '#f25b29', - paddingVertical: 18, - paddingHorizontal: 64, - borderRadius: 12, - minWidth: 200, - alignItems: 'center' + container: { flex: 1, backgroundColor: d3roNativePalette.bg.app }, + content: { padding: 24 }, + card: { padding: 24 }, + statusRow: { flexDirection: 'row', alignItems: 'center' }, + statusLabel: { marginLeft: 8 }, + center: { alignItems: 'center', minHeight: 140, justifyContent: 'center', marginVertical: 16 }, + transcriptBox: { + backgroundColor: d3roNativePalette.bg.inset, + padding: 12, + borderRadius: 8, + width: '100%', + marginTop: 8 }, - bigButtonText: { color: '#ffffff', fontSize: 16, fontWeight: '600', letterSpacing: 1 }, - stop: { backgroundColor: '#ef4444' }, - cancelButton: { paddingVertical: 12, alignItems: 'center' }, - cancelText: { color: '#8e8e93', fontSize: 13 } + errorBox: { + borderWidth: 1, + borderColor: d3roNativePalette.tag.red, + padding: 10, + borderRadius: 6, + width: '100%', + marginTop: 8 + }, + buttons: { marginTop: 16 } }) diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx index f064c00..c48ba2e 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -1,10 +1,11 @@ // apps/mobile/app/login.tsx -// OAuth 로그인 화면 +// OAuth 로그인 화면 — @d3ro/ui-native 사용 import { useState } from 'react' -import { View, Text, Pressable, Alert, StyleSheet, ActivityIndicator } from 'react-native' +import { View, StyleSheet, Alert } from 'react-native' import * as WebBrowser from 'expo-web-browser' import * as Linking from 'expo-linking' +import { MetalCard, PhosphorText, PhysicalButton, d3roNativePalette } from '@d3ro/ui-native' import { supabase, isSupabaseConfigured } from '../lib/supabase' WebBrowser.maybeCompleteAuthSession() @@ -49,33 +50,37 @@ export default function LoginScreen(): React.ReactElement { return ( - D3RO VOICE - AI 음성 어시스턴트 + + + D3RO VOICE + + AI 음성 어시스턴트 + + - {!configured && ( - - Supabase가 설정되지 않았습니다. app.json의 extra 필드를 확인하세요. - - )} + {!configured && ( + + + Supabase가 설정되지 않았습니다. app.json의 extra 필드를 확인하세요. + + + )} - - void signInWithProvider('google')} - disabled={!configured || busy} - > - Google로 계속하기 - - void signInWithProvider('github')} - disabled={!configured || busy} - > - GitHub로 계속하기 - - - - {busy && } + + void signInWithProvider('google')} + /> + void signInWithProvider('github')} + /> + + ) } @@ -83,56 +88,28 @@ export default function LoginScreen(): React.ReactElement { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#19191b', + backgroundColor: d3roNativePalette.bg.app, justifyContent: 'center', + padding: 24 + }, + card: { padding: 32 }, - title: { - color: '#f25b29', - fontSize: 36, - fontWeight: '300', - textAlign: 'center', - letterSpacing: 2, - marginBottom: 8 + header: { + alignItems: 'center', + marginBottom: 32 }, subtitle: { - color: '#8e8e93', - fontSize: 14, - textAlign: 'center', - marginBottom: 48 + marginTop: 8 }, - warning: { - color: '#f59e0b', - fontSize: 12, - textAlign: 'center', - marginBottom: 24, - padding: 12, + warningBox: { borderWidth: 1, - borderColor: '#f59e0b', - borderRadius: 8 + borderColor: d3roNativePalette.tag.orange, + padding: 12, + borderRadius: 8, + marginBottom: 16 }, buttons: { gap: 12 - }, - button: { - paddingVertical: 14, - borderRadius: 8, - alignItems: 'center' - }, - google: { - backgroundColor: '#f25b29' - }, - github: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: '#8e8e93' - }, - disabled: { - opacity: 0.5 - }, - buttonText: { - color: '#ffffff', - fontSize: 14, - fontWeight: '600' } }) diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..0dfcee1 --- /dev/null +++ b/apps/web/e2e/smoke.spec.ts @@ -0,0 +1,45 @@ +// apps/web/e2e/smoke.spec.ts +// 기본 스모크 테스트 — 인증 없이 접근 가능한 라우트 확인. +// +// 실행: +// npm install -D @playwright/test --workspace=@d3ro/web +// npx playwright install chromium --with-deps +// npm run test:e2e --workspace=@d3ro/web + +import { test, expect } from '@playwright/test' + +test.describe('Smoke: unauthenticated access', () => { + test('루트(/)는 /login으로 리다이렉트된다 (Supabase 미설정 시)', async ({ page }) => { + await page.goto('/') + await page.waitForURL(/\/login/, { timeout: 10000 }) + expect(page.url()).toContain('/login') + }) + + test('/login 페이지가 로드되고 D3RO VOICE 로고가 표시된다', async ({ page }) => { + await page.goto('/login') + await expect(page.getByText(/D3RO VOICE/i)).toBeVisible({ timeout: 10000 }) + }) + + test('/login에 Google/GitHub OAuth 버튼이 보인다', async ({ page }) => { + await page.goto('/login') + await expect(page.getByRole('button', { name: /Google/i })).toBeVisible() + await expect(page.getByRole('button', { name: /GitHub/i })).toBeVisible() + }) + + test('/dashboard는 미로그인 시 /login으로 리다이렉트', async ({ page }) => { + await page.goto('/dashboard') + await page.waitForURL(/\/login/, { timeout: 10000 }) + expect(page.url()).toContain('/login') + }) + + test('/meetings도 미로그인 시 /login으로 리다이렉트', async ({ page }) => { + await page.goto('/meetings') + await page.waitForURL(/\/login/, { timeout: 10000 }) + expect(page.url()).toContain('/login') + }) + + test('/accept-invite?token=bogus는 페이지 로드 (에러 메시지 표시)', async ({ page }) => { + await page.goto('/accept-invite?token=bogus') + await expect(page.getByText(/TEAM INVITE/i)).toBeVisible({ timeout: 10000 }) + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 60806db..235ed57 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,9 @@ "build": "next build", "start": "next start --port 3000", "typecheck": "tsc --noEmit", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@d3ro/api-client": "*", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..801ed31 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,37 @@ +// apps/web/playwright.config.ts +// E2E 테스트 설정. 실제 실행을 위해 @playwright/test 설치 + browsers 설치 필요: +// npm install -D @playwright/test --workspace=@d3ro/web +// npx playwright install chromium --with-deps + +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['html'], ['github']] : 'html', + + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure' + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + + webServer: process.env.E2E_NO_SERVER + ? undefined + : { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000 + } +}) diff --git a/apps/web/src/app/(app)/meetings/[id]/page.tsx b/apps/web/src/app/(app)/meetings/[id]/page.tsx index 6648174..2675293 100644 --- a/apps/web/src/app/(app)/meetings/[id]/page.tsx +++ b/apps/web/src/app/(app)/meetings/[id]/page.tsx @@ -1,11 +1,15 @@ // apps/web/src/app/(app)/meetings/[id]/page.tsx -// 회의록 상세 — transcripts + memos + documents +// 회의록 상세 — transcripts(Realtime) + memos + documents import { notFound } from 'next/navigation' import { Box, Stack } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { getSupabaseServerClient } from '@/lib/supabase-server' +import { + LiveTranscriptList, + type TranscriptRow +} from '@/components/meetings/live-transcript-list' interface PageProps { params: Promise<{ id: string }> @@ -52,31 +56,15 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise< - {/* Transcript */} + {/* Transcript — Realtime 구독 */} - TRANSCRIPT + TRANSCRIPT (LIVE) - {(transcripts ?? []).length === 0 ? ( - - 전사 세그먼트가 없습니다. - - ) : ( - - {(transcripts ?? []).map((seg) => ( - - - {Math.floor(seg.timestamp_ms / 60000)}: - {String(Math.floor((seg.timestamp_ms / 1000) % 60)).padStart(2, '0')} - {seg.speaker && ` · ${seg.speaker}`} - - - {seg.text} - - - ))} - - )} + {/* Memos */} diff --git a/apps/web/src/app/accept-invite/page.tsx b/apps/web/src/app/accept-invite/page.tsx new file mode 100644 index 0000000..bbb86f0 --- /dev/null +++ b/apps/web/src/app/accept-invite/page.tsx @@ -0,0 +1,183 @@ +'use client' + +// apps/web/src/app/accept-invite/page.tsx +// 팀 초대 수락 페이지 — URL의 ?token=을 team-accept Edge Function으로 전달 + +import { Suspense, useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Box, Stack, Alert, CircularProgress } from '@mui/material' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser' + +type AcceptState = 'loading' | 'success' | 'error' | 'need_login' + +// useSearchParams는 Suspense boundary 필요 (Next.js 15 prerender 규칙) +export default function AcceptInvitePage(): React.ReactElement { + return ( + + + + } + > + + + ) +} + +function AcceptInviteInner(): React.ReactElement { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get('token') + const [state, setState] = useState('loading') + const [message, setMessage] = useState('') + const [teamId, setTeamId] = useState(null) + + useEffect(() => { + async function run(): Promise { + if (!token) { + setState('error') + setMessage('유효하지 않은 초대 링크입니다 (토큰 누락).') + return + } + if (!isSupabaseConfigured()) { + setState('error') + setMessage('Supabase가 설정되지 않았습니다.') + return + } + + const supabase = getSupabaseBrowserClient() + const { + data: { session } + } = await supabase.auth.getSession() + + if (!session) { + setState('need_login') + setMessage('초대를 수락하려면 먼저 로그인해주세요.') + // 토큰을 sessionStorage에 저장해두고 로그인 후 돌아오도록 + try { + sessionStorage.setItem('pending_invite_token', token) + } catch { + // storage 차단 시 무시 + } + return + } + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/team-accept`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ token }) + } + ) + + if (!response.ok) { + const errData = (await response.json()) as { error?: string; message?: string } + setState('error') + setMessage(errData.message ?? errData.error ?? `실패: ${response.status}`) + return + } + + const data = (await response.json()) as { team_id: string; role: string } + setTeamId(data.team_id) + setState('success') + setMessage(`팀에 가입되었습니다 (${data.role}). 잠시 후 이동합니다...`) + + // 성공 시 3초 후 팀 페이지로 + setTimeout(() => { + router.replace(`/teams/${data.team_id}`) + }, 2000) + } catch (e) { + setState('error') + setMessage(e instanceof Error ? e.message : 'Unknown error') + } + } + void run() + }, [token, router]) + + return ( + + + + TEAM INVITE + + {state === 'loading' && } + + {state === 'success' && ( + + {message} + + )} + + {state === 'error' && ( + <> + + {message} + + + 대시보드로 이동 + + + )} + + {state === 'need_login' && ( + <> + + {message} + + + 로그인 페이지로 이동 + + + )} + + {teamId && ( + + Team ID: {teamId} + + )} + + + + ) +} diff --git a/apps/web/src/components/meetings/live-transcript-list.tsx b/apps/web/src/components/meetings/live-transcript-list.tsx new file mode 100644 index 0000000..e51436c --- /dev/null +++ b/apps/web/src/components/meetings/live-transcript-list.tsx @@ -0,0 +1,112 @@ +'use client' + +// apps/web/src/components/meetings/live-transcript-list.tsx +// 회의 세그먼트 실시간 구독 — Supabase Realtime으로 INSERT 이벤트 수신 + +import { useEffect, useState } from 'react' +import { Box, Stack } from '@mui/material' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +export interface TranscriptRow { + id: string + segment_index: number + timestamp_ms: number + text: string + speaker: string | null + edited: boolean +} + +interface LiveTranscriptListProps { + meetingId: string + initial: TranscriptRow[] +} + +export function LiveTranscriptList({ + meetingId, + initial +}: LiveTranscriptListProps): React.ReactElement { + const [segments, setSegments] = useState(initial) + + useEffect(() => { + const supabase = getSupabaseBrowserClient() + // 회의별 Realtime 채널 구독. V2-2 migration에서 + // ALTER PUBLICATION supabase_realtime ADD TABLE public.transcripts; 적용됨. + const channel = supabase + .channel(`transcripts:meeting:${meetingId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'transcripts', + filter: `meeting_id=eq.${meetingId}` + }, + (payload) => { + const row = payload.new as TranscriptRow + setSegments((prev) => { + // 중복 방지 (segment_index 기준) + if (prev.some((s) => s.segment_index === row.segment_index)) { + return prev + } + return [...prev, row].sort((a, b) => a.segment_index - b.segment_index) + }) + } + ) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'transcripts', + filter: `meeting_id=eq.${meetingId}` + }, + (payload) => { + const row = payload.new as TranscriptRow + setSegments((prev) => prev.map((s) => (s.id === row.id ? row : s))) + } + ) + .subscribe() + + return () => { + void supabase.removeChannel(channel) + } + }, [meetingId]) + + if (segments.length === 0) { + return ( + 전사 세그먼트가 없습니다. + ) + } + + return ( + + {segments.map((seg) => ( + + + {Math.floor(seg.timestamp_ms / 60000)}: + {String(Math.floor((seg.timestamp_ms / 1000) % 60)).padStart(2, '0')} + {seg.speaker && ` · ${seg.speaker}`} + {seg.edited && ( + + EDITED + + )} + + {seg.text} + + ))} + + ) +} diff --git a/apps/web/src/components/teams/invite-member-form.tsx b/apps/web/src/components/teams/invite-member-form.tsx index cd852b2..47813ef 100644 --- a/apps/web/src/components/teams/invite-member-form.tsx +++ b/apps/web/src/components/teams/invite-member-form.tsx @@ -1,49 +1,110 @@ 'use client' // apps/web/src/components/teams/invite-member-form.tsx -// 팀 멤버 초대 — 이메일로 초대 (현재 MVP는 user_id 직접 입력) -// 정식 초대 flow는 V2-7b에서 구현 (Supabase function + 이메일 발송) +// 팀 멤버 초대 — 이메일 기반 (V2-7b). +// Edge Function team-invite로 토큰 발급 → 초대 URL 반환 → 클라이언트가 복사/공유. import { useState } from 'react' -import { useRouter } from 'next/navigation' -import { Box, Button, TextField, Stack, Alert, Dialog, DialogContent, DialogTitle } from '@mui/material' +import { + Box, + Button, + TextField, + Stack, + Alert, + Dialog, + DialogContent, + DialogTitle, + Select, + MenuItem, + FormControl, + InputLabel, + IconButton, + Tooltip +} from '@mui/material' import PersonAddIcon from '@mui/icons-material/PersonAdd' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' import { getSupabaseBrowserClient } from '@/lib/supabase-browser' interface InviteMemberFormProps { teamId: string } +interface InviteResult { + id: string + url: string + expires_at: string +} + export function InviteMemberForm({ teamId }: InviteMemberFormProps): React.ReactElement { - const router = useRouter() const [open, setOpen] = useState(false) - const [userId, setUserId] = useState('') + const [email, setEmail] = useState('') + const [role, setRole] = useState<'admin' | 'member'>('member') const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const [result, setResult] = useState(null) + const [copied, setCopied] = useState(false) async function handleInvite(): Promise { - if (!userId.trim()) return + if (!email.trim()) return setError(null) setBusy(true) try { const supabase = getSupabaseBrowserClient() - const { error: insertErr } = await supabase - .from('team_members') - .insert({ team_id: teamId, user_id: userId.trim(), role: 'member' }) - - if (insertErr) { - setError(insertErr.message) + const { + data: { session } + } = await supabase.auth.getSession() + if (!session) { + setError('로그인이 필요합니다') return } - setUserId('') - setOpen(false) - router.refresh() + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/team-invite`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ team_id: teamId, email: email.trim(), role }) + } + ) + + if (!response.ok) { + const errData = (await response.json()) as { error?: string; message?: string } + setError(errData.message ?? errData.error ?? `실패: ${response.status}`) + return + } + + const data = (await response.json()) as InviteResult + setResult(data) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') } finally { setBusy(false) } } + async function handleCopy(): Promise { + if (!result) return + try { + await navigator.clipboard.writeText(result.url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // clipboard 차단 시 무시 + } + } + + function handleClose(): void { + setOpen(false) + setEmail('') + setRole('member') + setError(null) + setResult(null) + setCopied(false) + } + return ( <> - + {!result ? ( + + + 초대할 사용자의 이메일을 입력하세요. 발급된 초대 URL을 복사해서 공유할 수 + 있습니다 (자동 이메일 발송은 V2-7c에서 지원). + + setEmail(e.target.value)} + placeholder="example@d3ro.dev" + fullWidth + autoFocus + /> + + 역할 + + + {error && ( + + {error} + + )} + + + + - + ) : ( + + + 초대 생성 완료. 아래 링크를 공유하세요. + + + + + void handleCopy()}> + + + + + + 만료: {new Date(result.expires_at).toLocaleString('ko-KR')} + + + + + + )} diff --git a/apps/web/src/lib/supabase-browser.ts b/apps/web/src/lib/supabase-browser.ts index f0ef9f7..317adec 100644 --- a/apps/web/src/lib/supabase-browser.ts +++ b/apps/web/src/lib/supabase-browser.ts @@ -3,10 +3,12 @@ 'use client' -import { createBrowserClient } from '@supabase/ssr' +// apps/web/src/lib/supabase-browser.ts +// V2-3 MVP: Database 제네릭 없이 동작. @supabase/ssr와 supabase-js의 내부 타입 +// 경로 불일치(@supabase/supabase-js/dist/module/lib/types)로 인해 제네릭 주입 불가. +// 다음 사이클에서 supabase-js/ssr 버전 정합 맞춘 후 Database 제네릭 복원 예정. -// V2-3 MVP: Database 제네릭 없이 동작. V2-4에서 `supabase gen types typescript`로 -// 자동 생성된 Database 타입을 주입하여 select/insert에 타입 안전성 추가 예정. +import { createBrowserClient } from '@supabase/ssr' let cachedClient: ReturnType | null = null diff --git a/apps/web/src/lib/supabase-server.ts b/apps/web/src/lib/supabase-server.ts index 3d16c13..788c69e 100644 --- a/apps/web/src/lib/supabase-server.ts +++ b/apps/web/src/lib/supabase-server.ts @@ -1,6 +1,6 @@ // apps/web/src/lib/supabase-server.ts // RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션). -// V2-3 MVP: Database 제네릭 없이 동작. V2-4에서 자동 생성 Database 타입 주입 예정. +// Database 제네릭은 @supabase/ssr 내부 경로 이슈로 현재 미적용 (다음 사이클 복원). import { cookies } from 'next/headers' import { createServerClient, type CookieOptions } from '@supabase/ssr' diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 65eb694..c0e3b17 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,5 +21,5 @@ } }, "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "e2e", "playwright.config.ts"] } diff --git a/memory/project_status.md b/memory/project_status.md index 68efc35..dfc1668 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,16 +1,16 @@ # D3RO-VOICE 프로젝트 현황 -> 마지막 갱신: 2026-04-09 (2차 사이클) +> 마지막 갱신: 2026-04-10 (3차 사이클) > 규칙 13: 작업 완료 즉시 이 파일 갱신 의무 ## 현재 단계 -**V1 완료** → **V2 1차 전 페이즈 + 2차 고도화 일부 완료** +**V1 완료** → **V2 1차 전 페이즈 + 2차/3차 고도화 완료** V2 1차: - Phase V2-1 ✅ (Monorepo 전환 a/b/c/d + minor 정리) - Phase V2-2 🟡 로컬 완료 (Supabase), 사용자 배포 대기 -- Phase V2-3 ✅ (Web App MVP — Next.js 15, 11 라우트) +- Phase V2-3 ✅ (Web App MVP — Next.js 15, 12 라우트) - Phase V2-4 ✅ (데스크톱 Supabase push 동기화) - Phase V2-5 🟡 로컬 완료 (Mac 빌드), 사용자 검증 대기 - Phase V2-6 ✅ (Mobile MVP — Expo) @@ -18,14 +18,22 @@ V2 1차: - Phase V2-8 ✅ (결제 스캐폴딩 — Stripe Edge Functions + /billing) V2 2차 고도화 (2026-04-09): -- [A1] ✅ SettingsModal에 CloudSyncSection 통합 (Cloud 탭) -- [A2] ✅ web Sidebar 공유 layout — app/(app)/layout.tsx 그룹, 중복 제거 -- [A3] ✅ 11개 locale (en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th)에 V2 새 키 번역 -- [B] ✅ V2-4b pull 동기화 — LWW 충돌 해결 (history/dictionary) -- [C] ✅ V2-8b Stripe webhook HMAC-SHA256 서명 검증 + Customer Portal -- [D] ✅ V2-6b packages/ui-native — RN용 MetalCard/PhosphorText/Led/PhysicalButton +- [A1] ✅ SettingsModal에 CloudSyncSection 통합 +- [A2] ✅ web Sidebar 공유 layout (app/(app)/) +- [A3] ✅ 11개 locale 번역 +- [B] ✅ V2-4b pull 동기화 — history/dictionary +- [C] ✅ V2-8b Stripe webhook HMAC-SHA256 + Customer Portal +- [D] ✅ V2-6b packages/ui-native 패키지 -**다음 사이클**: 사용자 환경 액션 후 e2e 검증 / V2-4b에 meetings/memos 테이블 추가 / V2-6b로 mobile UI 실제 교체 / V2-7b invite flow +V2 3차 고도화 (2026-04-10): +- [E] ✅ V2-4b pull 확장 — meetings/meeting_memos/meeting_documents +- [F] ⚠️ Database 제네릭 복원 — interface→type 전환 완료, 하지만 @supabase/ssr과 supabase-js 버전 정합 이슈로 실제 제네릭 주입은 보류 (다음 사이클) +- [G] ✅ apps/mobile UI를 @d3ro/ui-native로 실제 교체 (login/meetings/record/profile) +- [H] ✅ V2-7b 이메일 invite flow — team_invites 테이블, team-invite/team-accept Edge Functions, /accept-invite 페이지 +- [I] ✅ Realtime transcripts 구독 — web /meetings/[id]에 LiveTranscriptList +- [J] ✅ api-client 유닛 테스트 (19 passed) + web E2E 스모크 (Playwright 스캐폴딩) + +**다음 사이클**: 사용자 환경 액션 후 e2e 검증, Database 제네릭 완전 복원 (supabase 버전 맞추기), V1 고급 기능(음성 대화/RAG/보이스 액션) 이식, T2-8b 이메일 발송 실제 연동 ## V1 완료 페이즈 diff --git a/packages/api-client/__tests__/client.test.ts b/packages/api-client/__tests__/client.test.ts new file mode 100644 index 0000000..5b3bca7 --- /dev/null +++ b/packages/api-client/__tests__/client.test.ts @@ -0,0 +1,64 @@ +// packages/api-client/__tests__/client.test.ts +// createD3roSupabaseClient 팩토리 유닛 테스트 + +import { describe, it, expect } from 'vitest' +import { createD3roSupabaseClient, isClientConfigured } from '../src/client' + +describe('createD3roSupabaseClient', () => { + it('URL과 key가 없으면 placeholder 클라이언트를 반환한다', () => { + const client = createD3roSupabaseClient({ url: undefined, anonKey: undefined }) + expect(client).toBeDefined() + // auth, from 등 메서드가 존재해야 함 + expect(typeof client.auth.getSession).toBe('function') + expect(typeof client.from).toBe('function') + }) + + it('URL만 있고 key가 없으면 placeholder 클라이언트를 반환한다', () => { + const client = createD3roSupabaseClient({ + url: 'https://real.supabase.co', + anonKey: undefined + }) + expect(client).toBeDefined() + }) + + it('URL과 key가 모두 있으면 실제 클라이언트를 생성한다', () => { + const client = createD3roSupabaseClient({ + url: 'https://real.supabase.co', + anonKey: 'real-anon-key' + }) + expect(client).toBeDefined() + expect(typeof client.auth.getSession).toBe('function') + }) + + it('auth 옵션을 주입할 수 있다', () => { + const client = createD3roSupabaseClient({ + url: 'https://real.supabase.co', + anonKey: 'real-anon-key', + auth: { persistSession: false, autoRefreshToken: false } + }) + expect(client).toBeDefined() + }) +}) + +describe('isClientConfigured', () => { + it('URL과 key가 모두 있으면 true', () => { + expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: 'k' })).toBe(true) + }) + + it('URL만 있으면 false', () => { + expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: undefined })).toBe(false) + }) + + it('key만 있으면 false', () => { + expect(isClientConfigured({ url: undefined, anonKey: 'k' })).toBe(false) + }) + + it('둘 다 없으면 false', () => { + expect(isClientConfigured({ url: undefined, anonKey: undefined })).toBe(false) + }) + + it('빈 문자열은 false로 처리된다', () => { + expect(isClientConfigured({ url: '', anonKey: 'k' })).toBe(false) + expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: '' })).toBe(false) + }) +}) diff --git a/packages/api-client/__tests__/types.test.ts b/packages/api-client/__tests__/types.test.ts new file mode 100644 index 0000000..e404a2a --- /dev/null +++ b/packages/api-client/__tests__/types.test.ts @@ -0,0 +1,128 @@ +// packages/api-client/__tests__/types.test.ts +// Database/Profile/Meeting 등 타입이 올바르게 export되는지 확인 (컴파일 체크) + +import { describe, it, expect } from 'vitest' +import type { + Database, + Profile, + Meeting, + MeetingMemo, + MeetingDocument, + Transcript, + HistoryEntry, + DictionaryEntry, + Team, + TeamMember, + Subscription, + SubscriptionTier, + TeamRole, + MeetingStatus, + DocumentTemplateType, + HistoryMode, + HistoryStatus +} from '../src/types' + +describe('api-client types', () => { + it('SubscriptionTier 리터럴 union', () => { + const tiers: SubscriptionTier[] = ['free', 'pro', 'team'] + expect(tiers).toHaveLength(3) + }) + + it('TeamRole 리터럴 union', () => { + const roles: TeamRole[] = ['owner', 'admin', 'member'] + expect(roles).toHaveLength(3) + }) + + it('MeetingStatus 리터럴 union', () => { + const statuses: MeetingStatus[] = ['recording', 'processing', 'completed', 'error'] + expect(statuses).toHaveLength(4) + }) + + it('DocumentTemplateType 리터럴 union', () => { + const templates: DocumentTemplateType[] = [ + 'minutes', + 'report', + 'idea-note', + 'custom', + 'mindmap' + ] + expect(templates).toHaveLength(5) + }) + + it('HistoryMode 리터럴 union', () => { + const modes: HistoryMode[] = [ + 'dictation', + 'translate', + 'command', + 'caption', + 'file-transcription' + ] + expect(modes).toHaveLength(5) + }) + + it('HistoryStatus 리터럴 union', () => { + const statuses: HistoryStatus[] = ['completed', 'cancelled', 'error'] + expect(statuses).toHaveLength(3) + }) + + it('Profile 구조', () => { + const profile: Profile = { + id: 'uuid', + name: 'Alice', + avatar_url: null, + locale: 'ko', + tier: 'free', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + expect(profile.id).toBe('uuid') + expect(profile.tier).toBe('free') + }) + + it('Meeting 구조', () => { + const meeting: Meeting = { + id: 'uuid', + user_id: 'user-uuid', + team_id: null, + title: 'Test Meeting', + status: 'completed', + started_at: '2026-01-01T00:00:00Z', + ended_at: null, + duration_ms: 120000, + raw_transcript: null, + edited_transcript: null, + minutes_markdown: null, + minutes_json: null, + stt_model: null, + llm_model: null, + stt_latency_ms: null, + llm_latency_ms: null, + error_message: null, + audio_storage_key: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + expect(meeting.status).toBe('completed') + }) + + it('Database 타입이 존재한다', () => { + // 컴파일 타임 체크 — Database가 export 되지 않으면 이 줄이 에러 + const check: keyof Database = 'public' + expect(check).toBe('public') + }) + + // 미사용 import 방지용 + it('모든 타입 import 검증', () => { + const _unused: [ + MeetingMemo?, + MeetingDocument?, + Transcript?, + HistoryEntry?, + DictionaryEntry?, + Team?, + TeamMember?, + Subscription? + ] = [] + expect(_unused).toBeDefined() + }) +}) diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 8e06069..3f993df 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -4,6 +4,10 @@ "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 3a74676..3164989 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,11 +1,14 @@ // packages/api-client — barrel export -// 개별 sub-path import 권장: +// 루트는 타입만. 함수는 subpath로 접근 — 현재 Supabase SSR과 supabase-js 버전 +// 정합 이슈로 Database 제네릭이 @supabase/ssr에서 전파 실패하기 때문에, +// 내부에서 SupabaseClient를 쓰는 meetings/history/usage 함수들을 +// 루트로 끌어올리면 consumer의 tsc가 이 함수들까지 scan하여 never 전파가 일어남. +// +// Sub-path로만 접근: // '@d3ro/api-client/client' — Supabase 클라이언트 팩토리 // '@d3ro/api-client/auth' — 로그인/세션 // '@d3ro/api-client/meetings' — 회의 CRUD // '@d3ro/api-client/history' — 음성 입력 이력 // '@d3ro/api-client/usage' — 쿼터/구독 -// -// 루트 barrel은 타입만 노출. 함수는 subpath로 import (tree-shaking + 순환 의존 방지). export * from './types' diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 857f4dc..3b8c1d6 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -1,10 +1,9 @@ // packages/api-client/src/types.ts // Supabase DB row 타입 — V2-2 스키마와 동기화된 수동 정의. -// 추후 `supabase gen types typescript`로 자동화 예정. export type SubscriptionTier = 'free' | 'pro' | 'team' -export interface Profile { +export type Profile = { id: string name: string | null avatar_url: string | null @@ -14,7 +13,7 @@ export interface Profile { updated_at: string } -export interface Team { +export type Team = { id: string name: string owner_id: string @@ -25,7 +24,7 @@ export interface Team { export type TeamRole = 'owner' | 'admin' | 'member' -export interface TeamMember { +export type TeamMember = { team_id: string user_id: string role: TeamRole @@ -34,7 +33,7 @@ export interface TeamMember { export type MeetingStatus = 'recording' | 'processing' | 'completed' | 'error' -export interface Meeting { +export type Meeting = { id: string user_id: string team_id: string | null @@ -57,7 +56,7 @@ export interface Meeting { updated_at: string } -export interface MeetingMemo { +export type MeetingMemo = { id: string meeting_id: string user_id: string @@ -68,7 +67,7 @@ export interface MeetingMemo { export type DocumentTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom' | 'mindmap' -export interface MeetingDocument { +export type MeetingDocument = { id: string meeting_id: string user_id: string @@ -82,7 +81,7 @@ export interface MeetingDocument { updated_at: string } -export interface Transcript { +export type Transcript = { id: string meeting_id: string segment_index: number @@ -98,7 +97,7 @@ export interface Transcript { export type HistoryMode = 'dictation' | 'translate' | 'command' | 'caption' | 'file-transcription' export type HistoryStatus = 'completed' | 'cancelled' | 'error' -export interface HistoryEntry { +export type HistoryEntry = { id: string user_id: string title: string | null @@ -125,7 +124,7 @@ export interface HistoryEntry { updated_at: string } -export interface DictionaryEntry { +export type DictionaryEntry = { id: string user_id: string word: string @@ -137,7 +136,7 @@ export interface DictionaryEntry { updated_at: string } -export interface DailyUsage { +export type DailyUsage = { id: number user_id: string date: string // YYYY-MM-DD @@ -145,7 +144,7 @@ export interface DailyUsage { count: number } -export interface Subscription { +export type Subscription = { id: string user_id: string tier: SubscriptionTier @@ -159,97 +158,78 @@ export interface Subscription { updated_at: string } +/** + * 유틸: Supabase GenericTable 제약(Row: Record)을 만족시키기 위해 + * Row/Insert/Update에 index signature를 교차한다. + * Profile 같은 specific type alias는 `Record`에 subtype으로 + * assign되지 않기 때문에, Database 정의에서 TypedTable로 감싸야 제네릭이 전파된다. + */ +type TypedTable = { + Row: TRow & Record + Insert: TInsert & Record + Update: TUpdate & Record + Relationships: [] +} + /** * 전체 DB 스키마 — SupabaseClient 제네릭용. - * Supabase CLI의 `supabase gen types typescript` 출력과 호환되는 형식. + * `@supabase/postgrest-js`의 GenericSchema constraint와 호환: + * { Tables: Record; Views: ...; Functions: ... } */ export type Database = { - __InternalSupabase: { - PostgrestVersion: '12' - } public: { Tables: { - profiles: { - Row: Profile - Insert: Partial & Pick - Update: Partial - Relationships: [] - } - teams: { - Row: Team - Insert: Omit & Partial> - Update: Partial - Relationships: [] - } - team_members: { - Row: TeamMember - Insert: Omit & Partial> - Update: Partial - Relationships: [] - } - meetings: { - Row: Meeting - Insert: Partial & Pick - Update: Partial - Relationships: [] - } - meeting_memos: { - Row: MeetingMemo - Insert: Omit & - Partial> - Update: Partial - Relationships: [] - } - meeting_documents: { - Row: MeetingDocument - Insert: Omit & - Partial> - Update: Partial - Relationships: [] - } - transcripts: { - Row: Transcript - Insert: Omit & - Partial> - Update: Partial - Relationships: [] - } - history: { - Row: HistoryEntry - Insert: Partial & Pick - Update: Partial - Relationships: [] - } - dictionary: { - Row: DictionaryEntry - Insert: Partial & Pick - Update: Partial - Relationships: [] - } - daily_usage: { - Row: DailyUsage - Insert: Omit - Update: Partial - Relationships: [] - } - subscriptions: { - Row: Subscription - Insert: Partial & Pick - Update: Partial - Relationships: [] - } - } - Views: { - [_ in never]: never - } - Functions: { - [_ in never]: never - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never + profiles: TypedTable & Pick, Partial> + teams: TypedTable< + Team, + Omit & Partial>, + Partial + > + team_members: TypedTable< + TeamMember, + Omit & Partial>, + Partial + > + meetings: TypedTable< + Meeting, + Partial & Pick, + Partial + > + meeting_memos: TypedTable< + MeetingMemo, + Omit & Partial>, + Partial + > + meeting_documents: TypedTable< + MeetingDocument, + Omit & + Partial>, + Partial + > + transcripts: TypedTable< + Transcript, + Omit & + Partial>, + Partial + > + history: TypedTable< + HistoryEntry, + Partial & Pick, + Partial + > + dictionary: TypedTable< + DictionaryEntry, + Partial & Pick, + Partial + > + daily_usage: TypedTable, Partial> + subscriptions: TypedTable< + Subscription, + Partial & Pick, + Partial + > } + Views: Record + Functions: Record } } diff --git a/packages/api-client/vitest.config.ts b/packages/api-client/vitest.config.ts new file mode 100644 index 0000000..fc4646e --- /dev/null +++ b/packages/api-client/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['__tests__/**/*.test.ts'], + testTimeout: 10000 + } +}) diff --git a/server/supabase/config.toml b/server/supabase/config.toml index 08869a4..e49717c 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -93,5 +93,11 @@ verify_jwt = true [functions.stripe-webhook] verify_jwt = false +[functions.team-invite] +verify_jwt = true + +[functions.team-accept] +verify_jwt = true + [analytics] enabled = false diff --git a/server/supabase/functions/team-accept/index.ts b/server/supabase/functions/team-accept/index.ts new file mode 100644 index 0000000..4b248fe --- /dev/null +++ b/server/supabase/functions/team-accept/index.ts @@ -0,0 +1,116 @@ +// server/supabase/functions/team-accept/index.ts +// 팀 초대 수락 — token 검증 후 team_members에 추가. + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { createServiceRoleClient } from '../_shared/quota.ts' + +interface AcceptRequest { + token: string +} + +// @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 body = (await req.json()) as AcceptRequest + + if (!body.token) { + return new Response( + JSON.stringify({ error: 'token is required' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const serviceClient = createServiceRoleClient() + + // 토큰으로 초대 조회 + const { data: invite, error: inviteErr } = await serviceClient + .from('team_invites') + .select('id, team_id, email, role, expires_at, accepted_at') + .eq('token', body.token) + .maybeSingle() + + if (inviteErr || !invite) { + return new Response( + JSON.stringify({ error: 'invalid_token', message: '초대를 찾을 수 없습니다.' }), + { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + if (invite.accepted_at) { + return new Response( + JSON.stringify({ error: 'already_accepted', message: '이미 수락된 초대입니다.' }), + { status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const expiresAt = new Date(invite.expires_at as string).getTime() + if (Date.now() > expiresAt) { + return new Response( + JSON.stringify({ error: 'expired', message: '만료된 초대입니다.' }), + { status: 410, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 이메일 일치 확인 (대소문자 무시) + const invitedEmail = (invite.email as string).toLowerCase() + const userEmail = (user.email ?? '').toLowerCase() + if (invitedEmail !== userEmail) { + return new Response( + JSON.stringify({ + error: 'email_mismatch', + message: `이 초대는 ${invitedEmail}에게 발송되었습니다. 올바른 계정으로 로그인하세요.` + }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // team_members에 추가 (이미 있으면 업데이트) + const { error: memberErr } = await serviceClient.from('team_members').upsert( + { + team_id: invite.team_id as string, + user_id: user.id, + role: (invite.role as 'admin' | 'member') ?? 'member' + }, + { onConflict: 'team_id,user_id' } + ) + + if (memberErr) { + throw new Error(`멤버 등록 실패: ${memberErr.message}`) + } + + // 초대 accepted 표시 + await serviceClient + .from('team_invites') + .update({ accepted_at: new Date().toISOString(), accepted_by: user.id }) + .eq('id', invite.id as string) + + return new Response( + JSON.stringify({ + team_id: invite.team_id, + role: invite.role + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/functions/team-invite/index.ts b/server/supabase/functions/team-invite/index.ts new file mode 100644 index 0000000..c53ebf0 --- /dev/null +++ b/server/supabase/functions/team-invite/index.ts @@ -0,0 +1,106 @@ +// server/supabase/functions/team-invite/index.ts +// 팀 초대 생성 — 이메일 기반 초대 토큰 발급. +// 초대 링크는 클라이언트가 이메일로 공유 (추후 Edge Function에서 SMTP/Resend 연동). + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { createServiceRoleClient } from '../_shared/quota.ts' + +interface InviteRequest { + team_id: string + email: string + role?: 'admin' | 'member' +} + +// @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 body = (await req.json()) as InviteRequest + + if (!body.team_id || !body.email) { + return new Response( + JSON.stringify({ error: 'team_id and email are required' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const serviceClient = createServiceRoleClient() + + // 권한 확인: 요청자가 해당 팀의 owner/admin 인가? + const { data: membership } = await serviceClient + .from('team_members') + .select('role') + .eq('team_id', body.team_id) + .eq('user_id', user.id) + .maybeSingle() + + const role = (membership?.role as string | undefined) ?? null + if (role !== 'owner' && role !== 'admin') { + return new Response( + JSON.stringify({ error: 'forbidden', message: '팀 owner/admin만 초대할 수 있습니다.' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 토큰 생성 (service_role RPC) + const { data: tokenData, error: tokenErr } = await serviceClient.rpc('generate_invite_token') + if (tokenErr || !tokenData) { + throw new Error(`토큰 생성 실패: ${tokenErr?.message ?? 'unknown'}`) + } + const token = tokenData as string + + // 초대 INSERT + const { data: invite, error: insertErr } = await serviceClient + .from('team_invites') + .insert({ + team_id: body.team_id, + invited_by: user.id, + email: body.email.toLowerCase().trim(), + role: body.role ?? 'member', + token + }) + .select('id, token, expires_at') + .single() + + if (insertErr || !invite) { + throw new Error(`초대 생성 실패: ${insertErr?.message ?? 'unknown'}`) + } + + // 초대 링크 구성 (클라이언트에서 이메일로 공유) + // @ts-expect-error — Deno.env + const siteUrl = Deno.env.get('SITE_URL') ?? 'https://d3ro.dev' + const inviteUrl = `${siteUrl}/accept-invite?token=${encodeURIComponent(token)}` + + // TODO(V2-7c): Resend/SendGrid로 이메일 자동 발송 + // 현재는 URL만 반환 → 클라이언트가 복사/공유 + + return new Response( + JSON.stringify({ + id: invite.id, + url: inviteUrl, + expires_at: invite.expires_at + }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } catch (err) { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return authErrorResponse(err as AuthError, corsHeaders) + } + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) + } +}) diff --git a/server/supabase/migrations/20260410000001_team_invites.sql b/server/supabase/migrations/20260410000001_team_invites.sql new file mode 100644 index 0000000..5d5a63c --- /dev/null +++ b/server/supabase/migrations/20260410000001_team_invites.sql @@ -0,0 +1,74 @@ +-- ============================================================================ +-- Phase V2-7b: 팀 초대 플로우 — team_invites 테이블 +-- 이메일 기반 초대 토큰. accept endpoint에서 토큰 검증 후 team_members에 추가. +-- ============================================================================ + +CREATE TABLE public.team_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + team_id uuid NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, + invited_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + email text NOT NULL, + role text NOT NULL CHECK (role IN ('admin', 'member')) DEFAULT 'member', + token text NOT NULL UNIQUE, + accepted_at timestamptz, + accepted_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL DEFAULT (now() + interval '7 days'), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_team_invites_team_id ON public.team_invites(team_id); +CREATE INDEX idx_team_invites_token ON public.team_invites(token); +CREATE INDEX idx_team_invites_email ON public.team_invites(email); + +-- RLS +ALTER TABLE public.team_invites ENABLE ROW LEVEL SECURITY; + +-- SELECT: 같은 팀 멤버는 초대 목록 조회 가능, 본인 이메일로 온 초대도 조회 가능 +CREATE POLICY "team_invites_read" ON public.team_invites + FOR SELECT USING ( + team_id IN (SELECT team_id FROM public.team_members WHERE user_id = auth.uid()) + OR lower(email) = lower((SELECT email FROM auth.users WHERE id = auth.uid())) + ); + +-- INSERT: 팀 owner/admin만 초대 생성 +CREATE POLICY "team_invites_insert_admin" ON public.team_invites + FOR INSERT WITH CHECK ( + invited_by = auth.uid() + AND team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +-- DELETE: owner/admin 또는 초대 당사자 취소 +CREATE POLICY "team_invites_delete" ON public.team_invites + FOR DELETE USING ( + invited_by = auth.uid() + OR team_id IN ( + SELECT team_id FROM public.team_members + WHERE user_id = auth.uid() AND role IN ('owner', 'admin') + ) + ); + +-- UPDATE는 Edge Function(service_role)에서 accepted_at/accepted_by 세팅용 + +-- ============================================================================ +-- 토큰 생성 헬퍼 (service_role RPC) — 암호학적 난수 +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.generate_invite_token() +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_token text; +BEGIN + v_token := encode(gen_random_bytes(24), 'base64'); + -- URL-safe 문자로 변환 + v_token := replace(replace(replace(v_token, '+', '-'), '/', '_'), '=', ''); + RETURN v_token; +END; +$$; + +REVOKE ALL ON FUNCTION public.generate_invite_token FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.generate_invite_token TO service_role;