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

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