From 1fa24ce3c96440204143cd8537b543cdd8ecbabe Mon Sep 17 00:00:00 2001 From: yunchan8804 Date: Fri, 10 Apr 2026 08:51:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(V2-4=EC=B0=A8):=20Database=20=EC=A0=9C?= =?UTF-8?q?=EB=84=A4=EB=A6=AD=20=EB=B3=B5=EC=9B=90=20+=20/chat=20+=20/know?= =?UTF-8?q?ledge=20+=20Resend=20+=20Push=20+=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 묶음 K — Database 제네릭 완전 주입: - @supabase/ssr 0.5→0.10, @supabase/supabase-js 2.45→2.103 bump - 버전 정합으로 @supabase/ssr의 Database 제약이 정상 동작 - packages/api-client/src/types.ts: - TypedTable로 Row & Record 교차 유지 - 각 테이블 Insert를 Partial & Pick<필수필드>로 교체 (optional 컬럼이 required로 추론되던 문제 해결) - push_tokens / team_invites 테이블 타입도 inline 추가 - index.ts: types + client + auth + meetings + history + usage 전체 re-export 복원 - apps/web supabase-browser/server에 제네릭 주입 묶음 L — /chat 페이지 (Voice Conversation 이식): - apps/web/src/components/chat/chat-panel.tsx (client) - 메시지 state, user/assistant 말풍선, MetalCard 래핑 - llm-proxy POST (non-streaming JSON), Anthropic 응답 형식 파싱 - Enter 전송, Shift+Enter 줄바꿈, 초기화 버튼 - apps/web/src/app/(app)/chat/page.tsx - Sidebar에 Chat 메뉴 추가 (ChatIcon) - ko.json nav.chat 키 묶음 M — /knowledge 페이지 (RAG 이식): - migrations/20260410000002_knowledge_documents.sql - knowledge_documents + knowledge_chunks 테이블 - tsvector 자동 생성 컬럼 + GIN 인덱스 (전문 검색) - RLS: 개인/팀 분기 (team_id NULL 가능) - pgvector는 주석 처리 (V2-M+1에서 활성화) - apps/web/src/app/(app)/knowledge/page.tsx — 카드 리스트 + 상태 배지 - components/knowledge/add-knowledge-form.tsx — 제목/타입/본문 입력, 800자 단위 청킹 후 documents+chunks INSERT - api-client types.ts에 KnowledgeDocument/Chunk 추가 + Database 등록 - Sidebar Knowledge 메뉴 (LibraryBooksIcon) + ko.json 키 묶음 N — team-invite Resend 이메일: - functions/team-invite/index.ts에 Resend API 호출 로직 추가 - RESEND_API_KEY 설정 시 HTML 이메일 발송 (팀 이름/초대자/버튼/만료) - 응답에 email_sent / email_error 포함 - API 키 없으면 기존대로 URL만 반환 묶음 O — Expo Push notification: - migrations/20260410000003_push_tokens.sql (user_id, token unique, platform) - functions/send-push/index.ts: 호출자 인증 + 본인/팀 멤버 권한 체크, 대상 사용자 push_tokens 조회, Expo Push API 배치 호출 - config.toml에 send-push 함수 등록 - apps/mobile/package.json에 expo-device + expo-notifications 추가 - apps/mobile/lib/push.ts: registerPushToken (권한 요청, projectId, Android 채널, Supabase upsert) - auth-context.tsx에서 로그인 직후 자동 등록 - app.json plugins에 expo-notifications 묶음 P — meetings/[id] 문서 생성: - apps/web/components/meetings/generate-document-button.tsx (client) - 4개 템플릿 메뉴 (minutes/report/idea-note/mindmap) - 각 템플릿별 systemPrompt 지정 - llm-proxy 호출 → meeting_documents INSERT - latency 측정, prompt_used 기록 - meetings/[id]/page.tsx DOCUMENTS 섹션에 버튼 노출 (transcript는 edited_transcript ?? raw_transcript 우선) 검증: - desktop typecheck + build OK - web typecheck + build OK (14 라우트: 기존 12 + chat + knowledge) - api-client test 19 passed 통계: - 총 Edge Functions 10개: stt/llm-proxy, stripe-checkout/portal/webhook, team-invite/accept, send-push - 총 SQL 마이그레이션 7개 - 웹 라우트 14개, 모바일 화면 5개 - 테스트 19개 passed --- apps/mobile/app.json | 13 +- apps/mobile/lib/auth-context.tsx | 9 +- apps/mobile/lib/push.ts | 77 ++++++ apps/mobile/package.json | 2 + apps/web/package.json | 4 +- apps/web/src/app/(app)/chat/page.tsx | 17 ++ apps/web/src/app/(app)/knowledge/page.tsx | 91 +++++++ apps/web/src/app/(app)/meetings/[id]/page.tsx | 13 +- apps/web/src/components/chat/chat-panel.tsx | 224 ++++++++++++++++++ .../knowledge/add-knowledge-form.tsx | 160 +++++++++++++ apps/web/src/components/layout/sidebar.tsx | 14 ++ .../meetings/generate-document-button.tsx | 183 ++++++++++++++ apps/web/src/lib/supabase-browser.ts | 11 +- apps/web/src/lib/supabase-server.ts | 7 +- memory/project_status.md | 22 +- package-lock.json | 79 +++--- packages/api-client/src/index.ts | 13 +- packages/api-client/src/types.ts | 91 ++++++- packages/i18n/src/locales/ko.json | 2 + server/supabase/config.toml | 3 + server/supabase/functions/send-push/index.ts | 142 +++++++++++ .../supabase/functions/team-invite/index.ts | 71 +++++- .../20260410000002_knowledge_documents.sql | 90 +++++++ .../migrations/20260410000003_push_tokens.sql | 25 ++ 24 files changed, 1284 insertions(+), 79 deletions(-) create mode 100644 apps/mobile/lib/push.ts create mode 100644 apps/web/src/app/(app)/chat/page.tsx create mode 100644 apps/web/src/app/(app)/knowledge/page.tsx create mode 100644 apps/web/src/components/chat/chat-panel.tsx create mode 100644 apps/web/src/components/knowledge/add-knowledge-form.tsx create mode 100644 apps/web/src/components/meetings/generate-document-button.tsx create mode 100644 server/supabase/functions/send-push/index.ts create mode 100644 server/supabase/migrations/20260410000002_knowledge_documents.sql create mode 100644 server/supabase/migrations/20260410000003_push_tokens.sql diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 04dca32..85004c8 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -27,7 +27,18 @@ "backgroundColor": "#19191b" } }, - "plugins": ["expo-router", "expo-secure-store", "expo-av"], + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-av", + [ + "expo-notifications", + { + "icon": "./assets/icon.png", + "color": "#f25b29" + } + ] + ], "experiments": { "typedRoutes": true } diff --git a/apps/mobile/lib/auth-context.tsx b/apps/mobile/lib/auth-context.tsx index 18bef91..2da4575 100644 --- a/apps/mobile/lib/auth-context.tsx +++ b/apps/mobile/lib/auth-context.tsx @@ -1,9 +1,10 @@ // apps/mobile/lib/auth-context.tsx -// Supabase 세션 Context +// Supabase 세션 Context + 로그인 시 Expo Push 토큰 자동 등록 import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import type { Session, User } from '@supabase/supabase-js' import { supabase } from './supabase' +import { registerPushToken } from './push' interface AuthContextValue { session: Session | null @@ -26,6 +27,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React .getSession() .then(({ data }) => { setSession(data.session) + if (data.session?.user.id) { + void registerPushToken(data.session.user.id) + } }) .finally(() => setLoading(false)) @@ -33,6 +37,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React data: { subscription } } = supabase.auth.onAuthStateChange((_event, newSession) => { setSession(newSession) + if (newSession?.user.id) { + void registerPushToken(newSession.user.id) + } }) return () => { diff --git a/apps/mobile/lib/push.ts b/apps/mobile/lib/push.ts new file mode 100644 index 0000000..3327daf --- /dev/null +++ b/apps/mobile/lib/push.ts @@ -0,0 +1,77 @@ +// apps/mobile/lib/push.ts +// Expo Push token 등록 — 앱 진입 시 호출하면 Supabase push_tokens 테이블에 upsert. + +import * as Notifications from 'expo-notifications' +import * as Device from 'expo-device' +import { Platform } from 'react-native' +import Constants from 'expo-constants' +import { supabase } from './supabase' + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true + }) +}) + +/** + * Expo push token을 받아 Supabase push_tokens 테이블에 upsert. + * 시뮬레이터/에뮬레이터에서는 token이 발급되지 않으므로 조용히 return. + */ +export async function registerPushToken(userId: string): Promise { + if (!Device.isDevice) { + return + } + + try { + // 권한 요청 + const { status: existingStatus } = await Notifications.getPermissionsAsync() + let finalStatus = existingStatus + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync() + finalStatus = status + } + if (finalStatus !== 'granted') { + return + } + + // EAS projectId는 app.json/eas.json에서 + const projectId = + (Constants.expoConfig?.extra?.eas?.projectId as string | undefined) ?? + (Constants.easConfig?.projectId as string | undefined) + + const tokenData = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined) + const token = tokenData.data + + if (!token) return + + const platform: 'ios' | 'android' | 'web' = + Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web' + + // Android 채널 설정 + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#f25b29' + }) + } + + // Supabase upsert + await supabase.from('push_tokens').upsert( + { + user_id: userId, + token, + platform, + device_name: Device.deviceName ?? null + }, + { onConflict: 'token' } + ) + } catch { + // push 등록 실패는 앱 진행 차단하지 않음 + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 85aa85d..6344e7e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,7 +17,9 @@ "expo": "~51.0.0", "expo-av": "~14.0.0", "expo-constants": "~16.0.0", + "expo-device": "~6.0.0", "expo-linking": "~6.3.0", + "expo-notifications": "~0.28.0", "expo-router": "~3.5.0", "expo-secure-store": "~13.0.0", "expo-status-bar": "~1.12.0", diff --git a/apps/web/package.json b/apps/web/package.json index 235ed57..f06bfcc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,8 +23,8 @@ "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "@mui/material-nextjs": "^7.0.0", - "@supabase/ssr": "^0.5.0", - "@supabase/supabase-js": "^2.45.0", + "@supabase/ssr": "^0.10.0", + "@supabase/supabase-js": "^2.103.0", "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/apps/web/src/app/(app)/chat/page.tsx b/apps/web/src/app/(app)/chat/page.tsx new file mode 100644 index 0000000..02bb2d2 --- /dev/null +++ b/apps/web/src/app/(app)/chat/page.tsx @@ -0,0 +1,17 @@ +// apps/web/src/app/(app)/chat/page.tsx +// AI 채팅 페이지 — auth/Sidebar는 (app)/layout.tsx가 제공 + +import { Box } from '@mui/material' +import { PhosphorText } from '@d3ro/ui/components/ds' +import { ChatPanel } from '@/components/chat/chat-panel' + +export default function ChatPage(): React.ReactElement { + return ( + + + CHAT + + + + ) +} diff --git a/apps/web/src/app/(app)/knowledge/page.tsx b/apps/web/src/app/(app)/knowledge/page.tsx new file mode 100644 index 0000000..d065380 --- /dev/null +++ b/apps/web/src/app/(app)/knowledge/page.tsx @@ -0,0 +1,91 @@ +// apps/web/src/app/(app)/knowledge/page.tsx +// 지식 베이스 — RAG 문서 리스트 + 추가 + +import { Box, Stack } from '@mui/material' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { AddKnowledgeForm } from '@/components/knowledge/add-knowledge-form' +import { getSupabaseServerClient } from '@/lib/supabase-server' + +interface KnowledgeDoc { + id: string + title: string + file_name: string | null + file_type: string | null + chunk_count: number + indexed: boolean + created_at: string +} + +async function loadDocs(): Promise { + try { + const supabase = await getSupabaseServerClient() + const { data } = await supabase + .from('knowledge_documents') + .select('id, title, file_name, file_type, chunk_count, indexed, created_at') + .order('created_at', { ascending: false }) + .limit(100) + return (data ?? []) as KnowledgeDoc[] + } catch { + return [] + } +} + +export default async function KnowledgePage(): Promise { + const docs = await loadDocs() + + return ( + + + KNOWLEDGE + + + 지식 베이스에 문서를 추가하면 AI 채팅/회의록 생성에 활용됩니다. (임베딩 기반 시맨틱 + 검색은 V2-M+1에서 추가 예정) + + + + + + + {docs.length === 0 ? ( + + 아직 지식 문서가 없습니다. 위에서 추가하세요. + + ) : ( + + {docs.map((doc) => ( + + + + + {doc.title} + + + {doc.file_type?.toUpperCase() ?? '—'} · {doc.chunk_count} chunks ·{' '} + {new Date(doc.created_at).toLocaleDateString('ko-KR')} + + + + {doc.indexed ? 'INDEXED' : 'PENDING'} + + + + ))} + + )} + + ) +} diff --git a/apps/web/src/app/(app)/meetings/[id]/page.tsx b/apps/web/src/app/(app)/meetings/[id]/page.tsx index 2675293..8c9a4dd 100644 --- a/apps/web/src/app/(app)/meetings/[id]/page.tsx +++ b/apps/web/src/app/(app)/meetings/[id]/page.tsx @@ -10,6 +10,7 @@ import { LiveTranscriptList, type TranscriptRow } from '@/components/meetings/live-transcript-list' +import { GenerateDocumentButton } from '@/components/meetings/generate-document-button' interface PageProps { params: Promise<{ id: string }> @@ -94,9 +95,15 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise< {/* Documents */} - - DOCUMENTS - + + DOCUMENTS + + {(documents ?? []).length === 0 ? ( 생성된 문서가 없습니다. diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx new file mode 100644 index 0000000..2b41ced --- /dev/null +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -0,0 +1,224 @@ +'use client' + +// apps/web/src/components/chat/chat-panel.tsx +// LLM 채팅 패널 — llm-proxy 호출 (SSE 스트리밍 대신 non-streaming JSON 첫 버전) +// V1의 VoiceConversationService 흐름을 web에 이식한 MVP + +import { useRef, useState } from 'react' +import { Box, Button, TextField, Stack, Alert, CircularProgress } from '@mui/material' +import SendIcon from '@mui/icons-material/Send' +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep' +import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser' + +interface Message { + id: string + role: 'user' | 'assistant' + content: string +} + +interface LlmResponse { + id: string + model: string + role: string + content: Array<{ type: string; text: string }> + stop_reason: string + usage: { input_tokens: number; output_tokens: number } +} + +export function ChatPanel(): React.ReactElement { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const scrollRef = useRef(null) + + function scrollToBottom(): void { + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }) + } + + async function handleSend(): Promise { + const text = input.trim() + if (!text || busy) return + + const userMsg: Message = { id: `u_${Date.now()}`, role: 'user', content: text } + const nextMessages = [...messages, userMsg] + setMessages(nextMessages) + setInput('') + setError(null) + setBusy(true) + scrollToBottom() + + try { + if (!isSupabaseConfigured()) { + throw new Error('Supabase가 설정되지 않아 LLM 호출이 불가능합니다') + } + + const supabase = getSupabaseBrowserClient() + const { + data: { session } + } = await supabase.auth.getSession() + + if (!session) { + throw new Error('로그인이 필요합니다') + } + + const payload = { + messages: nextMessages.map((m) => ({ role: m.role, content: m.content })), + max_tokens: 1024 + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/llm-proxy`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + ) + + if (!response.ok) { + const errTxt = await response.text() + throw new Error(`LLM 호출 실패: ${response.status} ${errTxt}`) + } + + const data = (await response.json()) as LlmResponse + const assistantText = data.content?.[0]?.text ?? '[응답 없음]' + const assistantMsg: Message = { + id: data.id, + role: 'assistant', + content: assistantText + } + setMessages((prev) => [...prev, assistantMsg]) + scrollToBottom() + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setBusy(false) + } + } + + function handleClear(): void { + setMessages([]) + setError(null) + } + + return ( + + {/* 메시지 영역 */} + + {messages.length === 0 ? ( + + 메시지를 입력해 대화를 시작하세요. + + ) : ( + + {messages.map((msg) => ( + + + {msg.role === 'user' ? 'YOU' : 'ASSISTANT'} + + + + {msg.content} + + + + ))} + {busy && ( + + + + 응답 생성 중... + + + )} + + )} + + + {error && ( + + {error} + + )} + + {/* 입력 영역 */} + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void handleSend() + } + }} + placeholder="메시지 입력... (Enter: 전송, Shift+Enter: 줄바꿈)" + disabled={busy} + size="small" + /> + + + + + ) +} diff --git a/apps/web/src/components/knowledge/add-knowledge-form.tsx b/apps/web/src/components/knowledge/add-knowledge-form.tsx new file mode 100644 index 0000000..ddaa2bc --- /dev/null +++ b/apps/web/src/components/knowledge/add-knowledge-form.tsx @@ -0,0 +1,160 @@ +'use client' + +// apps/web/src/components/knowledge/add-knowledge-form.tsx +// 텍스트/URL 기반 지식 문서 추가 (MVP — 파일 업로드는 추후) +// 제출 시 knowledge_documents + knowledge_chunks 직접 insert + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Box, Button, TextField, Stack, Alert, MenuItem, Select, FormControl, InputLabel } from '@mui/material' +import AddIcon from '@mui/icons-material/Add' +import { MetalCard } from '@d3ro/ui/components/ds' +import { d3roPalette, typoSx } from '@d3ro/ui/theme' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +const CHUNK_SIZE = 800 // 문자 단위. 간단한 고정 크기 청킹. + +function chunkText(text: string, size: number): string[] { + const chunks: string[] = [] + for (let i = 0; i < text.length; i += size) { + chunks.push(text.slice(i, i + size)) + } + return chunks.filter((c) => c.trim().length > 0) +} + +export function AddKnowledgeForm(): React.ReactElement { + const router = useRouter() + const [open, setOpen] = useState(false) + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [fileType, setFileType] = useState<'txt' | 'md'>('txt') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function handleSubmit(): Promise { + if (!title.trim() || !content.trim()) return + setError(null) + setBusy(true) + try { + const supabase = getSupabaseBrowserClient() + const { + data: { user } + } = await supabase.auth.getUser() + + if (!user) { + setError('로그인이 필요합니다') + return + } + + const chunks = chunkText(content, CHUNK_SIZE) + + // 1) 문서 INSERT + const { data: doc, error: docErr } = await supabase + .from('knowledge_documents') + .insert({ + user_id: user.id, + title: title.trim(), + file_name: null, + file_type: fileType, + chunk_count: chunks.length, + indexed: true, + indexed_at: new Date().toISOString() + }) + .select('id') + .single() + + if (docErr || !doc) { + setError(docErr?.message ?? '문서 생성 실패') + return + } + + // 2) 청크 INSERT (배치) + const chunkRows = chunks.map((c, i) => ({ + document_id: doc.id, + chunk_index: i, + content: c + })) + const { error: chunkErr } = await supabase.from('knowledge_chunks').insert(chunkRows) + if (chunkErr) { + setError(`청크 저장 실패: ${chunkErr.message}`) + return + } + + setTitle('') + setContent('') + setOpen(false) + router.refresh() + } finally { + setBusy(false) + } + } + + if (!open) { + return ( + + ) + } + + return ( + + 새 지식 문서 + + setTitle(e.target.value)} + fullWidth + disabled={busy} + /> + + 타입 + + + setContent(e.target.value)} + placeholder="텍스트를 붙여넣기하세요. 800자 단위로 자동 청킹됩니다." + fullWidth + disabled={busy} + /> + {error && ( + + {error} + + )} + + + + + + + ) +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index dc15878..1d876f3 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -8,6 +8,8 @@ import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from import DashboardIcon from '@mui/icons-material/Dashboard' import MeetingRoomIcon from '@mui/icons-material/MeetingRoom' import MicIcon from '@mui/icons-material/Mic' +import ChatIcon from '@mui/icons-material/Chat' +import LibraryBooksIcon from '@mui/icons-material/LibraryBooks' import GroupsIcon from '@mui/icons-material/Groups' import PaymentIcon from '@mui/icons-material/Payment' import LogoutIcon from '@mui/icons-material/Logout' @@ -47,6 +49,18 @@ export function Sidebar(): React.ReactElement { label: t('nav.record') ?? 'Record', icon: }, + { + key: 'chat', + path: '/chat', + label: t('nav.chat') ?? 'Chat', + icon: + }, + { + key: 'knowledge', + path: '/knowledge', + label: t('nav.knowledge') ?? 'Knowledge', + icon: + }, { key: 'teams', path: '/teams', diff --git a/apps/web/src/components/meetings/generate-document-button.tsx b/apps/web/src/components/meetings/generate-document-button.tsx new file mode 100644 index 0000000..ec2de0c --- /dev/null +++ b/apps/web/src/components/meetings/generate-document-button.tsx @@ -0,0 +1,183 @@ +'use client' + +// apps/web/src/components/meetings/generate-document-button.tsx +// 회의록에서 AI 문서 생성 — llm-proxy 호출 → meeting_documents INSERT + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Button, + Menu, + MenuItem, + CircularProgress, + Alert, + Box +} from '@mui/material' +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import { getSupabaseBrowserClient } from '@/lib/supabase-browser' + +type TemplateType = 'minutes' | 'report' | 'idea-note' | 'mindmap' + +interface TemplateDef { + key: TemplateType + label: string + systemPrompt: string + title: string +} + +const TEMPLATES: TemplateDef[] = [ + { + key: 'minutes', + label: '회의록', + title: '회의록', + systemPrompt: + '다음 회의 전사를 바탕으로 정돈된 회의록을 마크다운으로 작성하세요. 섹션: 요약 / 주요 결정사항 / 액션 아이템 / 논의 상세.' + }, + { + key: 'report', + label: '리포트', + title: '리포트', + systemPrompt: + '다음 회의 전사를 바탕으로 격식 있는 리포트를 마크다운으로 작성하세요. 섹션: 개요 / 배경 / 주요 내용 / 결론 / 권고사항.' + }, + { + key: 'idea-note', + label: '아이디어 노트', + title: '아이디어 노트', + systemPrompt: + '다음 회의 전사에서 아이디어와 인사이트를 추출해 마크다운으로 정리하세요. 각 아이디어에 맥락/가능성/다음 단계를 포함.' + }, + { + key: 'mindmap', + label: '마인드맵', + title: '마인드맵', + systemPrompt: + '다음 회의 전사의 핵심 주제와 하위 토픽을 마크다운 아웃라인 (마인드맵 구조)으로 작성하세요.' + } +] + +interface GenerateDocumentButtonProps { + meetingId: string + transcript: string +} + +interface LlmResponse { + id: string + model: string + content: Array<{ type: string; text: string }> +} + +export function GenerateDocumentButton({ + meetingId, + transcript +}: GenerateDocumentButtonProps): React.ReactElement { + const router = useRouter() + const [anchorEl, setAnchorEl] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleGenerate(tpl: TemplateDef): Promise { + setAnchorEl(null) + setError(null) + setBusy(true) + + try { + const supabase = getSupabaseBrowserClient() + const { + data: { session } + } = await supabase.auth.getSession() + if (!session) { + setError('로그인이 필요합니다') + return + } + + if (!transcript || transcript.trim().length === 0) { + setError('전사 내용이 없습니다. 먼저 회의를 녹음하세요.') + return + } + + // llm-proxy 호출 + const startedAt = Date.now() + const llmResp = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/llm-proxy`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + messages: [{ role: 'user', content: transcript }], + system: tpl.systemPrompt, + max_tokens: 4096 + }) + } + ) + + if (!llmResp.ok) { + const errTxt = await llmResp.text() + throw new Error(`LLM 호출 실패: ${llmResp.status} ${errTxt}`) + } + + const llmData = (await llmResp.json()) as LlmResponse + const content = llmData.content?.[0]?.text ?? '' + const latency = Date.now() - startedAt + + // meeting_documents INSERT + const { + data: { user } + } = await supabase.auth.getUser() + if (!user) { + setError('로그인이 필요합니다') + return + } + + const { error: insertErr } = await supabase.from('meeting_documents').insert({ + meeting_id: meetingId, + user_id: user.id, + template_type: tpl.key, + title: tpl.title, + content, + prompt_used: tpl.systemPrompt, + llm_model: llmData.model, + llm_latency_ms: latency + }) + + if (insertErr) { + setError(`문서 저장 실패: ${insertErr.message}`) + return + } + + router.refresh() + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setBusy(false) + } + } + + return ( + + + setAnchorEl(null)}> + {TEMPLATES.map((tpl) => ( + void handleGenerate(tpl)}> + {tpl.label} + + ))} + + {error && ( + + {error} + + )} + + ) +} diff --git a/apps/web/src/lib/supabase-browser.ts b/apps/web/src/lib/supabase-browser.ts index 317adec..4582fe6 100644 --- a/apps/web/src/lib/supabase-browser.ts +++ b/apps/web/src/lib/supabase-browser.ts @@ -4,21 +4,20 @@ 'use client' // 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 제네릭 복원 예정. +// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합) import { createBrowserClient } from '@supabase/ssr' +import type { Database } from '@d3ro/api-client' -let cachedClient: ReturnType | null = null +let cachedClient: ReturnType> | null = null -export function getSupabaseBrowserClient(): ReturnType { +export function getSupabaseBrowserClient(): ReturnType> { if (cachedClient) return cachedClient const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co' const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key' - cachedClient = createBrowserClient(url, key) + cachedClient = createBrowserClient(url, key) return cachedClient } diff --git a/apps/web/src/lib/supabase-server.ts b/apps/web/src/lib/supabase-server.ts index 788c69e..fd77c71 100644 --- a/apps/web/src/lib/supabase-server.ts +++ b/apps/web/src/lib/supabase-server.ts @@ -1,17 +1,18 @@ // apps/web/src/lib/supabase-server.ts // RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션). -// Database 제네릭은 @supabase/ssr 내부 경로 이슈로 현재 미적용 (다음 사이클 복원). +// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합). import { cookies } from 'next/headers' import { createServerClient, type CookieOptions } from '@supabase/ssr' +import type { Database } from '@d3ro/api-client' -export async function getSupabaseServerClient(): Promise> { +export async function getSupabaseServerClient(): Promise>> { const cookieStore = await cookies() const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co' const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key' - return createServerClient(url, key, { + return createServerClient(url, key, { cookies: { getAll() { return cookieStore.getAll() diff --git a/memory/project_status.md b/memory/project_status.md index dfc1668..eea5217 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,6 +1,6 @@ # D3RO-VOICE 프로젝트 현황 -> 마지막 갱신: 2026-04-10 (3차 사이클) +> 마지막 갱신: 2026-04-10 (4차 사이클) > 규칙 13: 작업 완료 즉시 이 파일 갱신 의무 ## 현재 단계 @@ -27,13 +27,21 @@ V2 2차 고도화 (2026-04-09): 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 스캐폴딩) +- [F] ⚠️ Database 제네릭 — interface→type 전환 (4차에서 완전 복원) +- [G] ✅ apps/mobile UI를 @d3ro/ui-native로 실제 교체 +- [H] ✅ V2-7b 이메일 invite flow (team_invites + Edge Functions + /accept-invite) +- [I] ✅ Realtime transcripts 구독 (LiveTranscriptList) +- [J] ✅ api-client 유닛 테스트 + web E2E 스모크 스캐폴딩 -**다음 사이클**: 사용자 환경 액션 후 e2e 검증, Database 제네릭 완전 복원 (supabase 버전 맞추기), V1 고급 기능(음성 대화/RAG/보이스 액션) 이식, T2-8b 이메일 발송 실제 연동 +V2 4차 고도화 (2026-04-10): +- [K] ✅ @supabase/ssr 0.5→0.10, supabase-js 2.45→2.103 bump → Database 제네릭 완전 주입, api-client 루트 barrel 복원 +- [L] ✅ V1 Voice Conversation → web /chat (ChatPanel, llm-proxy 호출) +- [M] ✅ V1 RAG → web /knowledge (knowledge_documents/chunks 테이블, 청킹 + tsvector 기반 전문 검색 준비, AddKnowledgeForm) +- [N] ✅ team-invite Resend 이메일 발송 (HTML 템플릿, RESEND_API_KEY 환경변수) +- [O] ✅ mobile Expo Push — push_tokens 테이블, send-push Edge Function (팀 멤버 권한 검사), expo-notifications 등록 로직, app.json plugins +- [P] ✅ meetings/[id] 문서 생성 버튼 — GenerateDocumentButton (minutes/report/idea-note/mindmap 4종), llm-proxy 호출 → meeting_documents INSERT + +**다음 사이클**: 사용자 환경 실제 연결(Supabase 배포 + Resend/Stripe 키 + EAS build), pgvector 기반 knowledge 시맨틱 검색, 회의 문서 편집 기능, /chat 스트리밍 전환, V1 VoiceAction web 포팅 ## V1 완료 페이즈 diff --git a/package-lock.json b/package-lock.json index 63c5a83..66fb172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,8 +86,8 @@ "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "@mui/material-nextjs": "^7.0.0", - "@supabase/ssr": "^0.5.0", - "@supabase/supabase-js": "^2.45.0", + "@supabase/ssr": "^0.10.0", + "@supabase/supabase-js": "^2.103.0", "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -4780,9 +4780,9 @@ } }, "node_modules/@supabase/auth-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.102.1.tgz", - "integrity": "sha512-2uH2WB0H98TOGDtaFWhxIcR42Dro/VB7VDZanz/4bVJsqioIue1m3TUqu3xciDm2W9r+1LXQvYNsYbQfWmD+uQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz", + "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -4792,9 +4792,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.102.1.tgz", - "integrity": "sha512-UcrcKTPnAIo+Yp9Jjq9XXwFbsmgRYY637mwka9ZjmTIWcX/xr1pote4OVvaGQycVY1KTiQgjMvpC0Q0yJhRq3w==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz", + "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -4810,9 +4810,9 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.102.1.tgz", - "integrity": "sha512-InLvXKAYf8BIqiv9jWOYudWB3rU8A9uMbcip5BQ5sLLNPrbO1Ekkr79OvlhZBgMNSppxVyC7wPPGzLxMcTZhlA==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz", + "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -4822,9 +4822,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.102.1.tgz", - "integrity": "sha512-h2fCumib/v6u7XMwSPgxnpfimjX4xCEayUHrxWLC7UurfQjUZJ0pmJDgm6yj80DnUerxuulRghwm5zXYysFG/Q==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz", + "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==", "license": "MIT", "dependencies": { "@supabase/phoenix": "^0.4.0", @@ -4837,22 +4837,21 @@ } }, "node_modules/@supabase/ssr": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz", - "integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz", + "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.7.0" + "cookie": "^1.0.2" }, "peerDependencies": { - "@supabase/supabase-js": "^2.43.4" + "@supabase/supabase-js": "^2.102.1" } }, "node_modules/@supabase/storage-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.102.1.tgz", - "integrity": "sha512-eCL9T4Xpe40nmKlkUJ7Zq/hk34db1xPiT0WL3Iv5MbJqHuCAe5TxhV8Rjqd6DNZrzjtfYObZtYl9jKJaHrivqw==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz", + "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -4863,16 +4862,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.102.1.tgz", - "integrity": "sha512-bChxPVeLDnYN9M2d/u4fXsvylwSQG5grAl+HN8f+ZD9a9PuVU+Ru+xGmEsk+b9Iz3rJC9ZQnQUJYQ28fApdWYA==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz", + "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.102.1", - "@supabase/functions-js": "2.102.1", - "@supabase/postgrest-js": "2.102.1", - "@supabase/realtime-js": "2.102.1", - "@supabase/storage-js": "2.102.1" + "@supabase/auth-js": "2.103.0", + "@supabase/functions-js": "2.103.0", + "@supabase/postgrest-js": "2.103.0", + "@supabase/realtime-js": "2.103.0", + "@supabase/storage-js": "2.103.0" }, "engines": { "node": ">=20.0.0" @@ -4968,12 +4967,6 @@ "@types/responselike": "^1.0.0" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -7207,12 +7200,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/core-util-is": { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 3164989..33d1756 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,10 +1,8 @@ // packages/api-client — barrel export -// 루트는 타입만. 함수는 subpath로 접근 — 현재 Supabase SSR과 supabase-js 버전 -// 정합 이슈로 Database 제네릭이 @supabase/ssr에서 전파 실패하기 때문에, -// 내부에서 SupabaseClient를 쓰는 meetings/history/usage 함수들을 -// 루트로 끌어올리면 consumer의 tsc가 이 함수들까지 scan하여 never 전파가 일어남. +// Database 타입 + 모든 도메인 함수 재수출. +// @supabase/ssr 0.10 + supabase-js 2.103 정합으로 제네릭이 consumer에 전파됨. // -// Sub-path로만 접근: +// Sub-path도 지원: // '@d3ro/api-client/client' — Supabase 클라이언트 팩토리 // '@d3ro/api-client/auth' — 로그인/세션 // '@d3ro/api-client/meetings' — 회의 CRUD @@ -12,3 +10,8 @@ // '@d3ro/api-client/usage' — 쿼터/구독 export * from './types' +export * from './client' +export * from './auth' +export * from './meetings' +export * from './history' +export * from './usage' diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 3b8c1d6..ba8a524 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -158,6 +158,32 @@ export type Subscription = { updated_at: string } +export type KnowledgeFileType = 'txt' | 'md' | 'pdf' | 'docx' | 'html' | 'url' + +export type KnowledgeDocument = { + id: string + user_id: string + team_id: string | null + title: string + file_name: string | null + file_type: KnowledgeFileType | null + source_url: string | null + storage_key: string | null + chunk_count: number + indexed: boolean + indexed_at: string | null + created_at: string + updated_at: string +} + +export type KnowledgeChunk = { + id: string + document_id: string + chunk_index: number + content: string + created_at: string +} + /** * 유틸: Supabase GenericTable 제약(Row: Record)을 만족시키기 위해 * Row/Insert/Update에 index signature를 교차한다. @@ -182,12 +208,12 @@ export type Database = { profiles: TypedTable & Pick, Partial> teams: TypedTable< Team, - Omit & Partial>, + Partial & Pick, Partial > team_members: TypedTable< TeamMember, - Omit & Partial>, + Partial & Pick, Partial > meetings: TypedTable< @@ -197,19 +223,19 @@ export type Database = { > meeting_memos: TypedTable< MeetingMemo, - Omit & Partial>, + Partial & + Pick, Partial > meeting_documents: TypedTable< MeetingDocument, - Omit & - Partial>, + Partial & + Pick, Partial > transcripts: TypedTable< Transcript, - Omit & - Partial>, + Partial & Pick, Partial > history: TypedTable< @@ -228,6 +254,57 @@ export type Database = { Partial & Pick, Partial > + knowledge_documents: TypedTable< + KnowledgeDocument, + Partial & Pick, + Partial + > + knowledge_chunks: TypedTable< + KnowledgeChunk, + Partial & + Pick, + Partial + > + push_tokens: TypedTable< + { + id: string + user_id: string + token: string + platform: 'ios' | 'android' | 'web' + device_name: string | null + created_at: string + updated_at: string + }, + { + user_id: string + token: string + platform: 'ios' | 'android' | 'web' + device_name?: string | null + }, + Partial<{ device_name: string | null }> + > + team_invites: TypedTable< + { + id: string + team_id: string + invited_by: string + email: string + role: 'admin' | 'member' + token: string + accepted_at: string | null + accepted_by: string | null + expires_at: string + created_at: string + }, + { + team_id: string + invited_by: string + email: string + role?: 'admin' | 'member' + token: string + }, + Partial<{ accepted_at: string | null; accepted_by: string | null }> + > } Views: Record Functions: Record diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index 5132786..b7232c2 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -8,6 +8,8 @@ "nav.settings": "설정", "nav.meetings": "회의", "nav.record": "녹음", + "nav.chat": "채팅", + "nav.knowledge": "지식", "nav.teams": "팀", "nav.billing": "결제", "nav.logout": "로그아웃", diff --git a/server/supabase/config.toml b/server/supabase/config.toml index e49717c..af3152c 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -99,5 +99,8 @@ verify_jwt = true [functions.team-accept] verify_jwt = true +[functions.send-push] +verify_jwt = true + [analytics] enabled = false diff --git a/server/supabase/functions/send-push/index.ts b/server/supabase/functions/send-push/index.ts new file mode 100644 index 0000000..8af3b14 --- /dev/null +++ b/server/supabase/functions/send-push/index.ts @@ -0,0 +1,142 @@ +// server/supabase/functions/send-push/index.ts +// Expo Push Notification 발송 — 서버에서 push_tokens 조회 후 Expo API 호출. +// 호출 예시: +// POST /functions/v1/send-push +// { user_id: "...", title: "회의록 준비 완료", body: "..." } + +import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' +import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' +import { createServiceRoleClient } from '../_shared/quota.ts' + +interface PushRequest { + user_id: string + title: string + body: string + data?: Record +} + +interface ExpoPushMessage { + to: string + sound?: 'default' | null + title: string + body: string + data?: Record +} + +// @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 { + // 호출자 인증 (현재 로그인된 user만 본인 또는 같은 팀 유저에게 발송 가능) + const caller = await requireUser(req) + const body = (await req.json()) as PushRequest + + if (!body.user_id || !body.title || !body.body) { + return new Response( + JSON.stringify({ error: 'user_id, title, body가 필요합니다' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + const serviceClient = createServiceRoleClient() + + // 권한 체크: 자기 자신에게 보내거나 같은 팀 멤버에게만 + if (body.user_id !== caller.id) { + const { data: sharedTeams } = await serviceClient + .from('team_members') + .select('team_id') + .eq('user_id', caller.id) + + const callerTeamIds = (sharedTeams ?? []).map((r: { team_id: string }) => r.team_id) + + if (callerTeamIds.length > 0) { + const { data: targetMembership } = await serviceClient + .from('team_members') + .select('team_id') + .eq('user_id', body.user_id) + .in('team_id', callerTeamIds) + + if (!targetMembership || targetMembership.length === 0) { + return new Response( + JSON.stringify({ error: 'forbidden', message: '같은 팀 멤버가 아닙니다.' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + } else { + return new Response( + JSON.stringify({ error: 'forbidden' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + } + + // 대상 유저의 push tokens 조회 + const { data: tokens, error: tokenErr } = await serviceClient + .from('push_tokens') + .select('token, platform') + .eq('user_id', body.user_id) + + if (tokenErr) { + throw new Error(`토큰 조회 실패: ${tokenErr.message}`) + } + + if (!tokens || tokens.length === 0) { + return new Response( + JSON.stringify({ sent: 0, message: 'push token 없음' }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // Expo Push API 호출 — 토큰 여러 개 배치로 + const messages: ExpoPushMessage[] = tokens.map((t: { token: string }) => ({ + to: t.token, + sound: 'default', + title: body.title, + body: body.body, + data: body.data ?? {} + })) + + const expoResp = await fetch('https://exp.host/--/api/v2/push/send', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Accept-encoding': 'gzip, deflate', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(messages) + }) + + if (!expoResp.ok) { + const errText = await expoResp.text() + throw new Error(`Expo Push API 실패: ${expoResp.status} ${errText}`) + } + + const result = (await expoResp.json()) as { data: Array<{ status: string; id?: string }> } + + return new Response( + JSON.stringify({ + sent: messages.length, + results: result.data + }), + { 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 index c53ebf0..6ff8d01 100644 --- a/server/supabase/functions/team-invite/index.ts +++ b/server/supabase/functions/team-invite/index.ts @@ -82,14 +82,79 @@ Deno.serve(async (req: Request) => { const siteUrl = Deno.env.get('SITE_URL') ?? 'https://d3ro.dev' const inviteUrl = `${siteUrl}/accept-invite?token=${encodeURIComponent(token)}` - // TODO(V2-7c): Resend/SendGrid로 이메일 자동 발송 - // 현재는 URL만 반환 → 클라이언트가 복사/공유 + // Resend API로 이메일 발송 (옵션 — RESEND_API_KEY 설정 시에만) + // @ts-expect-error — Deno.env + const resendKey = Deno.env.get('RESEND_API_KEY') ?? '' + // @ts-expect-error — Deno.env + const fromAddress = Deno.env.get('RESEND_FROM') ?? 'D3RO Voice ' + let emailSent = false + let emailError: string | null = null + + if (resendKey) { + try { + // 팀 이름 조회 (이메일 본문용) + const { data: teamRow } = await serviceClient + .from('teams') + .select('name') + .eq('id', body.team_id) + .maybeSingle() + const teamName = (teamRow?.name as string | undefined) ?? '팀' + + const inviterEmail = user.email ?? '(알 수 없음)' + + const resendResp = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${resendKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: fromAddress, + to: [body.email.toLowerCase().trim()], + subject: `${teamName} 팀에 초대되었습니다 — D3RO Voice`, + html: ` +
+

D3RO VOICE

+

안녕하세요.

+

${inviterEmail} 님이 당신을 ${teamName} 팀(${body.role ?? 'member'})에 초대했습니다.

+

+ + 초대 수락하기 + +

+

+ 이 초대는 7일 후 만료됩니다. 직접 링크를 복사하려면: +

+

+ ${inviteUrl} +

+
+

+ D3RO Voice — 로컬+클라우드 하이브리드 AI 음성 어시스턴트 +

+
+ ` + }) + }) + + if (resendResp.ok) { + emailSent = true + } else { + emailError = `Resend ${resendResp.status}: ${await resendResp.text()}` + } + } catch (e) { + emailError = e instanceof Error ? e.message : String(e) + } + } return new Response( JSON.stringify({ id: invite.id, url: inviteUrl, - expires_at: invite.expires_at + expires_at: invite.expires_at, + email_sent: emailSent, + email_error: emailError }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) diff --git a/server/supabase/migrations/20260410000002_knowledge_documents.sql b/server/supabase/migrations/20260410000002_knowledge_documents.sql new file mode 100644 index 0000000..db9d4a2 --- /dev/null +++ b/server/supabase/migrations/20260410000002_knowledge_documents.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- Phase V2-M: Knowledge Base — RAG 기초 (텍스트 청크 + 단순 검색) +-- V1 RAGService의 Supabase 버전. pgvector 임베딩은 V2-M+1에서 추가 예정. +-- ============================================================================ + +-- pgvector extension (미래 임베딩 검색용, 현재는 미사용) +-- CREATE EXTENSION IF NOT EXISTS vector; + +-- 지식 문서 메타데이터 +CREATE TABLE public.knowledge_documents ( + 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 NOT NULL, + file_name text, + file_type text CHECK (file_type IN ('txt', 'md', 'pdf', 'docx', 'html', 'url')), + source_url text, + storage_key text, -- Supabase Storage의 경로 (업로드된 경우) + chunk_count integer NOT NULL DEFAULT 0, + indexed boolean NOT NULL DEFAULT false, + indexed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_knowledge_documents_user_id ON public.knowledge_documents(user_id); +CREATE INDEX idx_knowledge_documents_team_id ON public.knowledge_documents(team_id); +CREATE INDEX idx_knowledge_documents_created ON public.knowledge_documents(user_id, created_at DESC); + +-- 청크 — 원문을 분할한 단위. 임베딩은 추후 vector 컬럼 추가. +CREATE TABLE public.knowledge_chunks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + document_id uuid NOT NULL REFERENCES public.knowledge_documents(id) ON DELETE CASCADE, + chunk_index integer NOT NULL, + content text NOT NULL, + -- embedding vector(768), -- V2-M+1에서 pgvector 활성화 + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_knowledge_chunks_document ON public.knowledge_chunks(document_id); +CREATE UNIQUE INDEX idx_knowledge_chunks_uniq ON public.knowledge_chunks(document_id, chunk_index); + +-- 임시: 전문 검색을 위한 tsvector (pgvector 적용 전 대체재) +ALTER TABLE public.knowledge_chunks + ADD COLUMN tsv tsvector + GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED; + +CREATE INDEX idx_knowledge_chunks_tsv ON public.knowledge_chunks USING gin(tsv); + +-- RLS ------------------------------------------------------------------------ +ALTER TABLE public.knowledge_documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.knowledge_chunks ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "knowledge_documents_read" ON public.knowledge_documents + 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 "knowledge_documents_insert" ON public.knowledge_documents + FOR INSERT WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "knowledge_documents_update" ON public.knowledge_documents + FOR UPDATE USING (user_id = auth.uid()); + +CREATE POLICY "knowledge_documents_delete" ON public.knowledge_documents + FOR DELETE USING (user_id = auth.uid()); + +-- 청크는 부모 문서 권한 경유 +CREATE POLICY "knowledge_chunks_read" ON public.knowledge_chunks + FOR SELECT USING ( + document_id IN (SELECT id FROM public.knowledge_documents) + ); + +CREATE POLICY "knowledge_chunks_insert" ON public.knowledge_chunks + FOR INSERT WITH CHECK ( + document_id IN (SELECT id FROM public.knowledge_documents WHERE user_id = auth.uid()) + ); + +CREATE POLICY "knowledge_chunks_delete" ON public.knowledge_chunks + FOR DELETE USING ( + document_id IN (SELECT id FROM public.knowledge_documents WHERE user_id = auth.uid()) + ); + +-- updated_at trigger +CREATE TRIGGER set_updated_at_knowledge_documents + BEFORE UPDATE ON public.knowledge_documents + FOR EACH ROW EXECUTE FUNCTION public.moddatetime(); diff --git a/server/supabase/migrations/20260410000003_push_tokens.sql b/server/supabase/migrations/20260410000003_push_tokens.sql new file mode 100644 index 0000000..73b264c --- /dev/null +++ b/server/supabase/migrations/20260410000003_push_tokens.sql @@ -0,0 +1,25 @@ +-- ============================================================================ +-- Phase V2-O: Expo Push Token 저장 +-- 모바일 앱 로그인 시 Expo push token을 등록. 서버에서 토큰으로 notification 발송. +-- ============================================================================ + +CREATE TABLE public.push_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + token text NOT NULL UNIQUE, + platform text NOT NULL CHECK (platform IN ('ios', 'android', 'web')), + device_name text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_push_tokens_user_id ON public.push_tokens(user_id); + +ALTER TABLE public.push_tokens ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "push_tokens_own" ON public.push_tokens + FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +CREATE TRIGGER set_updated_at_push_tokens + BEFORE UPDATE ON public.push_tokens + FOR EACH ROW EXECUTE FUNCTION public.moddatetime();