fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record
This commit is contained in:
parent
80538c0f23
commit
5b1621d314
9 changed files with 1238 additions and 56 deletions
241
apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx
Normal file
241
apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||||
|
import type { AudioPipelineResult, LocalAudioInput } from '../src/features/import/audio-import-types'
|
||||||
|
|
||||||
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const MEETING_ID = '22222222-2222-4222-8222-222222222222'
|
||||||
|
|
||||||
|
const mockBeginMeetingRecording = jest.fn(async () => ({}))
|
||||||
|
const mockCancelMeetingRecording = jest.fn(async () => ({}))
|
||||||
|
const mockFailMeetingRecording = jest.fn(async () => ({}))
|
||||||
|
const mockGetMeetingRecordingLanguage = jest.fn(async (): Promise<string | null> => 'ko')
|
||||||
|
const mockEnqueueAudioProcessing = jest.fn()
|
||||||
|
const mockProcessQueuedAudioNow = jest.fn()
|
||||||
|
const mockValidateLocalAudio = jest.fn()
|
||||||
|
const mockCleanup = jest.fn(async () => undefined)
|
||||||
|
const mockStart = jest.fn(async () => undefined)
|
||||||
|
const mockStop = jest.fn()
|
||||||
|
|
||||||
|
jest.mock('react-native-safe-area-context', () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||||
|
}))
|
||||||
|
jest.mock('@d3ro/i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key, locale: 'ko' }),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/auth-context', () => ({
|
||||||
|
useAuth: () => ({ user: { id: '11111111-1111-4111-8111-111111111111' } }),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/preferences-context', () => ({
|
||||||
|
useMobilePreferences: () => ({
|
||||||
|
palette: {
|
||||||
|
bg: { app: 'app', card: 'card', inset: 'inset' },
|
||||||
|
accent: { main: 'accent' },
|
||||||
|
tag: { red: 'red' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
jest.mock('@d3ro/ui-native', () => {
|
||||||
|
const { Pressable, Text, View } = require('react-native') as typeof import('react-native')
|
||||||
|
const Passthrough = ({ children }: React.PropsWithChildren<Record<string, unknown>>) => <View>{children}</View>
|
||||||
|
return {
|
||||||
|
AppStatusBar: () => null,
|
||||||
|
Header: () => null,
|
||||||
|
Led: () => null,
|
||||||
|
WaveBars: () => null,
|
||||||
|
NativeThemeProvider: Passthrough,
|
||||||
|
PhosphorText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||||
|
<Text {...props}>{children}</Text>
|
||||||
|
),
|
||||||
|
PhysicalButton: ({ label, onPress, disabled, testID }: {
|
||||||
|
label: string
|
||||||
|
onPress?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
testID?: string
|
||||||
|
}) => (
|
||||||
|
<Pressable testID={testID} disabled={disabled} onPress={onPress}>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
d3roNativeFonts: { mono: 'mono' },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../src/lib/audio-recorder', () => {
|
||||||
|
class RecorderBusyError extends Error {}
|
||||||
|
const session = {
|
||||||
|
restore: async () => ({ state: 'idle', recording: null, meetingId: null, interruptionReason: null }),
|
||||||
|
start: () => mockStart(),
|
||||||
|
stop: () => mockStop(),
|
||||||
|
cleanup: () => mockCleanup(),
|
||||||
|
cancel: async () => undefined,
|
||||||
|
pause: async () => undefined,
|
||||||
|
resume: async () => undefined,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
RecorderBusyError,
|
||||||
|
audioRecorder: {
|
||||||
|
acquire: async () => session,
|
||||||
|
requestPermission: async () => true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../src/features/import/audio-validation', () => ({
|
||||||
|
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() }))
|
||||||
|
jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() }))
|
||||||
|
jest.mock('../src/features/notifications/notification-native', () => ({
|
||||||
|
showLocalTranscriptionCompleted: jest.fn(async () => true),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/recording/durable-processing-queue', () => {
|
||||||
|
class QueuedAudioPendingError extends Error {}
|
||||||
|
return {
|
||||||
|
QueuedAudioPendingError,
|
||||||
|
discardQueuedAudio: jest.fn(async () => undefined),
|
||||||
|
enqueueAudioProcessing: (...args: unknown[]) => mockEnqueueAudioProcessing(...args),
|
||||||
|
processQueuedAudioNow: (...args: unknown[]) => mockProcessQueuedAudioNow(...args),
|
||||||
|
retryQueuedAudio: jest.fn(async () => undefined),
|
||||||
|
subscribeToDurableQueue: () => () => undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../src/features/meetings/meetings-service', () => ({
|
||||||
|
beginMeetingRecording: (...args: unknown[]) => mockBeginMeetingRecording(...(args as [])),
|
||||||
|
cancelMeetingRecording: (...args: unknown[]) => mockCancelMeetingRecording(...(args as [])),
|
||||||
|
failMeetingRecording: (...args: unknown[]) => mockFailMeetingRecording(...(args as [])),
|
||||||
|
getMeetingRecordingLanguage: (...args: unknown[]) => mockGetMeetingRecordingLanguage(...(args as [])),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import RecordScreen from '../src/screens/RecordScreen'
|
||||||
|
import { AudioPipelineError } from '../src/features/import/audio-import-types'
|
||||||
|
|
||||||
|
const RECORDING = {
|
||||||
|
uri: 'file:///mock/capture.wav',
|
||||||
|
path: '/mock/capture.wav',
|
||||||
|
fileName: 'capture.wav',
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
size: 64,
|
||||||
|
durationMs: 20 * 60_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
function validInput(): LocalAudioInput {
|
||||||
|
return {
|
||||||
|
uri: RECORDING.uri,
|
||||||
|
path: RECORDING.path,
|
||||||
|
fileName: RECORDING.fileName,
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
sizeBytes: RECORDING.size,
|
||||||
|
durationMs: RECORDING.durationMs,
|
||||||
|
source: 'recording',
|
||||||
|
dispose: async () => undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function meetingResult(): AudioPipelineResult {
|
||||||
|
return {
|
||||||
|
historyId: null,
|
||||||
|
audioFileId: 'audio',
|
||||||
|
meetingId: MEETING_ID,
|
||||||
|
processingJobId: 'job',
|
||||||
|
transcript: 'meeting transcript',
|
||||||
|
provider: 'test',
|
||||||
|
language: 'ko',
|
||||||
|
durationSeconds: 1,
|
||||||
|
deduplicated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string }
|
||||||
|
|
||||||
|
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
|
||||||
|
let renderer: ReactTestRenderer | undefined
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(
|
||||||
|
<RecordScreen
|
||||||
|
route={{ params }}
|
||||||
|
navigation={{ getParent: () => undefined, setParams }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (renderer === undefined) throw new Error('RecordScreen did not render')
|
||||||
|
return renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
async function press(renderer: ReactTestRenderer, testID: string): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
renderer.root.findByProps({ testID }).props.onPress()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(renderer: ReactTestRenderer, testID: string): boolean {
|
||||||
|
return renderer.root.findAllByProps({ testID }).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecordScreen meeting durability (redteam r2-27)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
mockStop.mockResolvedValue(RECORDING)
|
||||||
|
mockValidateLocalAudio.mockImplementation(async () => validInput())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hand-off that fails before persistence keeps the capture recoverable', async () => {
|
||||||
|
mockEnqueueAudioProcessing.mockRejectedValue(new AudioPipelineError('file-read', 'disk full'))
|
||||||
|
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, jest.fn())
|
||||||
|
|
||||||
|
await press(renderer, 'record-start')
|
||||||
|
await press(renderer, 'record-stop')
|
||||||
|
|
||||||
|
expect(mockCleanup).not.toHaveBeenCalled()
|
||||||
|
expect(has(renderer, 'record-process-recovered')).toBe(true)
|
||||||
|
expect(has(renderer, 'record-discard-recovered')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a recording over the upload limit is kept instead of deleted on STOP', async () => {
|
||||||
|
mockValidateLocalAudio.mockRejectedValue(
|
||||||
|
new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'),
|
||||||
|
)
|
||||||
|
const renderer = await render({}, jest.fn())
|
||||||
|
|
||||||
|
await press(renderer, 'record-start')
|
||||||
|
await press(renderer, 'record-stop')
|
||||||
|
|
||||||
|
expect(mockCleanup).not.toHaveBeenCalled()
|
||||||
|
expect(mockEnqueueAudioProcessing).not.toHaveBeenCalled()
|
||||||
|
expect(has(renderer, 'record-process-recovered')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an offline meeting language lookup does not block the hand-off', async () => {
|
||||||
|
mockGetMeetingRecordingLanguage.mockRejectedValueOnce(new TypeError('Network request failed'))
|
||||||
|
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
|
||||||
|
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
|
||||||
|
const renderer = await render({ meetingId: MEETING_ID }, jest.fn())
|
||||||
|
|
||||||
|
await press(renderer, 'record-start')
|
||||||
|
await press(renderer, 'record-stop')
|
||||||
|
|
||||||
|
expect(mockEnqueueAudioProcessing).toHaveBeenCalledTimes(1)
|
||||||
|
expect(has(renderer, 'record-transcript-result')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a finished meeting capture releases the binding so NEW RECORDING cannot restart it', async () => {
|
||||||
|
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
|
||||||
|
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
|
||||||
|
const setParams = jest.fn()
|
||||||
|
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, setParams)
|
||||||
|
|
||||||
|
await press(renderer, 'record-start')
|
||||||
|
expect(mockBeginMeetingRecording).toHaveBeenCalledWith(USER_ID, MEETING_ID)
|
||||||
|
await press(renderer, 'record-stop')
|
||||||
|
|
||||||
|
expect(setParams).toHaveBeenCalledWith({
|
||||||
|
meetingId: undefined,
|
||||||
|
meetingTitle: undefined,
|
||||||
|
meetingLanguage: undefined,
|
||||||
|
})
|
||||||
|
expect(has(renderer, 'record-view-meeting')).toBe(true)
|
||||||
|
expect(has(renderer, 'record-meeting-context')).toBe(false)
|
||||||
|
|
||||||
|
// The tab bar can bring the screen back with its old params still set.
|
||||||
|
await press(renderer, 'record-start')
|
||||||
|
expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockStart).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import { FileSystem } from 'react-native-file-access'
|
||||||
|
import {
|
||||||
|
AudioPipelineError,
|
||||||
|
MAX_IMPORT_BYTES,
|
||||||
|
type AudioPipelineResult,
|
||||||
|
type LocalAudioInput,
|
||||||
|
} from '../src/features/import/audio-import-types'
|
||||||
|
import type { RecordedAudio } from '../src/lib/audio-recorder'
|
||||||
|
|
||||||
|
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
|
||||||
|
const mockQueueMeetingRecording = jest.fn<Promise<unknown>, [string, string, number]>()
|
||||||
|
const mockMarkMeetingProcessingFailure = jest.fn(async () => undefined)
|
||||||
|
const mockFailMeetingRecording = jest.fn(async () => undefined)
|
||||||
|
const mockValidateLocalAudio = jest.fn<Promise<LocalAudioInput>, [unknown]>()
|
||||||
|
|
||||||
|
jest.mock('../src/features/import/audio-transcription-service', () => ({
|
||||||
|
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/import/audio-validation', () => ({
|
||||||
|
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/meetings/meetings-service', () => {
|
||||||
|
class MeetingServiceError extends Error {
|
||||||
|
readonly code: string
|
||||||
|
constructor(code: string, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.code = code
|
||||||
|
this.name = 'MeetingServiceError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
MeetingServiceError,
|
||||||
|
queueMeetingRecording: (userId: string, meetingId: string, durationMs: number) =>
|
||||||
|
mockQueueMeetingRecording(userId, meetingId, durationMs),
|
||||||
|
markMeetingProcessingFailure: () => mockMarkMeetingProcessingFailure(),
|
||||||
|
failMeetingRecording: () => mockFailMeetingRecording(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { MeetingServiceError } from '../src/features/meetings/meetings-service'
|
||||||
|
import {
|
||||||
|
clearQueuedAudioForUser,
|
||||||
|
durableQueueTestContract,
|
||||||
|
enqueueAudioProcessing,
|
||||||
|
processQueuedAudioNow,
|
||||||
|
QueuedAudioPendingError,
|
||||||
|
type DurableQueueItem,
|
||||||
|
} from '../src/features/recording/durable-processing-queue'
|
||||||
|
import { classifyMarkQueuedFailure } from '../src/features/recording/meeting-lifecycle-port'
|
||||||
|
import { mayDiscardOriginalOnFailure } from '../src/features/import/audio-input-ownership'
|
||||||
|
import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input'
|
||||||
|
|
||||||
|
const START_MS = 1_700_000_000_000
|
||||||
|
const USER = '44444444-4444-4444-8444-444444444444'
|
||||||
|
const MEETING = '55555555-5555-4555-8555-555555555555'
|
||||||
|
const ORIGINAL = '/mock/CacheDir/meeting-capture.wav'
|
||||||
|
|
||||||
|
const mockFileSystem = FileSystem as typeof FileSystem & {
|
||||||
|
filesystem: Map<string, string>
|
||||||
|
mkdir: (path: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingInput(dispose: () => Promise<void>, source: LocalAudioInput['source'] = 'recording'): LocalAudioInput {
|
||||||
|
return {
|
||||||
|
uri: `file://${ORIGINAL}`,
|
||||||
|
path: ORIGINAL,
|
||||||
|
fileName: 'meeting-capture.wav',
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
sizeBytes: 5,
|
||||||
|
durationMs: 1_234,
|
||||||
|
source,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(): AudioPipelineResult {
|
||||||
|
return {
|
||||||
|
historyId: null,
|
||||||
|
audioFileId: 'audio-file',
|
||||||
|
meetingId: MEETING,
|
||||||
|
processingJobId: 'job',
|
||||||
|
transcript: 'minutes',
|
||||||
|
provider: 'test',
|
||||||
|
language: 'ko',
|
||||||
|
durationSeconds: 1,
|
||||||
|
deduplicated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stored(): Promise<DurableQueueItem[]> {
|
||||||
|
return durableQueueTestContract.parseQueue(
|
||||||
|
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('meeting recording durability (redteam r2-27)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.useFakeTimers({ now: START_MS })
|
||||||
|
await AsyncStorage.clear()
|
||||||
|
mockFileSystem.filesystem.clear()
|
||||||
|
mockFileSystem.filesystem.set(ORIGINAL, 'audio')
|
||||||
|
// The upstream jest mock has no mkdir; the queue creates its directory.
|
||||||
|
mockFileSystem.mkdir = jest.fn(async (path: string) => path)
|
||||||
|
mockProcessAudioInput.mockReset()
|
||||||
|
mockQueueMeetingRecording.mockReset()
|
||||||
|
mockMarkMeetingProcessingFailure.mockClear()
|
||||||
|
mockFailMeetingRecording.mockClear()
|
||||||
|
mockValidateLocalAudio.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await clearQueuedAudioForUser(USER).catch(() => undefined)
|
||||||
|
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an offline stop keeps the meeting recording queued instead of deleting it', async () => {
|
||||||
|
mockQueueMeetingRecording.mockRejectedValue(
|
||||||
|
new MeetingServiceError('network', 'Network request failed'),
|
||||||
|
)
|
||||||
|
const dispose = jest.fn(async () => {
|
||||||
|
mockFileSystem.filesystem.delete(ORIGINAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = await enqueueAudioProcessing(recordingInput(dispose), {
|
||||||
|
userId: USER,
|
||||||
|
languageCode: 'ko',
|
||||||
|
meetingId: MEETING,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enqueue only persists; it never talks to the server.
|
||||||
|
expect(mockQueueMeetingRecording).not.toHaveBeenCalled()
|
||||||
|
expect(item).toMatchObject({ meetingId: MEETING, meetingQueuePending: true, status: 'pending' })
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockFileSystem.filesystem.get(item.path)).toBe('audio')
|
||||||
|
|
||||||
|
await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError)
|
||||||
|
expect(mockProcessAudioInput).not.toHaveBeenCalled()
|
||||||
|
const [retrying] = await stored()
|
||||||
|
expect(retrying).toMatchObject({
|
||||||
|
id: item.id,
|
||||||
|
status: 'retry',
|
||||||
|
lastErrorCode: 'persist',
|
||||||
|
meetingQueuePending: true,
|
||||||
|
})
|
||||||
|
expect(mockFileSystem.filesystem.get(item.path)).toBe('audio')
|
||||||
|
|
||||||
|
// Back online: the idempotent mark runs first, then processing.
|
||||||
|
mockQueueMeetingRecording.mockResolvedValue({})
|
||||||
|
mockProcessAudioInput.mockResolvedValue(result())
|
||||||
|
await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ transcript: 'minutes' })
|
||||||
|
expect(mockQueueMeetingRecording).toHaveBeenLastCalledWith(USER, MEETING, 1_234)
|
||||||
|
expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(2)
|
||||||
|
expect(await stored()).toEqual([])
|
||||||
|
expect(mockFileSystem.filesystem.has(item.path)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a successful mark is not repeated when processing itself is retried', async () => {
|
||||||
|
mockQueueMeetingRecording.mockResolvedValue({})
|
||||||
|
mockProcessAudioInput.mockRejectedValueOnce(new AudioPipelineError('upload', 'offline'))
|
||||||
|
const item = await enqueueAudioProcessing(recordingInput(async () => undefined), {
|
||||||
|
userId: USER,
|
||||||
|
languageCode: 'ko',
|
||||||
|
meetingId: MEETING,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError)
|
||||||
|
expect((await stored())[0]).toMatchObject({ status: 'retry', meetingQueuePending: false })
|
||||||
|
|
||||||
|
mockProcessAudioInput.mockResolvedValue(result())
|
||||||
|
await processQueuedAudioNow(USER, item.id)
|
||||||
|
// ended_at on the server is not pushed to the retry time.
|
||||||
|
expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a meeting the server no longer considers recordable is handed to processing, not retried forever', async () => {
|
||||||
|
mockQueueMeetingRecording.mockRejectedValue(
|
||||||
|
new MeetingServiceError('validation', 'recordable meeting not found'),
|
||||||
|
)
|
||||||
|
mockProcessAudioInput.mockResolvedValue(result())
|
||||||
|
const item = await enqueueAudioProcessing(recordingInput(async () => undefined), {
|
||||||
|
userId: USER,
|
||||||
|
languageCode: 'ko',
|
||||||
|
meetingId: MEETING,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ meetingId: MEETING })
|
||||||
|
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('items persisted before the pending flag existed are not re-marked', async () => {
|
||||||
|
mockProcessAudioInput.mockResolvedValue(result())
|
||||||
|
const path = `${durableQueueTestContract.queueDirectory}/queued-abc123.wav`
|
||||||
|
mockFileSystem.filesystem.set(path, 'audio')
|
||||||
|
const legacy: DurableQueueItem = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'abc123',
|
||||||
|
userId: USER,
|
||||||
|
meetingId: MEETING,
|
||||||
|
path,
|
||||||
|
uri: `file://${path}`,
|
||||||
|
fileName: 'legacy.wav',
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
sizeBytes: 5,
|
||||||
|
durationMs: 100,
|
||||||
|
source: 'recording',
|
||||||
|
languageCode: 'ko',
|
||||||
|
status: 'pending',
|
||||||
|
phase: null,
|
||||||
|
attempts: 0,
|
||||||
|
uploadedBytes: 0,
|
||||||
|
nextAttemptAtMs: START_MS,
|
||||||
|
lastErrorCode: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
createdAtMs: 1,
|
||||||
|
updatedAtMs: 1,
|
||||||
|
}
|
||||||
|
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify([legacy]))
|
||||||
|
|
||||||
|
await processQueuedAudioNow(USER, legacy.id)
|
||||||
|
expect(mockQueueMeetingRecording).not.toHaveBeenCalled()
|
||||||
|
expect(durableQueueTestContract.parseQueue(JSON.stringify([{ ...legacy, meetingQueuePending: 'yes' }]))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed copy keeps the original recording but still cleans an imported temp copy', async () => {
|
||||||
|
const recordingDispose = jest.fn(async () => undefined)
|
||||||
|
mockFileSystem.filesystem.delete(ORIGINAL)
|
||||||
|
await expect(enqueueAudioProcessing(recordingInput(recordingDispose), {
|
||||||
|
userId: USER,
|
||||||
|
languageCode: 'ko',
|
||||||
|
meetingId: MEETING,
|
||||||
|
})).rejects.toThrow()
|
||||||
|
expect(recordingDispose).not.toHaveBeenCalled()
|
||||||
|
expect(await stored()).toEqual([])
|
||||||
|
|
||||||
|
const importDispose = jest.fn(async () => undefined)
|
||||||
|
await expect(enqueueAudioProcessing(recordingInput(importDispose, 'file-picker'), {
|
||||||
|
userId: USER,
|
||||||
|
languageCode: 'ko',
|
||||||
|
})).rejects.toThrow()
|
||||||
|
expect(importDispose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a recording over the upload limit is retained for Record and still disposed for Talk', async () => {
|
||||||
|
const recording: RecordedAudio = {
|
||||||
|
uri: `file://${ORIGINAL}`,
|
||||||
|
path: ORIGINAL,
|
||||||
|
fileName: 'meeting-capture.wav',
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
size: MAX_IMPORT_BYTES + 1,
|
||||||
|
durationMs: 20 * 60_000,
|
||||||
|
} as RecordedAudio
|
||||||
|
mockValidateLocalAudio.mockRejectedValue(
|
||||||
|
new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const retained = jest.fn(async () => undefined)
|
||||||
|
await expect(prepareRecordedAudio(recording, retained, { onInvalid: 'retain' }))
|
||||||
|
.rejects.toMatchObject({ code: 'file-too-large' })
|
||||||
|
expect(retained).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const talk = jest.fn(async () => undefined)
|
||||||
|
await expect(prepareRecordedAudio(recording, talk)).rejects.toMatchObject({ code: 'file-too-large' })
|
||||||
|
expect(talk).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ownership and mark-queued policies', () => {
|
||||||
|
expect(mayDiscardOriginalOnFailure('recording')).toBe(false)
|
||||||
|
expect(mayDiscardOriginalOnFailure('file-picker')).toBe(true)
|
||||||
|
expect(mayDiscardOriginalOnFailure('share-intent')).toBe(true)
|
||||||
|
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('network', 'x'))).toBe('retry')
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('auth', 'x'))).toBe('retry')
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('server', 'x'))).toBe('retry')
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('validation', 'x'))).toBe('proceed')
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('forbidden', 'x'))).toBe('proceed')
|
||||||
|
expect(classifyMarkQueuedFailure(new MeetingServiceError('not-found', 'x'))).toBe('proceed')
|
||||||
|
expect(classifyMarkQueuedFailure(new TypeError('Network request failed'))).toBe('retry')
|
||||||
|
})
|
||||||
|
})
|
||||||
18
apps/mobile-rn/src/features/import/audio-input-ownership.ts
Normal file
18
apps/mobile-rn/src/features/import/audio-input-ownership.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { LocalAudioInput } from './audio-import-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may delete the original audio when a hand-off fails before the audio
|
||||||
|
* is safely persisted somewhere else.
|
||||||
|
*
|
||||||
|
* A capture is the only copy of what the user said, so a failed hand-off must
|
||||||
|
* leave it recoverable for the caller (Record keeps it as a recovered
|
||||||
|
* recording the user can process again or discard explicitly). Picked and
|
||||||
|
* shared files are app-owned temporary copies of a file the user still has
|
||||||
|
* elsewhere, so they are cleaned up as before.
|
||||||
|
*/
|
||||||
|
export function mayDiscardOriginalOnFailure(source: LocalAudioInput['source']): boolean {
|
||||||
|
return source !== 'recording';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What prepareRecordedAudio does with the capture when validation rejects it. */
|
||||||
|
export type InvalidRecordingPolicy = 'dispose' | 'retain';
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import type { RecordedAudio } from '../../lib/audio-recorder';
|
import type { RecordedAudio } from '../../lib/audio-recorder';
|
||||||
import type { LocalAudioInput } from './audio-import-types';
|
import type { LocalAudioInput } from './audio-import-types';
|
||||||
|
import type { InvalidRecordingPolicy } from './audio-input-ownership';
|
||||||
import { validateLocalAudio } from './audio-validation';
|
import { validateLocalAudio } from './audio-validation';
|
||||||
|
|
||||||
|
export interface PrepareRecordedAudioOptions {
|
||||||
|
/**
|
||||||
|
* 'dispose' (default) deletes a capture that fails validation, which suits
|
||||||
|
* ephemeral dictation (Talk). 'retain' leaves it untouched so the caller can
|
||||||
|
* keep it recoverable: a long meeting over the upload size limit must not
|
||||||
|
* be deleted the moment the user presses STOP.
|
||||||
|
*/
|
||||||
|
onInvalid?: InvalidRecordingPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareRecordedAudio(
|
export async function prepareRecordedAudio(
|
||||||
recording: RecordedAudio,
|
recording: RecordedAudio,
|
||||||
dispose: () => Promise<void>,
|
dispose: () => Promise<void>,
|
||||||
|
options: PrepareRecordedAudioOptions = {},
|
||||||
): Promise<LocalAudioInput> {
|
): Promise<LocalAudioInput> {
|
||||||
try {
|
try {
|
||||||
return await validateLocalAudio({
|
return await validateLocalAudio({
|
||||||
|
|
@ -18,7 +30,7 @@ export async function prepareRecordedAudio(
|
||||||
dispose,
|
dispose,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await dispose();
|
if ((options.onInvalid ?? 'dispose') === 'dispose') await dispose();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@ import {
|
||||||
type LocalAudioInput,
|
type LocalAudioInput,
|
||||||
} from '../import/audio-import-types';
|
} from '../import/audio-import-types';
|
||||||
import { processAudioInput } from '../import/audio-transcription-service';
|
import { processAudioInput } from '../import/audio-transcription-service';
|
||||||
import {
|
import { mayDiscardOriginalOnFailure } from '../import/audio-input-ownership';
|
||||||
failMeetingRecording,
|
|
||||||
markMeetingProcessingFailure,
|
|
||||||
queueMeetingRecording,
|
|
||||||
} from '../meetings/meetings-service';
|
|
||||||
import { createUuidV4 } from '../../lib/random-id';
|
import { createUuidV4 } from '../../lib/random-id';
|
||||||
import { createQueueRepository, type QueueRepository } from './durable-queue-repository';
|
import { createQueueRepository, type QueueRepository } from './durable-queue-repository';
|
||||||
import { createRetryScheduler, type RetryScheduler } from './retry-scheduler';
|
import { createRetryScheduler, type RetryScheduler } from './retry-scheduler';
|
||||||
|
import {
|
||||||
|
classifyMarkQueuedFailure,
|
||||||
|
meetingsServiceLifecyclePort,
|
||||||
|
type MeetingLifecyclePort,
|
||||||
|
} from './meeting-lifecycle-port';
|
||||||
|
|
||||||
const STORAGE_KEY = '@d3ro/audio-processing-queue/v1';
|
const STORAGE_KEY = '@d3ro/audio-processing-queue/v1';
|
||||||
const QUEUE_DIRECTORY = `${Dirs.DocumentDir}/d3ro-processing-queue`;
|
const QUEUE_DIRECTORY = `${Dirs.DocumentDir}/d3ro-processing-queue`;
|
||||||
|
|
@ -51,6 +52,13 @@ export interface DurableQueueItem {
|
||||||
lastErrorMessage: string | null;
|
lastErrorMessage: string | null;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
|
/**
|
||||||
|
* True while the server has not yet been told that this meeting capture
|
||||||
|
* stopped and is waiting for processing. Absent on non-meeting items and on
|
||||||
|
* items persisted before this field existed (their enqueue only kept the
|
||||||
|
* item after that call had succeeded).
|
||||||
|
*/
|
||||||
|
meetingQueuePending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DurableQueueEvent =
|
export type DurableQueueEvent =
|
||||||
|
|
@ -125,7 +133,8 @@ function parseQueue(raw: string | null): DurableQueueItem[] {
|
||||||
(item.lastErrorCode === null || (typeof item.lastErrorCode === 'string' && item.lastErrorCode.length <= 64)) &&
|
(item.lastErrorCode === null || (typeof item.lastErrorCode === 'string' && item.lastErrorCode.length <= 64)) &&
|
||||||
(item.lastErrorMessage === null || (typeof item.lastErrorMessage === 'string' && item.lastErrorMessage.length <= MAX_ERROR_LENGTH)) &&
|
(item.lastErrorMessage === null || (typeof item.lastErrorMessage === 'string' && item.lastErrorMessage.length <= MAX_ERROR_LENGTH)) &&
|
||||||
typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) &&
|
typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) &&
|
||||||
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs)
|
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) &&
|
||||||
|
(item.meetingQueuePending === undefined || typeof item.meetingQueuePending === 'boolean')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +146,8 @@ const repository: QueueRepository<DurableQueueItem> = createQueueRepository({
|
||||||
parse: parseQueue,
|
parse: parseQueue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let meetingLifecycle: MeetingLifecyclePort = meetingsServiceLifecyclePort;
|
||||||
|
|
||||||
const scheduler: RetryScheduler = createRetryScheduler({
|
const scheduler: RetryScheduler = createRetryScheduler({
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
|
@ -176,22 +187,34 @@ function fileExtension(fileName: string, mimeType: string): string {
|
||||||
return 'audio';
|
return 'audio';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the audio durable and nothing else: copy into queue storage, verify
|
||||||
|
* the copy, persist the item. The caller's original is released only after
|
||||||
|
* the item is persisted. When persisting fails, a recording is left untouched
|
||||||
|
* so the caller can keep it recoverable; an imported temporary copy is
|
||||||
|
* cleaned up as before. Server state transitions happen later, in the
|
||||||
|
* retryable processing step, so a network error can never undo a persisted
|
||||||
|
* item.
|
||||||
|
*/
|
||||||
export async function enqueueAudioProcessing(
|
export async function enqueueAudioProcessing(
|
||||||
input: LocalAudioInput,
|
input: LocalAudioInput,
|
||||||
options: { userId: string; languageCode: MeetingLanguage; meetingId?: string },
|
options: { userId: string; languageCode: MeetingLanguage; meetingId?: string },
|
||||||
): Promise<DurableQueueItem> {
|
): Promise<DurableQueueItem> {
|
||||||
|
const releaseOnFailure = async (): Promise<void> => {
|
||||||
|
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
|
||||||
|
};
|
||||||
if (!/^[0-9a-f-]{36}$/i.test(options.userId)) {
|
if (!/^[0-9a-f-]{36}$/i.test(options.userId)) {
|
||||||
await input.dispose();
|
await releaseOnFailure();
|
||||||
throw new AudioPipelineError('auth', 'A valid account is required for the processing queue');
|
throw new AudioPipelineError('auth', 'A valid account is required for the processing queue');
|
||||||
}
|
}
|
||||||
if (options.meetingId !== undefined && !/^[0-9a-f-]{36}$/i.test(options.meetingId)) {
|
if (options.meetingId !== undefined && !/^[0-9a-f-]{36}$/i.test(options.meetingId)) {
|
||||||
await input.dispose();
|
await releaseOnFailure();
|
||||||
throw new AudioPipelineError('persist', 'Meeting id is invalid');
|
throw new AudioPipelineError('persist', 'Meeting id is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = queueId();
|
const id = queueId();
|
||||||
const target = `${QUEUE_DIRECTORY}/queued-${id}.${fileExtension(input.fileName, input.mimeType)}`;
|
const target = `${QUEUE_DIRECTORY}/queued-${id}.${fileExtension(input.fileName, input.mimeType)}`;
|
||||||
let persisted = false;
|
let item: DurableQueueItem;
|
||||||
try {
|
try {
|
||||||
await ensureQueueDirectory();
|
await ensureQueueDirectory();
|
||||||
await FileSystem.cp(input.path, target);
|
await FileSystem.cp(input.path, target);
|
||||||
|
|
@ -200,7 +223,7 @@ export async function enqueueAudioProcessing(
|
||||||
throw new AudioPipelineError('file-read', 'Durable audio copy could not be verified');
|
throw new AudioPipelineError('file-read', 'Durable audio copy could not be verified');
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const item: DurableQueueItem = {
|
const created: DurableQueueItem = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
id,
|
id,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
|
|
@ -222,25 +245,19 @@ export async function enqueueAudioProcessing(
|
||||||
lastErrorMessage: null,
|
lastErrorMessage: null,
|
||||||
createdAtMs: now,
|
createdAtMs: now,
|
||||||
updatedAtMs: now,
|
updatedAtMs: now,
|
||||||
|
...(options.meetingId !== undefined ? { meetingQueuePending: true } : {}),
|
||||||
};
|
};
|
||||||
await mutateQueue(items => [...items.filter(current => current.id !== id), item]);
|
await mutateQueue(items => [...items.filter(current => current.id !== id), created]);
|
||||||
persisted = true;
|
item = created;
|
||||||
if (item.meetingId !== null) {
|
} catch (error) {
|
||||||
await queueMeetingRecording(
|
await removeOwnedFile(target).catch(() => undefined);
|
||||||
item.userId,
|
await releaseOnFailure().catch(() => undefined);
|
||||||
item.meetingId,
|
throw error;
|
||||||
item.durationMs ?? 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
// The durable copy now owns the audio; the original is no longer needed.
|
||||||
|
await input.dispose().catch(() => undefined);
|
||||||
emit({ type: 'changed', item });
|
emit({ type: 'changed', item });
|
||||||
return item;
|
return item;
|
||||||
} catch (error) {
|
|
||||||
if (persisted) await mutateQueue(items => items.filter(item => item.id !== id));
|
|
||||||
await removeOwnedFile(target).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await input.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueInput(item: DurableQueueItem): LocalAudioInput {
|
function queueInput(item: DurableQueueItem): LocalAudioInput {
|
||||||
|
|
@ -286,33 +303,59 @@ async function reportMeetingFailure(
|
||||||
terminal: boolean,
|
terminal: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (item.meetingId === null) return;
|
if (item.meetingId === null) return;
|
||||||
|
const meetingId = item.meetingId;
|
||||||
const idempotencyKey = await FileSystem.hash(item.path, 'SHA-256')
|
const idempotencyKey = await FileSystem.hash(item.path, 'SHA-256')
|
||||||
.then(hash => `mobile-meeting:${item.meetingId}:${hash.toLowerCase()}`)
|
.then(hash => `mobile-meeting:${meetingId}:${hash.toLowerCase()}`)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (idempotencyKey !== null) {
|
if (idempotencyKey !== null) {
|
||||||
try {
|
try {
|
||||||
await markMeetingProcessingFailure(
|
await meetingLifecycle.markProcessingFailure({
|
||||||
item.userId,
|
userId: item.userId,
|
||||||
item.meetingId,
|
meetingId,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
error.code,
|
errorCode: error.code,
|
||||||
error.message.slice(0, MAX_ERROR_LENGTH),
|
errorMessage: error.message.slice(0, MAX_ERROR_LENGTH),
|
||||||
terminal,
|
terminal,
|
||||||
);
|
});
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Upload failures happen before the server processing job exists.
|
// Upload failures happen before the server processing job exists.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
await failMeetingRecording(
|
await meetingLifecycle.failRecording(
|
||||||
item.userId,
|
item.userId,
|
||||||
item.meetingId,
|
meetingId,
|
||||||
error.message.slice(0, MAX_ERROR_LENGTH),
|
error.message.slice(0, MAX_ERROR_LENGTH),
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First, retryable processing step for a meeting capture: tell the server the
|
||||||
|
* recording stopped and is queued. Runs until it succeeds once per item. A
|
||||||
|
* reachable server that says the meeting is not recordable is not asked again
|
||||||
|
* (see classifyMarkQueuedFailure), while an unreachable one leaves the item
|
||||||
|
* for a later attempt instead of spending an upload on it.
|
||||||
|
*/
|
||||||
|
async function markMeetingQueued(item: DurableQueueItem): Promise<DurableQueueItem> {
|
||||||
|
if (item.meetingId === null || item.meetingQueuePending !== true) return item;
|
||||||
|
try {
|
||||||
|
await meetingLifecycle.markQueued(item.userId, item.meetingId, item.durationMs ?? 0);
|
||||||
|
} catch (cause) {
|
||||||
|
if (classifyMarkQueuedFailure(cause) === 'retry') {
|
||||||
|
throw new AudioPipelineError('persist', 'Meeting recording could not be marked as queued', cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Discarded while the call was in flight: do not resurrect the item.
|
||||||
|
if (discardedItems.has(item.id)) {
|
||||||
|
throw new AudioPipelineError('cancelled', 'Queued audio processing was cancelled');
|
||||||
|
}
|
||||||
|
const updated: DurableQueueItem = { ...item, meetingQueuePending: false, updatedAtMs: Date.now() };
|
||||||
|
await replaceItem(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineResult> {
|
async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineResult> {
|
||||||
if (!(await FileSystem.exists(item.path))) {
|
if (!(await FileSystem.exists(item.path))) {
|
||||||
throw new AudioPipelineError('file-read', 'Queued audio file is missing');
|
throw new AudioPipelineError('file-read', 'Queued audio file is missing');
|
||||||
|
|
@ -329,6 +372,7 @@ async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineRes
|
||||||
};
|
};
|
||||||
await replaceItem(current);
|
await replaceItem(current);
|
||||||
try {
|
try {
|
||||||
|
current = await markMeetingQueued(current);
|
||||||
const result = await processAudioInput(queueInput(current), {
|
const result = await processAudioInput(queueInput(current), {
|
||||||
expectedUserId: current.userId,
|
expectedUserId: current.userId,
|
||||||
languageCode: current.languageCode,
|
languageCode: current.languageCode,
|
||||||
|
|
@ -520,4 +564,11 @@ export const durableQueueTestContract = {
|
||||||
retryDelayMs,
|
retryDelayMs,
|
||||||
ownedQueuePath,
|
ownedQueuePath,
|
||||||
parseQueue,
|
parseQueue,
|
||||||
|
/** Swaps the meeting lifecycle port; returns a function restoring the default. */
|
||||||
|
useMeetingLifecyclePort(port: MeetingLifecyclePort): () => void {
|
||||||
|
meetingLifecycle = port;
|
||||||
|
return () => {
|
||||||
|
meetingLifecycle = meetingsServiceLifecyclePort;
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import {
|
||||||
|
failMeetingRecording,
|
||||||
|
markMeetingProcessingFailure,
|
||||||
|
queueMeetingRecording,
|
||||||
|
} from '../meetings/meetings-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side meeting state transitions the durable queue needs. The queue
|
||||||
|
* owns the local audio; these calls only tell the server what happened to it,
|
||||||
|
* so none of them may decide whether the local audio survives.
|
||||||
|
*/
|
||||||
|
export interface MeetingLifecyclePort {
|
||||||
|
/** Idempotent: recording -> processing with the captured duration. */
|
||||||
|
markQueued(userId: string, meetingId: string, durationMs: number): Promise<void>;
|
||||||
|
markProcessingFailure(failure: {
|
||||||
|
userId: string;
|
||||||
|
meetingId: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
errorCode: string;
|
||||||
|
errorMessage: string;
|
||||||
|
terminal: boolean;
|
||||||
|
}): Promise<void>;
|
||||||
|
failRecording(userId: string, meetingId: string, errorMessage: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meetingsServiceLifecyclePort: MeetingLifecyclePort = {
|
||||||
|
async markQueued(userId, meetingId, durationMs) {
|
||||||
|
await queueMeetingRecording(userId, meetingId, durationMs);
|
||||||
|
},
|
||||||
|
async markProcessingFailure(failure) {
|
||||||
|
await markMeetingProcessingFailure(
|
||||||
|
failure.userId,
|
||||||
|
failure.meetingId,
|
||||||
|
failure.idempotencyKey,
|
||||||
|
failure.errorCode,
|
||||||
|
failure.errorMessage,
|
||||||
|
failure.terminal,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async failRecording(userId, meetingId, errorMessage) {
|
||||||
|
await failMeetingRecording(userId, meetingId, errorMessage);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a failed markQueued means for the queued item.
|
||||||
|
*
|
||||||
|
* - 'retry': the server could not be reached or refused the session for now
|
||||||
|
* (network, auth, server, conflict). Keep the item and try again later;
|
||||||
|
* uploading now would fail for the same reason.
|
||||||
|
* - 'proceed': the server answered that the meeting is not in a recordable
|
||||||
|
* state (validation, forbidden, not-found), e.g. after this item already
|
||||||
|
* failed terminally once and the user retries it. Marking it queued will
|
||||||
|
* never succeed, so stop asking and let processing, whose begin step is the
|
||||||
|
* authoritative transition, decide.
|
||||||
|
*/
|
||||||
|
export type MeetingQueueFailurePolicy = 'retry' | 'proceed';
|
||||||
|
|
||||||
|
const NOT_RECORDABLE_CODES: ReadonlySet<string> = new Set(['validation', 'forbidden', 'not-found']);
|
||||||
|
|
||||||
|
export function classifyMarkQueuedFailure(error: unknown): MeetingQueueFailurePolicy {
|
||||||
|
// Structural check: meetings-service maps every RPC failure to a
|
||||||
|
// MeetingServiceError whose `code` is one of its MeetingServiceErrorCode.
|
||||||
|
if (!(error instanceof Error) || error.name !== 'MeetingServiceError') return 'retry';
|
||||||
|
const code: unknown = (error as Error & { code?: unknown }).code;
|
||||||
|
return typeof code === 'string' && NOT_RECORDABLE_CODES.has(code) ? 'proceed' : 'retry';
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
type LocalAudioInput,
|
type LocalAudioInput,
|
||||||
} from '../features/import/audio-import-types';
|
} from '../features/import/audio-import-types';
|
||||||
import { prepareRecordedAudio } from '../features/import/recorded-audio-input';
|
import { prepareRecordedAudio } from '../features/import/recorded-audio-input';
|
||||||
|
import { mayDiscardOriginalOnFailure } from '../features/import/audio-input-ownership';
|
||||||
import { useMobilePreferences } from '../lib/preferences-context';
|
import { useMobilePreferences } from '../lib/preferences-context';
|
||||||
import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native';
|
import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native';
|
||||||
import {
|
import {
|
||||||
|
|
@ -115,10 +116,18 @@ function isPipelineState(state: RecordingState): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecordScreenMeetingParams {
|
||||||
|
meetingId?: string;
|
||||||
|
meetingTitle?: string;
|
||||||
|
meetingLanguage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RecordScreenProps {
|
interface RecordScreenProps {
|
||||||
route: { params?: { meetingId?: string; meetingTitle?: string; meetingLanguage?: string; incomingMediaId?: string } }
|
route: { params?: RecordScreenMeetingParams & { incomingMediaId?: string } }
|
||||||
navigation: {
|
navigation: {
|
||||||
getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined
|
getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined
|
||||||
|
/** Clears the meeting binding once its capture is committed. */
|
||||||
|
setParams?: (params: RecordScreenMeetingParams) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +148,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
const [actionPending, setActionPending] = useState(false);
|
const [actionPending, setActionPending] = useState(false);
|
||||||
const [sharePending, setSharePending] = useState(false);
|
const [sharePending, setSharePending] = useState(false);
|
||||||
const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState<string | null>(null);
|
const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState<string | null>(null);
|
||||||
|
// Meeting whose capture produced the result on screen (for "view meeting").
|
||||||
|
const [completedMeetingId, setCompletedMeetingId] = useState<string | null>(null);
|
||||||
const actionPendingRef = useRef(false);
|
const actionPendingRef = useRef(false);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const recorderActiveRef = useRef(false);
|
const recorderActiveRef = useRef(false);
|
||||||
|
|
@ -146,12 +157,38 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
const queueItemIdRef = useRef<string | null>(null);
|
const queueItemIdRef = useRef<string | null>(null);
|
||||||
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
|
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
|
||||||
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
|
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
|
||||||
|
// Meeting the next START binds to. MeetingDetail arms it through route
|
||||||
|
// params; it is released once that meeting's capture is committed, so a
|
||||||
|
// later "NEW RECORDING" cannot re-begin (and restart) a finished meeting.
|
||||||
|
const armedMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
|
||||||
// Record's owner-scoped handle on the shared recorder; see recordSession().
|
// Record's owner-scoped handle on the shared recorder; see recordSession().
|
||||||
const recorderSessionRef = useRef<RecorderSession | null>(null);
|
const recorderSessionRef = useRef<RecorderSession | null>(null);
|
||||||
const routeMeetingId = route.params?.meetingId ?? null;
|
const routeMeetingId = route.params?.meetingId ?? null;
|
||||||
const routeMeetingTitle = route.params?.meetingTitle ?? null;
|
const routeMeetingTitle = route.params?.meetingTitle ?? null;
|
||||||
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
|
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
|
||||||
const handledIncomingMediaIds = useRef(new Set<string>());
|
const handledIncomingMediaIds = useRef(new Set<string>());
|
||||||
|
// Latest route binding, read by callbacks registered on an earlier render.
|
||||||
|
const routeMeetingIdRef = useRef<string | null>(routeMeetingId);
|
||||||
|
routeMeetingIdRef.current = routeMeetingId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A meeting binding covers one capture. Once the capture's transcript is
|
||||||
|
* committed, Record falls back to plain recording and the route params are
|
||||||
|
* cleared, so returning through the tab bar does not re-arm the meeting.
|
||||||
|
* MeetingDetail re-arms it explicitly ("record again") with fresh params.
|
||||||
|
*/
|
||||||
|
function releaseMeetingBinding(meetingId: string | null): void {
|
||||||
|
setCompletedMeetingId(meetingId);
|
||||||
|
if (meetingId === null) return;
|
||||||
|
if (armedMeetingIdRef.current === meetingId) armedMeetingIdRef.current = null;
|
||||||
|
if (activeMeetingIdRef.current === meetingId) activeMeetingIdRef.current = null;
|
||||||
|
if (routeMeetingIdRef.current === meetingId) {
|
||||||
|
navigation.setParams?.({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Latest release callback, for subscriptions registered on an earlier render.
|
||||||
|
const releaseMeetingBindingRef = useRef(releaseMeetingBinding);
|
||||||
|
releaseMeetingBindingRef.current = releaseMeetingBinding;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record owns meeting captures, including one the Android foreground service
|
* Record owns meeting captures, including one the Android foreground service
|
||||||
|
|
@ -222,6 +259,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return;
|
if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return;
|
||||||
queueItemIdRef.current = null;
|
queueItemIdRef.current = null;
|
||||||
operationUserRef.current = null;
|
operationUserRef.current = null;
|
||||||
|
releaseMeetingBindingRef.current(event.result.meetingId ?? activeMeetingIdRef.current);
|
||||||
setTranscript(event.result.transcript);
|
setTranscript(event.result.transcript);
|
||||||
setProvider(event.result.provider);
|
setProvider(event.result.provider);
|
||||||
setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000)));
|
setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000)));
|
||||||
|
|
@ -254,6 +292,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
armedMeetingIdRef.current = routeMeetingId;
|
||||||
if (!recorderActiveRef.current && recoveredRecordingRef.current === null) {
|
if (!recorderActiveRef.current && recoveredRecordingRef.current === null) {
|
||||||
activeMeetingIdRef.current = routeMeetingId;
|
activeMeetingIdRef.current = routeMeetingId;
|
||||||
}
|
}
|
||||||
|
|
@ -275,19 +314,29 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (mountedRef.current) setActionPending(false);
|
if (mountedRef.current) setActionPending(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the input to the durable queue and processes it. `onPersisted` runs
|
||||||
|
* once the queue owns its own copy; before that, a recording is still the
|
||||||
|
* caller's only copy and must not be deleted on failure.
|
||||||
|
*/
|
||||||
async function processInput(
|
async function processInput(
|
||||||
input: LocalAudioInput,
|
input: LocalAudioInput,
|
||||||
meetingId: string | null = activeMeetingIdRef.current,
|
meetingId: string | null = activeMeetingIdRef.current,
|
||||||
|
onPersisted?: () => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
await input.dispose();
|
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
|
||||||
throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
||||||
}
|
}
|
||||||
|
|
||||||
operationUserRef.current = user.id;
|
operationUserRef.current = user.id;
|
||||||
const routeLanguage = route.params?.meetingLanguage;
|
const routeLanguage = meetingId !== null && meetingId === route.params?.meetingId
|
||||||
|
? route.params?.meetingLanguage
|
||||||
|
: undefined;
|
||||||
|
// The language lookup is an optimisation, not a precondition: offline it
|
||||||
|
// falls back to the app locale instead of blocking the hand-off.
|
||||||
const storedLanguage = meetingId !== null && routeLanguage === undefined
|
const storedLanguage = meetingId !== null && routeLanguage === undefined
|
||||||
? await getMeetingRecordingLanguage(user.id, meetingId)
|
? await getMeetingRecordingLanguage(user.id, meetingId).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
const queued = await enqueueAudioProcessing(input, {
|
const queued = await enqueueAudioProcessing(input, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
@ -295,9 +344,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
meetingId: meetingId ?? undefined,
|
meetingId: meetingId ?? undefined,
|
||||||
});
|
});
|
||||||
queueItemIdRef.current = queued.id;
|
queueItemIdRef.current = queued.id;
|
||||||
|
onPersisted?.();
|
||||||
try {
|
try {
|
||||||
const result = await processQueuedAudioNow(user.id, queued.id);
|
const result = await processQueuedAudioNow(user.id, queued.id);
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
releaseMeetingBinding(result.meetingId ?? meetingId);
|
||||||
setTranscript(result.transcript);
|
setTranscript(result.transcript);
|
||||||
setProvider(result.provider);
|
setProvider(result.provider);
|
||||||
setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000)));
|
setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000)));
|
||||||
|
|
@ -335,9 +386,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
setDeduplicated(false);
|
setDeduplicated(false);
|
||||||
setDurationMs(0);
|
setDurationMs(0);
|
||||||
setMeteringDb(null);
|
setMeteringDb(null);
|
||||||
|
setCompletedMeetingId(null);
|
||||||
recoveredRecordingRef.current = null;
|
recoveredRecordingRef.current = null;
|
||||||
setState('starting');
|
setState('starting');
|
||||||
recorderActiveRef.current = true;
|
recorderActiveRef.current = true;
|
||||||
|
const meetingToBegin = armedMeetingIdRef.current;
|
||||||
let meetingBegun = false;
|
let meetingBegun = false;
|
||||||
try {
|
try {
|
||||||
// Re-check on every start (not the cached handle) so a busy recorder is
|
// Re-check on every start (not the cached handle) so a busy recorder is
|
||||||
|
|
@ -352,11 +405,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
setState('error');
|
setState('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (routeMeetingId !== null) {
|
if (meetingToBegin !== null) {
|
||||||
if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
|
||||||
await beginMeetingRecording(user.id, routeMeetingId);
|
await beginMeetingRecording(user.id, meetingToBegin);
|
||||||
meetingBegun = true;
|
meetingBegun = true;
|
||||||
activeMeetingIdRef.current = routeMeetingId;
|
activeMeetingIdRef.current = meetingToBegin;
|
||||||
} else {
|
} else {
|
||||||
activeMeetingIdRef.current = null;
|
activeMeetingIdRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
@ -367,10 +420,10 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (mountedRef.current) setState('recording');
|
if (mountedRef.current) setState('recording');
|
||||||
} catch (recordingError) {
|
} catch (recordingError) {
|
||||||
recorderActiveRef.current = false;
|
recorderActiveRef.current = false;
|
||||||
if (meetingBegun && user !== null && routeMeetingId !== null) {
|
if (meetingBegun && user !== null && meetingToBegin !== null) {
|
||||||
await failMeetingRecording(
|
await failMeetingRecording(
|
||||||
user.id,
|
user.id,
|
||||||
routeMeetingId,
|
meetingToBegin,
|
||||||
errorMessage(recordingError, t('mobile.rec.startFailed')),
|
errorMessage(recordingError, t('mobile.rec.startFailed')),
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -428,10 +481,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
}
|
}
|
||||||
setDurationMs(recording.durationMs);
|
setDurationMs(recording.durationMs);
|
||||||
setFileName(recording.fileName);
|
setFileName(recording.fileName);
|
||||||
const input = await prepareRecordedAudio(recording, async () => {
|
await handOffRecording(recorder, recording);
|
||||||
await recorder.cleanup(recording);
|
|
||||||
});
|
|
||||||
await processInput(input, activeMeetingIdRef.current);
|
|
||||||
} catch (recordingError) {
|
} catch (recordingError) {
|
||||||
recorderActiveRef.current = false;
|
recorderActiveRef.current = false;
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
|
@ -471,6 +521,40 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (snapshot === null || snapshot.state === 'idle') setState('error');
|
if (snapshot === null || snapshot.state === 'idle') setState('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands a finished capture to the durable queue. Until the queue has
|
||||||
|
* persisted its own copy the capture is the only copy, so a failure before
|
||||||
|
* that point (too large to upload, unreadable, no session, storage full)
|
||||||
|
* keeps it as a recovered recording the user can process again or discard
|
||||||
|
* explicitly, instead of deleting it. Failures after that point belong to
|
||||||
|
* the queue item and are rethrown.
|
||||||
|
*/
|
||||||
|
async function handOffRecording(
|
||||||
|
recorder: RecorderSession,
|
||||||
|
recording: RecordedAudio,
|
||||||
|
): Promise<void> {
|
||||||
|
let persisted = false;
|
||||||
|
try {
|
||||||
|
const input = await prepareRecordedAudio(
|
||||||
|
recording,
|
||||||
|
async () => {
|
||||||
|
await recorder.cleanup(recording);
|
||||||
|
},
|
||||||
|
{ onInvalid: 'retain' },
|
||||||
|
);
|
||||||
|
await processInput(input, activeMeetingIdRef.current, () => {
|
||||||
|
persisted = true;
|
||||||
|
});
|
||||||
|
} catch (handOffError) {
|
||||||
|
if (persisted) throw handOffError;
|
||||||
|
recoveredRecordingRef.current = recording;
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setMeteringDb(null);
|
||||||
|
setError(errorMessage(handOffError, t('mobile.rec.processFailed')));
|
||||||
|
setState('recoverable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processRecoveredRecording(): Promise<void> {
|
async function processRecoveredRecording(): Promise<void> {
|
||||||
const recording = recoveredRecordingRef.current;
|
const recording = recoveredRecordingRef.current;
|
||||||
if (recording === null || !beginAction()) return;
|
if (recording === null || !beginAction()) return;
|
||||||
|
|
@ -478,11 +562,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
setState('validating');
|
setState('validating');
|
||||||
try {
|
try {
|
||||||
const recorder = await recordSession();
|
const recorder = await recordSession();
|
||||||
const input = await prepareRecordedAudio(recording, async () => {
|
|
||||||
await recorder.cleanup(recording);
|
|
||||||
});
|
|
||||||
recoveredRecordingRef.current = null;
|
recoveredRecordingRef.current = null;
|
||||||
await processInput(input, activeMeetingIdRef.current);
|
await handOffRecording(recorder, recording);
|
||||||
} catch (processingError) {
|
} catch (processingError) {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setError(errorMessage(processingError, t('mobile.rec.processFailed')));
|
setError(errorMessage(processingError, t('mobile.rec.processFailed')));
|
||||||
|
|
@ -503,7 +584,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (meetingId !== null && user !== null) {
|
if (meetingId !== null && user !== null) {
|
||||||
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
activeMeetingIdRef.current = routeMeetingId;
|
activeMeetingIdRef.current = armedMeetingIdRef.current;
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setState('idle');
|
setState('idle');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -526,6 +607,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
let selectedForProcessing = false;
|
let selectedForProcessing = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
setState('selecting');
|
setState('selecting');
|
||||||
|
setCompletedMeetingId(null);
|
||||||
try {
|
try {
|
||||||
const input = await pickAudioFile();
|
const input = await pickAudioFile();
|
||||||
selectedForProcessing = true;
|
selectedForProcessing = true;
|
||||||
|
|
@ -566,6 +648,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
const previousState = state;
|
const previousState = state;
|
||||||
setError(null);
|
setError(null);
|
||||||
setState('selecting');
|
setState('selecting');
|
||||||
|
setCompletedMeetingId(null);
|
||||||
try {
|
try {
|
||||||
const input = await consumeIncomingMediaAudio();
|
const input = await consumeIncomingMediaAudio();
|
||||||
if (input === null) {
|
if (input === null) {
|
||||||
|
|
@ -644,7 +727,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
if (meetingId !== null && user !== null) {
|
if (meetingId !== null && user !== null) {
|
||||||
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
activeMeetingIdRef.current = routeMeetingId;
|
activeMeetingIdRef.current = armedMeetingIdRef.current;
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setState('idle');
|
setState('idle');
|
||||||
setTranscript('');
|
setTranscript('');
|
||||||
|
|
@ -830,15 +913,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{state === 'done' && activeMeetingIdRef.current !== null && (
|
{state === 'done' && completedMeetingId !== null && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.buttonSpacer} />
|
<View style={styles.buttonSpacer} />
|
||||||
<PhysicalButton
|
<PhysicalButton
|
||||||
label={t('mobile.rec.viewMeeting')}
|
label={t('mobile.rec.viewMeeting')}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const meetingId = activeMeetingIdRef.current;
|
navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId });
|
||||||
if (meetingId !== null) navigation.getParent()?.navigate('MeetingDetail', { meetingId });
|
|
||||||
}}
|
}}
|
||||||
testID="record-view-meeting"
|
testID="record-view-meeting"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
-- Re-recording a meeting must not destroy its existing transcript before the
|
||||||
|
-- new capture has produced one.
|
||||||
|
--
|
||||||
|
-- 20260821000022 made mobile_begin_meeting_recording wipe raw/edited
|
||||||
|
-- transcript, minutes, transcripts rows and unlink the meeting's audio the
|
||||||
|
-- moment a recording STARTS. A re-record that was then cancelled, failed or
|
||||||
|
-- lost (offline stop, oversized capture) left the meeting in 'error' with its
|
||||||
|
-- previous content gone for good, and since 20260929000004 the unlinked audio
|
||||||
|
-- is also queued for storage purge.
|
||||||
|
--
|
||||||
|
-- New split of responsibilities:
|
||||||
|
-- begin -> only marks the meeting as recording (status and timing).
|
||||||
|
-- complete -> on the FIRST successful completion of a processing job,
|
||||||
|
-- replaces the previous content: deletes old transcript rows,
|
||||||
|
-- clears minutes/LLM metadata and detaches older audio. A replay
|
||||||
|
-- of an already-succeeded job leaves derived content untouched.
|
||||||
|
-- cancel -> a meeting that still holds earlier content returns to
|
||||||
|
-- 'completed' instead of 'error'.
|
||||||
|
-- Signatures, grants and return shapes are unchanged.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.mobile_begin_meeting_recording(p_meeting_id uuid)
|
||||||
|
RETURNS public.meetings
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
current_user_id uuid := auth.uid();
|
||||||
|
result public.meetings;
|
||||||
|
BEGIN
|
||||||
|
IF current_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.meetings
|
||||||
|
SET status = 'recording',
|
||||||
|
started_at = now(),
|
||||||
|
ended_at = NULL,
|
||||||
|
error_message = NULL
|
||||||
|
WHERE id = p_meeting_id
|
||||||
|
AND user_id = current_user_id
|
||||||
|
RETURNING * INTO result;
|
||||||
|
|
||||||
|
IF result.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.mobile_cancel_meeting_recording(p_meeting_id uuid)
|
||||||
|
RETURNS public.meetings
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
current_user_id uuid := auth.uid();
|
||||||
|
result public.meetings;
|
||||||
|
BEGIN
|
||||||
|
IF current_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
UPDATE public.meetings m
|
||||||
|
SET status = CASE WHEN has_content.present THEN 'completed' ELSE 'error' END,
|
||||||
|
ended_at = now(),
|
||||||
|
error_message = CASE
|
||||||
|
WHEN has_content.present THEN NULL
|
||||||
|
ELSE 'Recording was cancelled before processing.'
|
||||||
|
END
|
||||||
|
FROM (
|
||||||
|
SELECT (
|
||||||
|
mm.raw_transcript IS NOT NULL
|
||||||
|
OR mm.edited_transcript IS NOT NULL
|
||||||
|
OR mm.minutes_markdown IS NOT NULL
|
||||||
|
OR mm.minutes_json IS NOT NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM public.transcripts t WHERE t.meeting_id = mm.id)
|
||||||
|
) AS present
|
||||||
|
FROM public.meetings mm
|
||||||
|
WHERE mm.id = p_meeting_id
|
||||||
|
) AS has_content
|
||||||
|
WHERE m.id = p_meeting_id
|
||||||
|
AND m.user_id = current_user_id
|
||||||
|
AND m.status = 'recording'
|
||||||
|
RETURNING m.* INTO result;
|
||||||
|
IF result.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'active meeting recording not found' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Same as 20260821000025 (language SSOT binding) plus the first-completion
|
||||||
|
-- replacement of the previous recording's content.
|
||||||
|
CREATE OR REPLACE FUNCTION public.mobile_complete_meeting_processing(
|
||||||
|
p_meeting_id uuid,
|
||||||
|
p_audio_file_id uuid,
|
||||||
|
p_idempotency_key text,
|
||||||
|
p_transcript text,
|
||||||
|
p_language text,
|
||||||
|
p_provider text,
|
||||||
|
p_duration_ms bigint,
|
||||||
|
p_stt_latency_ms integer
|
||||||
|
)
|
||||||
|
RETURNS public.meetings
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
current_user_id uuid := auth.uid();
|
||||||
|
safe_language text := lower(trim(p_language));
|
||||||
|
owned_audio public.audio_files;
|
||||||
|
owned_job public.processing_jobs;
|
||||||
|
first_completion boolean;
|
||||||
|
result public.meetings;
|
||||||
|
BEGIN
|
||||||
|
IF current_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
IF p_transcript IS NULL OR length(p_transcript) > 1000000
|
||||||
|
OR p_language IS NULL
|
||||||
|
OR char_length(safe_language) NOT BETWEEN 2 AND 35
|
||||||
|
OR safe_language !~ '^[a-z]{2,3}(-[a-z0-9]{2,8})*$'
|
||||||
|
OR p_provider IS NULL OR length(p_provider) NOT BETWEEN 1 AND 120
|
||||||
|
OR p_duration_ms < 0 OR p_stt_latency_ms < 0 THEN
|
||||||
|
RAISE EXCEPTION 'invalid transcription result' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO owned_audio
|
||||||
|
FROM public.audio_files
|
||||||
|
WHERE id = p_audio_file_id
|
||||||
|
AND user_id = current_user_id
|
||||||
|
AND meeting_id = p_meeting_id
|
||||||
|
AND history_id IS NULL
|
||||||
|
AND upload_status = 'uploaded'
|
||||||
|
FOR UPDATE;
|
||||||
|
IF owned_audio.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'uploaded meeting audio not found' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO owned_job
|
||||||
|
FROM public.processing_jobs
|
||||||
|
WHERE user_id = current_user_id
|
||||||
|
AND meeting_id = p_meeting_id
|
||||||
|
AND audio_file_id = p_audio_file_id
|
||||||
|
AND idempotency_key = p_idempotency_key
|
||||||
|
AND kind = 'transcription'
|
||||||
|
FOR UPDATE;
|
||||||
|
IF owned_job.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- A replay of an already committed job must not wipe minutes generated
|
||||||
|
-- from this very transcript after it was committed.
|
||||||
|
first_completion := owned_job.status IS DISTINCT FROM 'succeeded';
|
||||||
|
|
||||||
|
IF first_completion THEN
|
||||||
|
DELETE FROM public.transcripts WHERE meeting_id = p_meeting_id;
|
||||||
|
UPDATE public.audio_files
|
||||||
|
SET meeting_id = NULL
|
||||||
|
WHERE meeting_id = p_meeting_id
|
||||||
|
AND user_id = current_user_id
|
||||||
|
AND id <> p_audio_file_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.transcripts (
|
||||||
|
meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker, edited
|
||||||
|
) VALUES (
|
||||||
|
p_meeting_id, 0, 0, LEAST(p_duration_ms, 2147483647)::integer,
|
||||||
|
p_transcript, NULL, false
|
||||||
|
)
|
||||||
|
ON CONFLICT (meeting_id, segment_index) DO UPDATE
|
||||||
|
SET duration_ms = EXCLUDED.duration_ms,
|
||||||
|
text = EXCLUDED.text,
|
||||||
|
speaker = NULL,
|
||||||
|
edited = false,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
UPDATE public.meetings
|
||||||
|
SET status = 'completed',
|
||||||
|
ended_at = COALESCE(ended_at, now()),
|
||||||
|
duration_ms = p_duration_ms,
|
||||||
|
raw_transcript = p_transcript,
|
||||||
|
edited_transcript = NULL,
|
||||||
|
minutes_markdown = CASE WHEN first_completion THEN NULL ELSE minutes_markdown END,
|
||||||
|
minutes_json = CASE WHEN first_completion THEN NULL ELSE minutes_json END,
|
||||||
|
llm_model = CASE WHEN first_completion THEN NULL ELSE llm_model END,
|
||||||
|
llm_latency_ms = CASE WHEN first_completion THEN NULL ELSE llm_latency_ms END,
|
||||||
|
stt_model = p_provider,
|
||||||
|
stt_latency_ms = p_stt_latency_ms,
|
||||||
|
error_message = NULL,
|
||||||
|
audio_storage_key = owned_audio.storage_key,
|
||||||
|
language = safe_language
|
||||||
|
WHERE id = p_meeting_id AND user_id = current_user_id
|
||||||
|
RETURNING * INTO result;
|
||||||
|
|
||||||
|
UPDATE public.processing_jobs
|
||||||
|
SET status = 'succeeded',
|
||||||
|
progress = 100,
|
||||||
|
error_code = NULL,
|
||||||
|
error_message = NULL,
|
||||||
|
result = jsonb_build_object(
|
||||||
|
'audio_file_id', p_audio_file_id,
|
||||||
|
'language', safe_language,
|
||||||
|
'provider', p_provider,
|
||||||
|
'duration_ms', p_duration_ms
|
||||||
|
),
|
||||||
|
completed_at = now()
|
||||||
|
WHERE id = owned_job.id;
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.mobile_begin_meeting_recording(uuid) FROM PUBLIC, anon;
|
||||||
|
REVOKE ALL ON FUNCTION public.mobile_cancel_meeting_recording(uuid) FROM PUBLIC, anon;
|
||||||
|
REVOKE ALL ON FUNCTION public.mobile_complete_meeting_processing(
|
||||||
|
uuid, uuid, text, text, text, text, bigint, integer
|
||||||
|
) FROM PUBLIC, anon;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.mobile_begin_meeting_recording(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.mobile_cancel_meeting_recording(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.mobile_complete_meeting_processing(
|
||||||
|
uuid, uuid, text, text, text, text, bigint, integer
|
||||||
|
) TO authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- Re-recording a meeting keeps its previous transcript until the new capture
|
||||||
|
-- commits one (migration 20260929002700).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF condition IS NOT TRUE THEN
|
||||||
|
RAISE EXCEPTION 'assertion_failed: %', message;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
id, aud, role, email, encrypted_password, email_confirmed_at,
|
||||||
|
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'81000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
|
||||||
|
'rerecord-owner@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||||
|
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.meetings (
|
||||||
|
id, user_id, title, status, raw_transcript, minutes_markdown, llm_model
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'81000000-0000-4000-8000-000000000001',
|
||||||
|
'Completed meeting', 'completed', 'old transcript', 'old minutes', 'old-llm'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'82000000-0000-4000-8000-000000000002',
|
||||||
|
'81000000-0000-4000-8000-000000000001',
|
||||||
|
'Empty meeting', 'recording', NULL, NULL, NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.transcripts (meeting_id, segment_index, timestamp_ms, duration_ms, text)
|
||||||
|
VALUES
|
||||||
|
('82000000-0000-4000-8000-000000000001', 0, 0, 1000, 'old segment 0'),
|
||||||
|
('82000000-0000-4000-8000-000000000001', 1, 1000, 1000, 'old segment 1');
|
||||||
|
|
||||||
|
INSERT INTO public.audio_files (
|
||||||
|
id, user_id, meeting_id, source, original_name, storage_key, mime_type,
|
||||||
|
size_bytes, duration_ms, sha256, upload_status
|
||||||
|
) VALUES (
|
||||||
|
'83000000-0000-4000-8000-000000000001',
|
||||||
|
'81000000-0000-4000-8000-000000000001',
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'recording', 'old.wav',
|
||||||
|
'81000000-0000-4000-8000-000000000001/imports/old.wav',
|
||||||
|
'audio/wav', 32044, 1000, repeat('b', 64), 'uploaded'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET LOCAL ROLE authenticated;
|
||||||
|
SELECT set_config(
|
||||||
|
'request.jwt.claims',
|
||||||
|
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 1) Starting a re-record changes state only.
|
||||||
|
SELECT public.mobile_begin_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.meetings
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||||
|
AND status = 'recording'
|
||||||
|
AND raw_transcript = 'old transcript'
|
||||||
|
AND minutes_markdown = 'old minutes'
|
||||||
|
),
|
||||||
|
'begin keeps the previous transcript and minutes'
|
||||||
|
);
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT count(*) FROM public.transcripts
|
||||||
|
WHERE meeting_id = '82000000-0000-4000-8000-000000000001') = 2,
|
||||||
|
'begin keeps the previous transcript rows'
|
||||||
|
);
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.audio_files
|
||||||
|
WHERE id = '83000000-0000-4000-8000-000000000001'
|
||||||
|
AND meeting_id = '82000000-0000-4000-8000-000000000001'
|
||||||
|
),
|
||||||
|
'begin keeps the previous audio linked'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2) Cancelling the re-record restores the completed meeting.
|
||||||
|
SELECT public.mobile_cancel_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.meetings
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||||
|
AND status = 'completed'
|
||||||
|
AND error_message IS NULL
|
||||||
|
AND raw_transcript = 'old transcript'
|
||||||
|
),
|
||||||
|
'cancel returns a meeting with content to completed'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3) Cancelling a first recording still reports an error.
|
||||||
|
SELECT public.mobile_cancel_meeting_recording('82000000-0000-4000-8000-000000000002');
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.meetings
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000002'
|
||||||
|
AND status = 'error'
|
||||||
|
AND error_message = 'Recording was cancelled before processing.'
|
||||||
|
),
|
||||||
|
'cancel of an empty meeting keeps the error outcome'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4) A committed re-record replaces the previous content.
|
||||||
|
SELECT public.mobile_begin_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||||
|
RESET ROLE;
|
||||||
|
INSERT INTO public.audio_files (
|
||||||
|
id, user_id, meeting_id, source, original_name, storage_key, mime_type,
|
||||||
|
size_bytes, duration_ms, sha256, upload_status
|
||||||
|
) VALUES (
|
||||||
|
'83000000-0000-4000-8000-000000000002',
|
||||||
|
'81000000-0000-4000-8000-000000000001',
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'recording', 'new.wav',
|
||||||
|
'81000000-0000-4000-8000-000000000001/imports/new.wav',
|
||||||
|
'audio/wav', 32044, 2000, repeat('c', 64), 'uploaded'
|
||||||
|
);
|
||||||
|
SET LOCAL ROLE authenticated;
|
||||||
|
SELECT set_config(
|
||||||
|
'request.jwt.claims',
|
||||||
|
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
SELECT public.mobile_queue_meeting_recording('82000000-0000-4000-8000-000000000001', 2000);
|
||||||
|
SELECT public.mobile_begin_meeting_processing(
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'83000000-0000-4000-8000-000000000002',
|
||||||
|
'mobile-meeting:rerecord:cccccccc'
|
||||||
|
);
|
||||||
|
SELECT public.mobile_complete_meeting_processing(
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'83000000-0000-4000-8000-000000000002',
|
||||||
|
'mobile-meeting:rerecord:cccccccc',
|
||||||
|
'new transcript', 'ko', 'fixture-stt', 2000, 90
|
||||||
|
);
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.meetings
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||||
|
AND status = 'completed'
|
||||||
|
AND raw_transcript = 'new transcript'
|
||||||
|
AND minutes_markdown IS NULL
|
||||||
|
AND llm_model IS NULL
|
||||||
|
AND audio_storage_key = '81000000-0000-4000-8000-000000000001/imports/new.wav'
|
||||||
|
),
|
||||||
|
'completion replaces transcript and clears stale minutes'
|
||||||
|
);
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT array_agg(text ORDER BY segment_index) FROM public.transcripts
|
||||||
|
WHERE meeting_id = '82000000-0000-4000-8000-000000000001') = ARRAY['new transcript'],
|
||||||
|
'completion removes every previous transcript row'
|
||||||
|
);
|
||||||
|
RESET ROLE;
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
(SELECT meeting_id FROM public.audio_files
|
||||||
|
WHERE id = '83000000-0000-4000-8000-000000000001') IS NULL,
|
||||||
|
'completion detaches the previous audio'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5) Replaying the committed job does not wipe minutes generated afterwards.
|
||||||
|
UPDATE public.meetings
|
||||||
|
SET minutes_markdown = 'fresh minutes'
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000001';
|
||||||
|
SET LOCAL ROLE authenticated;
|
||||||
|
SELECT set_config(
|
||||||
|
'request.jwt.claims',
|
||||||
|
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
SELECT public.mobile_complete_meeting_processing(
|
||||||
|
'82000000-0000-4000-8000-000000000001',
|
||||||
|
'83000000-0000-4000-8000-000000000002',
|
||||||
|
'mobile-meeting:rerecord:cccccccc',
|
||||||
|
'new transcript', 'ko', 'fixture-stt', 2000, 90
|
||||||
|
);
|
||||||
|
SELECT pg_temp.assert_true(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.meetings
|
||||||
|
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||||
|
AND minutes_markdown = 'fresh minutes'
|
||||||
|
),
|
||||||
|
'a replayed completion keeps minutes generated from the committed transcript'
|
||||||
|
);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
ROLLBACK;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue