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

@ -0,0 +1,97 @@
import type { SubscriptionTier } from '@d3ro/api-client'
export type WebBillingProvider = 'payple' | 'stripe'
export type BillingInterval = 'day' | 'week' | 'month' | 'year'
export interface BillingCatalogPrice {
provider: WebBillingProvider
unitAmount: number
currency: string
interval: BillingInterval
intervalCount: number
}
export interface BillingCatalog {
plans: Record<'pro' | 'pro_plus', BillingCatalogPrice[]>
}
const PROVIDERS = new Set<WebBillingProvider>(['payple', 'stripe'])
const INTERVALS = new Set<BillingInterval>(['day', 'week', 'month', 'year'])
const PAID_TIERS = new Set(['pro', 'pro_plus'])
export function parseBillingCatalog(value: unknown): BillingCatalog | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as { schema_version?: unknown; plans?: unknown }
if (record.schema_version !== '1' || !Array.isArray(record.plans) || record.plans.length !== 2) return null
const plans: BillingCatalog['plans'] = { pro: [], pro_plus: [] }
const seenTiers = new Set<string>()
for (const rawPlan of record.plans) {
if (!rawPlan || typeof rawPlan !== 'object' || Array.isArray(rawPlan)) return null
const plan = rawPlan as { tier?: unknown; prices?: unknown }
if (typeof plan.tier !== 'string' || !PAID_TIERS.has(plan.tier) || seenTiers.has(plan.tier)) return null
if (!Array.isArray(plan.prices) || plan.prices.length > 2) return null
seenTiers.add(plan.tier)
const seenProviders = new Set<string>()
for (const rawPrice of plan.prices) {
if (!rawPrice || typeof rawPrice !== 'object' || Array.isArray(rawPrice)) return null
const price = rawPrice as Record<string, unknown>
if (
typeof price['provider'] !== 'string'
|| !PROVIDERS.has(price['provider'] as WebBillingProvider)
|| seenProviders.has(price['provider'])
|| !Number.isSafeInteger(price['unit_amount'])
|| (price['unit_amount'] as number) < 1
|| typeof price['currency'] !== 'string'
|| !/^[A-Z]{3}$/.test(price['currency'])
|| typeof price['interval'] !== 'string'
|| !INTERVALS.has(price['interval'] as BillingInterval)
|| !Number.isSafeInteger(price['interval_count'])
|| (price['interval_count'] as number) < 1
|| (price['interval_count'] as number) > 12
) return null
seenProviders.add(price['provider'])
plans[plan.tier as 'pro' | 'pro_plus'].push({
provider: price['provider'] as WebBillingProvider,
unitAmount: price['unit_amount'] as number,
currency: price['currency'],
interval: price['interval'] as BillingInterval,
intervalCount: price['interval_count'] as number,
})
}
}
return seenTiers.size === 2 ? { plans } : null
}
function intervalLabel(interval: BillingInterval, count: number): string {
const base: Record<BillingInterval, string> = {
day: '일',
week: '주',
month: '월',
year: '년',
}
return count === 1 ? base[interval] : `${count}${base[interval]}`
}
export function formatBillingPrice(price: BillingCatalogPrice): string {
const formatter = new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: price.currency,
currencyDisplay: 'symbol',
})
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2
const majorAmount = price.unitAmount / (10 ** fractionDigits)
return `${formatter.format(majorAmount)} / ${intervalLabel(price.interval, price.intervalCount)}`
}
export function formatPlanCatalogPrice(
tier: SubscriptionTier,
catalog: BillingCatalog | null,
): string | null {
if (tier === 'free') return '무료'
if (!catalog) return null
const prices = catalog.plans[tier]
if (prices.length === 0) return null
if (prices.length === 1) return formatBillingPrice(prices[0])
return prices.map((price) => `${price.provider === 'payple' ? 'Payple' : 'Stripe'} ${formatBillingPrice(price)}`).join(' · ')
}

View file

@ -0,0 +1,417 @@
import { SUPABASE_URL } from '@d3ro/core/supabase-config'
import type { SupabaseClient } from '@supabase/supabase-js'
export type BuiltinInstructionKey = 'translate_en' | 'summarize' | 'formal' | 'explain_code'
export interface CustomInstruction {
id: string
userId: string
builtinKey: BuiltinInstructionKey | null
name: string
description: string
prompt: string
icon: string
sortOrder: number
revision: number
createdAt: string
updatedAt: string
}
export interface InstructionDraft {
name: string
description: string
prompt: string
}
export interface InstructionState {
instructions: CustomInstruction[]
activeInstructionId: string | null
settingsRevision: number
}
export type CommandClientErrorCode =
| 'auth'
| 'cancelled'
| 'conflict'
| 'duplicate'
| 'invalid-request'
| 'invalid-response'
| 'model-not-allowed'
| 'network'
| 'not-found'
| 'provider-unavailable'
| 'quota-exceeded'
| 'server'
| 'timeout'
export class CommandClientError extends Error {
constructor(
public readonly code: CommandClientErrorCode,
public readonly retryable: boolean,
public readonly status: number | null = null,
message: string = code
) {
super(message)
this.name = 'CommandClientError'
}
}
export const COMMAND_INPUT_MAX_CHARS = 7_500
const MESSAGE_MAX_CHARS = 8_000
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const BUILTIN_KEYS = new Set<BuiltinInstructionKey>(['translate_en', 'summarize', 'formal', 'explain_code'])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function untyped(client: SupabaseClient): SupabaseClient {
return client
}
function requireUserId(userId: string): void {
if (!UUID_PATTERN.test(userId)) throw new CommandClientError('auth', false)
}
function normalizeDate(value: unknown): string | null {
return typeof value === 'string' && Number.isFinite(Date.parse(value))
? new Date(value).toISOString()
: null
}
function normalizeInstruction(value: unknown, userId: string): CustomInstruction {
if (!isRecord(value)) throw new CommandClientError('invalid-response', true)
const createdAt = normalizeDate(value.created_at)
const updatedAt = normalizeDate(value.updated_at)
const builtinKey = value.builtin_key === null
? null
: typeof value.builtin_key === 'string' && BUILTIN_KEYS.has(value.builtin_key as BuiltinInstructionKey)
? value.builtin_key as BuiltinInstructionKey
: undefined
if (
typeof value.id !== 'string'
|| !UUID_PATTERN.test(value.id)
|| value.user_id !== userId
|| builtinKey === undefined
|| typeof value.name !== 'string'
|| value.name.trim().length < 1
|| value.name.trim().length > 80
|| typeof value.description !== 'string'
|| value.description.length > 240
|| typeof value.prompt !== 'string'
|| value.prompt.trim().length < 1
|| value.prompt.trim().length > 4_000
|| typeof value.icon !== 'string'
|| value.icon.length < 1
|| value.icon.length > 32
|| !Number.isSafeInteger(value.sort_order)
|| Number(value.sort_order) < 0
|| !Number.isSafeInteger(value.revision)
|| Number(value.revision) < 1
|| createdAt === null
|| updatedAt === null
) {
throw new CommandClientError('invalid-response', true)
}
return {
id: value.id,
userId,
builtinKey,
name: value.name.trim(),
description: value.description.trim(),
prompt: value.prompt.trim(),
icon: value.icon,
sortOrder: Number(value.sort_order),
revision: Number(value.revision),
createdAt,
updatedAt
}
}
export function sortInstructions(instructions: CustomInstruction[]): CustomInstruction[] {
return [...instructions].sort((left, right) => {
const byOrder = left.sortOrder - right.sortOrder
if (byOrder !== 0) return byOrder
const byCreated = left.createdAt.localeCompare(right.createdAt)
return byCreated !== 0 ? byCreated : left.id.localeCompare(right.id)
})
}
function normalizeSettings(value: unknown, userId: string): { activeInstructionId: string | null; settingsRevision: number } {
if (
!isRecord(value)
|| value.user_id !== userId
|| (value.active_instruction_id !== null && (typeof value.active_instruction_id !== 'string' || !UUID_PATTERN.test(value.active_instruction_id)))
|| !Number.isSafeInteger(value.revision)
|| Number(value.revision) < 1
) {
throw new CommandClientError('invalid-response', true)
}
return { activeInstructionId: value.active_instruction_id as string | null, settingsRevision: Number(value.revision) }
}
function mapDatabaseError(error: unknown): CommandClientError {
if (error instanceof CommandClientError) return error
const candidate = error as { code?: unknown; message?: unknown }
const code = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string' ? candidate.message : 'Instruction request failed'
const normalized = message.toLowerCase()
if (code === '23505') return new CommandClientError('duplicate', false, null, message)
if (code === 'P0002') return new CommandClientError('not-found', false, null, message)
if (
error instanceof TypeError
|| normalized.includes('failed to fetch')
|| normalized.includes('network request failed')
|| normalized.includes('networkerror')
) return new CommandClientError('network', true, null, message)
if (code === '42501' || code === 'PGRST301' || normalized.includes('jwt')) {
return new CommandClientError('auth', false, null, message)
}
if (code.startsWith('22')) return new CommandClientError('invalid-request', false, null, message)
return new CommandClientError('server', true, null, message)
}
export function normalizeInstructionDraft(draft: InstructionDraft): InstructionDraft {
const name = draft.name.trim().replace(/\s+/g, ' ')
const description = draft.description.trim()
const prompt = draft.prompt.trim()
if (name.length < 1 || name.length > 80 || description.length > 240 || prompt.length < 1 || prompt.length > 4_000) {
throw new CommandClientError('invalid-request', false)
}
return { name, description, prompt }
}
export async function bootstrapInstructionState(
client: SupabaseClient,
userId: string
): Promise<InstructionState> {
requireUserId(userId)
try {
const instructionsResult = await untyped(client).rpc('bootstrap_custom_instructions')
if (instructionsResult.error) throw instructionsResult.error
const settingsResult = await untyped(client)
.from('user_settings')
.select('user_id,active_instruction_id,revision')
.eq('user_id', userId)
.maybeSingle()
if (settingsResult.error) throw settingsResult.error
if (!Array.isArray(instructionsResult.data) || !settingsResult.data) {
throw new CommandClientError('invalid-response', true)
}
const instructions = sortInstructions(
instructionsResult.data.map((row) => normalizeInstruction(row, userId))
)
for (const key of BUILTIN_KEYS) {
if (!instructions.some((instruction) => instruction.builtinKey === key)) {
throw new CommandClientError('invalid-response', true)
}
}
const settings = normalizeSettings(settingsResult.data, userId)
if (settings.activeInstructionId && !instructions.some((instruction) => instruction.id === settings.activeInstructionId)) {
throw new CommandClientError('invalid-response', true)
}
return { instructions, ...settings }
} catch (error) {
throw mapDatabaseError(error)
}
}
export async function setActiveInstruction(
client: SupabaseClient,
userId: string,
instructionId: string
): Promise<{ activeInstructionId: string; settingsRevision: number }> {
requireUserId(userId)
if (!UUID_PATTERN.test(instructionId)) throw new CommandClientError('invalid-request', false)
try {
const { data, error } = await untyped(client).rpc('set_active_custom_instruction', { instruction_id: instructionId })
if (error) throw error
const settings = normalizeSettings(data, userId)
if (settings.activeInstructionId !== instructionId) throw new CommandClientError('invalid-response', true)
return { activeInstructionId: instructionId, settingsRevision: settings.settingsRevision }
} catch (error) {
throw mapDatabaseError(error)
}
}
export async function createCustomInstruction(
client: SupabaseClient,
userId: string,
draft: InstructionDraft,
sortOrder: number
): Promise<CustomInstruction> {
requireUserId(userId)
const normalized = normalizeInstructionDraft(draft)
if (!Number.isSafeInteger(sortOrder) || sortOrder < 0) throw new CommandClientError('invalid-request', false)
try {
const { data, error } = await untyped(client)
.from('custom_instructions')
.insert({
user_id: userId,
builtin_key: null,
name: normalized.name,
description: normalized.description,
prompt: normalized.prompt,
icon: 'sparkles',
sort_order: sortOrder
})
.select('*')
.single()
if (error) throw error
return normalizeInstruction(data, userId)
} catch (error) {
throw mapDatabaseError(error)
}
}
function assertMutable(instruction: CustomInstruction, userId: string): void {
requireUserId(userId)
if (instruction.userId !== userId) throw new CommandClientError('auth', false)
if (instruction.builtinKey !== null) throw new CommandClientError('invalid-request', false)
}
export async function updateCustomInstruction(
client: SupabaseClient,
userId: string,
current: CustomInstruction,
draft: InstructionDraft
): Promise<CustomInstruction> {
assertMutable(current, userId)
const normalized = normalizeInstructionDraft(draft)
try {
const { data, error } = await untyped(client)
.from('custom_instructions')
.update(normalized)
.eq('user_id', userId)
.eq('id', current.id)
.is('builtin_key', null)
.eq('revision', current.revision)
.select('*')
.maybeSingle()
if (error) throw error
if (!data) throw new CommandClientError('conflict', false)
return normalizeInstruction(data, userId)
} catch (error) {
throw mapDatabaseError(error)
}
}
export async function reorderCustomInstruction(
client: SupabaseClient,
userId: string,
instructionId: string,
direction: 'up' | 'down'
): Promise<CustomInstruction[]> {
requireUserId(userId)
if (!UUID_PATTERN.test(instructionId) || (direction !== 'up' && direction !== 'down')) {
throw new CommandClientError('invalid-request', false)
}
try {
const { data, error } = await untyped(client).rpc('reorder_custom_instruction', {
instruction_id: instructionId,
direction
})
if (error) throw error
if (!Array.isArray(data)) throw new CommandClientError('invalid-response', true)
const instructions = sortInstructions(data.map((row) => normalizeInstruction(row, userId)))
if (!instructions.some((instruction) => instruction.id === instructionId && instruction.builtinKey === null)) {
throw new CommandClientError('invalid-response', true)
}
return instructions
} catch (error) {
throw mapDatabaseError(error)
}
}
export async function deleteCustomInstruction(
client: SupabaseClient,
userId: string,
current: CustomInstruction
): Promise<void> {
assertMutable(current, userId)
try {
const { data, error } = await untyped(client)
.from('custom_instructions')
.delete()
.eq('user_id', userId)
.eq('id', current.id)
.is('builtin_key', null)
.eq('revision', current.revision)
.select('id')
.maybeSingle()
if (error) throw error
if (!data) throw new CommandClientError('conflict', false)
} catch (error) {
throw mapDatabaseError(error)
}
}
export function buildInstructionPrompt(instructionPrompt: string, input: string): string {
const prompt = instructionPrompt.trim()
const normalizedInput = input.trim()
if (!prompt || prompt.length > 4_000 || !normalizedInput || normalizedInput.length > COMMAND_INPUT_MAX_CHARS) {
throw new CommandClientError('invalid-request', false)
}
const combined = prompt.includes('{{text}}')
? prompt.replace('{{text}}', normalizedInput)
: `${prompt}\n\n${normalizedInput}`
if (combined.length > MESSAGE_MAX_CHARS) throw new CommandClientError('invalid-request', false)
return combined
}
function responseText(value: unknown): string {
if (!isRecord(value) || !Array.isArray(value.content)) throw new CommandClientError('invalid-response', true)
const text = value.content
.filter((block): block is Record<string, unknown> => isRecord(block) && block.type === 'text')
.map((block) => typeof block.text === 'string' ? block.text : '')
.join('')
.trim()
if (!text) throw new CommandClientError('invalid-response', true)
return text
}
function errorForResponse(status: number, value: unknown): CommandClientError {
const code = isRecord(value) && typeof value.error === 'string' ? value.error : ''
if (status === 401) return new CommandClientError('auth', false, status)
if (status === 400) return new CommandClientError('invalid-request', false, status)
if (status === 403 || code === 'model_not_allowed') return new CommandClientError('model-not-allowed', false, status)
if (status === 429 || code === 'quota_exceeded') return new CommandClientError('quota-exceeded', false, status)
if (status === 503 || code === 'provider_unavailable') return new CommandClientError('provider-unavailable', true, status)
if (status === 504 || code === 'provider_timeout') return new CommandClientError('timeout', true, status)
return new CommandClientError('server', true, status)
}
export async function executeInstruction(
instructionPrompt: string,
input: string,
options: { accessToken: string; signal?: AbortSignal; timeoutMs?: number }
): Promise<string> {
const accessToken = options.accessToken.trim()
if (!accessToken) throw new CommandClientError('auth', false, 401)
const prompt = buildInstructionPrompt(instructionPrompt, input)
const controller = new AbortController()
const abortFromCaller = (): void => controller.abort()
options.signal?.addEventListener('abort', abortFromCaller, { once: true })
const timer = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 45_000)
try {
const response = await fetch(`${SUPABASE_URL}/functions/v1/llm-proxy`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: prompt }], max_tokens: 1_024, stream: false }),
signal: controller.signal
})
const body: unknown = await response.json().catch(() => null)
if (!response.ok) throw errorForResponse(response.status, body)
return responseText(body)
} catch (error) {
if (error instanceof CommandClientError) throw error
if (controller.signal.aborted) {
const callerCancelled = options.signal?.aborted === true
throw new CommandClientError(callerCancelled ? 'cancelled' : 'timeout', !callerCancelled)
}
throw new CommandClientError('network', true)
} finally {
window.clearTimeout(timer)
options.signal?.removeEventListener('abort', abortFromCaller)
}
}

View file

@ -0,0 +1,228 @@
import type { D3roSupabaseClient, Subscription } from '@d3ro/api-client'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface DashboardRecentEntry {
id: string
title: string | null
text: string
durationSeconds: number
mode: string
createdAt: string
}
export interface DashboardStats {
totalSessions: number
totalRecordingSeconds: number
totalWordCount: number
todaySessions: number
todayRecordingSeconds: number
todayWordCount: number
streakDays: number
recentHistory: DashboardRecentEntry[]
generatedAt: string
}
export interface DashboardSubscription {
tier: Subscription['tier']
status: string | null
provider: Subscription['provider']
overageCredits: number
currentPeriodEnd: string | null
cancelAt: string | null
autoRenewing: boolean | null
}
export interface DashboardSnapshot {
stats: DashboardStats
subscription: DashboardSubscription
}
export type DashboardClientErrorCode =
| 'auth'
| 'invalid-response'
| 'network'
| 'not-initialized'
| 'server'
export class DashboardClientError extends Error {
constructor(
public readonly code: DashboardClientErrorCode,
message: string
) {
super(message)
this.name = 'DashboardClientError'
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const TIERS = new Set(['free', 'pro', 'pro_plus'])
const PROVIDERS = new Set(['none', 'stripe', 'payple', 'google_play', 'app_store', 'admin'])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function nonNegativeNumber(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null
}
function nonNegativeInteger(value: unknown): number | null {
const numeric = nonNegativeNumber(value)
return numeric !== null && Number.isSafeInteger(numeric) ? numeric : null
}
function normalizeDate(value: unknown, nullable = false): string | null {
if (nullable && value === null) return null
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return null
return new Date(value).toISOString()
}
export function normalizeDashboardStats(value: unknown): DashboardStats {
if (!isRecord(value) || !Array.isArray(value.recent_history)) {
throw new DashboardClientError('invalid-response', 'Dashboard response shape is invalid')
}
const totalSessions = nonNegativeInteger(value.total_sessions)
const totalRecordingSeconds = nonNegativeNumber(value.total_recording_seconds)
const totalWordCount = nonNegativeInteger(value.total_word_count)
const todaySessions = nonNegativeInteger(value.today_sessions)
const todayRecordingSeconds = nonNegativeNumber(value.today_recording_seconds)
const todayWordCount = nonNegativeInteger(value.today_word_count)
const streakDays = nonNegativeInteger(value.streak_days)
const generatedAt = normalizeDate(value.generated_at)
if (
totalSessions === null
|| totalRecordingSeconds === null
|| totalWordCount === null
|| todaySessions === null
|| todayRecordingSeconds === null
|| todayWordCount === null
|| streakDays === null
|| generatedAt === null
) {
throw new DashboardClientError('invalid-response', 'Dashboard metrics are invalid')
}
const recentHistory = value.recent_history.map((candidate): DashboardRecentEntry => {
if (!isRecord(candidate)) {
throw new DashboardClientError('invalid-response', 'Dashboard history row is invalid')
}
const durationSeconds = nonNegativeNumber(candidate.duration_seconds)
const createdAt = normalizeDate(candidate.created_at)
if (
typeof candidate.id !== 'string'
|| (candidate.title !== null && typeof candidate.title !== 'string')
|| typeof candidate.text !== 'string'
|| typeof candidate.mode !== 'string'
|| durationSeconds === null
|| createdAt === null
) {
throw new DashboardClientError('invalid-response', 'Dashboard history row is invalid')
}
return {
id: candidate.id,
title: candidate.title,
text: candidate.text,
durationSeconds,
mode: candidate.mode,
createdAt
}
})
return {
totalSessions,
totalRecordingSeconds,
totalWordCount,
todaySessions,
todayRecordingSeconds,
todayWordCount,
streakDays,
recentHistory,
generatedAt
}
}
function normalizeSubscription(value: unknown): DashboardSubscription {
if (!isRecord(value)) {
throw new DashboardClientError('not-initialized', 'Subscription row is missing')
}
const overageCredits = nonNegativeInteger(value.overage_credits)
const periodEnd = normalizeDate(value.current_period_end, true)
const cancelAt = normalizeDate(value.cancel_at, true)
if (
typeof value.tier !== 'string'
|| !TIERS.has(value.tier)
|| (value.status !== null && typeof value.status !== 'string')
|| typeof value.provider !== 'string'
|| !PROVIDERS.has(value.provider)
|| overageCredits === null
|| (value.current_period_end !== null && periodEnd === null)
|| (value.cancel_at !== null && cancelAt === null)
|| (value.auto_renewing !== null && typeof value.auto_renewing !== 'boolean')
) {
throw new DashboardClientError('invalid-response', 'Subscription response shape is invalid')
}
return {
tier: value.tier as Subscription['tier'],
status: value.status,
provider: value.provider as Subscription['provider'],
overageCredits,
currentPeriodEnd: periodEnd,
cancelAt,
autoRenewing: value.auto_renewing
}
}
function mapDashboardError(error: unknown): DashboardClientError {
if (error instanceof DashboardClientError) return error
const candidate = error as { code?: unknown; message?: unknown }
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string' ? candidate.message : 'Dashboard request failed'
const normalized = message.toLowerCase()
if (
error instanceof TypeError
|| normalized.includes('failed to fetch')
|| normalized.includes('network request failed')
|| normalized.includes('networkerror')
) {
return new DashboardClientError('network', message)
}
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
return new DashboardClientError('auth', message)
}
return new DashboardClientError('server', message)
}
export async function loadDashboardSnapshot(
client: D3roSupabaseClient,
userId: string
): Promise<DashboardSnapshot> {
if (!UUID_PATTERN.test(userId)) {
throw new DashboardClientError('auth', 'A valid authenticated user is required')
}
try {
const untypedClient = client as unknown as SupabaseClient
const [statsResult, subscriptionResult] = await Promise.all([
untypedClient.rpc('mobile_dashboard_stats'),
client
.from('subscriptions')
.select('tier,status,provider,overage_credits,current_period_end,cancel_at,auto_renewing')
.eq('user_id', userId)
.maybeSingle()
])
if (statsResult.error) throw statsResult.error
if (subscriptionResult.error) throw subscriptionResult.error
if (!subscriptionResult.data) {
throw new DashboardClientError('not-initialized', 'Subscription row is missing')
}
return {
stats: normalizeDashboardStats(statsResult.data),
subscription: normalizeSubscription(subscriptionResult.data)
}
} catch (error) {
throw mapDashboardError(error)
}
}

View file

@ -0,0 +1,267 @@
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
export type DictionaryCategory = DictionaryEntry['category']
export type DictionaryFilter = DictionaryCategory | 'all'
export interface DictionaryCursor {
updatedAt: string
id: string
}
export interface DictionaryDraft {
word: string
pronunciation: string | null
category: DictionaryCategory
}
export interface DictionaryPageResult {
entries: DictionaryEntry[]
nextCursor: DictionaryCursor | null
total: number
}
export type DictionaryClientErrorCode =
| 'auth'
| 'conflict'
| 'duplicate'
| 'network'
| 'validation'
| 'server'
export class DictionaryClientError extends Error {
constructor(
public readonly code: DictionaryClientErrorCode,
message: string
) {
super(message)
this.name = 'DictionaryClientError'
}
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const CATEGORIES: readonly DictionaryCategory[] = ['user', 'technical', 'auto']
const MAX_WORD_LENGTH = 120
const MAX_PRONUNCIATION_LENGTH = 200
const MAX_PAGE_SIZE = 50
function requireUuid(value: string, code: 'auth' | 'validation', message: string): void {
if (!UUID_PATTERN.test(value)) throw new DictionaryClientError(code, message)
}
function normalizeText(value: string): string {
return value.trim().replace(/\s+/g, ' ')
}
export function sanitizeDictionarySearch(value: string): string {
return value
.trim()
.slice(0, 100)
.replace(/[%,()._'"\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function normalizeDictionaryDraft(draft: DictionaryDraft): DictionaryDraft {
const word = normalizeText(draft.word)
const pronunciation = normalizeText(draft.pronunciation ?? '') || null
if (word.length === 0 || word.length > MAX_WORD_LENGTH) {
throw new DictionaryClientError('validation', `단어는 1-${MAX_WORD_LENGTH}자로 입력해 주세요.`)
}
if (pronunciation !== null && pronunciation.length > MAX_PRONUNCIATION_LENGTH) {
throw new DictionaryClientError('validation', `발음은 ${MAX_PRONUNCIATION_LENGTH}자 이하여야 합니다.`)
}
if (!CATEGORIES.includes(draft.category)) {
throw new DictionaryClientError('validation', '사전 분류가 올바르지 않습니다.')
}
return { word, pronunciation, category: draft.category }
}
function mapDictionaryError(error: unknown): DictionaryClientError {
if (error instanceof DictionaryClientError) return error
const candidate = error as { code?: unknown; message?: unknown }
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string' ? candidate.message : 'Dictionary request failed'
const normalized = message.toLowerCase()
if (databaseCode === '23505') return new DictionaryClientError('duplicate', message)
if (
error instanceof TypeError
|| normalized.includes('failed to fetch')
|| normalized.includes('network request failed')
|| normalized.includes('networkerror')
) {
return new DictionaryClientError('network', message)
}
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
return new DictionaryClientError('auth', message)
}
if (databaseCode.startsWith('22')) return new DictionaryClientError('validation', message)
return new DictionaryClientError('server', message)
}
function assertOwned(userId: string, entry: DictionaryEntry): void {
requireUuid(userId, 'auth', 'Authenticated user is invalid')
requireUuid(entry.id, 'validation', 'Dictionary entry id is invalid')
if (entry.user_id !== userId) {
throw new DictionaryClientError('auth', 'Dictionary ownership could not be verified')
}
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (character) => `\\${character}`)
}
async function findDuplicate(
client: D3roSupabaseClient,
userId: string,
draft: DictionaryDraft,
excludingId?: string
): Promise<boolean> {
let query = client
.from('dictionary')
.select('id')
.eq('user_id', userId)
.eq('category', draft.category)
.ilike('word', escapeLikePattern(draft.word))
.limit(1)
if (excludingId) query = query.neq('id', excludingId)
const { data, error } = await query.maybeSingle()
if (error) throw error
return data !== null
}
export async function listDictionaryPage(
client: D3roSupabaseClient,
options: {
userId: string
search: string
category: DictionaryFilter
pageSize: number
cursor: DictionaryCursor | null
}
): Promise<DictionaryPageResult> {
requireUuid(options.userId, 'auth', 'Authenticated user is invalid')
if (options.category !== 'all' && !CATEGORIES.includes(options.category)) {
throw new DictionaryClientError('validation', 'Dictionary category is invalid')
}
if (options.cursor) {
requireUuid(options.cursor.id, 'validation', 'Dictionary cursor id is invalid')
if (!Number.isFinite(Date.parse(options.cursor.updatedAt))) {
throw new DictionaryClientError('validation', 'Dictionary cursor date is invalid')
}
}
const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE))
try {
let query = client
.from('dictionary')
.select('*', { count: 'exact' })
.eq('user_id', options.userId)
.order('updated_at', { ascending: false })
.order('id', { ascending: false })
.limit(pageSize + 1)
if (options.category !== 'all') query = query.eq('category', options.category)
const search = sanitizeDictionarySearch(options.search)
if (search) {
const pattern = `%${search}%`
query = query.or(`word.ilike.${pattern},pronunciation.ilike.${pattern}`)
}
if (options.cursor) {
query = query.or(
`updated_at.lt.${options.cursor.updatedAt},and(updated_at.eq.${options.cursor.updatedAt},id.lt.${options.cursor.id})`
)
}
const { data, error, count } = await query
if (error) throw error
const rows = data ?? []
for (const row of rows) assertOwned(options.userId, row)
const hasMore = rows.length > pageSize
const entries = hasMore ? rows.slice(0, pageSize) : rows
const last = entries.at(-1)
return {
entries,
nextCursor: hasMore && last ? { updatedAt: last.updated_at, id: last.id } : null,
total: count ?? entries.length
}
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function createDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
draft: DictionaryDraft
): Promise<DictionaryEntry> {
requireUuid(userId, 'auth', 'Authenticated user is invalid')
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(client, userId, normalized)) {
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
}
const { data, error } = await client
.from('dictionary')
.insert({ user_id: userId, ...normalized })
.select('*')
.single()
if (error) throw error
assertOwned(userId, data)
return data
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function updateDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
current: DictionaryEntry,
draft: DictionaryDraft
): Promise<DictionaryEntry> {
assertOwned(userId, current)
const normalized = normalizeDictionaryDraft(draft)
try {
if (await findDuplicate(client, userId, normalized, current.id)) {
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
}
const { data, error } = await client
.from('dictionary')
.update({
word: normalized.word,
pronunciation: normalized.pronunciation,
category: normalized.category
})
.eq('user_id', userId)
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('*')
.maybeSingle()
if (error) throw error
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
assertOwned(userId, data)
return data
} catch (error) {
throw mapDictionaryError(error)
}
}
export async function deleteDictionaryEntry(
client: D3roSupabaseClient,
userId: string,
current: DictionaryEntry
): Promise<void> {
assertOwned(userId, current)
try {
const { data, error } = await client
.from('dictionary')
.delete()
.eq('user_id', userId)
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('id')
.maybeSingle()
if (error) throw error
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
} catch (error) {
throw mapDictionaryError(error)
}
}

View file

@ -0,0 +1,212 @@
import type { D3roSupabaseClient, HistoryEntry } from '@d3ro/api-client'
export type HistoryListFilter = 'all' | 'favorites'
export interface HistoryCursor {
createdAt: string
id: string
}
export interface HistoryPageOptions {
userId: string
filter: HistoryListFilter
search: string
pageSize: number
cursor: HistoryCursor | null
}
export interface HistoryPageResult {
entries: HistoryEntry[]
nextCursor: HistoryCursor | null
}
export type HistoryClientErrorCode = 'auth' | 'conflict' | 'not-found' | 'validation' | 'network' | 'server'
export class HistoryClientError extends Error {
constructor(
public readonly code: HistoryClientErrorCode,
message: string
) {
super(message)
this.name = 'HistoryClientError'
}
}
export interface HistoryUpdate {
title?: string | null
original_text?: string
polished_text?: string | null
is_favorite?: boolean
}
const MAX_PAGE_SIZE = 50
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function requireUuid(value: string, code: 'auth' | 'validation', message: string): void {
if (!UUID_PATTERN.test(value)) throw new HistoryClientError(code, message)
}
function mapError(error: unknown): HistoryClientError {
if (error instanceof HistoryClientError) return error
const candidate = error as { code?: unknown; message?: unknown }
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
const message = typeof candidate?.message === 'string' ? candidate.message : 'History request failed'
const normalized = message.toLowerCase()
if (
error instanceof TypeError
|| normalized.includes('failed to fetch')
|| normalized.includes('network request failed')
|| normalized.includes('networkerror')
) {
return new HistoryClientError('network', message)
}
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
return new HistoryClientError('auth', message)
}
return new HistoryClientError('server', message)
}
export function sanitizeHistorySearch(value: string): string {
return value
.trim()
.slice(0, 100)
.replace(/[%,()._'"\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export async function listHistoryPage(
client: D3roSupabaseClient,
options: HistoryPageOptions
): Promise<HistoryPageResult> {
requireUuid(options.userId, 'auth', 'A valid authenticated user is required')
const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE))
try {
let query = client
.from('history')
.select('*')
.eq('user_id', options.userId)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(pageSize + 1)
if (options.filter === 'favorites') query = query.eq('is_favorite', true)
if (options.cursor !== null) {
query = query.or(
`created_at.lt.${options.cursor.createdAt},and(created_at.eq.${options.cursor.createdAt},id.lt.${options.cursor.id})`
)
}
const search = sanitizeHistorySearch(options.search)
if (search.length > 0) {
const pattern = `%${search}%`
query = query.or(
`title.ilike.${pattern},original_text.ilike.${pattern},polished_text.ilike.${pattern},summary_text.ilike.${pattern}`
)
}
const { data, error } = await query
if (error) throw error
const rows = data ?? []
const hasMore = rows.length > pageSize
const entries = hasMore ? rows.slice(0, pageSize) : rows
const last = entries.at(-1)
return {
entries,
nextCursor: hasMore && last ? { createdAt: last.created_at, id: last.id } : null
}
} catch (error) {
throw mapError(error)
}
}
export async function getHistoryEntry(
client: D3roSupabaseClient,
userId: string,
entryId: string
): Promise<HistoryEntry> {
requireUuid(userId, 'auth', 'A valid authenticated user is required')
requireUuid(entryId, 'validation', 'A valid history id is required')
try {
const { data, error } = await client
.from('history')
.select('*')
.eq('user_id', userId)
.eq('id', entryId)
.maybeSingle()
if (error) throw error
if (!data) throw new HistoryClientError('not-found', 'History entry was not found')
return data
} catch (error) {
throw mapError(error)
}
}
export async function updateHistoryEntryRevisionSafe(
client: D3roSupabaseClient,
userId: string,
entryId: string,
expectedRevision: number,
patch: HistoryUpdate
): Promise<HistoryEntry> {
requireUuid(userId, 'auth', 'A valid authenticated user is required')
requireUuid(entryId, 'validation', 'A valid history id is required')
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
throw new HistoryClientError('validation', 'Expected revision is invalid')
}
if (Object.keys(patch).length === 0) {
throw new HistoryClientError('validation', 'At least one field is required')
}
try {
const { data, error } = await client
.from('history')
.update({ ...patch, revision: expectedRevision + 1 })
.eq('user_id', userId)
.eq('id', entryId)
.eq('revision', expectedRevision)
.select('*')
.maybeSingle()
if (error) throw error
if (!data) throw new HistoryClientError('conflict', 'History revision changed')
return data
} catch (error) {
throw mapError(error)
}
}
export async function deleteHistoryEntryRevisionSafe(
client: D3roSupabaseClient,
userId: string,
entryId: string,
expectedRevision: number
): Promise<void> {
requireUuid(userId, 'auth', 'A valid authenticated user is required')
requireUuid(entryId, 'validation', 'A valid history id is required')
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
throw new HistoryClientError('validation', 'Expected revision is invalid')
}
try {
const { data, error } = await client
.from('history')
.delete()
.eq('user_id', userId)
.eq('id', entryId)
.eq('revision', expectedRevision)
.select('id')
.maybeSingle()
if (error) throw error
if (!data) throw new HistoryClientError('conflict', 'History revision changed')
} catch (error) {
throw mapError(error)
}
}

View file

@ -0,0 +1,120 @@
export type WebSttErrorCode =
| 'not_configured'
| 'auth_required'
| 'invalid_audio'
| 'payload_too_large'
| 'unsupported_audio'
| 'quota_exceeded'
| 'provider_unavailable'
| 'upstream_failed'
| 'invalid_response'
| 'network'
| 'cancelled'
export class WebSttError extends Error {
constructor(public readonly code: WebSttErrorCode) {
super(code)
this.name = 'WebSttError'
}
}
export interface WebSttResult {
transcript: string
confidence: number
languageCode: string
durationSeconds: number
provider: string
}
const MAX_WEB_AUDIO_BYTES = 25 * 1024 * 1024
function endpointFromSupabaseUrl(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) throw new Error('insecure endpoint')
if (url.username || url.password) throw new Error('credentialed endpoint')
return url.toString()
} catch {
throw new WebSttError('not_configured')
}
}
function parseResult(value: unknown): WebSttResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new WebSttError('invalid_response')
const result = value as Record<string, unknown>
if (
typeof result['transcript'] !== 'string'
|| result['transcript'].trim().length < 1
|| result['transcript'].length > 1_000_000
|| typeof result['confidence'] !== 'number'
|| !Number.isFinite(result['confidence'])
|| result['confidence'] < 0
|| result['confidence'] > 1
|| typeof result['language_code'] !== 'string'
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(result['language_code'])
|| typeof result['duration_seconds'] !== 'number'
|| !Number.isFinite(result['duration_seconds'])
|| result['duration_seconds'] < 0
|| result['duration_seconds'] > 24 * 60 * 60
|| typeof result['provider'] !== 'string'
|| !/^[a-z0-9._-]{1,64}$/.test(result['provider'])
) throw new WebSttError('invalid_response')
return {
transcript: result['transcript'].trim(),
confidence: result['confidence'],
languageCode: result['language_code'],
durationSeconds: result['duration_seconds'],
provider: result['provider'],
}
}
function statusError(status: number, payload: unknown): WebSttError {
const error = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)['error']
: null
if (status === 401 || status === 403) return new WebSttError('auth_required')
if (status === 413) return new WebSttError('payload_too_large')
if (status === 415) return new WebSttError('unsupported_audio')
if (status === 429 || error === 'quota_exceeded') return new WebSttError('quota_exceeded')
if (status === 503 || error === 'stt_provider_unavailable') return new WebSttError('provider_unavailable')
if (status === 502 || error === 'stt_upstream_failed') return new WebSttError('upstream_failed')
return new WebSttError('invalid_response')
}
export async function transcribeWebAudio(input: {
audio: Blob
accessToken: string
supabaseUrl: string
signal?: AbortSignal
fetchImpl?: typeof fetch
}): Promise<WebSttResult> {
if (!(input.audio instanceof Blob) || input.audio.size < 1) throw new WebSttError('invalid_audio')
if (input.audio.size > MAX_WEB_AUDIO_BYTES) throw new WebSttError('payload_too_large')
if (!/^audio\/webm(?:;|$)/i.test(input.audio.type)) throw new WebSttError('unsupported_audio')
if (!input.accessToken.trim()) throw new WebSttError('auth_required')
const form = new FormData()
form.append('audio', input.audio, 'recording.webm')
form.append('language_code', 'ko')
let response: Response
try {
response = await (input.fetchImpl ?? fetch)(endpointFromSupabaseUrl(input.supabaseUrl), {
method: 'POST',
headers: { Authorization: `Bearer ${input.accessToken}` },
body: form,
signal: input.signal,
})
} catch (error) {
if (input.signal?.aborted || (error instanceof DOMException && error.name === 'AbortError')) {
throw new WebSttError('cancelled')
}
throw new WebSttError('network')
}
const payload = await response.json().catch(() => null)
if (!response.ok) throw statusError(response.status, payload)
return parseResult(payload)
}