묶음 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
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
// server/supabase/functions/team-invite/index.ts
|
|
// 팀 초대 생성 — 이메일 기반 초대 토큰 발급.
|
|
// 초대 링크는 클라이언트가 이메일로 공유 (추후 Edge Function에서 SMTP/Resend 연동).
|
|
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
interface InviteRequest {
|
|
team_id: string
|
|
email: string
|
|
role?: 'admin' | 'member'
|
|
}
|
|
|
|
// @ts-expect-error — Deno 런타임 전역
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
|
|
if (req.method !== 'POST') {
|
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
try {
|
|
const user = await requireUser(req)
|
|
const body = (await req.json()) as InviteRequest
|
|
|
|
if (!body.team_id || !body.email) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'team_id and email are required' }),
|
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
const serviceClient = createServiceRoleClient()
|
|
|
|
// 권한 확인: 요청자가 해당 팀의 owner/admin 인가?
|
|
const { data: membership } = await serviceClient
|
|
.from('team_members')
|
|
.select('role')
|
|
.eq('team_id', body.team_id)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle()
|
|
|
|
const role = (membership?.role as string | undefined) ?? null
|
|
if (role !== 'owner' && role !== 'admin') {
|
|
return new Response(
|
|
JSON.stringify({ error: 'forbidden', message: '팀 owner/admin만 초대할 수 있습니다.' }),
|
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
// 토큰 생성 (service_role RPC)
|
|
const { data: tokenData, error: tokenErr } = await serviceClient.rpc('generate_invite_token')
|
|
if (tokenErr || !tokenData) {
|
|
throw new Error(`토큰 생성 실패: ${tokenErr?.message ?? 'unknown'}`)
|
|
}
|
|
const token = tokenData as string
|
|
|
|
// 초대 INSERT
|
|
const { data: invite, error: insertErr } = await serviceClient
|
|
.from('team_invites')
|
|
.insert({
|
|
team_id: body.team_id,
|
|
invited_by: user.id,
|
|
email: body.email.toLowerCase().trim(),
|
|
role: body.role ?? 'member',
|
|
token
|
|
})
|
|
.select('id, token, expires_at')
|
|
.single()
|
|
|
|
if (insertErr || !invite) {
|
|
throw new Error(`초대 생성 실패: ${insertErr?.message ?? 'unknown'}`)
|
|
}
|
|
|
|
// 초대 링크 구성 (클라이언트에서 이메일로 공유)
|
|
// @ts-expect-error — Deno.env
|
|
const siteUrl = Deno.env.get('SITE_URL') ?? 'https://d3ro.dev'
|
|
const inviteUrl = `${siteUrl}/accept-invite?token=${encodeURIComponent(token)}`
|
|
|
|
// 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,
|
|
email_sent: emailSent,
|
|
email_error: emailError
|
|
}),
|
|
{ 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' }
|
|
})
|
|
}
|
|
})
|