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
968
apps/mobile-rn/src/features/meetings/meetings-service.ts
Normal file
968
apps/mobile-rn/src/features/meetings/meetings-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue