feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -2,23 +2,27 @@
// createD3roSupabaseClient 팩토리 유닛 테스트
import { describe, it, expect } from 'vitest'
import { createD3roSupabaseClient, isClientConfigured } from '../src/client'
import {
createD3roSupabaseClient,
isClientConfigured,
SupabaseConfigurationError,
} from '../src/client'
describe('createD3roSupabaseClient', () => {
it('URL과 key가 없으면 placeholder 클라이언트를 반환한다', () => {
const client = createD3roSupabaseClient({ url: undefined, anonKey: undefined })
expect(client).toBeDefined()
// auth, from 등 메서드가 존재해야 함
expect(typeof client.auth.getSession).toBe('function')
expect(typeof client.from).toBe('function')
it('URL과 key가 없으면 설정 오류로 즉시 실패한다', () => {
expect(() => createD3roSupabaseClient({ url: undefined, anonKey: undefined }))
.toThrow(SupabaseConfigurationError)
})
it('URL만 있고 key가 없으면 placeholder 클라이언트를 반환한다', () => {
const client = createD3roSupabaseClient({
it('URL이나 key 중 하나만 있거나 공백이면 설정 오류로 즉시 실패한다', () => {
expect(() => createD3roSupabaseClient({
url: 'https://real.supabase.co',
anonKey: undefined
})
expect(client).toBeDefined()
anonKey: undefined,
})).toThrow(SupabaseConfigurationError)
expect(() => createD3roSupabaseClient({
url: ' ',
anonKey: 'anon-key',
})).toThrow(SupabaseConfigurationError)
})
it('URL과 key가 모두 있으면 실제 클라이언트를 생성한다', () => {
@ -60,5 +64,6 @@ describe('isClientConfigured', () => {
it('빈 문자열은 false로 처리된다', () => {
expect(isClientConfigured({ url: '', anonKey: 'k' })).toBe(false)
expect(isClientConfigured({ url: 'https://x.supabase.co', anonKey: '' })).toBe(false)
expect(isClientConfigured({ url: ' ', anonKey: 'k' })).toBe(false)
})
})

View file

@ -1,108 +1,82 @@
// packages/api-client/__tests__/transcribe.test.ts
// transcribeAudio 함수 단위 및 통합 인터페이스 테스트
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { transcribeAudio } from '../src/transcribe'
describe('transcribeAudio', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
const session = {
supabaseUrl: 'https://project.supabase.co',
anonKey: 'anon-key',
token: 'user-token',
}
it('Blob 입력 시 multipart/form-data로 /api/stt/transcribe를 호출하여 전사 결과를 반환한다', async () => {
const mockAudioBlob = new Blob(['mock-audio-content'], { type: 'audio/webm' })
const mockResponse = {
text: '안녕하세요, 클라우드 음성 전사 테스트입니다.',
function payload(transcript = '안녕하세요') {
return {
transcript,
confidence: 0.99,
language_code: 'ko',
duration_seconds: 2.5,
provider: 'groq',
}
}
describe('transcribeAudio', () => {
beforeEach(() => vi.unstubAllGlobals())
it('uses only the authenticated Supabase quota gateway', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload() }))
vi.stubGlobal('fetch', fetchMock)
const result = await transcribeAudio({
...session,
audio: new Blob(['audio'], { type: 'audio/webm' }),
language: 'ko',
})
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('https://project.supabase.co/functions/v1/stt-proxy')
expect(options.headers).toEqual({ Authorization: 'Bearer user-token', apikey: 'anon-key' })
expect(options.body).toBeInstanceOf(FormData)
expect((options.body as FormData).get('language_code')).toBe('ko')
expect(result).toEqual({
text: '안녕하세요',
confidence: 0.99,
language: 'ko',
durationSeconds: 2.5,
provider: 'groq',
modelId: 'whisper-large-v3-turbo',
latencyMs: 145,
cost: 0.000021,
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
latencyMs: expect.any(Number),
})
vi.stubGlobal('fetch', fetchMock)
const result = await transcribeAudio({
audio: mockAudioBlob,
language: 'ko',
apiBaseUrl: 'http://localhost:5000',
token: 'test-token',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
expect(options.method).toBe('POST')
expect(options.headers['Authorization']).toBe('Bearer test-token')
expect(options.body).toBeInstanceOf(FormData)
expect(result.text).toBe('안녕하세요, 클라우드 음성 전사 테스트입니다.')
expect(result.provider).toBe('groq')
expect(result.durationSeconds).toBe(2.5)
expect(result.latencyMs).toBe(145)
})
it('Base64 문자열 입력 시 JSON 페이로드로 전송한다', async () => {
const mockBase64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const mockResponse = {
text: 'Base64 전사 성공',
confidence: 0.98,
language: 'ko',
durationSeconds: 1.0,
provider: 'openai',
modelId: 'whisper-1',
latencyMs: 320,
cost: 0.0001,
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockResponse,
})
it('converts binary and base64 audio to multipart without a JSON bypass', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => payload('binary') }))
vi.stubGlobal('fetch', fetchMock)
const result = await transcribeAudio({
audio: mockBase64,
language: 'ko',
prompt: '의료 용어 가이드',
apiBaseUrl: 'http://localhost:5000',
})
await transcribeAudio({ ...session, audio: Uint8Array.from([0, 1, 2, 253, 254, 255]) })
await transcribeAudio({ ...session, audio: 'AAEC/f7/' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]
expect(url).toBe('http://localhost:5000/api/stt/transcribe')
expect(options.headers['Content-Type']).toBe('application/json')
const body = JSON.parse(options.body)
expect(body.audioBase64).toBe(mockBase64)
expect(body.language).toBe('ko')
expect(body.initialPrompt).toBe('의료 용어 가이드')
expect(result.text).toBe('Base64 전사 성공')
expect(fetchMock).toHaveBeenCalledTimes(2)
for (const [, options] of fetchMock.mock.calls) {
expect(options.body).toBeInstanceOf(FormData)
expect((options.body as FormData).get('audio')).toBeInstanceOf(Blob)
}
})
it('서버 응답이 4xx/5xx 실패 시 에러를 throw한다', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => 'Internal STT Proxy Failure',
})
)
it('requires token, anon key and a safe gateway origin before network access', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
transcribeAudio({
audio: new Blob(['data']),
apiBaseUrl: 'http://localhost:5000',
})
).rejects.toThrow('STT transcription failed (500): Internal STT Proxy Failure')
await expect(transcribeAudio({ ...session, token: '', audio: new Blob(['x']) }))
.rejects.toThrow('authenticated Supabase session')
await expect(transcribeAudio({ ...session, supabaseUrl: 'http://public.example.com', audio: new Blob(['x']) }))
.rejects.toThrow('configuration is invalid')
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not expose upstream bodies and rejects malformed success payloads', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503, text: async () => 'secret upstream body' })))
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
.rejects.toThrow('STT gateway request failed (503).')
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ transcript: 'x' }) })))
await expect(transcribeAudio({ ...session, audio: new Blob(['x']) }))
.rejects.toThrow('invalid response')
})
})

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/api-client",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT",

View file

@ -20,25 +20,37 @@ export interface CreateClientOptions {
}
}
/**
* Supabase .
* url anonKey가 "dummy" .
* env가 /SSR이 .
*/
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
const { url, anonKey, auth } = options
export class SupabaseConfigurationError extends Error {
readonly code = 'SUPABASE_CONFIGURATION_MISSING'
constructor() {
super('Supabase public URL and anonymous key are required.')
this.name = 'SupabaseConfigurationError'
}
}
export function requireSupabasePublicConfig(options: CreateClientOptions): {
url: string
anonKey: string
} {
const url = options.url?.trim()
const anonKey = options.anonKey?.trim()
if (!url || !anonKey) {
// Env가 없으면 fallback URL로 생성. 실제 호출은 런타임에 실패하지만
// 빌드/타입체크는 통과. 앱 상단에서 isClientConfigured()로 체크해야 함.
return createClient<Database>('https://placeholder.supabase.co', 'placeholder-anon-key', {
auth: {
persistSession: false,
autoRefreshToken: false
}
})
throw new SupabaseConfigurationError()
}
return { url, anonKey }
}
/**
* Supabase .
* .
*/
export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupabaseClient {
const { auth } = options
const { url, anonKey } = requireSupabasePublicConfig(options)
return createClient<Database>(url, anonKey, {
auth: {
persistSession: auth?.persistSession ?? true,
@ -52,5 +64,5 @@ export function createD3roSupabaseClient(options: CreateClientOptions): D3roSupa
* Env가 . UI에서 "Supabase 미설정" .
*/
export function isClientConfigured(options: CreateClientOptions): boolean {
return Boolean(options.url && options.anonKey)
return Boolean(options.url?.trim() && options.anonKey?.trim())
}

View file

@ -4,19 +4,25 @@
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from './types'
import { requireSupabasePublicConfig } from './client'
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
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'
const { url, anonKey } = requireSupabasePublicConfig({
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
})
cachedClient = createBrowserClient<Database>(url, key)
cachedClient = createBrowserClient<Database>(url, anonKey)
return cachedClient
}
export function isSupabaseConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
return Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
)
}

View file

@ -5,6 +5,7 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from './types'
import { requireSupabasePublicConfig } from './client'
/** next/headers cookies()가 반환하는 객체의 최소 인터페이스 */
export interface CookieStore {
@ -15,10 +16,12 @@ export interface CookieStore {
export function createSupabaseServerClient(
cookieStore: CookieStore,
): ReturnType<typeof createServerClient<Database>> {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
const { url, anonKey } = requireSupabasePublicConfig({
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
})
return createServerClient<Database>(url, key, {
return createServerClient<Database>(url, anonKey, {
cookies: {
getAll() {
return cookieStore.getAll()
@ -37,5 +40,8 @@ export function createSupabaseServerClient(
}
export function isSupabaseConfiguredServer(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
return Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
&& process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(),
)
}

View file

@ -1,13 +1,11 @@
// packages/api-client/src/transcribe.ts
// D3RO Cloud STT Transcription Unified Client
// D3RO Cloud STT client. User-facing transcription always passes through the
// Supabase Edge gateway that owns authentication, quota and usage accounting.
export interface TranscribeAudioParams {
audio: Blob | ArrayBuffer | Uint8Array | string // Blob, Binary, or Base64 string
audio: Blob | ArrayBuffer | Uint8Array | string
language?: string
prompt?: string
model?: string
provider?: string
apiBaseUrl?: string
supabaseUrl?: string
anonKey?: string
token?: string
}
@ -17,75 +15,108 @@ export interface TranscribeAudioResult {
language: string
durationSeconds: number
provider: string
modelId: string
latencyMs: number
cost: number
modelId?: string
cost?: number
}
/**
* (STT) API를 .
* ,
* (Groq, OpenAI, Deepgram, Google ) .
*/
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
const apiBase = (params.apiBaseUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000').replace(/\/$/, '')
const url = `${apiBase}/api/stt/transcribe`
function decodeBase64(value: string): Uint8Array {
const normalized = value.replace(/^data:[^,]*,/, '').replace(/\s+/g, '')
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new Error('Unsupported audio payload format.')
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const output: number[] = []
for (let offset = 0; offset < normalized.length; offset += 4) {
const chars = normalized.slice(offset, offset + 4)
const values = [...chars].map((char) => char === '=' ? 0 : alphabet.indexOf(char))
if (values.some((item) => item < 0)) throw new Error('Unsupported audio payload format.')
const combined = (values[0] << 18) | (values[1] << 12) | (values[2] << 6) | values[3]
output.push((combined >>> 16) & 0xff)
if (chars[2] !== '=') output.push((combined >>> 8) & 0xff)
if (chars[3] !== '=') output.push(combined & 0xff)
}
return Uint8Array.from(output)
}
const headers: Record<string, string> = {}
if (params.token) {
headers['Authorization'] = `Bearer ${params.token}`
function gatewayUrl(value: string): string {
try {
const url = new URL('/functions/v1/stt-proxy', value)
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password) throw new Error('invalid')
return url.toString()
} catch {
throw new Error('STT gateway configuration is invalid.')
}
}
function parseResult(value: unknown, latencyMs: number): TranscribeAudioResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('STT gateway returned an invalid response.')
}
const payload = value as Record<string, unknown>
if (
typeof payload.transcript !== 'string'
|| !payload.transcript.trim()
|| typeof payload.confidence !== 'number'
|| !Number.isFinite(payload.confidence)
|| payload.confidence < 0
|| payload.confidence > 1
|| typeof payload.language_code !== 'string'
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|| typeof payload.duration_seconds !== 'number'
|| !Number.isFinite(payload.duration_seconds)
|| payload.duration_seconds < 0
|| typeof payload.provider !== 'string'
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
) {
throw new Error('STT gateway returned an invalid response.')
}
return {
text: payload.transcript.trim(),
confidence: payload.confidence,
language: payload.language_code,
durationSeconds: payload.duration_seconds,
provider: payload.provider,
latencyMs,
}
}
export async function transcribeAudio(params: TranscribeAudioParams): Promise<TranscribeAudioResult> {
const supabaseUrl = params.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const anonKey = params.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
const token = params.token ?? ''
if (!supabaseUrl || !anonKey || !token) {
throw new Error('STT gateway requires an authenticated Supabase session.')
}
// 1. If audio is Blob or ArrayBuffer, send via FormData
let audio: Blob
const makeBlob = Blob as unknown as new (parts: unknown[], options?: { type?: string }) => Blob
if (params.audio instanceof Blob) {
const formData = new FormData()
formData.append('file', params.audio, 'recording.webm')
if (params.language) formData.append('language', params.language)
if (params.prompt) formData.append('prompt', params.prompt)
if (params.model) formData.append('model', params.model)
if (params.provider) formData.append('provider', params.provider)
const res = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
}
return (await res.json()) as TranscribeAudioResult
} else if (params.audio instanceof ArrayBuffer || params.audio instanceof Uint8Array) {
const blob = new Blob([params.audio as BlobPart], { type: 'audio/webm' })
return transcribeAudio({ ...params, audio: blob })
audio = params.audio
} else if (params.audio instanceof ArrayBuffer) {
audio = new makeBlob([params.audio], { type: 'audio/wav' })
} else if (params.audio instanceof Uint8Array) {
audio = new makeBlob([params.audio.slice().buffer], { type: 'audio/wav' })
} else if (typeof params.audio === 'string') {
// 2. If audio is Base64 string, send JSON payload
const payload = {
audioBase64: params.audio,
language: params.language || 'ko',
initialPrompt: params.prompt,
modelId: params.model,
provider: params.provider,
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(payload),
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`STT transcription failed (${res.status}): ${errText}`)
}
return (await res.json()) as TranscribeAudioResult
audio = new makeBlob([decodeBase64(params.audio).buffer], { type: 'audio/wav' })
} else {
throw new Error('Unsupported audio payload format.')
}
const formData = new FormData()
formData.append('audio', audio)
if (params.language && params.language !== 'auto') formData.append('language_code', params.language)
const startedAt = Date.now()
const response = await fetch(gatewayUrl(supabaseUrl), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
},
body: formData,
})
if (!response.ok) throw new Error(`STT gateway request failed (${response.status}).`)
return parseResult(await response.json().catch(() => null), Date.now() - startedAt)
}

View file

@ -32,6 +32,7 @@ export type TeamMember = {
}
export type MeetingStatus = 'recording' | 'processing' | 'completed' | 'error'
export type MeetingLanguage = 'ko' | 'en' | 'ja' | 'zh-cn'
export type Meeting = {
id: string
@ -52,6 +53,11 @@ export type Meeting = {
llm_latency_ms: number | null
error_message: string | null
audio_storage_key: string | null
language?: MeetingLanguage | null
attendees?: string[]
template_id?: string | null
creation_idempotency_key?: string | null
creation_request_hash?: string | null
created_at: string
updated_at: string
}
@ -77,6 +83,8 @@ export type MeetingDocument = {
prompt_used: string | null
llm_model: string | null
llm_latency_ms: number | null
template_id?: string | null
generation_idempotency_key?: string | null
created_at: string
updated_at: string
}
@ -120,10 +128,191 @@ export type HistoryEntry = {
llm_latency_ms: number | null
app_version: string
summary_text: string | null
is_favorite: boolean
revision: number
created_at: string
updated_at: string
}
export type MemoTag = {
id: string
user_id: string
history_id: string
tag: string
normalized_tag: string
created_at: string
}
export type TemplateKind = 'dictation' | 'meeting_document'
export type DictationTemplateField = {
id: string
name: string
label: string
promptText: string
required: boolean
maxDurationSec: number
}
export type UserTemplate = {
id: string
user_id: string
template_kind: TemplateKind
builtin_key: string | null
name: string
description: string | null
fields: DictationTemplateField[]
output_format: string | null
template_type: DocumentTemplateType | null
system_prompt: string | null
is_builtin: boolean
revision: number
created_at: string
updated_at: string
}
export type UserTemplateSelection = {
user_id: string
template_kind: TemplateKind
template_id: string
revision: number
updated_at: string
}
export type MeetingDocumentGenerationRequest = {
user_id: string
idempotency_key: string
meeting_id: string
template_id: string
request_hash: string
document_title: string
model: string
quota_feature: 'llm_haiku' | 'llm_sonnet' | 'llm_opus'
quota_limit: number
quota_period: 'daily' | 'weekly'
template_revision: number
template_type: DocumentTemplateType
transcript_hash: string
status: 'processing' | 'succeeded' | 'failed'
document_id: string | null
error_code: string | null
created_at: string
completed_at: string | null
}
export type AudioFileSource = 'recording' | 'file-picker' | 'share-intent'
export type AudioFileUploadStatus = 'pending' | 'uploading' | 'uploaded' | 'failed' | 'deleted'
export type AudioFile = {
id: string
user_id: string
history_id: string | null
meeting_id: string | null
source: AudioFileSource
original_name: string | null
storage_key: string
mime_type: string
size_bytes: number
duration_ms: number | null
sha256: string
upload_status: AudioFileUploadStatus
created_at: string
updated_at: string
}
export type ProcessingJobKind = 'transcription' | 'summary' | 'minutes' | 'diarization' | 'export'
export type ProcessingJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export type ProcessingJob = {
id: string
user_id: string
audio_file_id: string | null
history_id: string | null
meeting_id: string | null
kind: ProcessingJobKind
status: ProcessingJobStatus
progress: number
attempt_count: number
idempotency_key: string
error_code: string | null
error_message: string | null
result: Record<string, unknown> | null
started_at: string | null
completed_at: string | null
created_at: string
updated_at: string
}
export type UserSettings = {
user_id: string
theme_mode: 'system' | 'light' | 'dark'
locale: string
haptic_enabled: boolean
auto_polish_enabled: boolean
preferred_stt_model: string | null
preferred_llm_model: string | null
onboarding_version: number
tutorial_completed_at: string | null
revision: number
created_at: string
updated_at: string
}
export type DevicePlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos'
export type Device = {
id: string
user_id: string
installation_id: string
platform: DevicePlatform
device_name: string
app_version: string
os_version: string | null
push_token: string | null
last_seen_at: string
revoked_at: string | null
created_at: string
updated_at: string
}
export type StorePurchaseState =
| 'pending'
| 'purchased'
| 'cancelled'
| 'expired'
| 'refunded'
| 'on_hold'
| 'paused'
export type IapPurchase = {
id: string
user_id: string
platform: 'google_play' | 'app_store'
product_id: string
store_transaction_id: string | null
token_hash: string
purchase_state: StorePurchaseState
purchase_at: string | null
expires_at: string | null
auto_renewing: boolean | null
acknowledged_at: string | null
verified_at: string
created_at: string
updated_at: string
}
export type AdRewardClaim = {
id: string
user_id: string
network: string
placement: string
ad_unit_id: string
transaction_id: string
reward_tokens: number
verified_at: string
created_at: string
}
export type DictionaryEntry = {
id: string
user_id: string
@ -154,6 +343,15 @@ export type Subscription = {
stripe_customer_id: string | null
/** @deprecated Phase 3.2-B (Payple 이관 예정) */
stripe_subscription_id: string | null
payment_provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store'
payple_payer_id: string | null
payple_pay_oid: string | null
renewal_failures: number
admin_note: string | null
provider: 'none' | 'stripe' | 'payple' | 'google_play' | 'app_store' | 'admin'
store_product_id: string | null
store_purchase_id: string | null
auto_renewing: boolean | null
status: string | null
current_period_start: string | null
current_period_end: string | null
@ -247,6 +445,61 @@ export type Database = {
Partial<HistoryEntry> & Pick<HistoryEntry, 'user_id' | 'original_text' | 'duration'>,
Partial<HistoryEntry>
>
memo_tags: TypedTable<
MemoTag,
Partial<MemoTag> & Pick<MemoTag, 'user_id' | 'history_id' | 'tag'>,
Partial<MemoTag>
>
user_templates: TypedTable<
UserTemplate,
Partial<UserTemplate> & Pick<UserTemplate, 'user_id' | 'template_kind' | 'name'>,
Partial<UserTemplate>
>
user_template_selections: TypedTable<
UserTemplateSelection,
Partial<UserTemplateSelection> & Pick<UserTemplateSelection, 'user_id' | 'template_kind' | 'template_id'>,
Partial<UserTemplateSelection>
>
meeting_document_generation_requests: TypedTable<
MeetingDocumentGenerationRequest,
MeetingDocumentGenerationRequest,
Partial<MeetingDocumentGenerationRequest>
>
audio_files: TypedTable<
AudioFile,
Partial<AudioFile> &
Pick<AudioFile, 'user_id' | 'source' | 'storage_key' | 'mime_type' | 'size_bytes' | 'sha256'>,
Partial<AudioFile>
>
processing_jobs: TypedTable<
ProcessingJob,
Partial<ProcessingJob> &
Pick<ProcessingJob, 'user_id' | 'kind' | 'idempotency_key'>,
Partial<ProcessingJob>
>
user_settings: TypedTable<
UserSettings,
Partial<UserSettings> & Pick<UserSettings, 'user_id'>,
Partial<UserSettings>
>
devices: TypedTable<
Device,
Partial<Device> &
Pick<Device, 'user_id' | 'installation_id' | 'platform' | 'device_name' | 'app_version'>,
Partial<Device>
>
iap_purchases: TypedTable<
IapPurchase,
Partial<IapPurchase> &
Pick<IapPurchase, 'user_id' | 'platform' | 'product_id' | 'token_hash' | 'purchase_state'>,
Partial<IapPurchase>
>
ad_reward_claims: TypedTable<
AdRewardClaim,
Partial<AdRewardClaim> &
Pick<AdRewardClaim, 'user_id' | 'network' | 'placement' | 'ad_unit_id' | 'transaction_id' | 'reward_tokens'>,
Partial<AdRewardClaim>
>
dictionary: TypedTable<
DictionaryEntry,
Partial<DictionaryEntry> & Pick<DictionaryEntry, 'user_id' | 'word'>,
@ -354,6 +607,82 @@ export type Database = {
}
Returns: number
}
bootstrap_user_templates_v1: {
Args: Record<string, never>
Returns: UserTemplate[]
}
create_user_template_v1: {
Args: {
p_template_kind: TemplateKind
p_name: string
p_description?: string | null
p_fields?: DictationTemplateField[]
p_output_format?: string | null
p_system_prompt?: string | null
}
Returns: UserTemplate
}
update_user_template_v1: {
Args: {
p_template_id: string
p_expected_revision: number
p_name: string
p_description?: string | null
p_fields?: DictationTemplateField[]
p_output_format?: string | null
p_system_prompt?: string | null
}
Returns: UserTemplate
}
delete_user_template_v1: {
Args: { p_template_id: string; p_expected_revision: number }
Returns: boolean
}
select_user_template_v1: {
Args: { p_template_id: string; p_expected_revision?: number | null }
Returns: UserTemplateSelection
}
mobile_add_memo_tag_v1: {
Args: { p_history_id: string; p_tag: string }
Returns: MemoTag
}
mobile_remove_memo_tag_v1: {
Args: { p_history_id: string; p_tag: string }
Returns: boolean
}
mobile_rename_memo_tag_v1: {
Args: { p_old_tag: string; p_new_tag: string }
Returns: number
}
mobile_list_memo_tags_v1: {
Args: Record<string, never>
Returns: Array<{ tag: string; normalized_tag: string; history_count: number }>
}
mobile_search_memos_v1: {
Args: { p_query?: string; p_tag?: string | null; p_limit?: number; p_offset?: number }
Returns: Array<{ history_row: HistoryEntry; tags: string[] }>
}
mobile_create_meeting_workspace_v2: {
Args: {
p_title: string
p_attendees: string[]
p_language: string
p_template_id: string | null
p_idempotency_key: string
}
Returns: Meeting
}
grant_verified_ad_reward: {
Args: {
p_user_id: string
p_network: string
p_placement: string
p_ad_unit_id: string
p_transaction_id: string
p_reward_tokens: number
}
Returns: Record<string, unknown>
}
admin_usage_by_feature: {
Args: {
p_from: string