feat(V2-4차): Database 제네릭 복원 + /chat + /knowledge + Resend + Push + 문서 생성

묶음 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, Insert, Update>로 Row & Record<string, unknown> 교차 유지
  - 각 테이블 Insert를 Partial<Row> & Pick<필수필드>로 교체
    (optional 컬럼이 required로 추론되던 문제 해결)
  - push_tokens / team_invites 테이블 타입도 inline 추가
- index.ts: types + client + auth + meetings + history + usage 전체 re-export 복원
- apps/web supabase-browser/server에 <Database> 제네릭 주입

묶음 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
This commit is contained in:
yunchan8804 2026-04-10 08:51:53 +09:00
parent c167737198
commit 1fa24ce3c9
24 changed files with 1284 additions and 79 deletions

View file

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

View file

@ -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 () => {

77
apps/mobile/lib/push.ts Normal file
View file

@ -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<void> {
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 등록 실패는 앱 진행 차단하지 않음
}
}

View file

@ -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",

View file

@ -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"

View file

@ -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 (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 3 }}>
CHAT
</PhosphorText>
<ChatPanel />
</Box>
)
}

View file

@ -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<KnowledgeDoc[]> {
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<React.ReactElement> {
const docs = await loadDocs()
return (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 1 }}>
KNOWLEDGE
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13, mb: 4 }}>
AI / . (
V2-M+1 )
</Box>
<Box sx={{ mb: 4 }}>
<AddKnowledgeForm />
</Box>
{docs.length === 0 ? (
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>
. .
</MetalCard>
) : (
<Stack spacing={2}>
{docs.map((doc) => (
<MetalCard key={doc.id} sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box sx={{ flex: 1 }}>
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 0.5 }}>
{doc.title}
</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
{doc.file_type?.toUpperCase() ?? '—'} · {doc.chunk_count} chunks ·{' '}
{new Date(doc.created_at).toLocaleDateString('ko-KR')}
</Box>
</Box>
<Box
sx={{
ml: 2,
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: 10,
letterSpacing: '1px',
textTransform: 'uppercase',
bgcolor: doc.indexed ? d3roPalette.tag.greenBg : d3roPalette.tag.orangeBg,
color: doc.indexed ? d3roPalette.tag.green : d3roPalette.tag.orange
}}
>
{doc.indexed ? 'INDEXED' : 'PENDING'}
</Box>
</Stack>
</MetalCard>
))}
</Stack>
)}
</Box>
)
}

View file

@ -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 */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
DOCUMENTS
</PhosphorText>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<PhosphorText variant="heading">DOCUMENTS</PhosphorText>
<GenerateDocumentButton
meetingId={id}
transcript={
meeting.edited_transcript ?? meeting.raw_transcript ?? ''
}
/>
</Stack>
{(documents ?? []).length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
.

View file

@ -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<Message[]>([])
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
function scrollToBottom(): void {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
})
}
async function handleSend(): Promise<void> {
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 (
<Stack spacing={2} sx={{ height: 'calc(100vh - 120px)' }}>
{/* 메시지 영역 */}
<Box
ref={scrollRef}
sx={{
flex: 1,
overflowY: 'auto',
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: 2
}}
>
{messages.length === 0 ? (
<Box
sx={{
textAlign: 'center',
color: d3roPalette.text.muted,
fontSize: 13,
mt: 4
}}
>
.
</Box>
) : (
<Stack spacing={2}>
{messages.map((msg) => (
<Box
key={msg.id}
sx={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '80%'
}}
>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>
{msg.role === 'user' ? 'YOU' : 'ASSISTANT'}
</Box>
<MetalCard
sx={{
p: 2,
bgcolor: msg.role === 'user' ? d3roPalette.bg.elevated : d3roPalette.bg.card
}}
>
<Box
sx={{
...typoSx('body'),
color: d3roPalette.text.primary,
whiteSpace: 'pre-wrap'
}}
>
{msg.content}
</Box>
</MetalCard>
</Box>
))}
{busy && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="warning" />
<PhosphorText variant="label" color="label">
...
</PhosphorText>
</Box>
)}
</Stack>
)}
</Box>
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
{/* 입력 영역 */}
<Stack direction="row" spacing={1}>
<TextField
fullWidth
multiline
maxRows={4}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}}
placeholder="메시지 입력... (Enter: 전송, Shift+Enter: 줄바꿈)"
disabled={busy}
size="small"
/>
<Button
variant="contained"
startIcon={<SendIcon />}
onClick={() => void handleSend()}
disabled={busy || !input.trim()}
>
</Button>
<Button
variant="outlined"
startIcon={<DeleteSweepIcon />}
onClick={handleClear}
disabled={busy || messages.length === 0}
>
</Button>
</Stack>
</Stack>
)
}

View file

@ -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<string | null>(null)
const [busy, setBusy] = useState(false)
async function handleSubmit(): Promise<void> {
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 (
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
</Button>
)
}
return (
<MetalCard sx={{ p: 3, maxWidth: 720 }}>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}> </Box>
<Stack spacing={2}>
<TextField
label="제목"
size="small"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
disabled={busy}
/>
<FormControl size="small">
<InputLabel></InputLabel>
<Select value={fileType} label="타입" onChange={(e) => setFileType(e.target.value as 'txt' | 'md')}>
<MenuItem value="txt">Plain text</MenuItem>
<MenuItem value="md">Markdown</MenuItem>
</Select>
</FormControl>
<TextField
label="본문"
size="small"
multiline
minRows={8}
maxRows={16}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="텍스트를 붙여넣기하세요. 800자 단위로 자동 청킹됩니다."
fullWidth
disabled={busy}
/>
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
<Stack direction="row" spacing={1}>
<Button
variant="contained"
onClick={() => void handleSubmit()}
disabled={busy || !title.trim() || !content.trim()}
>
</Button>
<Button
variant="outlined"
onClick={() => {
setOpen(false)
setTitle('')
setContent('')
setError(null)
}}
disabled={busy}
>
</Button>
</Stack>
</Stack>
</MetalCard>
)
}

View file

@ -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: <MicIcon />
},
{
key: 'chat',
path: '/chat',
label: t('nav.chat') ?? 'Chat',
icon: <ChatIcon />
},
{
key: 'knowledge',
path: '/knowledge',
label: t('nav.knowledge') ?? 'Knowledge',
icon: <LibraryBooksIcon />
},
{
key: 'teams',
path: '/teams',

View file

@ -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<HTMLElement | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleGenerate(tpl: TemplateDef): Promise<void> {
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 (
<Box>
<Button
variant="contained"
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={(e) => setAnchorEl(e.currentTarget)}
disabled={busy}
>
{busy ? '생성 중...' : 'AI 문서 생성'}
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
{TEMPLATES.map((tpl) => (
<MenuItem key={tpl.key} onClick={() => void handleGenerate(tpl)}>
{tpl.label}
</MenuItem>
))}
</Menu>
{error && (
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
{error}
</Alert>
)}
</Box>
)
}

View file

@ -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<typeof createBrowserClient> | null = null
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient> {
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
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<Database>(url, key)
return cachedClient
}

View file

@ -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<ReturnType<typeof createServerClient>> {
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
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<Database>(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()

View file

@ -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 완료 페이즈

79
package-lock.json generated
View file

@ -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": {

View file

@ -1,10 +1,8 @@
// packages/api-client — barrel export
// 루트는 타입만. 함수는 subpath로 접근 — 현재 Supabase SSR과 supabase-js 버전
// 정합 이슈로 Database 제네릭이 @supabase/ssr에서 전파 실패하기 때문에,
// 내부에서 SupabaseClient<Database>를 쓰는 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'

View file

@ -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<string, unknown>)
* Row/Insert/Update에 index signature를 .
@ -182,12 +208,12 @@ export type Database = {
profiles: TypedTable<Profile, Partial<Profile> & Pick<Profile, 'id'>, Partial<Profile>>
teams: TypedTable<
Team,
Omit<Team, 'id' | 'created_at' | 'updated_at'> & Partial<Pick<Team, 'id'>>,
Partial<Team> & Pick<Team, 'name' | 'owner_id'>,
Partial<Team>
>
team_members: TypedTable<
TeamMember,
Omit<TeamMember, 'joined_at'> & Partial<Pick<TeamMember, 'joined_at'>>,
Partial<TeamMember> & Pick<TeamMember, 'team_id' | 'user_id'>,
Partial<TeamMember>
>
meetings: TypedTable<
@ -197,19 +223,19 @@ export type Database = {
>
meeting_memos: TypedTable<
MeetingMemo,
Omit<MeetingMemo, 'id' | 'created_at'> & Partial<Pick<MeetingMemo, 'id' | 'created_at'>>,
Partial<MeetingMemo> &
Pick<MeetingMemo, 'meeting_id' | 'user_id' | 'content' | 'timestamp_ms'>,
Partial<MeetingMemo>
>
meeting_documents: TypedTable<
MeetingDocument,
Omit<MeetingDocument, 'id' | 'created_at' | 'updated_at'> &
Partial<Pick<MeetingDocument, 'id' | 'created_at' | 'updated_at'>>,
Partial<MeetingDocument> &
Pick<MeetingDocument, 'meeting_id' | 'user_id' | 'template_type' | 'title' | 'content'>,
Partial<MeetingDocument>
>
transcripts: TypedTable<
Transcript,
Omit<Transcript, 'id' | 'created_at' | 'updated_at'> &
Partial<Pick<Transcript, 'id' | 'created_at' | 'updated_at'>>,
Partial<Transcript> & Pick<Transcript, 'meeting_id' | 'segment_index' | 'timestamp_ms' | 'text'>,
Partial<Transcript>
>
history: TypedTable<
@ -228,6 +254,57 @@ export type Database = {
Partial<Subscription> & Pick<Subscription, 'user_id'>,
Partial<Subscription>
>
knowledge_documents: TypedTable<
KnowledgeDocument,
Partial<KnowledgeDocument> & Pick<KnowledgeDocument, 'user_id' | 'title'>,
Partial<KnowledgeDocument>
>
knowledge_chunks: TypedTable<
KnowledgeChunk,
Partial<KnowledgeChunk> &
Pick<KnowledgeChunk, 'document_id' | 'chunk_index' | 'content'>,
Partial<KnowledgeChunk>
>
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<string, never>
Functions: Record<string, never>

View file

@ -8,6 +8,8 @@
"nav.settings": "설정",
"nav.meetings": "회의",
"nav.record": "녹음",
"nav.chat": "채팅",
"nav.knowledge": "지식",
"nav.teams": "팀",
"nav.billing": "결제",
"nav.logout": "로그아웃",

View file

@ -99,5 +99,8 @@ verify_jwt = true
[functions.team-accept]
verify_jwt = true
[functions.send-push]
verify_jwt = true
[analytics]
enabled = false

View file

@ -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<string, unknown>
}
interface ExpoPushMessage {
to: string
sound?: 'default' | null
title: string
body: string
data?: Record<string, unknown>
}
// @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' }
})
}
})

View file

@ -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 <noreply@d3ro.dev>'
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: `
<div style="font-family: system-ui, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #1a1a1c;">
<h1 style="color: #f25b29; font-weight: 300; letter-spacing: -1px;">D3RO VOICE</h1>
<p>.</p>
<p><strong>${inviterEmail}</strong> <strong>${teamName}</strong> (${body.role ?? 'member'}) .</p>
<p style="margin: 32px 0;">
<a href="${inviteUrl}"
style="display: inline-block; padding: 14px 32px; background: #f25b29; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 600;">
</a>
</p>
<p style="color: #8e8e93; font-size: 13px;">
7 . :
</p>
<p style="background: #f5f5f7; padding: 12px; border-radius: 6px; font-family: monospace; font-size: 11px; word-break: break-all;">
${inviteUrl}
</p>
<hr style="margin: 32px 0; border: 0; border-top: 1px solid #eee;" />
<p style="color: #8e8e93; font-size: 11px;">
D3RO Voice + AI
</p>
</div>
`
})
})
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' } }
)

View file

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

View file

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