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

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