feat(V2-3): Web App MVP — Next.js 15 + @d3ro/api-client
packages/api-client (@d3ro/api-client) 신규: - types.ts: 12개 테이블 Row/Insert/Update 타입 + Database 형식 - client.ts: createD3roSupabaseClient 팩토리 (placeholder fallback) - auth.ts: signInWithOAuth/signOut/getSession/onAuthStateChange - meetings.ts: 회의 + 메모 + 문서 + 전사 CRUD - history.ts: 음성 입력 이력 CRUD - usage.ts: 일일 쿼터 + 구독 조회 - 루트 barrel은 types만 re-export, 함수는 subpath import 전용 apps/web (@d3ro/web) 신규 — Next.js 15 App Router: - transpilePackages: @d3ro/core, ui, i18n, api-client - ThemeProvider (MUI + AppRouterCacheProvider) - I18nProvider (localStorage 어댑터) - AuthProvider (Supabase session Context) - 라우트 9개: - / (auth 상태 기반 리다이렉트) - /login (Google/GitHub OAuth, 미설정 경고) - /auth/callback (code -> session 교환) - /dashboard (요약 카드 + 최근 회의) - /meetings (카드 그리드 리스트) - /meetings/[id] (transcripts/memos/documents) - /record (getUserMedia + MediaRecorder + stt-proxy) - Sidebar, 인증 가드, 9바 웨이브폼, 레벨 미터 packages/ui 확장: - MetalCard가 BoxProps 상속 (sx/onClick 등 전달) - theme.ts에 typoSx(key) 헬퍼 추가 (d3roTypo -> MUI sx 변환) - d3roPalette.tag.blue 추가 (M5 정리 포함) - DS 컴포넌트 9개에 'use client' directive - MetalDial 미사용 import 제거 packages/i18n 확장: - ko.json에 7개 새 키 (nav.meetings/record/logout, login.*) 설계 결정: - Database 제네릭 현재는 기본 타입 (V2-4에서 supabase gen types로 자동화) - api-client barrel은 타입만 노출해서 컴파일 전파 차단 - DS 컴포넌트 client 경계 명시 - env 없어도 Next.js 빌드 성공 (placeholder URL/key) 검증: - web typecheck OK - desktop typecheck OK (회귀 없음) - web next build OK (9 라우트 정적/동적 생성) - desktop build OK (회귀 없음)
This commit is contained in:
parent
9742b2109a
commit
d0c33ca259
190 changed files with 6167 additions and 18 deletions
58
packages/api-client/src/auth.ts
Normal file
58
packages/api-client/src/auth.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// packages/api-client/src/auth.ts
|
||||
// Supabase Auth 래퍼. OAuth, session, 로그아웃.
|
||||
|
||||
import type { Session, User, Provider } from '@supabase/supabase-js'
|
||||
import type { D3roSupabaseClient } from './client'
|
||||
|
||||
export type { Session, User }
|
||||
|
||||
export interface SignInOptions {
|
||||
provider: Extract<Provider, 'google' | 'github' | 'apple'>
|
||||
redirectTo: string
|
||||
}
|
||||
|
||||
export async function signInWithOAuth(
|
||||
client: D3roSupabaseClient,
|
||||
options: SignInOptions
|
||||
): Promise<{ url: string | null; error: string | null }> {
|
||||
const { data, error } = await client.auth.signInWithOAuth({
|
||||
provider: options.provider,
|
||||
options: {
|
||||
redirectTo: options.redirectTo
|
||||
}
|
||||
})
|
||||
return {
|
||||
url: data?.url ?? null,
|
||||
error: error?.message ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(client: D3roSupabaseClient): Promise<{ error: string | null }> {
|
||||
const { error } = await client.auth.signOut()
|
||||
return { error: error?.message ?? null }
|
||||
}
|
||||
|
||||
export async function getSession(client: D3roSupabaseClient): Promise<Session | null> {
|
||||
const { data } = await client.auth.getSession()
|
||||
return data.session ?? null
|
||||
}
|
||||
|
||||
export async function getUser(client: D3roSupabaseClient): Promise<User | null> {
|
||||
const { data } = await client.auth.getUser()
|
||||
return data.user ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 변경 구독. 반환된 함수를 호출하면 구독 해제.
|
||||
*/
|
||||
export function onAuthStateChange(
|
||||
client: D3roSupabaseClient,
|
||||
callback: (session: Session | null) => void
|
||||
): () => void {
|
||||
const { data } = client.auth.onAuthStateChange((_event, session) => {
|
||||
callback(session)
|
||||
})
|
||||
return () => {
|
||||
data.subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
56
packages/api-client/src/client.ts
Normal file
56
packages/api-client/src/client.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// packages/api-client/src/client.ts
|
||||
// Supabase 클라이언트 팩토리. 각 앱이 자신의 env에서 URL/KEY를 주입하여 생성.
|
||||
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './types'
|
||||
|
||||
export type D3roSupabaseClient = SupabaseClient<Database>
|
||||
|
||||
export interface CreateClientOptions {
|
||||
url: string | undefined
|
||||
anonKey: string | undefined
|
||||
/**
|
||||
* SSR 환경에서 세션 영속화를 쿠키 기반으로 하려면 별도 storage adapter를 주입.
|
||||
* 지정하지 않으면 기본(localStorage in browser, 메모리 in SSR).
|
||||
*/
|
||||
auth?: {
|
||||
persistSession?: boolean
|
||||
autoRefreshToken?: boolean
|
||||
storageKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase 클라이언트 생성.
|
||||
* url 또는 anonKey가 비어있으면 "dummy" 클라이언트를 반환 — 모든 쿼리는 빈 결과.
|
||||
* 이렇게 해야 env가 세팅되지 않은 상태에서도 빌드/SSR이 실패하지 않는다.
|
||||
*/
|
||||
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
|
||||
const { url, anonKey, auth } = options
|
||||
|
||||
if (!url || !anonKey) {
|
||||
// Env가 없으면 fallback URL로 생성. 실제 호출은 런타임에 실패하지만
|
||||
// 빌드/타입체크는 통과. 앱 상단에서 isClientConfigured()로 체크해야 함.
|
||||
return createClient<Database>('https://placeholder.supabase.co', 'placeholder-anon-key', {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return createClient<Database>(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: auth?.persistSession ?? true,
|
||||
autoRefreshToken: auth?.autoRefreshToken ?? true,
|
||||
storageKey: auth?.storageKey
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Env가 실제로 세팅되었는지 확인. UI에서 "Supabase 미설정" 경고 표시용.
|
||||
*/
|
||||
export function isClientConfigured(options: CreateClientOptions): boolean {
|
||||
return Boolean(options.url && options.anonKey)
|
||||
}
|
||||
60
packages/api-client/src/history.ts
Normal file
60
packages/api-client/src/history.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// packages/api-client/src/history.ts
|
||||
// history 테이블 도메인 함수 (개인 음성 입력 이력)
|
||||
|
||||
import type { D3roSupabaseClient } from './client'
|
||||
import type { HistoryEntry, HistoryMode, HistoryStatus } from './types'
|
||||
|
||||
export async function listHistory(
|
||||
client: D3roSupabaseClient,
|
||||
options?: { limit?: number; mode?: HistoryMode; status?: HistoryStatus }
|
||||
): Promise<HistoryEntry[]> {
|
||||
let query = client
|
||||
.from('history')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (options?.limit) query = query.limit(options.limit)
|
||||
if (options?.mode) query = query.eq('mode', options.mode)
|
||||
if (options?.status) query = query.eq('status', options.status)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`listHistory failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function createHistoryEntry(
|
||||
client: D3roSupabaseClient,
|
||||
input: {
|
||||
userId: string
|
||||
originalText: string
|
||||
duration: number
|
||||
mode?: HistoryMode
|
||||
polishedText?: string | null
|
||||
detectedLanguage?: string | null
|
||||
}
|
||||
): Promise<HistoryEntry> {
|
||||
const { data, error } = await client
|
||||
.from('history')
|
||||
.insert({
|
||||
user_id: input.userId,
|
||||
original_text: input.originalText,
|
||||
duration: input.duration,
|
||||
mode: input.mode ?? 'dictation',
|
||||
polished_text: input.polishedText ?? null,
|
||||
detected_language: input.detectedLanguage ?? null,
|
||||
word_count: input.originalText.split(/\s+/).filter(Boolean).length
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`createHistoryEntry failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(
|
||||
client: D3roSupabaseClient,
|
||||
entryId: string
|
||||
): Promise<void> {
|
||||
const { error } = await client.from('history').delete().eq('id', entryId)
|
||||
if (error) throw new Error(`deleteHistoryEntry failed: ${error.message}`)
|
||||
}
|
||||
11
packages/api-client/src/index.ts
Normal file
11
packages/api-client/src/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// packages/api-client — barrel export
|
||||
// 개별 sub-path import 권장:
|
||||
// '@d3ro/api-client/client' — Supabase 클라이언트 팩토리
|
||||
// '@d3ro/api-client/auth' — 로그인/세션
|
||||
// '@d3ro/api-client/meetings' — 회의 CRUD
|
||||
// '@d3ro/api-client/history' — 음성 입력 이력
|
||||
// '@d3ro/api-client/usage' — 쿼터/구독
|
||||
//
|
||||
// 루트 barrel은 타입만 노출. 함수는 subpath로 import (tree-shaking + 순환 의존 방지).
|
||||
|
||||
export * from './types'
|
||||
225
packages/api-client/src/meetings.ts
Normal file
225
packages/api-client/src/meetings.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// packages/api-client/src/meetings.ts
|
||||
// meetings / meeting_memos / meeting_documents / transcripts 도메인 함수.
|
||||
|
||||
import type { D3roSupabaseClient } from './client'
|
||||
import type {
|
||||
Meeting,
|
||||
MeetingMemo,
|
||||
MeetingDocument,
|
||||
Transcript,
|
||||
DocumentTemplateType
|
||||
} from './types'
|
||||
|
||||
// ── meetings ─────────────────────────────────────────────────
|
||||
|
||||
export async function listMeetings(
|
||||
client: D3roSupabaseClient,
|
||||
options?: { limit?: number; teamId?: string | null }
|
||||
): Promise<Meeting[]> {
|
||||
let query = client
|
||||
.from('meetings')
|
||||
.select('*')
|
||||
.order('started_at', { ascending: false })
|
||||
|
||||
if (options?.limit) {
|
||||
query = query.limit(options.limit)
|
||||
}
|
||||
if (options?.teamId !== undefined) {
|
||||
query = options.teamId === null ? query.is('team_id', null) : query.eq('team_id', options.teamId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`listMeetings failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function getMeeting(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string
|
||||
): Promise<Meeting | null> {
|
||||
const { data, error } = await client
|
||||
.from('meetings')
|
||||
.select('*')
|
||||
.eq('id', meetingId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error(`getMeeting failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createMeeting(
|
||||
client: D3roSupabaseClient,
|
||||
input: {
|
||||
userId: string
|
||||
title?: string | null
|
||||
teamId?: string | null
|
||||
}
|
||||
): Promise<Meeting> {
|
||||
const { data, error } = await client
|
||||
.from('meetings')
|
||||
.insert({
|
||||
user_id: input.userId,
|
||||
title: input.title ?? null,
|
||||
team_id: input.teamId ?? null,
|
||||
status: 'recording'
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`createMeeting failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateMeeting(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string,
|
||||
patch: Partial<Meeting>
|
||||
): Promise<Meeting> {
|
||||
const { data, error } = await client
|
||||
.from('meetings')
|
||||
.update(patch)
|
||||
.eq('id', meetingId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`updateMeeting failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMeeting(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string
|
||||
): Promise<void> {
|
||||
const { error } = await client.from('meetings').delete().eq('id', meetingId)
|
||||
if (error) throw new Error(`deleteMeeting failed: ${error.message}`)
|
||||
}
|
||||
|
||||
// ── meeting_memos ────────────────────────────────────────────
|
||||
|
||||
export async function listMemos(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string
|
||||
): Promise<MeetingMemo[]> {
|
||||
const { data, error } = await client
|
||||
.from('meeting_memos')
|
||||
.select('*')
|
||||
.eq('meeting_id', meetingId)
|
||||
.order('timestamp_ms', { ascending: true })
|
||||
|
||||
if (error) throw new Error(`listMemos failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function createMemo(
|
||||
client: D3roSupabaseClient,
|
||||
input: {
|
||||
meetingId: string
|
||||
userId: string
|
||||
content: string
|
||||
timestampMs: number
|
||||
}
|
||||
): Promise<MeetingMemo> {
|
||||
const { data, error } = await client
|
||||
.from('meeting_memos')
|
||||
.insert({
|
||||
meeting_id: input.meetingId,
|
||||
user_id: input.userId,
|
||||
content: input.content,
|
||||
timestamp_ms: input.timestampMs
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`createMemo failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// ── meeting_documents ────────────────────────────────────────
|
||||
|
||||
export async function listDocuments(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string
|
||||
): Promise<MeetingDocument[]> {
|
||||
const { data, error } = await client
|
||||
.from('meeting_documents')
|
||||
.select('*')
|
||||
.eq('meeting_id', meetingId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) throw new Error(`listDocuments failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
client: D3roSupabaseClient,
|
||||
input: {
|
||||
meetingId: string
|
||||
userId: string
|
||||
templateType: DocumentTemplateType
|
||||
title: string
|
||||
content: string
|
||||
promptUsed?: string | null
|
||||
llmModel?: string | null
|
||||
}
|
||||
): Promise<MeetingDocument> {
|
||||
const { data, error } = await client
|
||||
.from('meeting_documents')
|
||||
.insert({
|
||||
meeting_id: input.meetingId,
|
||||
user_id: input.userId,
|
||||
template_type: input.templateType,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
prompt_used: input.promptUsed ?? null,
|
||||
llm_model: input.llmModel ?? null
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`createDocument failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// ── transcripts ──────────────────────────────────────────────
|
||||
|
||||
export async function listTranscripts(
|
||||
client: D3roSupabaseClient,
|
||||
meetingId: string
|
||||
): Promise<Transcript[]> {
|
||||
const { data, error } = await client
|
||||
.from('transcripts')
|
||||
.select('*')
|
||||
.eq('meeting_id', meetingId)
|
||||
.order('segment_index', { ascending: true })
|
||||
|
||||
if (error) throw new Error(`listTranscripts failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function appendTranscript(
|
||||
client: D3roSupabaseClient,
|
||||
input: {
|
||||
meetingId: string
|
||||
segmentIndex: number
|
||||
timestampMs: number
|
||||
text: string
|
||||
speaker?: string | null
|
||||
durationMs?: number | null
|
||||
}
|
||||
): Promise<Transcript> {
|
||||
const { data, error } = await client
|
||||
.from('transcripts')
|
||||
.insert({
|
||||
meeting_id: input.meetingId,
|
||||
segment_index: input.segmentIndex,
|
||||
timestamp_ms: input.timestampMs,
|
||||
text: input.text,
|
||||
speaker: input.speaker ?? null,
|
||||
duration_ms: input.durationMs ?? null
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw new Error(`appendTranscript failed: ${error.message}`)
|
||||
return data
|
||||
}
|
||||
255
packages/api-client/src/types.ts
Normal file
255
packages/api-client/src/types.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
// packages/api-client/src/types.ts
|
||||
// Supabase DB row 타입 — V2-2 스키마와 동기화된 수동 정의.
|
||||
// 추후 `supabase gen types typescript`로 자동화 예정.
|
||||
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'team'
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
name: string | null
|
||||
avatar_url: string | null
|
||||
locale: string
|
||||
tier: SubscriptionTier
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type TeamRole = 'owner' | 'admin' | 'member'
|
||||
|
||||
export interface TeamMember {
|
||||
team_id: string
|
||||
user_id: string
|
||||
role: TeamRole
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export type MeetingStatus = 'recording' | 'processing' | 'completed' | 'error'
|
||||
|
||||
export interface Meeting {
|
||||
id: string
|
||||
user_id: string
|
||||
team_id: string | null
|
||||
title: string | null
|
||||
status: MeetingStatus
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
duration_ms: number | null
|
||||
raw_transcript: string | null
|
||||
edited_transcript: string | null
|
||||
minutes_markdown: string | null
|
||||
minutes_json: Record<string, unknown> | null
|
||||
stt_model: string | null
|
||||
llm_model: string | null
|
||||
stt_latency_ms: number | null
|
||||
llm_latency_ms: number | null
|
||||
error_message: string | null
|
||||
audio_storage_key: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MeetingMemo {
|
||||
id: string
|
||||
meeting_id: string
|
||||
user_id: string
|
||||
content: string
|
||||
timestamp_ms: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type DocumentTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom' | 'mindmap'
|
||||
|
||||
export interface MeetingDocument {
|
||||
id: string
|
||||
meeting_id: string
|
||||
user_id: string
|
||||
template_type: DocumentTemplateType
|
||||
title: string
|
||||
content: string
|
||||
prompt_used: string | null
|
||||
llm_model: string | null
|
||||
llm_latency_ms: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
id: string
|
||||
meeting_id: string
|
||||
segment_index: number
|
||||
timestamp_ms: number
|
||||
duration_ms: number | null
|
||||
text: string
|
||||
speaker: string | null
|
||||
edited: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type HistoryMode = 'dictation' | 'translate' | 'command' | 'caption' | 'file-transcription'
|
||||
export type HistoryStatus = 'completed' | 'cancelled' | 'error'
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string | null
|
||||
original_text: string
|
||||
polished_text: string | null
|
||||
focused_app: string | null
|
||||
focused_app_name: string | null
|
||||
focused_app_window_title: string | null
|
||||
mode: HistoryMode
|
||||
status: HistoryStatus
|
||||
error_code: string | null
|
||||
audio_storage_key: string | null
|
||||
duration: number
|
||||
detected_language: string | null
|
||||
mic_device: string | null
|
||||
word_count: number
|
||||
stt_model: string | null
|
||||
llm_model: string | null
|
||||
stt_latency_ms: number | null
|
||||
llm_latency_ms: number | null
|
||||
app_version: string
|
||||
summary_text: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DictionaryEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
word: string
|
||||
pronunciation: string | null
|
||||
category: 'user' | 'auto' | 'technical'
|
||||
usage_count: number
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DailyUsage {
|
||||
id: number
|
||||
user_id: string
|
||||
date: string // YYYY-MM-DD
|
||||
feature: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string
|
||||
user_id: string
|
||||
tier: SubscriptionTier
|
||||
stripe_customer_id: string | null
|
||||
stripe_subscription_id: string | null
|
||||
status: string | null
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
cancel_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 DB 스키마 — SupabaseClient<Database> 제네릭용.
|
||||
* Supabase CLI의 `supabase gen types typescript` 출력과 호환되는 형식.
|
||||
*/
|
||||
export type Database = {
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: '12'
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
profiles: {
|
||||
Row: Profile
|
||||
Insert: Partial<Profile> & Pick<Profile, 'id'>
|
||||
Update: Partial<Profile>
|
||||
Relationships: []
|
||||
}
|
||||
teams: {
|
||||
Row: Team
|
||||
Insert: Omit<Team, 'id' | 'created_at' | 'updated_at'> & Partial<Pick<Team, 'id'>>
|
||||
Update: Partial<Team>
|
||||
Relationships: []
|
||||
}
|
||||
team_members: {
|
||||
Row: TeamMember
|
||||
Insert: Omit<TeamMember, 'joined_at'> & Partial<Pick<TeamMember, 'joined_at'>>
|
||||
Update: Partial<TeamMember>
|
||||
Relationships: []
|
||||
}
|
||||
meetings: {
|
||||
Row: Meeting
|
||||
Insert: Partial<Meeting> & Pick<Meeting, 'user_id'>
|
||||
Update: Partial<Meeting>
|
||||
Relationships: []
|
||||
}
|
||||
meeting_memos: {
|
||||
Row: MeetingMemo
|
||||
Insert: Omit<MeetingMemo, 'id' | 'created_at'> &
|
||||
Partial<Pick<MeetingMemo, 'id' | 'created_at'>>
|
||||
Update: Partial<MeetingMemo>
|
||||
Relationships: []
|
||||
}
|
||||
meeting_documents: {
|
||||
Row: MeetingDocument
|
||||
Insert: Omit<MeetingDocument, 'id' | 'created_at' | 'updated_at'> &
|
||||
Partial<Pick<MeetingDocument, 'id' | 'created_at' | 'updated_at'>>
|
||||
Update: Partial<MeetingDocument>
|
||||
Relationships: []
|
||||
}
|
||||
transcripts: {
|
||||
Row: Transcript
|
||||
Insert: Omit<Transcript, 'id' | 'created_at' | 'updated_at'> &
|
||||
Partial<Pick<Transcript, 'id' | 'created_at' | 'updated_at'>>
|
||||
Update: Partial<Transcript>
|
||||
Relationships: []
|
||||
}
|
||||
history: {
|
||||
Row: HistoryEntry
|
||||
Insert: Partial<HistoryEntry> & Pick<HistoryEntry, 'user_id' | 'original_text' | 'duration'>
|
||||
Update: Partial<HistoryEntry>
|
||||
Relationships: []
|
||||
}
|
||||
dictionary: {
|
||||
Row: DictionaryEntry
|
||||
Insert: Partial<DictionaryEntry> & Pick<DictionaryEntry, 'user_id' | 'word'>
|
||||
Update: Partial<DictionaryEntry>
|
||||
Relationships: []
|
||||
}
|
||||
daily_usage: {
|
||||
Row: DailyUsage
|
||||
Insert: Omit<DailyUsage, 'id'>
|
||||
Update: Partial<DailyUsage>
|
||||
Relationships: []
|
||||
}
|
||||
subscriptions: {
|
||||
Row: Subscription
|
||||
Insert: Partial<Subscription> & Pick<Subscription, 'user_id'>
|
||||
Update: Partial<Subscription>
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
54
packages/api-client/src/usage.ts
Normal file
54
packages/api-client/src/usage.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// packages/api-client/src/usage.ts
|
||||
// daily_usage 조회 (쿼터 UI 표시용)
|
||||
|
||||
import type { D3roSupabaseClient } from './client'
|
||||
import type { DailyUsage, Subscription } from './types'
|
||||
|
||||
export async function getTodayUsage(
|
||||
client: D3roSupabaseClient,
|
||||
feature: string
|
||||
): Promise<number> {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { data, error } = await client
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('date', today)
|
||||
.eq('feature', feature)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
// RLS로 인해 빈 결과도 정상. 에러는 throw하지 않고 0 반환.
|
||||
return 0
|
||||
}
|
||||
return (data?.count as number | undefined) ?? 0
|
||||
}
|
||||
|
||||
export async function listUsageLastDays(
|
||||
client: D3roSupabaseClient,
|
||||
days: number
|
||||
): Promise<DailyUsage[]> {
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - days)
|
||||
const sinceStr = since.toISOString().slice(0, 10)
|
||||
|
||||
const { data, error } = await client
|
||||
.from('daily_usage')
|
||||
.select('*')
|
||||
.gte('date', sinceStr)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error) throw new Error(`listUsageLastDays failed: ${error.message}`)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function getSubscription(
|
||||
client: D3roSupabaseClient
|
||||
): Promise<Subscription | null> {
|
||||
const { data, error } = await client
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return null
|
||||
return data
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue