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,968 @@
import type { RealtimeChannel } from '@supabase/supabase-js'
import type {
D3roSupabaseClient,
Meeting,
MeetingLanguage,
MeetingDocument,
MeetingMemo,
ProcessingJob,
Transcript,
} from '@d3ro/api-client'
import { supabase } from '../../lib/supabase'
import { createUuidV4 } from '../../lib/random-id'
export interface MeetingListOptions {
userId: string
search?: string
page?: number
pageSize?: number
}
export interface MeetingListResult {
meetings: Meeting[]
total: number
page: number
pageSize: number
hasMore: boolean
}
export interface MeetingCreationDraft {
title: string
attendees: string[]
language: MeetingLanguage
templateId: string | null
idempotencyKey: string
}
export interface NormalizedMeetingCreationDraft {
title: string
attendees: string[]
language: MeetingLanguage
templateId: string | null
idempotencyKey: string
}
export interface MeetingDetail {
meeting: Meeting
transcripts: Transcript[]
memos: MeetingMemo[]
documents: MeetingDocument[]
}
export interface MeetingTranscriptView {
source: 'segments' | 'edited' | 'raw' | 'empty'
text: string
}
export type MeetingServiceErrorCode =
| 'auth'
| 'conflict'
| 'forbidden'
| 'network'
| 'not-found'
| 'server'
| 'validation'
export class MeetingServiceError extends Error {
constructor(
public readonly code: MeetingServiceErrorCode,
message: string,
) {
super(message)
this.name = 'MeetingServiceError'
}
}
export interface MeetingDetailSubscription {
unsubscribe: () => Promise<void>
}
export interface MeetingProcessingResult {
transcript: string
language: string
provider: string
durationMs: number
sttLatencyMs: number
}
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 DEFAULT_PAGE_SIZE = 30
const MAX_PAGE_SIZE = 50
const MAX_TITLE_LENGTH = 160
const MAX_MEMO_LENGTH = 4_000
const MAX_DOCUMENT_CONTENT_LENGTH = 100_000
const MAX_ATTENDEES = 50
const MAX_ATTENDEE_LENGTH = 120
function client(): D3roSupabaseClient {
return supabase as unknown as D3roSupabaseClient
}
function requireUuid(value: string, label: string): void {
if (!UUID_PATTERN.test(value)) {
throw new MeetingServiceError('auth', `A valid ${label} is required`)
}
}
function normalizeWhitespace(value: string): string {
return value.trim().replace(/\s+/g, ' ')
}
export function normalizeMeetingTitle(value: string): string {
const title = normalizeWhitespace(value)
if (title.length === 0 || title.length > MAX_TITLE_LENGTH) {
throw new MeetingServiceError(
'validation',
`Meeting titles must contain 1-${MAX_TITLE_LENGTH} characters`,
)
}
return title
}
export function normalizeMeetingLanguage(value: string): MeetingLanguage {
const language = value.trim().toLocaleLowerCase('en-US')
if (!(['ko', 'en', 'ja', 'zh-cn'] as const).includes(language as MeetingLanguage)) {
throw new MeetingServiceError('validation', 'Meeting language is invalid')
}
return language as MeetingLanguage
}
export function normalizeMeetingAttendees(values: string[]): string[] {
if (!Array.isArray(values) || values.length > MAX_ATTENDEES) {
throw new MeetingServiceError('validation', `Meetings support up to ${MAX_ATTENDEES} attendees`)
}
const normalized = values.map((value) => {
if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/.test(value)) {
throw new MeetingServiceError('validation', 'Meeting attendee is invalid')
}
const attendee = normalizeWhitespace(value)
if (attendee.length < 1 || attendee.length > MAX_ATTENDEE_LENGTH) {
throw new MeetingServiceError('validation', 'Meeting attendee is invalid')
}
return attendee
})
const identities = new Set(normalized.map((attendee) => attendee.toLocaleLowerCase('en-US')))
if (identities.size !== normalized.length) {
throw new MeetingServiceError('validation', 'Meeting attendees must be unique')
}
return normalized
}
export function normalizeMeetingCreationDraft(
draft: MeetingCreationDraft,
): NormalizedMeetingCreationDraft {
// 문서 템플릿은 선택 사항이다 — 없이 생성한 회의는 문서 생성 시점에
// 사용자 템플릿 라이브러리에서 템플릿이 해결된다.
if (draft.templateId !== null) {
requireUuid(draft.templateId, 'meeting template id')
}
requireUuid(draft.idempotencyKey, 'meeting creation idempotency key')
return {
title: normalizeMeetingTitle(draft.title),
attendees: normalizeMeetingAttendees(draft.attendees),
language: normalizeMeetingLanguage(draft.language),
templateId: draft.templateId === null ? null : draft.templateId.toLowerCase(),
idempotencyKey: draft.idempotencyKey.toLowerCase(),
}
}
export function createMeetingCreationIdempotencyKey(): string {
try {
return createUuidV4()
} catch {
throw new MeetingServiceError('server', 'Secure UUID generation is unavailable')
}
}
export function normalizeMemoContent(value: string): string {
const content = value.trim()
if (content.length === 0 || content.length > MAX_MEMO_LENGTH) {
throw new MeetingServiceError(
'validation',
`Meeting memos must contain 1-${MAX_MEMO_LENGTH} characters`,
)
}
return content
}
export function normalizeDocumentDraft(
titleValue: string,
contentValue: string,
): { title: string; content: string } {
const title = normalizeMeetingTitle(titleValue)
const content = contentValue.trim()
if (content.length > MAX_DOCUMENT_CONTENT_LENGTH) {
throw new MeetingServiceError(
'validation',
`Meeting documents must not exceed ${MAX_DOCUMENT_CONTENT_LENGTH} characters`,
)
}
return { title, content }
}
export function sanitizeMeetingSearch(value: string): string {
return value
.trim()
.slice(0, 100)
.replace(/[\\%_]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export function computeMemoTimestamp(
meetingStartedAt: string,
nowMs = Date.now(),
): number {
const startedAt = Date.parse(meetingStartedAt)
if (!Number.isFinite(startedAt) || !Number.isSafeInteger(nowMs)) {
throw new MeetingServiceError('validation', 'Meeting time is invalid')
}
return Math.max(0, nowMs - startedAt)
}
export function buildMeetingTranscriptView(
meeting: Meeting,
transcripts: Transcript[],
): MeetingTranscriptView {
if (transcripts.length > 0) {
return {
source: 'segments',
text: transcripts
.slice()
.sort((left, right) => left.segment_index - right.segment_index)
.map((segment) => segment.speaker?.trim()
? `${segment.speaker.trim()}: ${segment.text}`
: segment.text)
.join('\n'),
}
}
if (meeting.edited_transcript?.trim()) {
return { source: 'edited', text: meeting.edited_transcript }
}
if (meeting.raw_transcript?.trim()) {
return { source: 'raw', text: meeting.raw_transcript }
}
return { source: 'empty', text: '' }
}
function assertMeetingRow(row: Meeting | null, expectedId?: string): Meeting {
if (row === null) {
throw new MeetingServiceError('not-found', 'Meeting was not found')
}
requireUuid(row.id, 'meeting id')
requireUuid(row.user_id, 'meeting owner id')
if (expectedId !== undefined && row.id !== expectedId) {
throw new MeetingServiceError('server', 'Server returned a different meeting')
}
if (!Number.isFinite(Date.parse(row.started_at)) || !Number.isFinite(Date.parse(row.updated_at))) {
throw new MeetingServiceError('server', 'Server returned invalid meeting dates')
}
return row
}
function assertProcessingJobRow(row: ProcessingJob | null): ProcessingJob {
if (row === null) {
throw new MeetingServiceError('server', 'Processing job was not returned')
}
requireUuid(row.id, 'processing job id')
requireUuid(row.user_id, 'processing job owner id')
if (!Number.isInteger(row.progress) || row.progress < 0 || row.progress > 100) {
throw new MeetingServiceError('server', 'Processing job progress is invalid')
}
return row
}
function assertDetailRelationships(detail: MeetingDetail): MeetingDetail {
const meetingId = detail.meeting.id
const invalidChild = [
...detail.transcripts,
...detail.memos,
...detail.documents,
].find((row) => row.meeting_id !== meetingId)
if (invalidChild !== undefined) {
throw new MeetingServiceError('server', 'Server returned data from a different meeting')
}
return detail
}
function toMeetingServiceError(error: unknown): MeetingServiceError {
if (error instanceof MeetingServiceError) 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
: 'Meeting request failed'
const lower = message.toLowerCase()
if (
error instanceof TypeError
|| lower.includes('network request failed')
|| lower.includes('failed to fetch')
|| lower.includes('networkerror')
) {
return new MeetingServiceError('network', message)
}
if (code === '42501' || lower.includes('permission denied') || lower.includes('row-level security')) {
return new MeetingServiceError('forbidden', message)
}
if (code === 'PGRST301' || lower.includes('jwt')) {
return new MeetingServiceError('auth', message)
}
if (code === 'PT409') return new MeetingServiceError('conflict', message)
if (code.startsWith('22')) return new MeetingServiceError('validation', message)
return new MeetingServiceError('server', message)
}
async function callMeetingRpc<T>(
name: string,
params: Record<string, unknown>,
): Promise<T> {
const { data, error } = await supabase.rpc(name as never, params as never)
if (error !== null) throw toMeetingServiceError(error)
return data as T
}
export async function listMeetingsPage(
options: MeetingListOptions,
): Promise<MeetingListResult> {
requireUuid(options.userId, 'authenticated user')
const page = Math.max(0, Math.floor(options.page ?? 0))
const pageSize = Math.max(1, Math.min(
Math.floor(options.pageSize ?? DEFAULT_PAGE_SIZE),
MAX_PAGE_SIZE,
))
const search = sanitizeMeetingSearch(options.search ?? '')
try {
let query = client()
.from('meetings')
.select('*', { count: 'exact' })
.order('started_at', { ascending: false })
.order('id', { ascending: false })
.range(page * pageSize, (page + 1) * pageSize - 1)
if (search.length > 0) query = query.ilike('title', `%${search}%`)
const { data, error, count } = await query
if (error !== null) throw error
const meetings = (data ?? []).map((row) => assertMeetingRow(row))
const total = count ?? meetings.length
return {
meetings,
total,
page,
pageSize,
hasMore: (page + 1) * pageSize < total,
}
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function getMeetingDetail(
userId: string,
meetingId: string,
): Promise<MeetingDetail> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
try {
const [meetingResult, transcriptResult, memoResult, documentResult] = await Promise.all([
client().from('meetings').select('*').eq('id', meetingId).maybeSingle(),
client()
.from('transcripts')
.select('*')
.eq('meeting_id', meetingId)
.order('segment_index', { ascending: true }),
client()
.from('meeting_memos')
.select('*')
.eq('meeting_id', meetingId)
.order('timestamp_ms', { ascending: true })
.order('created_at', { ascending: true }),
client()
.from('meeting_documents')
.select('*')
.eq('meeting_id', meetingId)
.order('created_at', { ascending: true }),
])
const firstError = [
meetingResult.error,
transcriptResult.error,
memoResult.error,
documentResult.error,
].find((error) => error !== null)
if (firstError !== undefined && firstError !== null) throw firstError
return assertDetailRelationships({
meeting: assertMeetingRow(meetingResult.data, meetingId),
transcripts: transcriptResult.data ?? [],
memos: memoResult.data ?? [],
documents: documentResult.data ?? [],
})
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function getMeetingRecordingLanguage(
userId: string,
meetingId: string,
): Promise<MeetingLanguage | null> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
try {
const { data, error } = await client()
.from('meetings')
.select('*')
.eq('id', meetingId)
.maybeSingle()
if (error !== null) throw error
const meeting = assertMeetingRow(data, meetingId)
if (meeting.user_id !== userId) {
throw new MeetingServiceError('forbidden', 'Meeting recording is owner-only')
}
return meeting.language == null ? null : normalizeMeetingLanguage(meeting.language)
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function createMeetingWorkspace(
userId: string,
titleValue: string,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
const title = normalizeMeetingTitle(titleValue)
try {
const { data, error } = await client()
.from('meetings')
.insert({ user_id: userId, title, status: 'recording' })
.select('*')
.single()
if (error !== null) throw error
return assertMeetingRow(data)
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function createMeetingWorkspaceWithMetadata(
userId: string,
draft: MeetingCreationDraft,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
const normalized = normalizeMeetingCreationDraft(draft)
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_create_meeting_workspace_v2',
{
p_title: normalized.title,
p_attendees: normalized.attendees,
p_language: normalized.language,
p_template_id: normalized.templateId,
p_idempotency_key: normalized.idempotencyKey,
},
)
const meeting = assertMeetingRow(row)
if (
meeting.user_id !== userId
|| meeting.status !== 'recording'
|| meeting.title !== normalized.title
|| meeting.language !== normalized.language
|| meeting.template_id !== normalized.templateId
|| meeting.creation_idempotency_key !== normalized.idempotencyKey
|| !Array.isArray(meeting.attendees)
|| JSON.stringify(meeting.attendees) !== JSON.stringify(normalized.attendees)
) {
throw new MeetingServiceError('server', 'Created meeting metadata was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function updateMeetingTitleRevisionSafe(
current: Meeting,
titleValue: string,
): Promise<Meeting> {
assertMeetingRow(current)
const title = normalizeMeetingTitle(titleValue)
if (current.title === title) return current
try {
const { data, error } = await client()
.from('meetings')
.update({ title })
.eq('id', current.id)
.eq('updated_at', current.updated_at)
.select('*')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Meeting changed, was removed, or is no longer editable',
)
}
return assertMeetingRow(data, current.id)
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function deleteMeetingRevisionSafe(
userId: string,
current: Meeting,
): Promise<void> {
requireUuid(userId, 'authenticated user')
assertMeetingRow(current)
if (current.user_id !== userId) {
throw new MeetingServiceError('forbidden', 'Only the meeting owner can delete it')
}
try {
const { data, error } = await client()
.from('meetings')
.delete()
.eq('id', current.id)
.eq('user_id', userId)
.eq('updated_at', current.updated_at)
.select('id')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Meeting changed or was removed on another device',
)
}
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function createMeetingMemo(
userId: string,
meeting: Meeting,
contentValue: string,
nowMs = Date.now(),
): Promise<MeetingMemo> {
requireUuid(userId, 'authenticated user')
assertMeetingRow(meeting)
const content = normalizeMemoContent(contentValue)
const timestampMs = computeMemoTimestamp(meeting.started_at, nowMs)
try {
const { data, error } = await client()
.from('meeting_memos')
.insert({
meeting_id: meeting.id,
user_id: userId,
content,
timestamp_ms: timestampMs,
})
.select('*')
.single()
if (error !== null) throw error
if (data.meeting_id !== meeting.id || data.user_id !== userId) {
throw new MeetingServiceError('server', 'Server returned a different memo')
}
return data
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function updateMeetingMemoConflictSafe(
userId: string,
current: MeetingMemo,
contentValue: string,
): Promise<MeetingMemo> {
requireUuid(userId, 'authenticated user')
requireUuid(current.id, 'memo id')
if (current.user_id !== userId) {
throw new MeetingServiceError('forbidden', 'Only the memo author can edit it')
}
const content = normalizeMemoContent(contentValue)
if (content === current.content) return current
try {
const { data, error } = await client()
.from('meeting_memos')
.update({ content })
.eq('id', current.id)
.eq('meeting_id', current.meeting_id)
.eq('user_id', userId)
.eq('content', current.content)
.eq('timestamp_ms', current.timestamp_ms)
.eq('created_at', current.created_at)
.select('*')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Memo changed or was removed on another device',
)
}
return data
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function deleteMeetingMemoConflictSafe(
userId: string,
current: MeetingMemo,
): Promise<void> {
requireUuid(userId, 'authenticated user')
requireUuid(current.id, 'memo id')
if (current.user_id !== userId) {
throw new MeetingServiceError('forbidden', 'Only the memo author can delete it')
}
try {
const { data, error } = await client()
.from('meeting_memos')
.delete()
.eq('id', current.id)
.eq('meeting_id', current.meeting_id)
.eq('user_id', userId)
.eq('content', current.content)
.eq('timestamp_ms', current.timestamp_ms)
.eq('created_at', current.created_at)
.select('id')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Memo changed or was removed on another device',
)
}
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function updateMeetingDocumentRevisionSafe(
current: MeetingDocument,
titleValue: string,
contentValue: string,
): Promise<MeetingDocument> {
requireUuid(current.id, 'document id')
const draft = normalizeDocumentDraft(titleValue, contentValue)
if (draft.title === current.title && draft.content === current.content) return current
try {
const { data, error } = await client()
.from('meeting_documents')
.update(draft)
.eq('id', current.id)
.eq('meeting_id', current.meeting_id)
.eq('updated_at', current.updated_at)
.select('*')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Document changed, was removed, or is no longer editable',
)
}
return data
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function deleteMeetingDocumentRevisionSafe(
userId: string,
current: MeetingDocument,
): Promise<void> {
requireUuid(userId, 'authenticated user')
requireUuid(current.id, 'document id')
if (current.user_id !== userId) {
throw new MeetingServiceError('forbidden', 'Only the document author can delete it')
}
try {
const { data, error } = await client()
.from('meeting_documents')
.delete()
.eq('id', current.id)
.eq('meeting_id', current.meeting_id)
.eq('user_id', userId)
.eq('updated_at', current.updated_at)
.select('id')
.maybeSingle()
if (error !== null) throw error
if (data === null) {
throw new MeetingServiceError(
'conflict',
'Document changed or was removed on another device',
)
}
} catch (error) {
throw toMeetingServiceError(error)
}
}
export function subscribeToMeetingDetail(
meetingId: string,
onRemoteChange: () => void,
onStatus: (status: string) => void,
): MeetingDetailSubscription {
requireUuid(meetingId, 'meeting id')
let channel: RealtimeChannel | null = supabase
.channel(`mobile-meeting-${meetingId}`)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'meetings', filter: `id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'transcripts', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'transcripts', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'meeting_memos', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'meeting_memos', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'meeting_documents', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'meeting_documents', filter: `meeting_id=eq.${meetingId}` },
onRemoteChange,
)
.subscribe(onStatus)
return {
unsubscribe: async (): Promise<void> => {
if (channel === null) return
const activeChannel = channel
channel = null
await supabase.removeChannel(activeChannel)
},
}
}
export async function beginMeetingRecording(
userId: string,
meetingId: string,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_begin_meeting_recording',
{ p_meeting_id: meetingId },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'recording') {
throw new MeetingServiceError('server', 'Meeting recording state was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function beginMeetingProcessing(
userId: string,
meetingId: string,
audioFileId: string,
idempotencyKey: string,
): Promise<ProcessingJob> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
requireUuid(audioFileId, 'audio file id')
try {
const row = await callMeetingRpc<ProcessingJob | null>(
'mobile_begin_meeting_processing',
{
p_meeting_id: meetingId,
p_audio_file_id: audioFileId,
p_idempotency_key: idempotencyKey,
},
)
const job = assertProcessingJobRow(row)
if (
job.user_id !== userId ||
job.meeting_id !== meetingId ||
job.audio_file_id !== audioFileId ||
job.idempotency_key !== idempotencyKey
) {
throw new MeetingServiceError('server', 'Processing job linkage is invalid')
}
return job
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function queueMeetingRecording(
userId: string,
meetingId: string,
durationMs: number,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
if (!Number.isFinite(durationMs) || durationMs < 0) {
throw new MeetingServiceError('validation', 'Recording duration is invalid')
}
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_queue_meeting_recording',
{ p_meeting_id: meetingId, p_duration_ms: Math.round(durationMs) },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'processing') {
throw new MeetingServiceError('server', 'Queued meeting state was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function completeMeetingProcessing(
userId: string,
meetingId: string,
audioFileId: string,
idempotencyKey: string,
result: MeetingProcessingResult,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
requireUuid(audioFileId, 'audio file id')
if (
!Number.isFinite(result.durationMs) || result.durationMs < 0 ||
!Number.isFinite(result.sttLatencyMs) || result.sttLatencyMs < 0
) {
throw new MeetingServiceError('validation', 'Meeting processing timing is invalid')
}
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_complete_meeting_processing',
{
p_meeting_id: meetingId,
p_audio_file_id: audioFileId,
p_idempotency_key: idempotencyKey,
p_transcript: result.transcript,
p_language: result.language,
p_provider: result.provider,
p_duration_ms: Math.round(result.durationMs),
p_stt_latency_ms: Math.round(result.sttLatencyMs),
},
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'completed') {
throw new MeetingServiceError('server', 'Meeting completion was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function markMeetingProcessingFailure(
userId: string,
meetingId: string,
idempotencyKey: string,
errorCode: string,
message: string,
terminal: boolean,
): Promise<ProcessingJob> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
const safeCode = errorCode.trim().slice(0, 64)
const safeMessage = message.trim().slice(0, 500)
if (safeCode.length === 0 || safeMessage.length === 0) {
throw new MeetingServiceError('validation', 'Processing failure is invalid')
}
try {
const row = await callMeetingRpc<ProcessingJob | null>(
'mobile_mark_meeting_processing_failure',
{
p_meeting_id: meetingId,
p_idempotency_key: idempotencyKey,
p_error_code: safeCode,
p_error_message: safeMessage,
p_terminal: terminal,
},
)
return assertProcessingJobRow(row)
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function cancelMeetingRecording(
userId: string,
meetingId: string,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_cancel_meeting_recording',
{ p_meeting_id: meetingId },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'error') {
throw new MeetingServiceError('server', 'Meeting cancellation was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}
export async function failMeetingRecording(
userId: string,
meetingId: string,
message: string,
): Promise<Meeting> {
requireUuid(userId, 'authenticated user')
requireUuid(meetingId, 'meeting id')
const safeMessage = message.trim().slice(0, 500)
if (safeMessage.length === 0) {
throw new MeetingServiceError('validation', 'Recording failure is invalid')
}
try {
const row = await callMeetingRpc<Meeting | null>(
'mobile_fail_meeting_recording',
{ p_meeting_id: meetingId, p_error_message: safeMessage },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'error') {
throw new MeetingServiceError('server', 'Meeting error state was not confirmed')
}
return meeting
} catch (error) {
throw toMeetingServiceError(error)
}
}