feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
97
apps/web/src/lib/billing-catalog.ts
Normal file
97
apps/web/src/lib/billing-catalog.ts
Normal 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(' · ')
|
||||
}
|
||||
417
apps/web/src/lib/command-client.ts
Normal file
417
apps/web/src/lib/command-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
228
apps/web/src/lib/dashboard-client.ts
Normal file
228
apps/web/src/lib/dashboard-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
267
apps/web/src/lib/dictionary-client.ts
Normal file
267
apps/web/src/lib/dictionary-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
212
apps/web/src/lib/history-client.ts
Normal file
212
apps/web/src/lib/history-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
120
apps/web/src/lib/web-stt-client.ts
Normal file
120
apps/web/src/lib/web-stt-client.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue